Compare commits

..

107 Commits
v1 ... master

Author SHA1 Message Date
jordi fita mas 01807de5ca Add tax base to invoices exported ODS file
Accountants asks for that information, too.
2024-10-12 03:04:56 +02:00
jordi fita mas f1534e6cd2 Add the date of the last payment/collection to ODS export
Requested by Clara, that she wanted to know that date for internal
processes.  We agreed on adding only the most recent payment/collection
date, instead of adding all of them, for multiple payments/collections,
and she can know whether that date is for a partial or a complete
payment/collection with the status column.
2024-10-03 14:06:15 +02:00
jordi fita mas cfd7a0c701 Make <main> use all the available space in <body>
Otherwise, in a table with only two or three rows, since <main> does
not reach the bottom, opening a menu will trigger the scroll due to
overflow.
2024-10-03 13:58:59 +02:00
jordi fita mas 4ae9fc5cfa Compact product row of quotes and invoices
Not by much, but it looks a bit more like “invoice rows” rather than
a bunch of scattered forms.

Part of #57.
2024-09-10 12:41:35 +02:00
jordi fita mas ce4d29b83b Replace the htmx-only delete button for payments with a form
This way, it also works without JavaScript, that is not that important,
maybe, but i like that it does.
2024-09-09 11:33:10 +02:00
jordi fita mas 193409eed8 Add the menuitem role to expense’s delete button 2024-09-09 11:26:28 +02:00
jordi fita mas f5a9e819eb Allow to delete expenses
I remove the related taxes and attachments, but keep related payments
because i believe it is not likely that deleting a paid expense is what
the user wants.  If the user wants to do so, she can delete the payments
first.

Part of #84
2024-09-09 00:08:02 +02:00
jordi fita mas 82da7f4697 Remove almost all data-hx-boost attributes
Since 16e80b5ae, <body> no longer has overflow, thus no scroll.  As a
consequence, htmx no longer is able to scroll up <main> when it changes
due to the default, implicit `show:true` applied to the request: is
calls <main>’s scrollIntoView, however there is nothing to scroll to.

I probably could fix it by changing the target of `show`, or even add
a `scroll` directive to all boosted links, but at this point i think it
is better no not boost links at all, as they do what i want—show the new
page from the top—with less markup, plus the browser now show a loading
animation, and it is not that slower, too.
2024-09-08 01:29:30 +02:00
jordi fita mas 2eecdcfc3e Rename “Payment method” to “Invoicing method”
This is only for user-visible strings; the name from the point of view
of code and database remains the same.

This is an attempt to force a distinction between payment method, used
in invoices, and payment accounts, for payments.

Closes #100.
2024-09-06 12:13:27 +02:00
jordi fita mas 16e80b5ae0 Keep header fixed, and remove footer
Now that the navigation is inside the header, and the header is not
as tall as it once was, it makes sense to keep it always on the top of
the page, scrolling only within <main>, since it is the main menu, and
fairly used to switch from screen to screen.

I removed the footer because now it would be always visible, and i was
a bit weary of having the application name repeated that much.  It was
there mainly for the version number, that helps me check i installed the
application’s latest version on the server, but that role can also be
filled with meta tags.

Closes #97.
2024-09-05 14:37:30 +02:00
jordi fita mas 36423c8636 Shrink user menu’s button and application title
They were too big now that we have the main navigation in the header.

This is part of #98.
2024-09-05 14:23:22 +02:00
jordi fita mas 6ef551a846 Move top-level navigation bar inside body’s header
This is mostly to save on vertical space, since the header was almost
useless but took a lot of screen real estate.
2024-09-04 12:38:37 +02:00
jordi fita mas 292720de28 Add an edit form for payment methods
I do not particularly enjoy an htmx-only way of doing that, because it
means that it can only work with JavaScript, but i think this is already
a lost cause, unfortunately.  If i have time, i will try to make the
HTML-only form work too.

In this case, i have to put back the same row when updating or
cancelling the form, which is inside index.html.  Instead of moving that
part to a separate file, i tried to define a block as a “template
fragment” and try to render that part only.  Surprisingly, it works;
i am happy.

Closes #74.
2024-08-30 02:45:40 +02:00
jordi fita mas e8a44e480e Add field to update default payment method
It is only shown if there is more than one payment method, otherwise
it seems useless as there are nothing to change.

This closes #72.
2024-08-28 11:10:03 +02:00
jordi fita mas 0b74c7a91c Instruct htmx that HTTP 422 is not a “fatal error”
I use HTTP 422 to signal that a form was submitted with bad data,
which i believe is the correct status code: “indicates that the server
understands the content type of the request content […], and the syntax
of the request content is correct, but it was unable to process the
contained instructions.”[0]

htmx, however, treats all 4xx status codes as error and, by default,
does not swap the target with the response’s content.  Until i found out
that i could change that behaviour, i worked around this limitation by
returning HTTP 200 for htmx requests, but it is a waste of time given
that htmx _can_ accept HTTP 422 as a non-error.

[0]: https://www.rfc-editor.org/rfc/rfc9110#name-422-unprocessable-content
2024-08-27 11:07:39 +02:00
jordi fita mas 790417e12c Swap payments/collections and downloads in invoice/expenses index
It makes more sense to have the payment link readily available, given
that downloads for expenses are rather uncommon, and, when we implement
electronic invoicing, the invoice PDF will be less useful too.
2024-08-26 14:47:22 +02:00
jordi fita mas b815a18967 Remove status parameter from edit_expense and forms
For the same reasons as with expenses[0], users are no longer expected
to manually set invoice status, and is now linked to their collections.

In this case, however, we had to remove the ‘sent’ and ‘unpaid’ status
options, because these _should_ only be set manually, as there is no
way for the application to know when to set them.  Thus, there could
be inconsistencies, like invoices set to ‘unpaid’ when they actually
have collections, or invoices that were ‘sent’, then transitioned to
‘partial’/‘paid’ due to a collection, but then reset to ‘created’ if the
collection was deleted.

[0]: ac0143b2b0
2024-08-26 10:42:38 +02:00
jordi fita mas 7b1220c9f6 Add top margin to form footers 2024-08-21 11:38:52 +02:00
jordi fita mas e3d1e1fd1d Show payments in negative in the index 2024-08-21 11:34:26 +02:00
jordi fita mas 93a95d77d0 Fix the URL to invoices from payment index 2024-08-21 11:23:55 +02:00
jordi fita mas d4ef6c3254 Add a new collection “subsection” for invoices
This is mostly the same subsection as payments is for expense, added in
4f646e35d.  In this case i call it “collections”, but it is actually
the same payments section.
2024-08-21 11:22:53 +02:00
jordi fita mas 7f31b10cce Add payment collection
This is the same as a payment, but the user is the payee instead of the
payer.

I used a different relation than payment because i do not know any other
way to encode the constraint that only invoices can have a collection,
while expenses have only payments.

Besides the name and the fact that they are related to invoices, a
collection is pretty much the same as a payment.
2024-08-21 03:36:12 +02:00
jordi fita mas c6c550a036 Add check constraint to payment’s amount 2024-08-18 05:20:36 +02:00
jordi fita mas 4f646e35d6 Add a new payments “subsection” for expenses
With Oriol we agreed that to add new payments to expenses we should
direct users to a separate payments section, much like the general
payments but centered around the payments of the given expense.

In fact, the only thing i had to do is extract the expense from the
URL, and then adjust the base URI to keep things always within the
correct section; the rest of the code is shared with the general
section.
2024-08-17 05:31:01 +02:00
jordi fita mas eb880fed36 Fix label for payment date input 2024-08-16 01:58:59 +02:00
jordi fita mas 268ab9989a Link from the invoice number in expense index to its edit form
If there is no invoice number, then they can use the edit item from the
menu, but most expenses do have an invoice, thus this is easier for the
most usual case.
2024-08-16 01:44:20 +02:00
jordi fita mas dda32db683 Show the invoice number in expense’s title, if any, or slug otherwise 2024-08-16 01:40:20 +02:00
jordi fita mas a30e015639 Add inline tags form to payments 2024-08-15 04:18:35 +02:00
jordi fita mas fa57c4b191 Refactor inline tag edit form into its own file
I was repeating myself a lot for this use case, because each one needed
a different URL and SQL query, however they were kind of structurally
similar and could be refactored into common functions.
2024-08-15 04:18:18 +02:00
jordi fita mas dca8b3a719 Add the document (expense) column to payment index page 2024-08-15 03:59:30 +02:00
jordi fita mas 9ab08deaa1 Include the taxes when updating an expense to paid or partial 2024-08-15 03:51:30 +02:00
jordi fita mas 7f21a2131e Add the where company_id filter to accounts and payments queries
I actually did not forget them, and i did not add them on purpose,
mistakenly believing that PostgreSQL’s row-level policies would project
only rows from the current company.  That is actually how Camper works,
but that’s because we use the request’s domain name to select the
company; here we use the path, and the row-level policy would return
rows from all companies the user belongs to.
2024-08-15 02:59:46 +02:00
jordi fita mas f95936c523 Split the tax details “mega dialog” into separate pages
I needed to place the payment accounts section somewhere, and the most
logical place seemed to be that dialog, where users can set up company
parameters.

However, that dialog was already saturated with related, but ultimately
independent forms, and adding the account section would make things
even worse, specially given that we need to be able to edit those
accounts in a separate page.

We agreed to separate that dialog into tabs, which means separate pages.
When i had everything in a separated page, then i did not know how to
actually share the code for the tabs, and decided that, for now, these
“tabs” would be items from the profile menu.  Same function, different
presentation.
2024-08-14 04:08:13 +02:00
jordi fita mas e626c7b4bd Style the payment status column in index 2024-08-13 02:36:07 +02:00
jordi fita mas ac0143b2b0 Remove the status parameter from add_expense and edit_expense, and forms
Users are no longer expected to manually set the status of an expense
and, instead, have to add payments to such expense to mark it as partial
or paid.

That means that the PL/pgSQL functions must not accept a status
parameter, the edit and new forms should no longer have a field for
the status, and that the expense list should no longer have the “quick
edit” for their status.  That’s why it no longer should have a pointer
cursor, unlike invoice or quote status.
2024-08-13 02:34:21 +02:00
jordi fita mas 71a0a82a3f Change partial expenses to pending when reverting available status
Otherwise, i could have an expense that i have set to partial during
development that prevents sqitch to rebase because it is still in use.
2024-08-13 02:31:15 +02:00
jordi fita mas c95f172499 Add attachments to payments 2024-08-12 00:08:18 +02:00
jordi fita mas 58cef8c00b Refactor common code to download invoice and expenses attachments 2024-08-12 00:07:30 +02:00
jordi fita mas 4deb698265 No need for extension_pgcrypto for functions with UUID parameters 2024-08-11 23:30:57 +02:00
jordi fita mas 778f9c1555 Allow removal of payments
I am using an htmx-infused button to remove the payment, but that
button can not have the CSRF token as value, thus i have to send it in a
header.

The removal of payments warrants a functions, instead of just DELETE
(and CASCADE) as i do for payment methods, because i have to adjust the
status of expenses too.  Since i already have functions for everything,
it is not worth using triggers just for that.
2024-08-11 03:22:37 +02:00
jordi fita mas ad5bc271b6 Add the payments section
This actually should be the “payments and receivables” section, however
this is quite a mouthful; a “receivable” is a payment made **to** you,
therefore “payments” is ok.

In fact, there is still no receivables in there, as they should be in
a separate relation, to constraint them to invoices instead of expenses.
It will be done in a separate commit.

Since this section will be, in a sense, sort of simplified accounting,
i needed to introduce the “payment account” concept.  There is no way,
yet, for users to add them, because i have to revamp the “tax details”
section, but this commit started to grow too big already.

The same reasoning for the attachment payment slips as PDF to payment:
something i have to add, but not yet in this commit.
2024-08-10 04:34:07 +02:00
jordi fita mas f546632a89 Remove a stray Println from expenseForm.MustFillFromDatabase 2024-08-07 00:47:34 +02:00
jordi fita mas c3fa23727f Include customer’s VAT number to the expense list in ODS too
It was requested by Clara.
2024-07-20 22:52:23 +02:00
jordi fita mas 505fa0f154 Include customer’s VAT number to the invoice list in ODS
It was requested by Clara.
2024-07-20 22:52:23 +02:00
oriol carbonell pujolàs 64be350677 Add styles for small screens 2024-04-08 09:19:52 +02:00
oriol carbonell pujolàs 4363073682 Add down-arrow icon next to download button 2024-04-08 09:18:49 +02:00
oriol carbonell pujolàs 3e6f44f778 Add Tàndem’s logo to home footer 2024-04-08 09:17:57 +02:00
jordi fita mas faf7ee8ed5 Set `white-space: pre-wrap` to first td of quotes and invoices
It is common to want to enumerate in a description, for instance when
adding specifications for a hosting, and that enumeration should be
formatted as the user wrote, otherwise it becomes useless.

Closes #94.
2024-03-13 02:56:37 +01:00
jordi fita mas a689e2f734 Hide footer when printing invoices and quotes
Closes #96
2024-03-13 02:53:02 +01:00
jordi fita mas 405c833490 Add Guix file 2024-02-06 17:43:05 +01:00
jordi fita mas 65413637ac Add a column for each tax type when exporting invoices and expenses
In the HTML tables i only compute the aggregated amount by tax class
(e.g., IVA, IRPF), but here we need the actual tax (e.g., IVA 4 %)
because this spreadsheet is intended for accountants.

I can easily extract the amounts from invoice_tax_amount and
expense_tax_amount, but i also need to add the columns to the
spreadsheet, and always with the same order—does not matter much which,
only the same—, that’s why i had to sort the tax IDs when exporting, as
Go does not guarantee an order for maps.

Closes #92
2024-01-26 02:30:11 +01:00
oriol carbonell pujolàs 6fcc19bebf Update styles and home page 2024-01-21 01:58:55 +01:00
jordi fita mas 5b0ca28b97 Fix the revert of contact_phone and contact_tax_details
This is mostly to be able to sqitch rebase and restart with the demo
data during development, as it is very unlikely that i will need to
revert those at this point.
2024-01-20 21:34:09 +01:00
jordi fita mas 662ba59be3 Add contacts, invoices, and expenses to the demo
This is mostly to have a better looking dashboard, especially with the
year and last year filters.
2024-01-20 21:33:16 +01:00
jordi fita mas 24a4bf2583 Use kbd and samp for menu options in cookies privacy 2024-01-20 20:32:55 +01:00
jordi fita mas 2ec88eddae Add type comment to login.gohtml 2024-01-20 20:23:47 +01:00
jordi fita mas 5f7b798eb4 Prefill login form when using the demo database
This is to help up “sell” the service: people can look around the demo
to see whether it fits them.  Of course, everyone should have the same
username in the demo.

We talked about having the username and password displayed above the
form in the template, but i think it makes more sense to give users as
little work as necessary.  Plus, that means i do not have to write them
down while developing.

Whether the database is demo or not is not something that directly
depends on the environment, but rather on which database we are
connected to, thus an environment variable would not make much sense—it
has to be something of the database.

PostgreSQL has no PRAGMA application_id or PRAGMA user_version as with
SQLite to include application-specific values to the database.  The
equivalent would be customized options[0], intended for modules
configuration, but that would require me to execute an ALTER DATABASE
in demo.sql with an specific datbase name, or force the use of psql to
run script the script, because then i can use the :DBNAME placeholder.

I guess that the most “standard” way is to just create a function that
returns a know value if the database is demo.  Sqitch does not add that
function, therefore it is unlikely to be there by change unless it is
the demo database.

https://www.postgresql.org/docs/15/runtime-config-custom.html
2024-01-20 20:23:26 +01:00
jordi fita mas 2bd7b2e952 Fix revert for contact_web
If there were contacts added with add_contact passing an empty string
to `web` parameter, contact_web would not have such row, and during the
revert the `web` parameter of contact would end up being NULL.
2024-01-20 19:47:26 +01:00
jordi fita mas 843379a908 Remove the extra logo reference from login.gohtml
It is not in the common web.gohtml template file.
2024-01-20 19:06:59 +01:00
jordi fita mas 61fc8ee255 Debian: copy de modified build.go to the src folder in _build
I _believe_ this is the folder that Debian actually compiles, not the
top level.
2024-01-19 23:54:32 +01:00
jordi fita mas b28f29eb24 Fix DEB_UPSTREAM_VERSION to DEB_VERSION_UPSTREAM 2024-01-19 23:19:29 +01:00
jordi fita mas 4d2af368d2 Include pkg-info.mk in Debian rules for DEB_UPSTREAM_VERSION 2024-01-19 23:12:01 +01:00
jordi fita mas b4b049aab9 Include numerusVersion to CSS and JavaScript’s URIs 2024-01-19 23:09:25 +01:00
jordi fita mas e0bdb89472 Add legal disclaimer and privacy and cookies policies’ texts
The legal stuff. Required by Spanish law when setting up a site intended
for pecuniary gain, directly or indirectly.

Now we have more pages to the “public web”, and moved the header and
footer from home to the common layout.  I also took the opportunity to
change the element from <div> to the appropriate element based on their
use (i.e., <header> and <footer>).

I removed the <div> around the logo because i did not see any use for
it.  I may be from a previous design iteration, but it had no style
applied nor any usage at all in JavaScript.
2024-01-19 23:05:01 +01:00
jordi fita mas f15294c042 Use <small> to mark the application’s version up
I mistakenly thought that <small> was de inverse of the deprecated <big>
element, but apparently it is for small-print text and such, thus suited
for this case.
2024-01-19 22:59:06 +01:00
jordi fita mas 0937cfcf33 Remove redundant units of measure from numerus.css 2024-01-19 20:03:37 +01:00
jordi fita mas 18b38f593c Add the application’s version on the footer
This is mostly to reassure people that we are running the same version
as published on numerus.cat.  Or at least, try.

Go 1.18 adds the info from git if the package is build from a git
repository, but this is not the case in OBS, so i instead relay on a
constant for the version number.  This constant is “updated” by Debian’s
rules, mostly due to the discussion in [0].

[0]: https://github.com/golang/go/issues/22706
2024-01-19 20:03:04 +01:00
jordi fita mas 22ee6343e2 Use errors.Is to compare ErrServerClosed in main 2024-01-19 19:44:11 +01:00
oriol carbonell pujolàs 7e377f550c Add more content to home 2024-01-19 10:27:15 +01:00
oriol carbonell pujolàs d3afde9e21 Fix typos in user type description in home 2024-01-19 10:25:18 +01:00
oriol carbonell pujolàs a5fdeb9ab4 Update style and home page 2024-01-18 22:25:30 +01:00
oriol carbonell pujolàs 45a45d7cc9 Update styles 2024-01-18 21:19:20 +01:00
jordi fita mas b62f86950e Fix the link to AGPL and add a couple of NBSP to home 2024-01-18 21:15:52 +01:00
jordi fita mas 2d0572e1d6 Replace “open source” with “free software” in home 2024-01-18 21:12:58 +01:00
jordi fita mas 6de4135fa6 Reformat numerus.css 2024-01-18 21:10:54 +01:00
oriol carbonell pujolàs f3fdc0d743 Update home page with proper marketing text 2024-01-18 21:08:49 +01:00
jordi fita mas e34ef4f458 Remove the “sales” box from the dashboard
There are no sales yet in Numerus, and having that summary there
confuses people.

He left the space there, without any text or background color, to
maintain the position of the rest.

Closes #87.
2023-11-13 14:46:57 +01:00
jordi fita mas 31a655ae7f Add aria-current attribute to links in the top menu
This is mainly to be able to stylize them using CSS; the current style
i set i just a placeholder to check that it works as expected.

Most of these links needs to check for the URI’s prefix, because they
are links to a whole section, but the first link must check for the
exact match, otherwise it would match every other URI, as all of them
start with /company/{uuid}.

The server does not return the markup for the top navigation when usin
HTMx, though, hence i have to change the current class using JavaScript.

I am not sure if the correct value for aria-current is “page” when the
link is not for the actual page the user is currently in, like when is
in the new quote page, but it seems to be the most appropriate value
from the enumeration given in the specifications, except, perhaps, for
the “location” value, but i was unable to find any example of that value
anywhere.

Part of #89.
2023-11-13 14:42:27 +01:00
oriol carbonell pujolàs c3e1597972 Add rollover to top menu items 2023-11-13 13:11:33 +01:00
jordi fita mas e322ddd168 Add custom elements polyfill
I use customized built-in components, extended from any HTML elements
(e.g., HTMLDivElement), for product search, multiselect, and tags input
fields.  The idea is that people that without JavaScript could still use
the regular, non-customized, inputs.

It turns out, this does not work so well: the CSS assumes that
JavaScript is enabled, and web components supported by the browser. If
one of these fails, then the controls are unusable—the multiselect is
too short, and the tags field accepts invalid characters that the
backend does not validate until it fails with a database error.

We discovered this because Apple does not implement customized built-in
components[0], hence it does not use my JavaScript code and forms,
the expenses forms in particular, are almost useless.

The way to fix this is to replace the regular inputs with autonomous
web components, extended from HTMLElement, using JavaScript, because
it would only do so with JavaScript enabled, and the CSS style would
only apply to these components, not the regular input fields.

However, currently i do not have time to do the proper fix, and have
to use a polyfill for Safari to support customized built-in components.
Shame.

[0]: https://lists.w3.org/Archives/Public/public-webapps/2013OctDec/0801.html
2023-11-11 03:32:03 +01:00
jordi fita mas 998159d1d7 Add option to switch to another company
This is for users that belong to more than one company.  It is just a
page with links to the home of each company that the user belongs to.

Had to add a second company to the demo data to test it properly, even
though i already have unit tests for multicompany, but, you know….
2023-11-06 13:52:34 +01:00
jordi fita mas 4e831d94db Avoid panic error when there is no expense to compute the sum of 2023-11-06 13:18:02 +01:00
jordi fita mas ef215f1e6e Add a cache of OID in database to register types
It makes no sense to retrieve the same OIDs each and every connection,
because they are not going to change unless the database is reset,
something it is very unlikely to happen in production.

Thus, it is best to query them the first time the application connects
to the database, that it is done at startup to query the available
languages, and then reuse the OIDs.

I can get away of using an “unprotected” map, instead of sync.Map or a
map in tandem with sync.RWMutex, because the application establishes a
connection at startup from a single goroutine, and it registers _all_
types we will need to register within the application’s lifespan, hence
it there will be no more writes to that map once the web server is
listening for incoming connections.

This is risky, however, and i hope i do not have to regret it.
2023-10-27 12:44:24 +02:00
jordi fita mas 2501b7d226 Set enctype to multipart/form-data for invoice status form
Since this form is “shared” with the “full” form, that now includes
files, it has to be multipart/form-data too.
2023-10-02 18:48:28 +02:00
jordi fita mas 0fd0cf5a38 Add the sum of the base and taxes to expenses’ index
Expands on #79
2023-10-02 16:36:42 +02:00
jordi fita mas 80a6a802a2 Make sure the selected taxes in show expense is nil if there is none
For some reason, pgx tries to convert [""] to an int array and fails,
because "" is not a number, of course.
2023-10-02 12:49:54 +02:00
jordi fita mas 831becf6fd Add the base and tax columns to expenses’ index
Closes #80
2023-10-02 12:16:50 +02:00
jordi fita mas 60ec335769 Sort expenses by date desc, and then by name and total
This make more sense, as is the same order user by invoices, and the
most recent expense is at the top.

Closes #79
2023-10-02 11:04:35 +02:00
jordi fita mas 52256c3cb9 Fix compute_new_expense_amount to set 0.00 to taxes when subtotal is ''
The problem is that parse_price('', 2) returned NULL instead of throwing
and exception: it seems that accessing var[1] of a text[] variable set
to the empty array, {}, returns NULL, and NULL::integer is, of course,
still NULL.

Apparently, this is the only case, until now, that i had an empty
subtotal, and i did not know what to do: should i keep the function as
is and just handle its NULL return, change it to return 0 in that case,
or raise an exception?

The argument for the first two options, to leave it as is or to
return zero, was that it was convenient for me to allow empty strings as
input values, because that’s what i get from an empty <input>; returning
zero would avoid an extra coalesce everywhere the function was used.

The argument in favor to the last option, an exception, was that the
empty string does not represent an integer, nor a “unknown” (NULL)
integer, therefore the function should do the same when i pass in any
other string that does not represent an integer, just as “a.b”.

At the end i went for option three, because it is the one that breaks
fewer expectatives: casting an empty string to integer, or passing
an empty string as the first value to to_number() throw and exception in
PostgreSQL; my function should do the same.  Heck, that what **i**
expected it to do because of the casting inside the function.

To still allow empty strings as parameter to compute_new_expense_amount,
the only case so far, i only had to check for that empty string and
convert it to the string representation of zero, so that parse_price
returns the value i want for that function.  This, of course, breaks
the same expectatives as returning NULL for to_price, but i think it is
OK in this case because to_price is more general—used in many more
cases—than compute_new_expense_amount, which is only intended for that
HTML form.

Closes #77.
2023-08-25 14:19:27 +02:00
jordi fita mas 1c6375b51d Do not give “false ID” to invoice products that come from quotations
When adding “free-form products” to quotes they do not have a product
ID, but i has coalescing the NULL to zero because product_id is an
integer and can not coalesce a nullable integer to an empty string.

However, that causes problems when trying to create the invoice for that
quote, because it tries to add products that have an ID of 0 and the
foreign key, obviously, fail.

At first i modified NewInvoiceProductArray.EncodeBinary to check for
"0" as well as the empty string, but i realized this was wrong: the
problem was because i gave these products an ID when they do not have
any.  And the solution is to cast product_id to a text, which is what
will get converted anyway because i the only thing i do to it is to
store to a string-backed InputForm field.

Closes #73.
2023-08-11 19:47:10 +02:00
jordi fita mas 0c4ef97dff Add option to export the list of quotes, invoices, and expenses to ODS
This was requested by a potential user, as they want to be able to do
whatever they want to do to these lists with a spreadsheet.

In fact, they requested to be able to export to CSV, but, as always,
using CSV is a minefield because of Microsoft: since their Excel product
is fucking unable to write and read CSV from different locales, even if
using the same exact Excel product, i can not also create a CSV file
that is guaranteed to work on all locales.  If i used the non-standard
sep=; thing to tell Excel that it is a fucking stupid application, then
proper applications would show that line as a row, which is the correct
albeit undesirable behaviour.

The solution is to use a spreadsheet file format that does not have this
issue.  As far as I know, by default Excel is able to read XLSX and ODS
files, but i refuse to use the artificially complex, not the actually
used in Excel, and lobbied standard that Microsoft somehow convinced ISO
to publish, as i am using a different format because of the mess they
made, and i do not want to bend over in front of them, so ODS it is.

ODS is neither an elegant or good format by any means, but at least i
can write them using simple strings, because there is no ODS library
in Debian and i am not going to write yet another DEB package for an
overengineered package to write a simple table—all i want is to say
“here are these n columns, and these m columns; have a good day!”.

Part of #51.
2023-07-18 13:29:36 +02:00
jordi fita mas 835e52dbcb Return HTTP 404 instead of 500 for invalid UUID values in URL
Since most of PL/pgSQL functions accept a `uuid` domain, we get an error
if the value is not valid, forcing us to return an HTTP 500, as we
can not detect that the error was due to that.

Instead, i now validate that the slug is indeed a valid UUID before
attempting to send it to the database, returning the correct HTTP error
code and avoiding useless calls to the database.

I based the validation function of Parse() from Google’s uuid package[0]
because this function is an order or magnitude faster in benchmarks:

  goos: linux
  goarch: amd64
  pkg: dev.tandem.ws/tandem/numerus/pkg
  cpu: Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz
  BenchmarkValidUuid-4            36946050                29.37 ns/op
  BenchmarkValidUuid_Re-4          3633169               306.70 ns/op

The regular expression used for the benchmark was:

  var re = regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")

And the input parameter for both functions was the following valid UUID,
because most of the time the passed UUID will be valid:

  "f47ac10b-58cc-0372-8567-0e02b2c3d479"

I did not use the uuid package, even though it is in Debian’s
repository, because i only need to check whether the value is valid,
not convert it to a byte array.  As far as i know, that package can not
do that.

[0]: https://github.com/google/uuid
2023-07-17 12:07:23 +02:00
jordi fita mas 5e8bed8452 Add reset button to filters
I want this button, as well as the submit button, to be on a row below
the filters’ input, especially for quotes and invoices, that have the
most filters and looks weird with the button wedged in.  Thus, i added
a <fieldset> around all the filters.

Closes #69
2023-07-16 20:56:11 +02:00
jordi fita mas 51c789ca13 Add nowrap to toggle’s labels
Sometimes, if the space is tight, the browser may break the radio button
and the single-word label for the toggle, which looks very weird.

I did not set nowrap to all radio button because the no wrap would
prevent long labels to break, and i am not sure if there is any such
radio option.  I know that for the toggle there is not any.
2023-07-14 10:44:38 +02:00
jordi fita mas ae1e294144 Fix body class to show the filters without JavaScript
This class must match the one set by the “Filter” button so that when
there is no JavaScript filters forms are always visible.
2023-07-14 10:25:39 +02:00
jordi fita mas a7c1df20f0 Compute the total amount, base plus taxes, of all expenses
This works mostly like invoices: i have to “update” the expense form
to compute its total based on the subtotal and the selected taxes,
although in this case i do no need to compute the subtotal because that
is given by the user.

Nevertheless, i added a new function to compute that total because it
was already hairy enough for the dashboard, that also needs to compute
the tota, not just the base, and i wanted to test that function.

There is no need for a custom input type for that function as it only
needs a couple of simple domains.   I have created the output type,
though, because otherwise i would need to have records or “reuse” any
other “amount” output type, which would be confusing.\

Part of #68.
2023-07-13 20:50:26 +02:00
jordi fita mas 7d55e949fc Validate expenseForm.Text only once 2023-07-13 18:14:06 +02:00
jordi fita mas bb7af20a17 Add attachments to invoices
Works exactly the same as for expenses, and this is sometimes convenient
for keeping transfer slips from customers and such.

I actually did not know where to add the download from this attachment,
because if add a column to the index it can easily be confused with the
download icon for the actual invoice.

Part of #66.
2023-07-12 20:06:53 +02:00
jordi fita mas 66ab3b4bf7 Remove an unnecessary truncate from expense_attachment test 2023-07-12 20:03:36 +02:00
jordi fita mas b48a974086 Add expenses statuses
We only want two statuses for expense: not yet paid (pending), and paid.
Thus, it is a bit different from quotes and invoices, because expenses
do not pass throw the “workflow” of created→sent→{pending,paid}. That’s
way in this case the status field is already in the new expense form,
instead of hidden, and by pending is not equivalent to created but
unpaid (i.e., the same status color).

With the new select field in the form, the file field no longer can
span two columns or it would be alone on the next row.

Closes #67.
2023-07-11 15:33:26 +02:00
jordi fita mas b7578a56df Refactor getting the invoice status options in a single function 2023-07-11 15:26:23 +02:00
jordi fita mas fa97f53dd7 Change link color to full blue
This is mostly to have better contrast with the alternative row color
(#EDE95E), because with Firefox’s default link color (#1B6ACB), on Linux
at least, the contrast ratio is 4,5:1.

Closes #62.
2023-07-11 13:52:04 +02:00
jordi fita mas 1164210d84 Add the customer name to the invoice’s PDF file name
This was requested by Oriol; there are no other technical or legal
requirements for this.

I can not simply append the customer name to the file because it could
have characters that are not valid in file name depending on the
operating system, so i have to “slugify” it.

Closes #65
2023-07-07 11:34:34 +02:00
jordi fita mas c174fb447c Move data-hx-boost inside the <nav> for quotes and invoices
This is because the download button must *not* be boosted, or HTMx would
just slap the binary data into the document as is, without downloading
it.
2023-07-07 11:34:34 +02:00
jordi fita mas 1bb6870f26 Keep products in invoices/quotes sorted by (roughly) insertion order
There was no explicit `order by` in the queries that list the products
of quotes and invoices, so PostgreSQL was free to use any order it
wanted.  In this case, since was am grouping first by name, the result
was sorted by product name.

This is not an issue in most cases, albeit a bit rude to the user,
except for when the products *have* to in the same order the user
entered them, because they are monthly fees or something like that, that
must be ordered by month _number_, not by their _name_; the user will
usually input them in the correct order they want them on the invoice or
quote.

Sorting by *_product_id does *not* guarantee that they will always be
in insertion order, because the sequence can “wrap”, but i think i am
going to have bigger problems at that point.

Closes #63
2023-07-07 11:34:26 +02:00
jordi fita mas 58dd69773a Add aria-label to <summary> without text content
Using Orca or similar accessibility tools, it was not possible to
understand what these “menus” were intended for because they had only
icons without any alternative text, thus nothing to speak aloud with.
2023-07-06 11:49:36 +02:00
jordi fita mas d697b340e9 Remove scroll after update quote or invoice form
By default, when using hx-boost, HTMx adds the `show:top` modifier to
`hx-swap`.  This is actually the wanted behaviour in these cases,
because it means that you are following a link to a “different” page,
and it makes sense to start from the top.

However, for quote and invoice forms is a hindrance because you are
usually editing the bottom-most product, as new lines are appended,
therefore you are all the time having to scroll down to the line.
Every. Single. Time.

Here i reverted the show:top to just show:false to keep the scroll as
is.  I also added the show:bottom to the new product button, because
that way the window show entirely the new line.

I could not use `show🪟bottom`, however, despite appearing in the
documentation, because that, somehow, ended up doing the same as
`show🪟top`.  Not sure if a bug or something i did not understand.

Closes #58.
2023-07-04 19:55:58 +02:00
282 changed files with 14718 additions and 2038 deletions

View File

@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"log"
"net/http"
"os"
@ -19,9 +20,12 @@ func main() {
}
defer db.Close()
var demo bool
_ = db.QueryRow(context.Background(), "select database_is_numerus_demo()").Scan(&demo)
srv := http.Server{
Addr: ":8080",
Handler: numerus.NewRouter(db),
Handler: numerus.NewRouter(db, demo),
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 2 * time.Minute,
@ -29,7 +33,7 @@ func main() {
go func() {
log.Printf("INFO - listening on %s\n", srv.Addr)
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("http server: %v", err)
}
}()

3
debian/control vendored
View File

@ -10,8 +10,9 @@ Build-Depends:
golang-github-jackc-pgx-v4-dev,
golang-github-julienschmidt-httprouter-dev,
golang-github-leonelquinteros-gotext-dev,
golang-golang-x-text-dev,
golang-github-rainycape-unidecode-dev,
golang-github-tealeg-xlsx-dev,
golang-golang-x-text-dev,
postgresql-all (>= 217~),
sqitch,
pgtap,

4
debian/rules vendored
View File

@ -1,9 +1,13 @@
#!/usr/bin/make -f
include /usr/share/dpkg/pkg-info.mk
%:
dh $@ --builddirectory=_build --buildsystem=golang --with=golang
execute_before_dh_auto_build:
printf 'package pkg\n\nconst Version = "%s"\n' "$(DEB_VERSION_UPSTREAM)" > pkg/build.go
cp pkg/build.go _build/src/dev.tandem.ws/tandem/numerus/pkg/build.go
make
execute_after_dh_auto_test:

View File

@ -2,6 +2,8 @@ begin;
set search_path to auth, numerus, public;
create or replace function public.database_is_numerus_demo() returns bool as $$ select true $$ language sql;
alter sequence user_user_id_seq restart with 123;
insert into auth."user" (email, name, password, role)
values ('demo@numerus', 'Demo User', 'demo', 'invoicer')
@ -12,12 +14,16 @@ set constraints "company_default_payment_method_id_fkey" deferred;
alter sequence company_company_id_seq restart with 123;
insert into company (business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_payment_method_id, legal_disclaimer)
values ('Juli Verd', 'ES40404040D', 'Pesebre', parse_packed_phone_number('972 50 60 70', 'ES'), 'info@numerus.cat', 'https://numerus.cat/', 'C/ de lHort', 'Castelló dEmpúries', 'Girona', '17486', 'ES', 'EUR', 124, 'Juli Verd és responsable del tractament de les seves dades dacord amb el RGPD i la LOPDGDD, i les tracta per a mantenir una relació mercantil/comercial amb vostè. Les conservarà mentre es mantingui aquesta relació i no es comunicaran a tercers. Pot exercir els drets daccés, rectificació, portabilitat, supressió, limitació i oposició a Juli Verd, amb domicili Carrer de lHort 71, 17486 Castelló dEmpúries o enviant un correu electrònic a info@numerus.cat. Per a qualsevol reclamació pot acudir a agpd.es. Per a més informació pot consultar la nostra política de privacitat a numerus.cat.');
values ('Juli Verd', 'ES40404040D', 'Pessebre', parse_packed_phone_number('972 50 60 70', 'ES'), 'info@numerus.cat', 'https://numerus.cat/', 'C/ de lHort', 'Castelló dEmpúries', 'Girona', '17486', 'ES', 'EUR', 124, 'Juli Verd és responsable del tractament de les seves dades dacord amb el RGPD i la LOPDGDD, i les tracta per a mantenir una relació mercantil/comercial amb vostè. Les conservarà mentre es mantingui aquesta relació i no es comunicaran a tercers. Pot exercir els drets daccés, rectificació, portabilitat, supressió, limitació i oposició a Juli Verd, amb domicili Carrer de lHort 71, 17486 Castelló dEmpúries o enviant un correu electrònic a info@numerus.cat. Per a qualsevol reclamació pot acudir a agpd.es. Per a més informació pot consultar la nostra política de privacitat a numerus.cat.')
, ('Pere Gil', 'ES41414141L', 'Betlem', parse_packed_phone_number('972 80 90 00', 'ES'), 'info@numerus.cat', 'https://numerus.cat/', 'C/ de lHort', 'Castelló dEmpúries', 'Girona', '17486', 'ES', 'EUR', 126, 'Pere Gil és responsable del tractament de les seves dades dacord amb el RGPD i la LOPDGDD, i les tracta per a mantenir una relació mercantil/comercial amb vostè. Les conservarà mentre es mantingui aquesta relació i no es comunicaran a tercers. Pot exercir els drets daccés, rectificació, portabilitat, supressió, limitació i oposició a Pere Gil, amb domicili Carrer de lHort 71, 17486 Castelló dEmpúries o enviant un correu electrònic a info@numerus.cat. Per a qualsevol reclamació pot acudir a agpd.es. Per a més informació pot consultar la nostra política de privacitat a numerus.cat.')
;
alter sequence payment_method_payment_method_id_seq restart with 123;
insert into payment_method (company_id, name, instructions)
values (123, 'Efectiu', 'Pagament en efectiu al comptat.')
, (123, 'Transferència', E'Pagament per transferència bancària al compte:\n\nES0123456789012345678901\n\nBIC AAAABBCCDD')
, (124, 'Efectiu', 'Pagament en efectiu al comptat.')
, (124, 'Transferència', E'Pagament per transferència bancària al compte:\n\nES0123456789012345678901\n\nBIC AAAABBCCDD')
;
set constraints "company_default_payment_method_id_fkey" immediate;
@ -25,12 +31,16 @@ set constraints "company_default_payment_method_id_fkey" immediate;
insert into company_user (company_id, user_id)
values (123, 123)
, (123, 124)
, (124, 123)
, (124, 124)
;
alter sequence tax_class_tax_class_id_seq restart with 123;
insert into tax_class (company_id, name)
values (123, 'IRPF')
, (123, 'IVA')
, (124, 'IRPF')
, (124, 'IVA')
;
alter sequence tax_tax_id_seq restart with 123;
@ -39,15 +49,25 @@ values (123, 123, 'Retenció 15 %', -0.15)
, (123, 124, 'IVA 21 %', 0.21)
, (123, 124, 'IVA 10 %', 0.10)
, (123, 124, 'IVA 4 %', 0.04)
, (124, 123, 'Retenció 15 %', -0.15)
, (124, 124, 'IVA 21 %', 0.21)
, (124, 124, 'IVA 10 %', 0.10)
, (124, 124, 'IVA 4 %', 0.04)
;
alter sequence contact_contact_id_seq restart with 123;
select add_contact (123, 'Melcior', '0732621', 'melcio@reismags.cat', '', '(Rei Blanc,1,"C/ Principal, 1",Shiraz,Fars,1,IR)', '', '', array['pesebre', 'mag']);
select add_contact (123, 'Gaspar', '111', 'gaspar@reismags.cat', '', '(Rei Ros,2,"C/ Principal, 2",Nova Delhi,Delhi,2,IN)', '', '', array['pesebre', 'mag']);
select add_contact (123, 'Baltasar', '1-111-111', 'baltasar@reismags.cat', '', '(Rei Negre,3,"C/ Principal, 3",Sanaa,Sanaa,3,YE)', '', '', array['pesebre', 'mag']);
select add_contact (123, 'Caganera', '222 222 222', 'caganera@pesebre.cat', '', '(Caganera,41414141L,"C/ De lHort, 4",Olot,Girona,17800,ES)', '', '', array['pesebre', 'persona']);
select add_contact (123, 'Bou', '333 333 333', 'bou@pesebre.cat', '', '(Bou,41414142C,"C/ De la Palla, 5",Sant Climent Sescebes,Girona,17751,ES)', '', '', array['pesebre', 'bestia']);
select add_contact (123, 'Rabadà', '444 444 444', 'rabada@pesebre.cat', '', '(Rabadà,41414143K,"C/ De les Ovelles, 6",Fornells de la Selva,Girona,17458,ES)', '', '', array['pesebre', 'persona']);
-- customers
select add_contact (123, 'Melcior', '0732621', 'melcio@reismags.cat', '', '(Rei Blanc,1,"C/ Principal, 1",Shiraz,Fars,1,IR)', '', '', array['pesebre', 'mag', 'client']);
select add_contact (123, 'Gaspar', '111', 'gaspar@reismags.cat', '', '(Rei Ros,2,"C/ Principal, 2",Nova Delhi,Delhi,2,IN)', '', '', array['pesebre', 'mag', 'client']);
select add_contact (123, 'Baltasar', '1-111-111', 'baltasar@reismags.cat', '', '(Rei Negre,3,"C/ Principal, 3",Sanaa,Sanaa,3,YE)', '', '', array['pesebre', 'mag', 'client']);
select add_contact (123, 'Caganera', '222 222 222', 'caganera@pesebre.cat', '', '(Caganera,41414141L,"C/ De lHort, 4",Olot,Girona,17800,ES)', '', '', array['pesebre', 'persona', 'client']);
select add_contact (123, 'Bou', '333 333 333', 'bou@pesebre.cat', '', '(Bou,41414142C,"C/ De la Palla, 5",Sant Climent Sescebes,Girona,17751,ES)', '', '', array['pesebre', 'bestia', 'client']);
select add_contact (123, 'Rabadà', '444 444 444', 'rabada@pesebre.cat', '', '(Rabadà,41414143K,"C/ De les Ovelles, 6",Fornells de la Selva,Girona,17458,ES)', '', '', array['pesebre', 'persona', 'client']);
-- suppliers
select add_contact(123, 'TGSS', '', '', '', null, '', '', array['govern']);
select add_contact(123, 'Quadre Estable', '', '', '', null, '', '', array['proveidor']);
select add_contact(123, 'De tot i +', '', '', '', null, '', '', array['proveidor']);
select add_contact(123, 'Els números', '', '', '', null, '', '', array['gestoria']);
alter sequence product_product_id_seq restart with 123;
select add_product(123, 'Or', 'Metall de transició tou, brillant, groc, pesant, mal·leable, dúctil i que no reacciona amb la majoria de productes químics, però és sensible al clor i a laigua règia.', '55.92', array[124], array['metall']);
@ -58,17 +78,122 @@ select add_product(123, 'Cavall Fort', 'Revista quinzenal en llengua catalana i
select add_product(123, 'Palla', 'Tija seca dels cereals després que el gra o llavor ha estat separat mitjançant la trilla.', '25.00', array[125], array['necessitat']);
select add_product(123, 'Teia', 'Fusta resinosa de pi i daltres arbres, provinent sobretot del cor de larbre, que crema amb molta facilitat.', '7.00', array[124], array['obsolet']);
alter table payment_account alter column payment_account_id restart with 123;
select add_payment_account_bank(123, 'Guardiola', 'ES2820958297603648596978');
select add_payment_account_cash(123, 'Matalàs');
alter sequence invoice_invoice_id_seq restart with 123;
alter sequence invoice_product_invoice_product_id_seq restart with 123;
select add_invoice(123, (current_date - '338 days'::interval)::date, 124, '', 123, '{producte,mag}','{"(124,Encens,Goma resina fragrant que desprèn una olor característica quan es crema.,2.26,460,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.53,1,0.0,{124})"}');
select add_collection(123, 123, (current_date - '337 days'::interval)::date, 123, 'Cobrament de FRA123', '1200.50', '{}');
select add_invoice(123, (current_date - '334 days'::interval)::date, 124, '', 123, '{producte,mag}','{"(124,Encens,Goma resina fragrant que desprèn una olor característica quan es crema.,2.26,460,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.53,1,0.0,{124})"}');
select add_collection(123, 124, (current_date - '330 days'::interval)::date, 123, 'Cobrament de FRA124', '1200.50', '{}');
select add_invoice(123, (current_date - '327 days'::interval)::date, 123, '', 123, '{producte,mag}','{"(123,Or,\"Metall de transició tou, brillant, groc, pesant, mal·leable, dúctil i que no reacciona amb la majoria de productes químics, però és sensible al clor i a laigua règia.\",57.82,18,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.43,1,0.0,{124})"}');
select add_collection(123, 125, (current_date - '317 days'::interval)::date, 123, 'Cobrament de FRA125', '1200.50', '{}');
select add_invoice(123, (current_date - '317 days'::interval)::date, 128, 'Vol esmorzar!', 123, '{producte}','{"(129,Teia,\"Fusta resinosa de pi i daltres arbres, provinent sobretot del cor de larbre, que crema amb molta facilitat.\",7.00,1,0.0,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 126, (current_date - '316 days'::interval)::date, 124, 'Cobrament de FRA126', '12.87', '{}');
select add_invoice(123, (current_date - '314 days'::interval)::date, 123, '', 123, '{producte,mag}','{"(123,Or,\"Metall de transició tou, brillant, groc, pesant, mal·leable, dúctil i que no reacciona amb la majoria de productes químics, però és sensible al clor i a laigua règia.\",57.82,18,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.43,1,0.0,{124})"}');
select add_collection(123, 127, (current_date - '310 days'::interval)::date, 123, 'Cobrament de FRA127', '1200.50', '{}');
select add_invoice(123, (current_date - '311 days'::interval)::date, 128, 'Vol esmorzar!', 123, '{producte}','{"(129,Teia,\"Fusta resinosa de pi i daltres arbres, provinent sobretot del cor de larbre, que crema amb molta facilitat.\",7.00,1,0.0,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 128, (current_date - '311 days'::interval)::date, 124, 'Cobrament de FRA128', '12.87', '{}');
select add_invoice(123, (current_date - '278 days'::interval)::date, 125, '', 123, '{producte,mag}','{"(125,Mirra,Goma resinosa aromàtica de color gris groguenc i gust amargant.,7.22,144,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.45,1,0.0,{124})"}');
select add_collection(123, 129, (current_date - '270 days'::interval)::date, 123, 'Cobrament de FRA129', '1200.50', '{}');
select add_invoice(123, (current_date - '274 days'::interval)::date, 127, '', 123, '{producte,bestia}','{"(128,Palla,Tija seca dels cereals després que el gra o llavor ha estat separat mitjançant la trilla.,25.00,25,0.0,{125})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 130, (current_date - '272 days'::interval)::date, 123, 'Cobrament de FRA130', '691.90', '{}');
select add_invoice(123, (current_date - '267 days'::interval)::date, 126, '', 123, '{producte,higiene}','{"(126,\"Paper higiènic (pack de 32 U)\",Paper que susa per mantenir la higiene personal després de defecar o orinar.,7.99,10,0.0,{126})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 131, (current_date - '265 days'::interval)::date, 123, 'Cobrament de FRA131', '87.50', '{}');
select add_invoice(123, (current_date - '257 days'::interval)::date, 126, '', 123, '{producte,higiene}','{"(126,\"Paper higiènic (pack de 32 U)\",Paper que susa per mantenir la higiene personal després de defecar o orinar.,7.99,10,0.0,{126})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 132, (current_date - '250 days'::interval)::date, 124, 'Cobrament de FRA132', '87.50', '{}');
select add_invoice(123, (current_date - '254 days'::interval)::date, 127, '', 123, '{producte,bestia}','{"(128,Palla,Tija seca dels cereals després que el gra o llavor ha estat separat mitjançant la trilla.,25.00,25,0.0,{125})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 133, (current_date - '220 days'::interval)::date, 123, 'Cobrament de FRA133', '691.90', '{}');
select add_invoice(123, (current_date - '251 days'::interval)::date, 125, '', 123, '{producte,mag}','{"(125,Mirra,Goma resinosa aromàtica de color gris groguenc i gust amargant.,7.22,144,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.45,1,0.0,{124})"}');
select add_collection(123, 134, (current_date - '245 days'::interval)::date, 123, 'Cobrament de FRA134', '1200.50', '{}');
select add_invoice(123, (current_date - '208 days'::interval)::date, 127, '', 123, '{producte,bestia}','{"(128,Palla,Tija seca dels cereals després que el gra o llavor ha estat separat mitjançant la trilla.,25.00,25,0.0,{125})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 135, (current_date - '190 days'::interval)::date, 123, 'Cobrament de FRA135', '691.90', '{}');
select add_invoice(123, (current_date - '204 days'::interval)::date, 127, '', 123, '{producte,bestia}','{"(128,Palla,Tija seca dels cereals després que el gra o llavor ha estat separat mitjançant la trilla.,25.00,25,0.0,{125})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 136, (current_date - '200 days'::interval)::date, 123, 'Cobrament de FRA136', '691.90', '{}');
select add_invoice(123, (current_date - '197 days'::interval)::date, 125, '', 123, '{producte,mag}','{"(125,Mirra,Goma resinosa aromàtica de color gris groguenc i gust amargant.,7.22,144,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.45,1,0.0,{124})"}');
select add_collection(123, 137, (current_date - '190 days'::interval)::date, 123, 'Cobrament de FRA137', '1200.50', '{}');
select add_invoice(123, (current_date - '187 days'::interval)::date, 128, 'Vol esmorzar!', 123, '{producte}','{"(129,Teia,\"Fusta resinosa de pi i daltres arbres, provinent sobretot del cor de larbre, que crema amb molta facilitat.\",7.00,1,0.0,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 138, (current_date - '186 days'::interval)::date, 124, 'Cobrament de FRA138', '12.87', '{}');
select add_invoice(123, (current_date - '184 days'::interval)::date, 123, '', 123, '{producte,mag}','{"(123,Or,\"Metall de transició tou, brillant, groc, pesant, mal·leable, dúctil i que no reacciona amb la majoria de productes químics, però és sensible al clor i a laigua règia.\",57.82,18,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.43,1,0.0,{124})"}');
select add_collection(123, 139, (current_date - '181 days'::interval)::date, 123, 'Cobrament de FRA139', '1200.50', '{}');
select add_invoice(123, (current_date - '181 days'::interval)::date, 125, '', 123, '{producte,mag}','{"(125,Mirra,Goma resinosa aromàtica de color gris groguenc i gust amargant.,7.22,144,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.45,1,0.0,{124})"}');
select add_collection(123, 140, (current_date - '177 days'::interval)::date, 123, 'Cobrament de FRA140', '1200.50', '{}');
select add_invoice(123, (current_date - '148 days'::interval)::date, 126, '', 123, '{producte,higiene}','{"(126,\"Paper higiènic (pack de 32 U)\",Paper que susa per mantenir la higiene personal després de defecar o orinar.,7.99,10,0.0,{126})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 141, (current_date - '140 days'::interval)::date, 123, 'Cobrament de FRA141', '87.50', '{}');
select add_invoice(123, (current_date - '144 days'::interval)::date, 126, '', 123, '{producte,higiene}','{"(126,\"Paper higiènic (pack de 32 U)\",Paper que susa per mantenir la higiene personal després de defecar o orinar.,7.99,10,0.0,{126})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 142, (current_date - '143 days'::interval)::date, 123, 'Cobrament de FRA142', '87.50', '{}');
select add_invoice(123, (current_date - '137 days'::interval)::date, 123, '', 123, '{producte,mag}','{"(123,Or,\"Metall de transició tou, brillant, groc, pesant, mal·leable, dúctil i que no reacciona amb la majoria de productes químics, però és sensible al clor i a laigua règia.\",57.82,18,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.43,1,0.0,{124})"}');
select add_collection(123, 143, (current_date - '137 days'::interval)::date, 123, 'Cobrament de FRA143', '1200.50', '{}');
select add_invoice(123, (current_date - '127 days'::interval)::date, 124, '', 123, '{producte,mag}','{"(124,Encens,Goma resina fragrant que desprèn una olor característica quan es crema.,2.26,460,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.53,1,0.0,{124})"}');
select add_collection(123, 144, (current_date - '120 days'::interval)::date, 123, 'Cobrament de FRA144', '1200.50', '{}');
select add_invoice(123, (current_date - '124 days'::interval)::date, 124, '', 123, '{producte,mag}','{"(124,Encens,Goma resina fragrant que desprèn una olor característica quan es crema.,2.26,460,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.53,1,0.0,{124})"}');
select add_collection(123, 145, (current_date - '123 days'::interval)::date, 123, 'Cobrament de FRA145', '1200.50', '{}');
select add_invoice(123, (current_date - '121 days'::interval)::date, 128, 'Vol esmorzar!', 123, '{producte}','{"(129,Teia,\"Fusta resinosa de pi i daltres arbres, provinent sobretot del cor de larbre, que crema amb molta facilitat.\",7.00,1,0.0,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 146, (current_date - '101 days'::interval)::date, 124, 'Cobrament de FRA146', '12.87', '{}');
select add_invoice(123, (current_date - '78 days'::interval)::date, 126, '', 123, '{producte,higiene}','{"(126,\"Paper higiènic (pack de 32 U)\",Paper que susa per mantenir la higiene personal després de defecar o orinar.,7.99,10,0.0,{126})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 147, (current_date - '60 days'::interval)::date, 123, 'Cobrament de FRA147', '87.50', '{}');
select add_invoice(123, (current_date - '74 days'::interval)::date, 124, '', 123, '{producte,mag}','{"(124,Encens,Goma resina fragrant que desprèn una olor característica quan es crema.,2.26,460,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.53,1,0.0,{124})"}');
select add_collection(123, 148, (current_date - '61 days'::interval)::date, 123, 'Cobrament de FRA148', '1200.50', '{}');
select add_invoice(123, (current_date - '67 days'::interval)::date, 128, 'Vol esmorzar!', 123, '{producte}','{"(129,Teia,\"Fusta resinosa de pi i daltres arbres, provinent sobretot del cor de larbre, que crema amb molta facilitat.\",7.00,1,0.0,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 149, (current_date - '66 days'::interval)::date, 124, 'Cobrament de FRA149', '12.87', '{}');
select add_invoice(123, (current_date - '57 days'::interval)::date, 123, '', 123, '{producte,mag}','{"(123,Or,\"Metall de transició tou, brillant, groc, pesant, mal·leable, dúctil i que no reacciona amb la majoria de productes químics, però és sensible al clor i a laigua règia.\",57.82,18,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.43,1,0.0,{124})"}');
select add_collection(123, 150, (current_date - '55 days'::interval)::date, 123, 'Cobrament de FRA150', '1200.50', '{}');
select add_invoice(123, (current_date - '54 days'::interval)::date, 127, '', 123, '{producte,bestia}','{"(128,Palla,Tija seca dels cereals després que el gra o llavor ha estat separat mitjançant la trilla.,25.00,25,0.0,{125})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 151, (current_date - '50 days'::interval)::date, 124, 'Cobrament de FRA151', '691.90', '{}');
select add_invoice(123, (current_date - '51 days'::interval)::date, 125, '', 123, '{producte,mag}','{"(125,Mirra,Goma resinosa aromàtica de color gris groguenc i gust amargant.,7.22,144,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.45,1,0.0,{124})"}');
select add_collection(123, 152, (current_date - '44 days'::interval)::date, 123, 'Cobrament de FRA152', '1200.50', '{}');
select add_invoice(123, (current_date - '28 days'::interval)::date, 128, 'Vol esmorzar!', 123, '{producte}','{"(129,Teia,\"Fusta resinosa de pi i daltres arbres, provinent sobretot del cor de larbre, que crema amb molta facilitat.\",7.00,1,0.0,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 153, (current_date - '28 days'::interval)::date, 124, 'Cobrament de FRA153', '12.87', '{}');
select add_invoice(123, (current_date - '24 days'::interval)::date, 127, '', 123, '{producte,bestia}','{"(128,Palla,Tija seca dels cereals després que el gra o llavor ha estat separat mitjançant la trilla.,25.00,25,0.0,{125})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_invoice(123, (current_date - '17 days'::interval)::date, 126, '', 123, '{producte,higiene}','{"(126,\"Paper higiènic (pack de 32 U)\",Paper que susa per mantenir la higiene personal després de defecar o orinar.,7.99,10,0.0,{126})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_invoice(123, (current_date - '7 days'::interval)::date, 125, '', 123, '{producte,mag}','{"(125,Mirra,Goma resinosa aromàtica de color gris groguenc i gust amargant.,7.22,144,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.45,1,0.0,{124})"}');
select add_invoice(123, (current_date - '4 days'::interval)::date, 124, '', 123, '{producte,mag}','{"(124,Encens,Goma resina fragrant que desprèn una olor característica quan es crema.,2.26,460,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.53,1,0.0,{124})"}');
select add_collection(123, 157, (current_date - '2 days'::interval)::date, 123, 'Primer cobrament de FRA157', '1000.00', '{}');
select add_invoice(123, (current_date - '1 days'::interval)::date, 123, '', 123, '{producte,mag}','{"(123,Or,\"Metall de transició tou, brillant, groc, pesant, mal·leable, dúctil i que no reacciona amb la majoria de productes químics, però és sensible al clor i a laigua règia.\",57.82,18,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.43,1,0.0,{124})"}');
update invoice set invoice_status = 'paid' where invoice_id in (123, 127);
update invoice set invoice_status = 'unpaid' where invoice_id = 125;
update invoice set invoice_status = 'sent' where invoice_id = 126;
alter sequence expense_expense_id_seq restart with 123;
select add_expense(123, (date_trunc('month', current_date) - '11 months + 14 day'::interval)::date, 130, 'ABC123', '256.12', '{124}', '{}');
select add_payment(123, 123, (date_trunc('month', current_date) - '11 months + 04 day'::interval)::date, 123, 'Pagament dABC123', '256.12', '{}');
select add_expense(123, (date_trunc('month', current_date) - '11 months + 8 day'::interval)::date, 131, '123ABC', '1023.17', '{124}', '{}');
select add_payment(123, 124, (date_trunc('month', current_date) - '11 months'::interval)::date, 123, 'Pagament d123ABC', '1023.17', '{}');
select add_expense(123, (date_trunc('month', current_date) - '11 months + 1 day'::interval)::date, 129, 'N CMDPGGNZG', '299.17', '{}', array['autonoms']);
select add_payment(123, 125, (date_trunc('month', current_date) - '10 months + 15 day'::interval)::date, 123, 'Pagament dN CMDPGGNZG', '299.17', '{}');
select add_expense(123, (date_trunc('month', current_date) - '10 months + 20 day'::interval)::date, 131, '123XYZ', '23.17', '{124}', '{}');
select add_payment(123, 126, (date_trunc('month', current_date) - '10 months + 15 day'::interval)::date, 124, 'Pagament d123XYZ', '23.17', '{}');
select add_expense(123, (date_trunc('month', current_date) - '10 months + 1 day'::interval)::date, 129, 'N QHVLDAN29', '299.17', '{}', array['autonoms']);
select add_payment(123, 127, (date_trunc('month', current_date) - '10 months'::interval)::date, 123, 'Pagament dN QHVLDAN29', '299.17', '{}');
select add_expense(123, (date_trunc('month', current_date) - '9 months + 2 day'::interval)::date, 130, 'XYZ123', '62.21', '{124}', '{}');
select add_payment(123, 128, (date_trunc('month', current_date) - '8 months + 28 day'::interval)::date, 123, 'Pagament dXYZ123', '62.21', '{}');
select add_expense(123, (date_trunc('month', current_date) - '9 months + 1 day'::interval)::date, 129, 'N WXMHH1R5Q', '299.17', '{}', array['autonoms']);
select add_payment(123, 129, (date_trunc('month', current_date) - '8 months + 28 day'::interval)::date, 123, 'Pagament dN WXMHH1R5Q', '299.17', '{}');
select add_expense(123, (current_date - '9 months'::interval)::date, 132, '00/0001', '117.74', '{124}', array['gestor']);
select add_payment(123, 130, (date_trunc('month', current_date) - '8 months + 15 day'::interval)::date, 124, 'Pagament de 00/0001', '117.74', '{}');
select add_expense(123, (date_trunc('month', current_date) - '8 months + 1 day'::interval)::date, 129, 'N NRP28PWY8', '299.17', '{}', array['autonoms']);
select add_payment(123, 131, (date_trunc('month', current_date) - '8 months'::interval)::date, 123, 'Pagament dN NRP28PWY8', '299.17', '{}');
select add_expense(123, (date_trunc('month', current_date) - '7 months + 1 day'::interval)::date, 129, 'N D256225DF', '299.17', '{}', array['autonoms']);
select add_payment(123, 132, (date_trunc('month', current_date) - '6 months + 15 day'::interval)::date, 123, 'Pagament dN D256225DF', '299.17', '{}');
select add_expense(123, (date_trunc('month', current_date) - '6 months + 15 day'::interval)::date, 130, 'ZZZ888', '162.21', '{124}', '{}');
select add_payment(123, 133, (date_trunc('month', current_date) - '6 months + 15 day'::interval)::date, 124, 'Pagament de ZZZ888', '80.00', '{}');
select add_expense(123, (date_trunc('month', current_date) - '6 months + 1 day'::interval)::date, 129, 'N K90XS7C3Q', '299.17', '{}', array['autonoms']);
select add_payment(123, 134, (date_trunc('month', current_date) - '5 months + 25 day'::interval)::date, 123, 'Pagament dN K90XS7C3Q', '299.17', '{}');
select add_expense(123, (current_date - '6 months'::interval)::date, 132, '00/0054', '117.74', '{124}', array['gestor']);
select add_payment(123, 135, (date_trunc('month', current_date) - '5 months + 29 day'::interval)::date, 123, 'Pagament dN ', '299.17', '{}');
select add_expense(123, (date_trunc('month', current_date) - '5 months + 1 day'::interval)::date, 129, 'N MCPDGGZNG', '299.17', '{}', array['autonoms']);
select add_payment(123, 136, (date_trunc('month', current_date) - '4 months + 29 day'::interval)::date, 123, 'Pagament dN MCPDGGZNG', '299.17', '{}');
select add_expense(123, (date_trunc('month', current_date) - '4 months + 1 day'::interval)::date, 129, 'N HQLVAD2N9', '299.17', '{}', array['autonoms']);
select add_payment(123, 137, (date_trunc('month', current_date) - '4 months + 1 day'::interval)::date, 123, 'Pagament dN HQLVAD2N9', '299.17', '{}');
select add_expense(123, (date_trunc('month', current_date) - '3 months + 1 day'::interval)::date, 129, 'N QXWHM1H5R', '299.17', '{}', array['autonoms']);
select add_payment(123, 138, (date_trunc('month', current_date) - '2 months + 15 day'::interval)::date, 123, 'Pagament dN QXWHM1H5R', '299.17', '{}');
select add_expense(123, (current_date - '3 months'::interval)::date, 132, '00/0331', '117.74', '{124}', array['gestor']);
select add_expense(123, (date_trunc('month', current_date) - '2 months + 1 day'::interval)::date, 129, 'N 8RN2PP8YW', '299.17', '{}', array['autonoms']);
select add_payment(123, 140, (date_trunc('month', current_date) - '1 months'::interval)::date, 123, 'Pagament dN 8RN2PP8YW', '299.17', '{}');
select add_expense(123, (date_trunc('month', current_date) - '1 months + 1 day'::interval)::date, 129, 'N F2D6522D5', '299.17', '{}', array['autonoms']);
select add_payment(123, 141, (date_trunc('month', current_date) - '1 months + 1 day'::interval)::date, 123, 'Pagament dN F2D6522D5', '299.17', '{}');
select add_expense(123, (date_trunc('month', current_date) - '1 day'::interval)::date, 129, 'N F2D6522D5', '299.17', '{}', array['autonoms']);
select add_payment(123, 142, (date_trunc('month', current_date) - '18 day'::interval)::date, 123, 'Pagament dN F2D6522D5', '299.17', '{}');
select add_expense(123, (current_date - '22 day'::interval)::date, 131, '321ABC', '1023.17', '{124}', '{}');
select add_expense(123, (current_date - '11 day'::interval)::date, 130, 'ABC321', '256.12', '{124}', '{}');
commit;

68
deploy/add_collection.sql Normal file
View File

@ -0,0 +1,68 @@
-- Deploy numerus:add_collection to pg
-- requires: roles
-- requires: schema_numerus
-- requires: collection
-- requires: invoice_collection
-- requires: company
-- requires: currency
-- requires: parse_price
-- requires: tag_name
-- requires: update_invoice_collection_status
begin;
set search_path to numerus, public;
create or replace function add_collection(company integer, invoice_id integer, collection_date date, payment_account_id integer, description text, amount text, tags tag_name[]) returns uuid as
$$
declare
cid integer;
cslug uuid;
amount_cents integer;
begin
insert into collection
( company_id
, payment_account_id
, description
, collection_date
, amount
, currency_code
, payment_status
, tags
)
select company_id
, payment_account_id
, description
, collection_date
, parse_price(amount, currency.decimal_digits)
, currency_code
, 'complete'
, tags
from company
join currency using (currency_code)
where company.company_id = add_collection.company
returning collection_id, slug, collection.amount
into cid, cslug, amount_cents
;
if invoice_id is not null then
-- must be inserted before updating statuses, so that it can see this
-- collections amount too.
insert into invoice_collection (invoice_id, collection_id)
values (invoice_id, cid)
;
perform update_invoice_collection_status(cid, invoice_id, amount_cents);
end if;
return cslug;
end
$$
language plpgsql
;
revoke execute on function add_collection(integer, integer, date, integer, text, text, tag_name[]) from public;
grant execute on function add_collection(integer, integer, date, integer, text, text, tag_name[]) to invoicer;
grant execute on function add_collection(integer, integer, date, integer, text, text, tag_name[]) to admin;
commit;

View File

@ -8,11 +8,15 @@
-- requires: parse_price
-- requires: tax
-- requires: tag_name
-- requires: expense_status
-- requires: expense_expense_status
begin;
set search_path to numerus, public;
drop function if exists add_expense(integer, text, date, integer, text, text, integer[], tag_name[]);
create or replace function add_expense(company integer, invoice_date date, contact_id integer, invoice_number text, amount text, taxes integer[], tags tag_name[]) returns uuid as
$$
declare

50
deploy/add_expense@v1.sql Normal file
View File

@ -0,0 +1,50 @@
-- Deploy numerus:add_expense to pg
-- requires: schema_numerus
-- requires: expense
-- requires: expense_tax
-- requires: tax
-- requires: company
-- requires: currency
-- requires: parse_price
-- requires: tax
-- requires: tag_name
begin;
set search_path to numerus, public;
create or replace function add_expense(company integer, invoice_date date, contact_id integer, invoice_number text, amount text, taxes integer[], tags tag_name[]) returns uuid as
$$
declare
eid integer;
eslug uuid;
begin
insert into expense (company_id, contact_id, invoice_number, invoice_date, amount, currency_code, tags)
select company_id
, contact_id
, invoice_number
, invoice_date
, parse_price(amount, currency.decimal_digits)
, currency_code
, tags
from company
join currency using (currency_code)
where company.company_id = add_expense.company
returning expense_id, slug
into eid, eslug;
insert into expense_tax (expense_id, tax_id, tax_rate)
select eid, tax_id, tax.rate
from tax
join unnest(taxes) as etax(tax_id) using (tax_id);
return eslug;
end;
$$
language plpgsql;
revoke execute on function add_expense(integer, date, integer, text, text, integer[], tag_name[]) from public;
grant execute on function add_expense(integer, date, integer, text, text, integer[], tag_name[]) to invoicer;
grant execute on function add_expense(integer, date, integer, text, text, integer[], tag_name[]) to admin;
commit;

55
deploy/add_expense@v2.sql Normal file
View File

@ -0,0 +1,55 @@
-- Deploy numerus:add_expense to pg
-- requires: schema_numerus
-- requires: expense
-- requires: expense_tax
-- requires: tax
-- requires: company
-- requires: currency
-- requires: parse_price
-- requires: tax
-- requires: tag_name
-- requires: expense_status
-- requires: expense_expense_status
begin;
set search_path to numerus, public;
create or replace function add_expense(company integer, status text, invoice_date date, contact_id integer, invoice_number text, amount text, taxes integer[], tags tag_name[]) returns uuid as
$$
declare
eid integer;
eslug uuid;
begin
insert into expense (company_id, contact_id, invoice_number, invoice_date, amount, currency_code, expense_status, tags)
select company_id
, contact_id
, invoice_number
, invoice_date
, parse_price(amount, currency.decimal_digits)
, currency_code
, status
, tags
from company
join currency using (currency_code)
where company.company_id = add_expense.company
returning expense_id, slug
into eid, eslug;
insert into expense_tax (expense_id, tax_id, tax_rate)
select eid, tax_id, tax.rate
from tax
join unnest(taxes) as etax(tax_id) using (tax_id);
return eslug;
end;
$$
language plpgsql;
revoke execute on function add_expense(integer, text, date, integer, text, text, integer[], tag_name[]) from public;
grant execute on function add_expense(integer, text, date, integer, text, text, integer[], tag_name[]) to invoicer;
grant execute on function add_expense(integer, text, date, integer, text, text, integer[], tag_name[]) to admin;
drop function if exists add_expense(integer, date, integer, text, text, integer[], tag_name[]);
commit;

67
deploy/add_payment.sql Normal file
View File

@ -0,0 +1,67 @@
-- Deploy numerus:add_payment to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment
-- requires: expense_payment
-- requires: company
-- requires: currency
-- requires: parse_price
-- requires: tag_name
-- requires: update_expense_payment_status
begin;
set search_path to numerus, public;
create or replace function add_payment(company integer, expense_id integer, payment_date date, payment_account_id integer, description text, amount text, tags tag_name[]) returns uuid as
$$
declare
pslug uuid;
pid integer;
amount_cents integer;
begin
insert into payment
( company_id
, payment_account_id
, description
, payment_date
, amount
, currency_code
, payment_status
, tags
)
select company_id
, payment_account_id
, description
, payment_date
, parse_price(amount, currency.decimal_digits)
, currency_code
, 'complete'
, tags
from company
join currency using (currency_code)
where company.company_id = add_payment.company
returning payment_id, slug, payment.amount
into pid, pslug, amount_cents
;
if expense_id is not null then
-- must be inserted before updating statuses, so that it can see this
-- payments amount too.
insert into expense_payment (expense_id, payment_id)
values (expense_id, pid);
perform update_expense_payment_status(pid, expense_id, amount_cents);
end if;
return pslug;
end
$$
language plpgsql
;
revoke execute on function add_payment(integer, integer, date, integer, text, text, tag_name[]) from public;
grant execute on function add_payment(integer, integer, date, integer, text, text, tag_name[]) to invoicer;
grant execute on function add_payment(integer, integer, date, integer, text, text, tag_name[]) to admin;
commit;

View File

@ -0,0 +1,35 @@
-- Deploy numerus:add_payment_account_bank to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment_account
-- requires: payment_account_bank
begin;
set search_path to numerus, public;
create or replace function add_payment_account_bank(company integer, name text, iban iban) returns uuid as
$$
declare
account_id integer;
account_slug uuid;
begin
insert into payment_account (company_id, payment_account_type, name)
select company, 'bank', add_payment_account_bank.name
returning payment_account_id, slug into account_id, account_slug;
insert into payment_account_bank (payment_account_id, iban)
values (account_id, iban)
;
return account_slug;
end;
$$
language plpgsql
;
revoke execute on function add_payment_account_bank(integer, text, iban) from public;
grant execute on function add_payment_account_bank(integer, text, iban) to invoicer;
grant execute on function add_payment_account_bank(integer, text, iban) to admin;
commit;

View File

@ -0,0 +1,34 @@
-- Deploy numerus:add_payment_account_card to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment_account
-- requires: payment_account_card
begin;
set search_path to numerus, public;
create or replace function add_payment_account_card(company integer, name text, four_digits text, exp_date date) returns uuid as
$$
declare
account_id integer;
account_slug uuid;
begin
insert into payment_account (company_id, payment_account_type, name)
select company, 'card', add_payment_account_card.name
returning payment_account_id, slug into account_id, account_slug;
insert into payment_account_card (payment_account_id, last_four_digits, expiration_date)
values (account_id, four_digits, exp_date);
return account_slug;
end
$$
language plpgsql
;
revoke execute on function add_payment_account_card(integer, text, text, date) from public;
grant execute on function add_payment_account_card(integer, text, text, date) to invoicer;
grant execute on function add_payment_account_card(integer, text, text, date) to admin;
commit;

View File

@ -0,0 +1,23 @@
-- Deploy numerus:add_payment_account_cash to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment_account
begin;
set search_path to numerus, public;
create or replace function add_payment_account_cash(company integer, name text) returns uuid as
$$
insert into payment_account (company_id, payment_account_type, name)
values (company, 'cash', name)
returning slug;
$$
language sql
;
revoke execute on function add_payment_account_cash(integer, text) from public;
grant execute on function add_payment_account_cash(integer, text) to invoicer;
grant execute on function add_payment_account_cash(integer, text) to admin;
commit;

View File

@ -0,0 +1,23 @@
-- Deploy numerus:add_payment_account_other to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment_account
begin;
set search_path to numerus, public;
create or replace function add_payment_account_other(company integer, name text) returns uuid as
$$
insert into payment_account (company_id, payment_account_type, name)
values (company, 'other', name)
returning slug;
$$
language sql
;
revoke execute on function add_payment_account_other(integer, text) from public;
grant execute on function add_payment_account_other(integer, text) to invoicer;
grant execute on function add_payment_account_other(integer, text) to admin;
commit;

View File

@ -0,0 +1,30 @@
-- Deploy numerus:attach_to_collection to pg
-- requires: roles
-- requires: schema_numerus
-- requires: collection
-- requires: collection_attachment
begin;
set search_path to numerus, public;
create or replace function attach_to_collection(collection_slug uuid, original_filename text, mime_type text, content bytea) returns void as
$$
insert into collection_attachment (collection_id, original_filename, mime_type, content)
select collection_id, original_filename, mime_type, content
from collection
where slug = collection_slug
on conflict (collection_id) do update
set original_filename = excluded.original_filename
, mime_type = excluded.mime_type
, content = excluded.content
;
$$
language sql
;
revoke execute on function attach_to_collection(uuid, text, text, bytea) from public;
grant execute on function attach_to_collection(uuid, text, text, bytea) to invoicer;
grant execute on function attach_to_collection(uuid, text, text, bytea) to admin;
commit;

View File

@ -0,0 +1,30 @@
-- Deploy numerus:attach_to_invoice to pg
-- requires: schema_numerus
-- requires: roles
-- requires: invoice
-- requires: invoice_attachment
begin;
set search_path to numerus, public;
create or replace function attach_to_invoice(invoice_slug uuid, original_filename text, mime_type text, content bytea) returns void as
$$
insert into invoice_attachment (invoice_id, original_filename, mime_type, content)
select invoice_id, original_filename, mime_type, content
from invoice
where slug = invoice_slug
on conflict (invoice_id) do update
set original_filename = excluded.original_filename
, mime_type = excluded.mime_type
, content = excluded.content
;
$$
language sql
;
revoke execute on function attach_to_invoice(uuid, text, text, bytea) from public;
grant execute on function attach_to_invoice(uuid, text, text, bytea) to invoicer;
grant execute on function attach_to_invoice(uuid, text, text, bytea) to admin;
commit;

View File

@ -0,0 +1,30 @@
-- Deploy numerus:attach_to_payment to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment
-- requires: payment_attachment
begin;
set search_path to numerus, public;
create or replace function attach_to_payment(payment_slug uuid, original_filename text, mime_type text, content bytea) returns void as
$$
insert into payment_attachment (payment_id, original_filename, mime_type, content)
select payment_id, original_filename, mime_type, content
from payment
where slug = payment_slug
on conflict (payment_id) do update
set original_filename = excluded.original_filename
, mime_type = excluded.mime_type
, content = excluded.content
;
$$
language sql
;
revoke execute on function attach_to_payment(uuid, text, text, bytea) from public;
grant execute on function attach_to_payment(uuid, text, text, bytea) to invoicer;
grant execute on function attach_to_payment(uuid, text, text, bytea) to admin;
commit;

View File

@ -0,0 +1,27 @@
-- Deploy numerus:available_expense_status to pg
-- requires: schema_numerus
-- requires: expense_status
-- requires: expense_status_i18n
begin;
set search_path to numerus;
insert into expense_status (expense_status, name)
values ('pending', 'Pending')
, ('partial', 'Partial')
, ('paid', 'Paid')
on conflict (expense_status) do nothing
;
insert into expense_status_i18n (expense_status, lang_tag, name)
values ('pending', 'ca', 'Pendent')
, ('partial', 'ca', 'Parcial')
, ('paid', 'ca', 'Pagada')
, ('pending', 'es', 'Pendiente')
, ('partial', 'es', 'Parcial')
, ('paid', 'es', 'Pagada')
on conflict (expense_status, lang_tag) do nothing
;
commit;

View File

@ -0,0 +1,22 @@
-- Deploy numerus:available_expense_status to pg
-- requires: schema_numerus
-- requires: expense_status
-- requires: expense_status_i18n
begin;
set search_path to numerus;
insert into expense_status (expense_status, name)
values ('pending', 'Pending')
, ('paid', 'Paid')
;
insert into expense_status_i18n (expense_status, lang_tag, name)
values ('pending', 'ca', 'Pendent')
, ('paid', 'ca', 'Pagada')
, ('pending', 'es', 'Pendiente')
, ('paid', 'es', 'Pagada')
;
commit;

View File

@ -10,19 +10,28 @@ set search_path to numerus;
insert into invoice_status (invoice_status, name)
values ('created', 'Created')
, ('sent', 'Sent')
, ('partial', 'Partial')
, ('paid', 'Paid')
, ('unpaid', 'Unpaid')
on conflict (invoice_status) do nothing
;
insert into invoice_status_i18n (invoice_status, lang_tag, name)
values ('created', 'ca', 'Creada')
, ('sent', 'ca', 'Enviada')
, ('partial', 'ca', 'Parcial')
, ('paid', 'ca', 'Cobrada')
, ('unpaid', 'ca', 'No cobrada')
, ('created', 'es', 'Creada')
, ('sent', 'es', 'Enviada')
, ('partial', 'es', 'Parcial')
, ('paid', 'es', 'Cobrada')
, ('unpaid', 'es', 'No cobrada')
on conflict (invoice_status, lang_tag) do nothing
;
update invoice set invoice_status = 'created' where invoice_status in ('sent', 'unpaid');
delete from invoice_status_i18n where invoice_status in ('sent', 'unpaid');
delete from invoice_status where invoice_status in ('sent', 'unpaid');
commit;

View File

@ -0,0 +1,28 @@
-- Deploy numerus:available_invoice_status to pg
-- requires: schema_numerus
-- requires: invoice_status
-- requires: invoice_status_i18n
begin;
set search_path to numerus;
insert into invoice_status (invoice_status, name)
values ('created', 'Created')
, ('sent', 'Sent')
, ('paid', 'Paid')
, ('unpaid', 'Unpaid')
;
insert into invoice_status_i18n (invoice_status, lang_tag, name)
values ('created', 'ca', 'Creada')
, ('sent', 'ca', 'Enviada')
, ('paid', 'ca', 'Cobrada')
, ('unpaid', 'ca', 'No cobrada')
, ('created', 'es', 'Creada')
, ('sent', 'es', 'Enviada')
, ('paid', 'es', 'Cobrada')
, ('unpaid', 'es', 'No cobrada')
;
commit;

View File

@ -0,0 +1,28 @@
-- Deploy numerus:available_payment_account_types to pg
-- requires: schema_numerus
-- requires: payment_account_type
-- requires: payment_account_type_i18n
begin;
set search_path to numerus;
insert into payment_account_type (payment_account_type, name)
values ('bank', 'Bank')
, ('card', 'Credit Card')
, ('cash', 'Cash')
, ('other', 'Other')
;
insert into payment_account_type_i18n (payment_account_type, lang_tag, name)
values ('bank', 'ca', 'Banc')
, ('card', 'ca', 'Targeta de crèdit')
, ('cash', 'ca', 'Efectiu')
, ('other', 'ca', 'Altres')
, ('bank', 'es', 'Banco')
, ('card', 'es', 'Tarjeta de crédito')
, ('cash', 'es', 'Efectivo')
, ('other', 'es', 'Otros')
;
commit;

View File

@ -0,0 +1,22 @@
-- Deploy numerus:available_payment_status to pg
-- requires: schema_numerus
-- requires: payment_status
-- requires: payment_status_i18n
begin;
set search_path to numerus, public;
insert into payment_status (payment_status, name)
values ('partial', 'Partial')
, ('complete', 'Complete')
;
insert into payment_status_i18n (payment_status, lang_tag, name)
values ('partial', 'ca', 'Parcial')
, ('partial', 'es', 'Parcial')
, ('complete', 'ca', 'Complet')
, ('complete', 'es', 'Completo')
;
commit;

45
deploy/collection.sql Normal file
View File

@ -0,0 +1,45 @@
-- Deploy numerus:collection to pg
-- requires: roles
-- requires: schema_numerus
-- requires: company
-- requires: payment_account
-- requires: currency
-- requires: tag_name
-- requires: payment_status
-- requires: extension_pgcrypto
begin;
set search_path to numerus, public;
create table collection (
collection_id integer generated by default as identity primary key,
company_id integer not null references company,
slug uuid not null unique default gen_random_uuid(),
description text not null,
collection_date date not null default current_date,
payment_account_id integer not null references payment_account,
amount integer not null constraint collection_amount_positive check (amount > 0),
currency_code text not null references currency,
tags tag_name[] not null default '{}',
payment_status text not null default 'complete' references payment_status,
created_at timestamptz not null default current_timestamp
);
grant select, insert, update, delete on table collection to invoicer;
grant select, insert, update, delete on table collection to admin;
alter table collection enable row level security;
create policy company_policy
on collection
using (
exists(
select 1
from company_user
join user_profile using (user_id)
where company_user.company_id = collection.company_id
)
);
commit;

View File

@ -0,0 +1,32 @@
-- Deploy numerus:collection_attachment to pg
-- requires: roles
-- requires: schema_numerus
-- requires: collection
begin;
set search_path to numerus, public;
create table collection_attachment (
collection_id integer primary key references collection,
original_filename text not null,
mime_type text not null,
content bytea not null
);
grant select, insert, update, delete on table collection_attachment to invoicer;
grant select, insert, update, delete on table collection_attachment to admin;
alter table collection_attachment enable row level security;
create policy company_policy
on collection_attachment
using (
exists(
select 1
from collection
where collection.collection_id = collection_attachment.collection_id
)
);
commit;

View File

@ -0,0 +1,68 @@
-- Deploy numerus:compute_new_expense_amount to pg
-- requires: schema_numerus
-- requires: roles
-- requires: company
-- requires: tax
-- requires: new_expense_amount
begin;
set search_path to numerus, public;
create or replace function compute_new_expense_amount(company_id integer, subtotal text, taxes integer[]) returns new_expense_amount as
$$
declare
result new_expense_amount;
begin
if trim(subtotal) = '' then
subtotal = '0';
end if;
if array_length(taxes, 1) > 0 then
with line as (
select round(parse_price(subtotal, currency.decimal_digits)) as price
, tax_id
, decimal_digits
from unnest (taxes) as tax(tax_id)
join company on company.company_id = compute_new_expense_amount.company_id
join currency using (currency_code)
)
, tax_amount as (
select tax_id
, sum(round(price * tax.rate)::integer)::integer as amount
, decimal_digits
from line
join tax using (tax_id)
group by tax_id, decimal_digits
)
, tax_total as (
select sum(amount)::integer as amount
, array_agg(array[name, to_price(amount, decimal_digits)]) as taxes
from tax_amount
join tax using (tax_id)
)
select coalesce(tax_total.taxes, array[]::text[][])
, to_price(price::integer + coalesce(tax_total.amount, 0), decimal_digits) as total
from line, tax_total
into result.taxes, result.total;
else
select array[]::text[][]
, to_price(coalesce(parse_price(subtotal, decimal_digits), 0), decimal_digits)
from company
join currency using (currency_code)
where company.company_id = compute_new_expense_amount.company_id
into result.taxes, result.total
;
end if;
return result;
end;
$$
language plpgsql
stable
;
revoke execute on function compute_new_expense_amount(integer, text, integer[]) from public;
grant execute on function compute_new_expense_amount(integer, text, integer[]) to invoicer;
grant execute on function compute_new_expense_amount(integer, text, integer[]) to admin;
commit;

View File

@ -0,0 +1,53 @@
-- Deploy numerus:edit_collection to pg
-- requires: roles
-- requires: schema_numerus
-- requires: collection
-- requires: invoice_collection
-- requires: currency
-- requires: parse_price
-- requires: tag_name
-- requires: update_invoice_collection_status
begin;
set search_path to numerus, public;
create or replace function edit_collection(collection_slug uuid, collection_date date, payment_account_id integer, description text, amount text, tags tag_name[]) returns uuid as
$$
declare
cid integer;
iid integer;
amount_cents integer;
begin
update collection
set collection_date = edit_collection.collection_date
, payment_account_id = edit_collection.payment_account_id
, description = edit_collection.description
, amount = parse_price(edit_collection.amount, decimal_digits)
, tags = edit_collection.tags
from currency
where slug = collection_slug
and currency.currency_code = collection.currency_code
returning collection_id, collection.amount
into cid, amount_cents
;
select invoice_id into iid
from invoice_collection
where collection_id = cid;
if iid is not null then
perform update_invoice_collection_status(cid, iid, amount_cents);
end if;
return collection_slug;
end
$$
language plpgsql
;
revoke execute on function edit_collection(uuid, date, integer, text, text, tag_name[]) from public;
grant execute on function edit_collection(uuid, date, integer, text, text, tag_name[]) to invoicer;
grant execute on function edit_collection(uuid, date, integer, text, text, tag_name[]) to admin;
commit;

View File

@ -5,11 +5,15 @@
-- requires: parse_price
-- requires: tax
-- requires: tag_name
-- requires: expense_status
-- requires: expense_expense_status
begin;
set search_path to numerus, public;
drop function if exists edit_expense(uuid, text, date, integer, text, text, integer[], tag_name[]);
create or replace function edit_expense(expense_slug uuid, invoice_date date, contact_id integer, invoice_number text, amount text, taxes integer[], tags tag_name[]) returns uuid as
$$
declare

View File

@ -0,0 +1,50 @@
-- Deploy numerus:edit_expense to pg
-- requires: schema_numerus
-- requires: expense
-- requires: currency
-- requires: parse_price
-- requires: tax
-- requires: tag_name
begin;
set search_path to numerus, public;
create or replace function edit_expense(expense_slug uuid, invoice_date date, contact_id integer, invoice_number text, amount text, taxes integer[], tags tag_name[]) returns uuid as
$$
declare
eid integer;
begin
update expense
set invoice_date = edit_expense.invoice_date
, contact_id = edit_expense.contact_id
, invoice_number = edit_expense.invoice_number
, amount = parse_price(edit_expense.amount, decimal_digits)
, tags = edit_expense.tags
from currency
where slug = expense_slug
and currency.currency_code = expense.currency_code
returning expense_id
into eid;
if eid is null then
return null;
end if;
delete from expense_tax where expense_id = eid;
insert into expense_tax (expense_id, tax_id, tax_rate)
select eid, tax_id, tax.rate
from tax
join unnest(taxes) as etax(tax_id) using (tax_id);
return expense_slug;
end;
$$
language plpgsql;
revoke execute on function edit_expense(uuid, date, integer, text, text, integer[], tag_name[]) from public;
grant execute on function edit_expense(uuid, date, integer, text, text, integer[], tag_name[]) to invoicer;
grant execute on function edit_expense(uuid, date, integer, text, text, integer[], tag_name[]) to admin;
commit;

View File

@ -0,0 +1,53 @@
-- Deploy numerus:edit_expense to pg
-- requires: schema_numerus
-- requires: expense
-- requires: currency
-- requires: parse_price
-- requires: tax
-- requires: tag_name
-- requires: expense_status
-- requires: expense_expense_status
begin;
set search_path to numerus, public;
create or replace function edit_expense(expense_slug uuid, status text, invoice_date date, contact_id integer, invoice_number text, amount text, taxes integer[], tags tag_name[]) returns uuid as
$$
declare
eid integer;
begin
update expense
set invoice_date = edit_expense.invoice_date
, contact_id = edit_expense.contact_id
, invoice_number = edit_expense.invoice_number
, amount = parse_price(edit_expense.amount, decimal_digits)
, expense_status = status
, tags = edit_expense.tags
from currency
where slug = expense_slug
and currency.currency_code = expense.currency_code
returning expense_id
into eid;
if eid is null then
return null;
end if;
delete from expense_tax where expense_id = eid;
insert into expense_tax (expense_id, tax_id, tax_rate)
select eid, tax_id, tax.rate
from tax
join unnest(taxes) as etax(tax_id) using (tax_id);
return expense_slug;
end;
$$
language plpgsql;
revoke execute on function edit_expense(uuid, text, date, integer, text, text, integer[], tag_name[]) from public;
grant execute on function edit_expense(uuid, text, date, integer, text, text, integer[], tag_name[]) to invoicer;
grant execute on function edit_expense(uuid, text, date, integer, text, text, integer[], tag_name[]) to admin;
commit;

View File

@ -14,7 +14,9 @@ begin;
set search_path to numerus, public;
create or replace function edit_invoice(invoice_slug uuid, invoice_status text, contact_id integer, notes text, payment_method_id integer, tags tag_name[], products edited_invoice_product[]) returns uuid as
drop function if exists edit_invoice(uuid, text, integer, text, integer, tag_name[], edited_invoice_product[]);
create or replace function edit_invoice(invoice_slug uuid, contact_id integer, notes text, payment_method_id integer, tags tag_name[], products edited_invoice_product[]) returns uuid as
$$
declare
iid integer;
@ -27,7 +29,6 @@ declare
begin
update invoice
set contact_id = edit_invoice.contact_id
, invoice_status = edit_invoice.invoice_status
, notes = edit_invoice.notes
, payment_method_id = edit_invoice.payment_method_id
, tags = edit_invoice.tags
@ -103,9 +104,9 @@ end;
$$
language plpgsql;
revoke execute on function edit_invoice(uuid, text, integer, text, integer, tag_name[], edited_invoice_product[]) from public;
grant execute on function edit_invoice(uuid, text, integer, text, integer, tag_name[], edited_invoice_product[]) to invoicer;
grant execute on function edit_invoice(uuid, text, integer, text, integer, tag_name[], edited_invoice_product[]) to admin;
revoke execute on function edit_invoice(uuid, integer, text, integer, tag_name[], edited_invoice_product[]) from public;
grant execute on function edit_invoice(uuid, integer, text, integer, tag_name[], edited_invoice_product[]) to invoicer;
grant execute on function edit_invoice(uuid, integer, text, integer, tag_name[], edited_invoice_product[]) to admin;
commit;

111
deploy/edit_invoice@v2.sql Normal file
View File

@ -0,0 +1,111 @@
-- Deploy numerus:edit_invoice to pg
-- requires: schema_numerus
-- requires: invoice
-- requires: currency
-- requires: parse_price
-- requires: edited_invoice_product
-- requires: tax
-- requires: invoice_product
-- requires: invoice_product_product
-- requires: invoice_product_tax
-- requires: tag_name
begin;
set search_path to numerus, public;
create or replace function edit_invoice(invoice_slug uuid, invoice_status text, contact_id integer, notes text, payment_method_id integer, tags tag_name[], products edited_invoice_product[]) returns uuid as
$$
declare
iid integer;
products_to_keep integer[];
products_to_delete integer[];
company integer;
ccode text;
product edited_invoice_product;
ipid integer;
begin
update invoice
set contact_id = edit_invoice.contact_id
, invoice_status = edit_invoice.invoice_status
, notes = edit_invoice.notes
, payment_method_id = edit_invoice.payment_method_id
, tags = edit_invoice.tags
where slug = invoice_slug
returning invoice_id, company_id, currency_code
into iid, company, ccode
;
if iid is null then
return null;
end if;
foreach product in array products
loop
if product.invoice_product_id is null then
insert into invoice_product (invoice_id, name, description, price, quantity, discount_rate)
select iid
, product.name
, coalesce(product.description, '')
, parse_price(product.price, currency.decimal_digits)
, product.quantity
, product.discount_rate
from currency
where currency_code = ccode
returning invoice_product_id
into ipid;
else
ipid := product.invoice_product_id;
update invoice_product
set name = product.name
, description = coalesce(product.description, '')
, price = parse_price(product.price, currency.decimal_digits)
, quantity = product.quantity
, discount_rate = product.discount_rate
from currency
where invoice_product_id = ipid
and currency_code = ccode;
end if;
products_to_keep := array_append(products_to_keep, ipid);
if product.product_id is null then
delete from invoice_product_product where invoice_product_id = ipid;
else
insert into invoice_product_product (invoice_product_id, product_id)
values (ipid, product.product_id)
on conflict (invoice_product_id) do update
set product_id = product.product_id;
end if;
delete from invoice_product_tax where invoice_product_id = ipid;
insert into invoice_product_tax (invoice_product_id, tax_id, tax_rate)
select ipid, tax_id, tax.rate
from tax
join unnest(product.tax) as ptax(tax_id) using (tax_id);
end loop;
select array_agg(invoice_product_id)
into products_to_delete
from invoice_product
where invoice_id = iid
and not (invoice_product_id = any(products_to_keep));
if array_length(products_to_delete, 1) > 0 then
delete from invoice_product_tax where invoice_product_id = any(products_to_delete);
delete from invoice_product_product where invoice_product_id = any(products_to_delete);
delete from invoice_product where invoice_product_id = any(products_to_delete);
end if;
return invoice_slug;
end;
$$
language plpgsql;
revoke execute on function edit_invoice(uuid, text, integer, text, integer, tag_name[], edited_invoice_product[]) from public;
grant execute on function edit_invoice(uuid, text, integer, text, integer, tag_name[], edited_invoice_product[]) to invoicer;
grant execute on function edit_invoice(uuid, text, integer, text, integer, tag_name[], edited_invoice_product[]) to admin;
commit;

53
deploy/edit_payment.sql Normal file
View File

@ -0,0 +1,53 @@
-- Deploy numerus:edit_payment to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment
-- requires: expense_payment
-- requires: currency
-- requires: parse_price
-- requires: tag_name
-- requires: update_expense_payment_status
begin;
set search_path to numerus, public;
create or replace function edit_payment(payment_slug uuid, payment_date date, payment_account_id integer, description text, amount text, tags tag_name[]) returns uuid as
$$
declare
pid integer;
eid integer;
amount_cents integer;
begin
update payment
set payment_date = edit_payment.payment_date
, payment_account_id = edit_payment.payment_account_id
, description = edit_payment.description
, amount = parse_price(edit_payment.amount, decimal_digits)
, tags = edit_payment.tags
from currency
where slug = payment_slug
and currency.currency_code = payment.currency_code
returning payment_id, payment.amount
into pid, amount_cents
;
select expense_id into eid
from expense_payment
where payment_id = pid;
if eid is not null then
perform update_expense_payment_status(pid, eid, amount_cents);
end if;
return payment_slug;
end
$$
language plpgsql
;
revoke execute on function edit_payment(uuid, date, integer, text, text, tag_name[]) from public;
grant execute on function edit_payment(uuid, date, integer, text, text, tag_name[]) to invoicer;
grant execute on function edit_payment(uuid, date, integer, text, text, tag_name[]) to admin;
commit;

View File

@ -0,0 +1,43 @@
-- Deploy numerus:edit_payment_account_bank to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment_account
-- requires: payment_account_bank
-- requires: extension_iban
begin;
set search_path to numerus, public;
create or replace function edit_payment_account_bank(account_slug uuid, new_name text, new_iban iban) returns uuid as
$$
declare
account_id int;
begin
update payment_account
set name = new_name
where slug = account_slug
and payment_account_type = 'bank'
returning payment_account_id into account_id
;
if account_id is null then
return null;
end if;
update payment_account_bank
set iban = new_iban
where payment_account_id = account_id
;
return account_slug;
end
$$
language plpgsql
;
revoke execute on function edit_payment_account_bank(uuid, text, iban) from public;
grant execute on function edit_payment_account_bank(uuid, text, iban) to invoicer;
grant execute on function edit_payment_account_bank(uuid, text, iban) to admin;
commit;

View File

@ -0,0 +1,43 @@
-- Deploy numerus:edit_payment_account_card to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment_account
-- requires: payment_account_card
begin;
set search_path to numerus, public;
create or replace function edit_payment_account_card(account_slug uuid, new_name text, new_last_digits text, new_exp_date date) returns uuid as
$$
declare
account_id integer;
begin
update payment_account
set name = new_name
where slug = account_slug
and payment_account_type = 'card'
returning payment_account_id into account_id
;
if account_id is null then
return null;
end if;
update payment_account_card
set last_four_digits = new_last_digits
, expiration_date = new_exp_date
where payment_account_id = account_id
;
return account_slug;
end
$$
language plpgsql
;
revoke execute on function edit_payment_account_card(uuid, text, text, date) from public;
grant execute on function edit_payment_account_card(uuid, text, text, date) to invoicer;
grant execute on function edit_payment_account_card(uuid, text, text, date) to admin;
commit;

View File

@ -0,0 +1,26 @@
-- Deploy numerus:edit_payment_account_cash to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment_account
begin;
set search_path to numerus, public;
create or replace function edit_payment_account_cash(account_slug uuid, new_name text) returns uuid as
$$
update payment_account
set name = new_name
where slug = account_slug
and payment_account_type = 'cash'
returning slug
;
$$
language sql
;
revoke execute on function edit_payment_account_cash(uuid, text) from public;
grant execute on function edit_payment_account_cash(uuid, text) to invoicer;
grant execute on function edit_payment_account_cash(uuid, text) to admin;
commit;

View File

@ -0,0 +1,26 @@
-- Deploy numerus:edit_payment_account_other to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment_account
begin;
set search_path to numerus, public;
create or replace function edit_payment_account_other(account_slug uuid, new_name text) returns uuid as
$$
update payment_account
set name = new_name
where slug = account_slug
and payment_account_type = 'other'
returning slug
;
$$
language sql
;
revoke execute on function edit_payment_account_other(uuid, text) from public;
grant execute on function edit_payment_account_other(uuid, text) to invoicer;
grant execute on function edit_payment_account_other(uuid, text) to admin;
commit;

View File

@ -0,0 +1,12 @@
-- Deploy numerus:expense_expense_status to pg
-- requires: expense
begin;
set search_path to numerus, public;
alter table expense
add column expense_status text not null default 'pending' references expense_status
;
commit;

View File

@ -0,0 +1,32 @@
-- Deploy numerus:expense_payment to pg
-- requires: roles
-- requires: schema_numerus
-- requires: expense
-- requires: payment
begin;
set search_path to numerus, public;
create table expense_payment (
expense_id integer not null references expense,
payment_id integer not null references payment,
primary key (expense_id, payment_id)
);
grant select, insert, update, delete on table expense_payment to invoicer;
grant select, insert, update, delete on table expense_payment to admin;
alter table expense_payment enable row level security;
create policy company_policy
on expense_payment
using (
exists(
select 1
from expense
where expense.expense_id = expense_payment.expense_id
)
);
commit;

17
deploy/expense_status.sql Normal file
View File

@ -0,0 +1,17 @@
-- Deploy numerus:expense_status to pg
-- requires: schema_numerus
-- requires: roles
begin;
set search_path to numerus, public;
create table expense_status (
expense_status text primary key,
name text not null
);
grant select on table expense_status to invoicer;
grant select on table expense_status to admin;
commit;

View File

@ -0,0 +1,21 @@
-- Deploy numerus:expense_status_i18n to pg
-- requires: schema_numerus
-- requires: roles
-- requires: expense_status
-- requires: language
begin;
set search_path to numerus, public;
create table expense_status_i18n (
expense_status text not null references expense_status,
lang_tag text not null references language,
name text not null,
primary key (expense_status, lang_tag)
);
grant select on table expense_status_i18n to invoicer;
grant select on table expense_status_i18n to admin;
commit;

View File

@ -0,0 +1,32 @@
-- Deploy numerus:invoice_attachment to pg
-- requires: schema_numerus
-- requires: roles
-- requires: invoice
begin;
set search_path to numerus, public;
create table invoice_attachment (
invoice_id integer primary key references invoice,
original_filename text not null,
mime_type text not null,
content bytea not null
);
grant select, insert, update, delete on table invoice_attachment to invoicer;
grant select, insert, update, delete on table invoice_attachment to admin;
alter table invoice_attachment enable row level security;
create policy company_policy
on invoice_attachment
using (
exists(
select 1
from invoice
where invoice.invoice_id = invoice_attachment.invoice_id
)
);
commit;

View File

@ -0,0 +1,32 @@
-- Deploy numerus:invoice_collection to pg
-- requires: roles
-- requires: schema_numerus
-- requires: invoice
-- requires: collection
begin;
set search_path to numerus, public;
create table invoice_collection (
invoice_id integer not null references invoice,
collection_id integer not null references collection,
primary key (invoice_id, collection_id)
);
grant select, insert, update, delete on table invoice_collection to invoicer;
grant select, insert, update, delete on table invoice_collection to admin;
alter table invoice_collection enable row level security;
create policy company_policy
on invoice_collection
using (
exists(
select 1
from invoice
where invoice.invoice_id = invoice_collection.invoice_id
)
);
commit;

View File

@ -0,0 +1,13 @@
-- Deploy numerus:new_expense_amount to pg
-- requires: schema_numerus
begin;
set search_path to numerus, public;
create type new_expense_amount as (
taxes text[][],
total text
);
commit;

View File

@ -24,6 +24,9 @@ begin
end if;
result := parts[1]::integer;
if result is null then
raise invalid_parameter_value using message = price || ' is not a valid price representation.';
end if;
for d in 1..decimal_digits loop
result := result * 10;
end loop;

53
deploy/parse_price@v1.sql Normal file
View File

@ -0,0 +1,53 @@
-- Deploy numerus:parse_price to pg
-- requires: schema_public
begin;
set search_path to numerus, public;
create or replace function parse_price(price text, decimal_digits integer) returns integer as
$$
declare
parts text[];
result int;
frac_part text;
sign int := 1;
begin
if price like '-%' Then
sign := -1;
price := substring(price from 2);
end if;
parts := string_to_array(price, '.');
if array_length(parts, 1) > 2 then
raise invalid_parameter_value using message = price || ' is not a valid price representation.';
end if;
result := parts[1]::integer;
for d in 1..decimal_digits loop
result := result * 10;
end loop;
if array_length(parts, 1) = 2 then
frac_part := rtrim(parts[2], '0');
if length(frac_part) > decimal_digits then
raise invalid_parameter_value using message = price || ' has too many digits in the fraction part.';
end if;
frac_part := rpad(frac_part, decimal_digits, '0');
result := result + frac_part::integer;
end if;
return sign * result;
end;
$$
language plpgsql
immutable;
comment on function parse_price(text, integer) is
'Converts the string representation of a price in decimal form to cents, according to the number of decimal digits passed.';
revoke execute on function parse_price(text, integer) from public;
grant execute on function parse_price(text, integer) to invoicer;
grant execute on function parse_price(text, integer) to admin;
commit;

47
deploy/payment.sql Normal file
View File

@ -0,0 +1,47 @@
-- Deploy numerus:payment to pg
-- requires: roles
-- requires: schema_numerus
-- requires: company
-- requires: payment_account
-- requires: currency
-- requires: tag_name
-- requires: payment_status
-- requires: extension_pgcrypto
begin;
set search_path to numerus, public;
create table payment (
payment_id integer generated by default as identity primary key,
company_id integer not null references company,
slug uuid not null unique default gen_random_uuid(),
description text not null,
payment_date date not null default current_date,
payment_account_id integer not null references payment_account,
amount integer not null constraint payment_amount_positive check (amount > 0),
currency_code text not null references currency,
tags tag_name[] not null default '{}',
payment_status text not null default 'complete' references payment_status,
created_at timestamptz not null default current_timestamp
);
create index on payment using gin (tags);
grant select, insert, update, delete on table payment to invoicer;
grant select, insert, update, delete on table payment to admin;
alter table payment enable row level security;
create policy company_policy
on payment
using (
exists(
select 1
from company_user
join user_profile using (user_id)
where company_user.company_id = payment.company_id
)
);
commit;

View File

@ -0,0 +1,38 @@
-- Deploy numerus:payment_account to pg
-- requires: roles
-- requires: schema_numerus
-- requires: company
-- requires: payment_account_type
-- requires: extension_pgcrypto
begin;
set search_path to numerus, public;
create table payment_account (
payment_account_id integer generated by default as identity primary key,
company_id integer not null references company,
slug uuid not null unique default gen_random_uuid(),
payment_account_type text not null references payment_account_type,
name text not null constraint payment_account_name_not_empty check(length(trim(name)) > 0),
unique (payment_account_id, payment_account_type)
);
grant select, insert, update, delete on table payment_account to invoicer;
grant select, insert, update, delete on table payment_account to admin;
alter table payment_account enable row level security;
create policy company_policy
on payment_account
using (
exists(
select 1
from company_user
join user_profile using (user_id)
where company_user.company_id = payment_account.company_id
)
);
commit;

View File

@ -0,0 +1,33 @@
-- Deploy numerus:payment_account_bank to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment_account
-- requires: extension_iban
begin;
set search_path to numerus, public;
create table payment_account_bank (
payment_account_id integer primary key,
payment_account_type text not null default 'bank' constraint payment_account_type_is_bank check (payment_account_type = 'bank'),
iban iban not null,
foreign key (payment_account_id, payment_account_type) references payment_account (payment_account_id, payment_account_type)
);
grant select, insert, update, delete on table payment_account_bank to invoicer;
grant select, insert, update, delete on table payment_account_bank to admin;
alter table payment_account_bank enable row level security;
create policy company_policy
on payment_account_bank
using (
exists(
select 1
from payment_account
where payment_account.payment_account_id = payment_account_bank.payment_account_id
)
);
commit;

View File

@ -0,0 +1,33 @@
-- Deploy numerus:payment_account_card to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment_account
begin;
set search_path to numerus, public;
create table payment_account_card (
payment_account_id integer primary key,
payment_account_type text not null default 'card' constraint payment_account_type_is_card check (payment_account_type = 'card'),
last_four_digits text not null constraint last_four_digits_are_digits check ( last_four_digits ~ '^\d{4}$'),
expiration_date date not null,
foreign key (payment_account_id, payment_account_type) references payment_account (payment_account_id, payment_account_type)
);
grant select, insert, update, delete on table payment_account_card to invoicer;
grant select, insert, update, delete on table payment_account_card to admin;
alter table payment_account_card enable row level security;
create policy company_policy
on payment_account_card
using (
exists(
select 1
from payment_account
where payment_account.payment_account_id = payment_account_card.payment_account_id
)
);
commit;

View File

@ -0,0 +1,17 @@
-- Deploy numerus:payment_account_type to pg
-- requires: roles
-- requires: schema_numerus
begin;
set search_path to numerus, public;
create table payment_account_type (
payment_account_type text primary key,
name text not null
);
grant select on table payment_account_type to invoicer;
grant select on table payment_account_type to admin;
commit;

View File

@ -0,0 +1,21 @@
-- Deploy numerus:payment_account_type_i18n to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment_account_type
-- requires: language
begin;
set search_path to numerus, public;
create table payment_account_type_i18n (
payment_account_type text not null references payment_account_type,
lang_tag text not null references language,
name text not null,
primary key (payment_account_type, lang_tag)
);
grant select on table payment_account_type_i18n to invoicer;
grant select on table payment_account_type_i18n to admin;
commit;

View File

@ -0,0 +1,33 @@
-- Deploy numerus:payment_attachment to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment
begin;
set search_path to numerus, public;
create table payment_attachment (
payment_id integer primary key references payment,
original_filename text not null,
mime_type text not null,
content bytea not null
);
grant select, insert, update, delete on table payment_attachment to invoicer;
grant select, insert, update, delete on table payment_attachment to admin;
alter table payment_attachment enable row level security;
create policy company_policy
on payment_attachment
using (
exists(
select 1
from payment
where payment.payment_id = payment_attachment.payment_id
)
);
commit;

17
deploy/payment_status.sql Normal file
View File

@ -0,0 +1,17 @@
-- Deploy numerus:payment_status to pg
-- requires: roles
-- requires: schema_numerus
begin;
set search_path to numerus, public;
create table payment_status (
payment_status text primary key,
name text not null
);
grant select on table payment_status to invoicer;
grant select on table payment_status to admin;
commit;

View File

@ -0,0 +1,21 @@
-- Deploy numerus:payment_status_i18n to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment_status
-- requires: language
begin;
set search_path to numerus, public;
create table payment_status_i18n (
payment_status text not null references payment_status,
lang_tag text not null references language,
name text not null,
primary key (payment_status, lang_tag)
);
grant select on table payment_status_i18n to invoicer;
grant select on table payment_status_i18n to admin;
commit;

View File

@ -0,0 +1,40 @@
-- Deploy numerus:remove_collection to pg
-- requires: roles
-- requires: schema_numerus
-- requires: invoice_collection
-- requires: collection
-- requires: collection_attachment
-- requires: update_invoice_collection_status
begin;
set search_path to numerus, public;
create or replace function remove_collection(collection_slug uuid) returns void as
$$
declare
cid integer;
iid integer;
begin
select collection_id into cid from collection where slug = collection_slug;
if not found then
return;
end if;
delete from invoice_collection where collection_id = cid returning invoice_id into iid;
if iid is not null then
perform update_invoice_collection_status(null, iid, 0);
end if;
delete from collection_attachment where collection_id = cid;
delete from collection where collection_id = cid;
end
$$
language plpgsql
;
revoke execute on function remove_collection(uuid) from public;
grant execute on function remove_collection(uuid) to invoicer;
grant execute on function remove_collection(uuid) to admin;
commit;

34
deploy/remove_expense.sql Normal file
View File

@ -0,0 +1,34 @@
-- Deploy numerus:remove_expense to pg
-- requires: roles
-- requires: schema_numerus
-- requires: expense_tax
-- requires: expense_attachment
-- requires: expense
begin;
set search_path to numerus, public;
create or replace function remove_expense(expense_slug uuid) returns void as
$$
declare
eid integer;
begin
select expense_id into eid from expense where slug = expense_slug;
if not found then
return;
end if;
delete from expense_tax where expense_id = eid;
delete from expense_attachment where expense_id = eid;
delete from expense where expense_id = eid;
end
$$
language plpgsql
;
revoke execute on function remove_expense(uuid) from public;
grant execute on function remove_expense(uuid) to invoicer;
grant execute on function remove_expense(uuid) to admin;
commit;

40
deploy/remove_payment.sql Normal file
View File

@ -0,0 +1,40 @@
-- Deploy numerus:remove_payment to pg
-- requires: roles
-- requires: schema_numerus
-- requires: expense_payment
-- requires: payment
-- requires: payment_attachment
-- requires: update_expense_payment_status
begin;
set search_path to numerus, public;
create or replace function remove_payment(payment_slug uuid) returns void as
$$
declare
pid integer;
eid integer;
begin
select payment_id into pid from payment where slug = payment_slug;
if not found then
return;
end if;
delete from expense_payment where payment_id = pid returning expense_id into eid;
if eid is not null then
perform update_expense_payment_status(null, eid, 0);
end if;
delete from payment_attachment where payment_id = pid;
delete from payment where payment_id = pid;
end
$$
language plpgsql
;
revoke execute on function remove_payment(uuid) from public;
grant execute on function remove_payment(uuid) to invoicer;
grant execute on function remove_payment(uuid) to admin;
commit;

View File

@ -0,0 +1,51 @@
-- Deploy numerus:update_expense_payment_status to pg
-- requires: roles
-- requires: schema_numerus
-- requires: expense
-- requires: payment
-- requires: expense_payment
-- requires: expense_tax_amount
-- requires: available_expense_status
-- requires: available_payment_status
begin;
set search_path to numerus, public;
create or replace function update_expense_payment_status(pid integer, eid integer, amount_cents integer) returns void as
$$
update payment
set payment_status = case when expense.amount + coalesce(tax.amount, 0) > amount_cents or exists (select 1 from expense_payment as ep where ep.expense_id = expense.expense_id and payment_id <> pid) then 'partial' else 'complete' end
from expense
left join ( select expense_id, sum(amount) as amount from expense_tax_amount group by expense_id) as tax using (expense_id)
where expense.expense_id = eid
and payment_id = pid
;
update expense
set expense_status = case
when paid_amount >= expense.amount + tax_amount then 'paid'
when paid_amount = 0 then 'pending'
else 'partial' end
from (
select coalesce (sum(payment.amount), 0) as paid_amount
from expense_payment
join payment using (payment_id)
where expense_payment.expense_id = eid
) as payment,
(
select coalesce (sum(amount), 0) as tax_amount
from expense_tax_amount
where expense_id = eid
) as tax
where expense.expense_id = eid
;
$$
language sql
;
revoke execute on function update_expense_payment_status(integer, integer, integer) from public;
grant execute on function update_expense_payment_status(integer, integer, integer) to invoicer;
grant execute on function update_expense_payment_status(integer, integer, integer) to admin;
commit;

View File

@ -0,0 +1,49 @@
-- Deploy numerus:update_invoice_collection_status to pg
-- requires: roles
-- requires: schema_numerus
-- requires: invoice
-- requires: collection
-- requires: invoice_collection
-- requires: invoice_amount
-- requires: available_invoice_status
-- requires: available_payment_status
begin;
set search_path to numerus, public;
create or replace function update_invoice_collection_status(cid integer, iid integer, amount_cents integer) returns void as
$$
update collection
set payment_status = case when invoice_amount.total > amount_cents or exists (select 1 from invoice_collection as ic where ic.collection_id = invoice_amount.invoice_id and collection_id <> cid) then 'partial' else 'complete' end
from invoice_amount
where invoice_id = iid
and collection_id = cid
;
update invoice
set invoice_status = case
when collected_amount >= total_amount then 'paid'
when collected_amount = 0 then 'created'
else 'partial' end
from (
select coalesce(sum(collection.amount), 0) as collected_amount
from invoice_collection
join collection using (collection_id)
where invoice_collection.invoice_id = iid
) as collection,
(
select total as total_amount
from invoice_amount
where invoice_id = iid
) as amount
where invoice.invoice_id = iid;
$$
language sql
;
revoke execute on function update_invoice_collection_status(integer, integer, integer) from public;
grant execute on function update_invoice_collection_status(integer, integer, integer) to invoicer;
grant execute on function update_invoice_collection_status(integer, integer, integer) to admin;
commit;

1
go.mod
View File

@ -7,6 +7,7 @@ require (
github.com/jackc/pgx/v4 v4.15.0
github.com/julienschmidt/httprouter v1.3.0
github.com/leonelquinteros/gotext v1.5.0
github.com/rainycape/unidecode v0.0.0-20150906181237-c9cf8cdbbfe8
github.com/tealeg/xlsx v0.0.0-20181024002044-dbf71b6a931e
golang.org/x/text v0.7.0
)

2
go.sum
View File

@ -92,6 +92,8 @@ github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rainycape/unidecode v0.0.0-20150906181237-c9cf8cdbbfe8 h1:iZTHFqK/oFrjyFDkiw5U/RjQxkMlkpq6tHQIO407i+s=
github.com/rainycape/unidecode v0.0.0-20150906181237-c9cf8cdbbfe8/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=

257
guix.scm Normal file
View File

@ -0,0 +1,257 @@
(use-modules (gnu packages)
(gnu packages base)
(gnu packages compression)
(gnu packages databases)
(gnu packages geo)
(gnu packages messaging)
(gnu packages perl)
(gnu packages perl-check)
(gnu packages pkg-config)
(gnu packages protobuf)
(gnu packages web)
(gnu packages xml)
(guix build-system gnu)
(guix build-system perl)
(guix download)
(guix git-download)
(guix licenses)
(guix packages))
(define vat
(package
(name "vat")
(version "0.3")
(source (origin
(method url-fetch)
(uri (string-append
"https://dev.tandem.ws/tandem/" name "/archive/v" version ".tar.gz"))
(sha256
(base32
"0jbgakz7ip09hrnbbg1dm02n5zx7sv0magvw7s6g7rbbpy6wpqwh"))))
(build-system gnu-build-system)
(arguments
`(#:tests? #f
#:make-flags
(list (string-append "datadir=" (assoc-ref %outputs "out") "/share")
(string-append "docdir="(assoc-ref %outputs "out") "/share/doc")
(string-append "pkglibdir="(assoc-ref %outputs "out") "/lib")
(string-append "bindir=" (assoc-ref %outputs "out") "/bin"))
#:phases
(modify-phases %standard-phases
(delete 'configure))))
(inputs
`(("postgresql" ,postgresql-15)))
(home-page "https://dev.tandem.ws/tandem/vat/")
(synopsis "VAT identification numbers for PostgreSQL")
(description "VAT identification numbers library for PostgreSQL")
(license (x11-style "https://www.postgresql.org/about/licence/"))))
(define pg-libphonenumber
(let ((commit "753e2fa4be452620455a099aeda917648f2da70a")
(revision "1"))
(package
(name "pg-libphonenumber")
(version (git-version "0.1.0" revision commit))
(source (origin
(method git-fetch)
(uri (git-reference
(url "https://github.com/blm768/pg-libphonenumber")
(commit commit)))
(file-name (git-file-name name version))
(sha256
(base32
"01syw93giq0pz80qzrdr79cr4p6w8lx129y1gsn2avc97r7pqanj"))))
(build-system gnu-build-system)
(arguments
`(#:tests? #f
#:make-flags
(list (string-append "datadir=" (assoc-ref %outputs "out") "/share")
(string-append "docdir="(assoc-ref %outputs "out") "/share/doc")
(string-append "pkglibdir="(assoc-ref %outputs "out") "/lib")
(string-append "bindir=" (assoc-ref %outputs "out") "/bin"))
#:phases
(modify-phases %standard-phases
(delete 'configure))))
(inputs
`(("postgresql" ,postgresql-15)
("libphonenumber" ,libphonenumber)
("protobuf" ,protobuf)))
(home-page "https://github.com/blm768/pg-libphonenumber")
(synopsis "PostgreSQL extension for libphonenumber")
(description "pg_libphonenumber is a (partially implemented!) PostgreSQL extension that provides access to Googles libphonenumber.")
(license asl2.0))))
(define pguri
(let ((commit "00241b96b8a285aa7ec0a81b5a4c0a664a044192")
(revision "1"))
(package
(name "pguri")
(version (git-version "1.20151224" revision commit))
(source (origin
(method git-fetch)
(uri (git-reference
(url "https://github.com/petere/pguri")
(commit commit)))
(file-name (git-file-name name version))
(sha256
(base32
"0lz5nlqix60mxcjkqn3zn7q62xx0qbmybng3v0h049mf68l80ch9"))))
(build-system gnu-build-system)
(arguments
`(#:tests? #f
#:make-flags
(list (string-append "datadir=" (assoc-ref %outputs "out") "/share")
(string-append "docdir="(assoc-ref %outputs "out") "/share/doc")
(string-append "pkglibdir="(assoc-ref %outputs "out") "/lib")
(string-append "bindir=" (assoc-ref %outputs "out") "/bin"))
#:phases
(modify-phases %standard-phases
(delete 'configure))))
(inputs
`(("postgresql" ,postgresql-15)
("pkg-config" ,pkg-config)
("uriparser" ,uriparser)))
(home-page "https://github.com/petere/pguri")
(synopsis "uri type for PostgreSQL ")
(description "This is an extension for PostgreSQL that provides a uri data type. Advantages over using plain text for storing URIs include: URI syntax checking, functions for extracting URI components, and human-friendly sorting.")
(license asl2.0))))
(define postgresql-iban
(let ((commit "0e533afb4d6bdb5af615d71ee16db9528e501ba6")
(revision "1"))
(package
(name "PostgreSQL-IBAN")
(version (git-version "1.0.0" revision commit))
(source (origin
(method git-fetch)
(uri (git-reference
(url "https://github.com/yorickdewid/PostgreSQL-IBAN.git")
(commit commit)))
(file-name (git-file-name name version))
(sha256
(base32
"1fqjk0amdr3mvhq6n7ig6lxs8xckm6vh5nxm8m1rlar82081agh2"))
(patches (search-patches "postgresql-iban-enable-nls.patch"))))
(build-system gnu-build-system)
(arguments
`(#:tests? #f
#:make-flags
(list (string-append "datadir=" (assoc-ref %outputs "out") "/share")
(string-append "docdir="(assoc-ref %outputs "out") "/share/doc")
(string-append "pkglibdir="(assoc-ref %outputs "out") "/lib")
(string-append "bindir=" (assoc-ref %outputs "out") "/bin"))
#:phases
(modify-phases %standard-phases
(delete 'configure))))
(inputs
`(("postgresql" ,postgresql-15)))
(home-page "https://github.com/yorickdewid/PostgreSQL-IBAN")
(synopsis "PostgreSQL extension that can verify International Bank Account Numbers")
(description "PostgreSQL IBAN extension that can verify International Bank Account Numbers. This ensures that only valid bank account numbers are stored.")
(license gpl3+))))
(define postgresql-15/xml
(package
(inherit postgresql-15)
(arguments
`(#:configure-flags '("--with-uuid=e2fs" "--with-openssl" "--with-libxml")
#:phases
(modify-phases %standard-phases
(add-before 'configure 'patch-/bin/sh
(lambda _
;; Refer to the actual shell.
(substitute* '("src/bin/pg_ctl/pg_ctl.c"
"src/bin/psql/command.c")
(("/bin/sh") (which "sh")))
#t))
(add-after 'build 'build-contrib
(lambda _
(invoke "make" "-C" "contrib")))
(add-after 'install 'install-contrib
(lambda _
(invoke "make" "-C" "contrib" "install"))))))
(inputs
`(("libxml2" ,libxml2)
,@(package-inputs postgresql-15)))))
(define perl-tap-parser-sourcehandler-pgtap
(package
(name "perl-tap-parser-sourcehandler-pgtap")
(version "3.36")
(source
(origin
(method url-fetch)
(uri (string-append "mirror://cpan/authors/id/D/DW/DWHEELER/"
"TAP-Parser-SourceHandler-pgTAP-" version ".tar.gz"))
(sha256
(base32 "0rwcx6z0xg1jrwnsyhb4a3aq3g7ff1a510g5v1paqgh65r9m3gh7"))))
(build-system perl-build-system)
(inputs
`(("perl-module-build" ,perl-module-build)
("perl-test-pod" ,perl-test-pod)
("perl-test-pod-coverage" ,perl-test-pod-coverage)))
(home-page "https://metacpan.org/pod/TAP::Parser::SourceHandler::pgTAP")
(synopsis "Stream TAP from pgTAP test scripts")
(description "This source handler executes pgTAP tests. It does two things: 1) Looks at the TAP::Parser::Source passed to it to determine whether or not the source in question is in fact a pgTAP test (\"can_handle\"). And, 2) Creates an iterator that will call psql to run the pgTAP tests (\"make_iterator\"). Unless you're writing a plugin or subclassing TAP::Parser, you probably won't need to use this module directly.")
(license perl-license)))
(define pgtap
(package
(name "pgtap")
(version "1.2.0")
(source (origin
(method url-fetch)
(uri (string-append
"https://api.pgxn.org/dist/pgtap/" version
"/pgtap-" version ".zip"))
(sha256
(base32
"106p24wslq39h9hrscf415x7s1nb6l21xjdzpg3dh73gawslfmqv"))))
(build-system gnu-build-system)
(arguments
`(#:tests? #f
#:make-flags
(list (string-append "datadir=" (assoc-ref %outputs "out") "/share")
(string-append "docdir="(assoc-ref %outputs "out") "/share/doc")
(string-append "pkglibdir="(assoc-ref %outputs "out") "/lib")
(string-append "bindir=" (assoc-ref %outputs "out") "/bin"))
#:phases
(modify-phases %standard-phases
(delete 'configure))))
(inputs
`(("postgresql" ,postgresql)))
(native-inputs
`(("perl" ,perl)
("which" ,which)
("unzip" ,unzip)))
(home-page "https://pgtap.org")
(synopsis "Unit testining for PostgreSQL")
(description "pgTAP is a suite of database functions that make it easy to write TAP-emitting unit tests in psql scripts or xUnit-style test functions.")
(license (x11-style "https://www.postgresql.org/about/licence/"))))
(package
(name "numerus")
(version "0.1.0")
(source (origin
(method git-fetch)
(uri (git-reference
(url "https://dev.tandem.ws/tandem/numerus.git")
(commit "666935b54c8f2d735d8fe5591407ca231bba7e4c")))
(sha256
(base32
"0p31j7qing7nhnpngsnnfvx6wz70ryj4q5k7l81anh2z19nzwbk8"))))
(build-system gnu-build-system)
(inputs
`(("sqitch" ,sqitch)
("pgtap" ,pgtap)
("vat" ,vat)
("perl-tap-parser-sourcehandler-pgtap" ,perl-tap-parser-sourcehandler-pgtap)
("perl" ,perl)
("pg-libphonenumber" ,pg-libphonenumber)
("pguri" ,pguri)
("PostgreSQL-IBAN" ,postgresql-iban)
("postgresql" ,postgresql-15/xml)))
(synopsis "Simple invoicing and accounting web application")
(description "A simple web application to keep invoice and accouting records, intended for freelancers working in Spain.")
(home-page "https://dev.tandem.ws/tandem/numerus")
(license agpl3+))

338
pkg/accounts.go Normal file
View File

@ -0,0 +1,338 @@
package pkg
import (
"context"
"github.com/jackc/pgtype"
"github.com/julienschmidt/httprouter"
"html/template"
"net/http"
"time"
)
const (
ExpirationDateFormat = "01/06"
AccountTypeBank = "bank"
AccountTypeCard = "card"
AccountTypeCash = "cash"
AccountTypeOther = "other"
)
func servePaymentAccountIndex(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
conn := getConn(r)
company := mustGetCompany(r)
locale := getLocale(r)
page := NewPaymentAccountIndexPage(r.Context(), conn, company, locale)
page.MustRender(w, r)
}
type PaymentAccountIndexPage struct {
Accounts []*PaymentAccountEntry
}
func NewPaymentAccountIndexPage(ctx context.Context, conn *Conn, company *Company, locale *Locale) *PaymentAccountIndexPage {
return &PaymentAccountIndexPage{
Accounts: mustCollectPaymentAccountEntries(ctx, conn, company, locale),
}
}
func (page *PaymentAccountIndexPage) MustRender(w http.ResponseWriter, r *http.Request) {
mustRenderMainTemplate(w, r, "payments/accounts/index.gohtml", page)
}
type PaymentAccountEntry struct {
ID int
Slug string
Type string
TypeLabel string
Name string
IBAN string
LastFourDigits string
ExpirationDate string
}
func mustCollectPaymentAccountEntries(ctx context.Context, conn *Conn, company *Company, locale *Locale) []*PaymentAccountEntry {
rows := conn.MustQuery(ctx, `
select payment_account_id
, slug
, payment_account.payment_account_type
, coalesce(i18n.name, payment_account_type.name)
, payment_account.name
, coalesce(iban::text, '') as iban
, coalesce(last_four_digits, '') as last_four_digits
, expiration_date
from payment_account
left join payment_account_bank using (payment_account_id, payment_account_type)
left join payment_account_card using (payment_account_id, payment_account_type)
join payment_account_type using (payment_account_type)
left join payment_account_type_i18n as i18n on payment_account_type.payment_account_type = i18n.payment_account_type and i18n.lang_tag = $1
where company_id = $2
order by payment_account_id
`, locale.Language.String(), company.Id)
defer rows.Close()
var entries []*PaymentAccountEntry
for rows.Next() {
entry := &PaymentAccountEntry{}
var expirationDate pgtype.Date
if err := rows.Scan(&entry.ID, &entry.Slug, &entry.Type, &entry.TypeLabel, &entry.Name, &entry.IBAN, &entry.LastFourDigits, &expirationDate); err != nil {
panic(err)
}
if expirationDate.Status == pgtype.Present {
entry.ExpirationDate = expirationDate.Time.Format(ExpirationDateFormat)
}
entries = append(entries, entry)
}
if rows.Err() != nil {
panic(rows.Err())
}
return entries
}
func servePaymentAccountForm(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
locale := getLocale(r)
conn := getConn(r)
company := mustGetCompany(r)
form := newPaymentAccountForm(r.Context(), conn, locale, company)
slug := params[0].Value
if slug == "new" {
form.MustRender(w, r)
return
}
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
if !form.MustFillFromDatabase(r.Context(), conn, slug) {
http.NotFound(w, r)
return
}
form.MustRender(w, r)
}
type PaymentAccountForm struct {
locale *Locale
company *Company
Slug string
Type *RadioField
Name *InputField
IBAN *InputField
LastFourDigits *InputField
ExpirationMonthYear *InputField
}
func newPaymentAccountForm(ctx context.Context, conn *Conn, locale *Locale, company *Company) *PaymentAccountForm {
return &PaymentAccountForm{
locale: locale,
company: company,
Type: &RadioField{
Name: "payment_account_type",
Label: pgettext("input", "Type", locale),
Required: true,
Options: MustGetRadioOptions(ctx, conn, "select payment_account_type, i18n.name from payment_account_type join payment_account_type_i18n as i18n using (payment_account_type) where i18n.lang_tag = $1 order by payment_account_type", locale.Language.String()),
Attributes: []template.HTMLAttr{
`x-model="type"`,
},
},
Name: &InputField{
Name: "name",
Label: pgettext("input", "Name", locale),
Required: true,
Type: "text",
},
IBAN: &InputField{
Name: "iban",
Label: pgettext("input", "IBAN", locale),
Required: true,
Type: "text",
},
LastFourDigits: &InputField{
Name: "last_four_digits",
Label: pgettext("input", "Cards last four digits", locale),
Required: true,
Type: "text",
Attributes: []template.HTMLAttr{
`maxlength="4"`,
`minlength="4"`,
`pattern="[0-9]{4}"`,
},
},
ExpirationMonthYear: &InputField{
Name: "expiration_date",
Label: pgettext("input", "Expiration date", locale),
Required: true,
Type: "text",
Attributes: []template.HTMLAttr{
`maxlength="5"`,
`minlength="5"`,
`pattern="[0-9]{2}/[0-9]{2}"`,
},
},
}
}
func (f *PaymentAccountForm) MustRender(w http.ResponseWriter, r *http.Request) {
if f.Slug == "" {
mustRenderMainTemplate(w, r, "payments/accounts/new.gohtml", f)
} else {
mustRenderMainTemplate(w, r, "payments/accounts/edit.gohtml", f)
}
}
func (f *PaymentAccountForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool {
selectedType := f.Type.Selected
var expirationDate pgtype.Date
if notFoundErrorOrPanic(conn.QueryRow(ctx, `
select payment_account_type
, name
, coalesce(iban::text, '') as iban
, coalesce(last_four_digits, '') as last_four_digits
, expiration_date
from payment_account
left join payment_account_bank using (payment_account_id, payment_account_type)
left join payment_account_card using (payment_account_id, payment_account_type)
where slug = $1
`, slug).Scan(
f.Type,
f.Name,
f.IBAN,
f.LastFourDigits,
&expirationDate)) {
f.Type.Selected = selectedType
return false
}
f.Slug = slug
if expirationDate.Status == pgtype.Present {
f.ExpirationMonthYear.Val = expirationDate.Time.Format(ExpirationDateFormat)
} else {
f.ExpirationMonthYear.Val = ""
}
return true
}
func (f *PaymentAccountForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
f.Type.FillValue(r)
f.Name.FillValue(r)
f.IBAN.FillValue(r)
f.LastFourDigits.FillValue(r)
f.ExpirationMonthYear.FillValue(r)
return nil
}
func (f *PaymentAccountForm) Validate(ctx context.Context, conn *Conn) bool {
validator := newFormValidator()
if validator.CheckValidRadioOption(f.Type, gettext("Selected payment account type is not valid.", f.locale)) {
switch f.Type.Selected {
case AccountTypeBank:
if validator.CheckRequiredInput(f.IBAN, gettext("IBAN can not be empty.", f.locale)) {
validator.CheckValidIBANInput(ctx, conn, f.IBAN, gettext("This value is not a valid IBAN.", f.locale))
}
case AccountTypeCard:
if validator.CheckRequiredInput(f.LastFourDigits, gettext("Last four digits can not be empty.", f.locale)) {
if validator.CheckInputLength(f.LastFourDigits, 4, gettext("You must enter the cards last four digits", f.locale)) {
validator.CheckValidInteger(f.LastFourDigits, 0, 9999, gettext("Last four digits must be a number.", f.locale))
}
}
if validator.CheckRequiredInput(f.ExpirationMonthYear, gettext("Expiration date can not be empty.", f.locale)) {
_, err := time.Parse(ExpirationDateFormat, f.ExpirationMonthYear.Val)
validator.checkInput(f.ExpirationMonthYear, err == nil, gettext("Expiration date should be a valid date in format MM/YY (e.g., 08/24).", f.locale))
}
}
}
validator.CheckRequiredInput(f.Name, gettext("Payment account name can not be empty.", f.locale))
return validator.AllOK()
}
func (f *PaymentAccountForm) ExpirationDate() (time.Time, error) {
date, err := time.Parse(ExpirationDateFormat, f.ExpirationMonthYear.Val)
if err != nil {
return date, err
}
return date.AddDate(0, 1, -1), nil
}
func handleAddPaymentAccount(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
locale := getLocale(r)
conn := getConn(r)
company := mustGetCompany(r)
form := newPaymentAccountForm(r.Context(), conn, locale, company)
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if !form.Validate(r.Context(), conn) {
w.WriteHeader(http.StatusUnprocessableEntity)
form.MustRender(w, r)
return
}
switch form.Type.Selected {
case AccountTypeBank:
conn.MustExec(r.Context(), "select add_payment_account_bank($1, $2, $3)", company.Id, form.Name, form.IBAN)
case AccountTypeCard:
date, err := form.ExpirationDate()
if err != nil {
panic(err)
}
conn.MustExec(r.Context(), "select add_payment_account_card($1, $2, $3, $4)", company.Id, form.Name, form.LastFourDigits, date)
case AccountTypeCash:
conn.MustExec(r.Context(), "select add_payment_account_cash($1, $2)", company.Id, form.Name)
case AccountTypeOther:
conn.MustExec(r.Context(), "select add_payment_account_other($1, $2)", company.Id, form.Name)
}
htmxRedirect(w, r, companyURI(company, "/payment-accounts"))
}
func handleEditPaymentAccount(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
conn := getConn(r)
locale := getLocale(r)
company := mustGetCompany(r)
form := newPaymentAccountForm(r.Context(), conn, locale, company)
form.Slug = params[0].Value
if !ValidUuid(form.Slug) {
http.NotFound(w, r)
return
}
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if !form.Validate(r.Context(), conn) {
w.WriteHeader(http.StatusUnprocessableEntity)
form.MustRender(w, r)
return
}
var found string
switch form.Type.Selected {
case AccountTypeBank:
found = conn.MustGetText(r.Context(), "", "select edit_payment_account_bank($1, $2, $3)", form.Slug, form.Name, form.IBAN)
case AccountTypeCard:
date, err := form.ExpirationDate()
if err != nil {
panic(err)
}
found = conn.MustGetText(r.Context(), "", "select edit_payment_account_card($1, $2, $3, $4)", form.Slug, form.Name, form.LastFourDigits, date)
case AccountTypeCash:
found = conn.MustGetText(r.Context(), "", "select edit_payment_account_cash($1, $2)", form.Slug, form.Name)
case AccountTypeOther:
found = conn.MustGetText(r.Context(), "", "select edit_payment_account_other($1, $2)", form.Slug, form.Name)
}
if found == "" {
http.NotFound(w, r)
return
}
htmxRedirect(w, r, companyURI(company, "/payment-accounts"))
}

25
pkg/attachment.go Normal file
View File

@ -0,0 +1,25 @@
package pkg
import (
"github.com/julienschmidt/httprouter"
"net/http"
"strconv"
)
func serveAttachment(w http.ResponseWriter, r *http.Request, params httprouter.Params, sql string) {
slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
conn := getConn(r)
var contentType string
var content []byte
if notFoundErrorOrPanic(conn.QueryRow(r.Context(), sql, slug).Scan(&contentType, &content)) {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(content)), 10))
_, _ = w.Write(content)
}

3
pkg/build.go Normal file
View File

@ -0,0 +1,3 @@
package pkg
const Version = "1.1~git"

View File

@ -3,6 +3,7 @@ package pkg
import (
"context"
"errors"
"fmt"
"github.com/julienschmidt/httprouter"
"html/template"
"math"
@ -82,27 +83,23 @@ type PaymentMethod struct {
Id int
Name string
Instructions string
IsDefault bool
}
type taxDetailsForm struct {
locale *Locale
TradeName *InputField
BusinessName *InputField
VATIN *InputField
Phone *InputField
Email *InputField
Web *InputField
Address *InputField
City *InputField
Province *InputField
PostalCode *InputField
Country *SelectField
Currency *SelectField
InvoiceNumberFormat *InputField
NextInvoiceNumber *InputField
QuoteNumberFormat *InputField
NextQuoteNumber *InputField
LegalDisclaimer *InputField
locale *Locale
TradeName *InputField
BusinessName *InputField
VATIN *InputField
Phone *InputField
Email *InputField
Web *InputField
Address *InputField
City *InputField
Province *InputField
PostalCode *InputField
Country *SelectField
Currency *SelectField
}
func newTaxDetailsForm(ctx context.Context, conn *Conn, locale *Locale) *taxDetailsForm {
@ -202,6 +199,204 @@ func newTaxDetailsForm(ctx context.Context, conn *Conn, locale *Locale) *taxDeta
Required: true,
Selected: []string{"EUR"},
},
}
}
func (form *taxDetailsForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
form.TradeName.FillValue(r)
form.BusinessName.FillValue(r)
form.VATIN.FillValue(r)
form.Phone.FillValue(r)
form.Email.FillValue(r)
form.Web.FillValue(r)
form.Address.FillValue(r)
form.City.FillValue(r)
form.Province.FillValue(r)
form.PostalCode.FillValue(r)
form.Country.FillValue(r)
form.Currency.FillValue(r)
return nil
}
func (form *taxDetailsForm) Validate(ctx context.Context, conn *Conn) bool {
validator := newFormValidator()
country := ""
if validator.CheckValidSelectOption(form.Country, gettext("Selected country is not valid.", form.locale)) {
country = form.Country.Selected[0]
}
validator.CheckRequiredInput(form.BusinessName, gettext("Business name can not be empty.", form.locale))
validator.CheckInputMinLength(form.BusinessName, 2, gettext("Business name must have at least two letters.", form.locale))
if validator.CheckRequiredInput(form.VATIN, gettext("VAT number can not be empty.", form.locale)) {
validator.CheckValidVATINInput(ctx, conn, form.VATIN, country, gettext("This value is not a valid VAT number.", form.locale))
}
if validator.CheckRequiredInput(form.Phone, gettext("Phone can not be empty.", form.locale)) {
validator.CheckValidPhoneInput(ctx, conn, form.Phone, country, gettext("This value is not a valid phone number.", form.locale))
}
if validator.CheckRequiredInput(form.Email, gettext("Email can not be empty.", form.locale)) {
validator.CheckValidEmailInput(form.Email, gettext("This value is not a valid email. It should be like name@domain.com.", form.locale))
}
if form.Web.Val != "" {
validator.CheckValidURL(form.Web, gettext("This value is not a valid web address. It should be like https://domain.com/.", form.locale))
}
validator.CheckRequiredInput(form.Address, gettext("Address can not be empty.", form.locale))
validator.CheckRequiredInput(form.City, gettext("City can not be empty.", form.locale))
validator.CheckRequiredInput(form.Province, gettext("Province can not be empty.", form.locale))
if validator.CheckRequiredInput(form.PostalCode, gettext("Postal code can not be empty.", form.locale)) {
validator.CheckValidPostalCode(ctx, conn, form.PostalCode, country, gettext("This value is not a valid postal code.", form.locale))
}
validator.CheckValidSelectOption(form.Currency, gettext("Selected currency is not valid.", form.locale))
return validator.AllOK()
}
func (form *taxDetailsForm) mustFillFromDatabase(ctx context.Context, conn *Conn, company *Company) *taxDetailsForm {
err := conn.QueryRow(ctx, `
select business_name
, substr(vatin::text, 3)
, trade_name
, phone
, email
, web
, address
, city
, province
, postal_code
, country_code
, currency_code
from company
where company.company_id = $1`, company.Id).Scan(
form.BusinessName,
form.VATIN,
form.TradeName,
form.Phone,
form.Email,
form.Web,
form.Address,
form.City,
form.Province,
form.PostalCode,
form.Country,
form.Currency,
)
if err != nil {
panic(err)
}
return form
}
type TaxDetailsPage struct {
DetailsForm *taxDetailsForm
}
func (page *TaxDetailsPage) MustRender(w http.ResponseWriter, r *http.Request) {
mustRenderMainTemplate(w, r, "company/tax-details.gohtml", page)
}
func GetCompanyTaxDetailsForm(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
mustRenderTaxDetailsForm(w, r, newTaxDetailsFormFromDatabase(r))
}
func newTaxDetailsFormFromDatabase(r *http.Request) *taxDetailsForm {
locale := getLocale(r)
conn := getConn(r)
form := newTaxDetailsForm(r.Context(), conn, locale)
company := mustGetCompany(r)
form.mustFillFromDatabase(r.Context(), conn, company)
return form
}
func HandleCompanyTaxDetailsForm(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
locale := getLocale(r)
conn := getConn(r)
form := newTaxDetailsForm(r.Context(), conn, locale)
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if ok := form.Validate(r.Context(), conn); !ok {
w.WriteHeader(http.StatusUnprocessableEntity)
mustRenderTaxDetailsForm(w, r, form)
return
}
company := mustGetCompany(r)
conn.MustExec(r.Context(), `
update company
set business_name = $1
, vatin = ($11 || $2)::vatin
, trade_name = $3
, phone = parse_packed_phone_number($4, $11)
, email = $5
, web = $6
, address = $7
, city = $8
, province = $9
, postal_code = $10
, country_code = $11
, currency_code = $12
where company_id = $13
`,
form.BusinessName,
form.VATIN,
form.TradeName,
form.Phone,
form.Email,
form.Web,
form.Address,
form.City,
form.Province,
form.PostalCode,
form.Country,
form.Currency,
company.Id)
htmxRedirect(w, r, companyURI(company, "/tax-details"))
}
func mustRenderTaxDetailsForm(w http.ResponseWriter, r *http.Request, form *taxDetailsForm) {
page := &TaxDetailsPage{
DetailsForm: form,
}
page.MustRender(w, r)
}
func mustGetCompany(r *http.Request) *Company {
company := getCompany(r)
if company == nil {
panic(errors.New("company: required but not found"))
}
return company
}
func serveCompanyInvoicingForm(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
conn := getConn(r)
company := mustGetCompany(r)
locale := getLocale(r)
form := newInvoicingFormFromDatabase(r.Context(), conn, company, locale)
form.MustRender(w, r)
}
type InvoicingForm struct {
locale *Locale
InvoiceNumberFormat *InputField
NextInvoiceNumber *InputField
QuoteNumberFormat *InputField
NextQuoteNumber *InputField
LegalDisclaimer *InputField
}
func newInvoicingForm(locale *Locale) *InvoicingForm {
return &InvoicingForm{
locale: locale,
InvoiceNumberFormat: &InputField{
Name: "invoice_number_format",
Label: pgettext("input", "Invoice number format", locale),
@ -240,82 +435,15 @@ func newTaxDetailsForm(ctx context.Context, conn *Conn, locale *Locale) *taxDeta
}
}
func (form *taxDetailsForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
form.TradeName.FillValue(r)
form.BusinessName.FillValue(r)
form.VATIN.FillValue(r)
form.Phone.FillValue(r)
form.Email.FillValue(r)
form.Web.FillValue(r)
form.Address.FillValue(r)
form.City.FillValue(r)
form.Province.FillValue(r)
form.PostalCode.FillValue(r)
form.Country.FillValue(r)
form.Currency.FillValue(r)
form.InvoiceNumberFormat.FillValue(r)
form.NextInvoiceNumber.FillValue(r)
form.QuoteNumberFormat.FillValue(r)
form.NextQuoteNumber.FillValue(r)
form.LegalDisclaimer.FillValue(r)
return nil
func newInvoicingFormFromDatabase(ctx context.Context, conn *Conn, company *Company, locale *Locale) *InvoicingForm {
form := newInvoicingForm(locale)
form.mustFillFromDatabase(ctx, conn, company)
return form
}
func (form *taxDetailsForm) Validate(ctx context.Context, conn *Conn) bool {
validator := newFormValidator()
country := ""
if validator.CheckValidSelectOption(form.Country, gettext("Selected country is not valid.", form.locale)) {
country = form.Country.Selected[0]
}
validator.CheckRequiredInput(form.BusinessName, gettext("Business name can not be empty.", form.locale))
validator.CheckInputMinLength(form.BusinessName, 2, gettext("Business name must have at least two letters.", form.locale))
if validator.CheckRequiredInput(form.VATIN, gettext("VAT number can not be empty.", form.locale)) {
validator.CheckValidVATINInput(ctx, conn, form.VATIN, country, gettext("This value is not a valid VAT number.", form.locale))
}
if validator.CheckRequiredInput(form.Phone, gettext("Phone can not be empty.", form.locale)) {
validator.CheckValidPhoneInput(ctx, conn, form.Phone, country, gettext("This value is not a valid phone number.", form.locale))
}
if validator.CheckRequiredInput(form.Email, gettext("Email can not be empty.", form.locale)) {
validator.CheckValidEmailInput(form.Email, gettext("This value is not a valid email. It should be like name@domain.com.", form.locale))
}
if form.Web.Val != "" {
validator.CheckValidURL(form.Web, gettext("This value is not a valid web address. It should be like https://domain.com/.", form.locale))
}
validator.CheckRequiredInput(form.Address, gettext("Address can not be empty.", form.locale))
validator.CheckRequiredInput(form.City, gettext("City can not be empty.", form.locale))
validator.CheckRequiredInput(form.Province, gettext("Province can not be empty.", form.locale))
if validator.CheckRequiredInput(form.PostalCode, gettext("Postal code can not be empty.", form.locale)) {
validator.CheckValidPostalCode(ctx, conn, form.PostalCode, country, gettext("This value is not a valid postal code.", form.locale))
}
validator.CheckValidSelectOption(form.Currency, gettext("Selected currency is not valid.", form.locale))
validator.CheckRequiredInput(form.InvoiceNumberFormat, gettext("Invoice number format can not be empty.", form.locale))
validator.CheckValidInteger(form.NextInvoiceNumber, 1, math.MaxInt32, gettext("Next invoice number must be a number greater than zero.", form.locale))
validator.CheckRequiredInput(form.QuoteNumberFormat, gettext("Quotation number format can not be empty.", form.locale))
validator.CheckValidInteger(form.NextQuoteNumber, 1, math.MaxInt32, gettext("Next quotation number must be a number greater than zero.", form.locale))
return validator.AllOK()
}
func (form *taxDetailsForm) mustFillFromDatabase(ctx context.Context, conn *Conn, company *Company) *taxDetailsForm {
func (form *InvoicingForm) mustFillFromDatabase(ctx context.Context, conn *Conn, company *Company) {
err := conn.QueryRow(ctx, `
select business_name
, substr(vatin::text, 3)
, trade_name
, phone
, email
, web
, address
, city
, province
, postal_code
, country_code
, currency_code
, invoice_number_format
select invoice_number_format
, quote_number_format
, legal_disclaimer
, coalesce(invoice_number_counter.currval, 0) + 1
@ -328,18 +456,6 @@ func (form *taxDetailsForm) mustFillFromDatabase(ctx context.Context, conn *Conn
on quote_number_counter.company_id = company.company_id
and quote_number_counter.year = date_part('year', current_date)
where company.company_id = $1`, company.Id).Scan(
form.BusinessName,
form.VATIN,
form.TradeName,
form.Phone,
form.Email,
form.Web,
form.Address,
form.City,
form.Province,
form.PostalCode,
form.Country,
form.Currency,
form.InvoiceNumberFormat,
form.QuoteNumberFormat,
form.LegalDisclaimer,
@ -349,36 +465,39 @@ func (form *taxDetailsForm) mustFillFromDatabase(ctx context.Context, conn *Conn
if err != nil {
panic(err)
}
return form
}
type TaxDetailsPage struct {
DetailsForm *taxDetailsForm
NewTaxForm *taxForm
Taxes []*Tax
NewPaymentMethodForm *paymentMethodForm
PaymentMethods []*PaymentMethod
func (form *InvoicingForm) MustRender(w http.ResponseWriter, r *http.Request) {
mustRenderMainTemplate(w, r, "company/invoicing.gohtml", form)
}
func GetCompanyTaxDetailsForm(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
mustRenderTaxDetailsForm(w, r, newTaxDetailsFormFromDatabase(r))
func (form *InvoicingForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
form.InvoiceNumberFormat.FillValue(r)
form.NextInvoiceNumber.FillValue(r)
form.QuoteNumberFormat.FillValue(r)
form.NextQuoteNumber.FillValue(r)
form.LegalDisclaimer.FillValue(r)
return nil
}
func newTaxDetailsFormFromDatabase(r *http.Request) *taxDetailsForm {
func (form *InvoicingForm) Validate() bool {
validator := newFormValidator()
validator.CheckRequiredInput(form.InvoiceNumberFormat, gettext("Invoice number format can not be empty.", form.locale))
validator.CheckValidInteger(form.NextInvoiceNumber, 1, math.MaxInt32, gettext("Next invoice number must be a number greater than zero.", form.locale))
validator.CheckRequiredInput(form.QuoteNumberFormat, gettext("Quotation number format can not be empty.", form.locale))
validator.CheckValidInteger(form.NextQuoteNumber, 1, math.MaxInt32, gettext("Next quotation number must be a number greater than zero.", form.locale))
return validator.AllOK()
}
func handleCompanyInvoicingForm(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
locale := getLocale(r)
conn := getConn(r)
form := newTaxDetailsForm(r.Context(), conn, locale)
company := mustGetCompany(r)
form.mustFillFromDatabase(r.Context(), conn, company)
return form
}
func HandleCompanyTaxDetailsForm(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
locale := getLocale(r)
conn := getConn(r)
form := newTaxDetailsForm(r.Context(), conn, locale)
form := newInvoicingForm(locale)
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@ -387,11 +506,9 @@ func HandleCompanyTaxDetailsForm(w http.ResponseWriter, r *http.Request, _ httpr
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if ok := form.Validate(r.Context(), conn); !ok {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
mustRenderTaxDetailsForm(w, r, form)
if ok := form.Validate(); !ok {
w.WriteHeader(http.StatusUnprocessableEntity)
form.MustRender(w, r)
return
}
company := mustGetCompany(r)
@ -399,35 +516,11 @@ func HandleCompanyTaxDetailsForm(w http.ResponseWriter, r *http.Request, _ httpr
defer tx.MustRollback(r.Context())
tx.MustExec(r.Context(), `
update company
set business_name = $1
, vatin = ($11 || $2)::vatin
, trade_name = $3
, phone = parse_packed_phone_number($4, $11)
, email = $5
, web = $6
, address = $7
, city = $8
, province = $9
, postal_code = $10
, country_code = $11
, currency_code = $12
, invoice_number_format = $13
, quote_number_format = $14
, legal_disclaimer = $15
where company_id = $16
set invoice_number_format = $1
, quote_number_format = $2
, legal_disclaimer = $3
where company_id = $4
`,
form.BusinessName,
form.VATIN,
form.TradeName,
form.Phone,
form.Email,
form.Web,
form.Address,
form.City,
form.Province,
form.PostalCode,
form.Country,
form.Currency,
form.InvoiceNumberFormat,
form.QuoteNumberFormat,
form.LegalDisclaimer,
@ -449,60 +542,39 @@ func HandleCompanyTaxDetailsForm(w http.ResponseWriter, r *http.Request, _ httpr
company.Id,
form.NextQuoteNumber.Integer()-1)
tx.MustCommit(r.Context())
if IsHTMxRequest(r) {
w.Header().Set(HxTrigger, "closeModal")
w.WriteHeader(http.StatusNoContent)
} else {
http.Redirect(w, r, companyURI(company, "/tax-details"), http.StatusSeeOther)
}
htmxRedirect(w, r, companyURI(company, "/invoicing"))
}
func mustRenderTaxDetailsForm(w http.ResponseWriter, r *http.Request, form *taxDetailsForm) {
conn := getConn(r)
locale := getLocale(r)
page := &TaxDetailsPage{
DetailsForm: form,
NewTaxForm: newTaxForm(r.Context(), conn, mustGetCompany(r), locale),
NewPaymentMethodForm: newPaymentMethodForm(locale),
}
mustRenderTexDetailsPage(w, r, page)
}
func mustRenderTaxForm(w http.ResponseWriter, r *http.Request, form *taxForm) {
page := &TaxDetailsPage{
DetailsForm: newTaxDetailsFormFromDatabase(r),
NewTaxForm: form,
NewPaymentMethodForm: newPaymentMethodForm(getLocale(r)),
}
mustRenderTexDetailsPage(w, r, page)
}
func mustRenderPaymentMethodForm(w http.ResponseWriter, r *http.Request, form *paymentMethodForm) {
page := &TaxDetailsPage{
DetailsForm: newTaxDetailsFormFromDatabase(r),
NewTaxForm: newTaxForm(r.Context(), getConn(r), mustGetCompany(r), getLocale(r)),
NewPaymentMethodForm: form,
}
mustRenderTexDetailsPage(w, r, page)
}
func mustRenderTexDetailsPage(w http.ResponseWriter, r *http.Request, page *TaxDetailsPage) {
func serveCompanyTaxes(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
conn := getConn(r)
company := mustGetCompany(r)
page.Taxes = mustGetTaxes(r.Context(), conn, company)
page.PaymentMethods = mustCollectPaymentMethods(r.Context(), conn, company)
mustRenderModalTemplate(w, r, "tax-details.gohtml", page)
locale := getLocale(r)
page := newTaxesPage(r.Context(), conn, company, locale)
page.MustRender(w, r)
}
func mustGetCompany(r *http.Request) *Company {
company := getCompany(r)
if company == nil {
panic(errors.New("company: required but not found"))
type TaxesPage struct {
Taxes []*Tax
Form *taxForm
}
func newTaxesPage(ctx context.Context, conn *Conn, company *Company, locale *Locale) *TaxesPage {
form := newTaxForm(ctx, conn, company, locale)
return newTaxesPageWithForm(ctx, conn, company, form)
}
func newTaxesPageWithForm(ctx context.Context, conn *Conn, company *Company, form *taxForm) *TaxesPage {
return &TaxesPage{
Taxes: mustCollectTaxes(ctx, conn, company),
Form: form,
}
return company
}
func mustGetTaxes(ctx context.Context, conn *Conn, company *Company) []*Tax {
func (page *TaxesPage) MustRender(w http.ResponseWriter, r *http.Request) {
mustRenderMainTemplate(w, r, "company/taxes.gohtml", page)
}
func mustCollectTaxes(ctx context.Context, conn *Conn, company *Company) []*Tax {
rows, err := conn.Query(ctx, "select tax_id, tax.name, tax_class.name, (rate * 100)::integer from tax join tax_class using (tax_class_id) where tax.company_id = $1 order by rate, tax.name", company.Id)
if err != nil {
panic(err)
@ -525,29 +597,6 @@ func mustGetTaxes(ctx context.Context, conn *Conn, company *Company) []*Tax {
return taxes
}
func mustCollectPaymentMethods(ctx context.Context, conn *Conn, company *Company) []*PaymentMethod {
rows, err := conn.Query(ctx, "select payment_method_id, name, instructions from payment_method where company_id = $1 order by name", company.Id)
if err != nil {
panic(err)
}
defer rows.Close()
var methods []*PaymentMethod
for rows.Next() {
method := &PaymentMethod{}
err = rows.Scan(&method.Id, &method.Name, &method.Instructions)
if err != nil {
panic(err)
}
methods = append(methods, method)
}
if rows.Err() != nil {
panic(rows.Err())
}
return methods
}
type taxForm struct {
locale *Locale
Name *InputField
@ -604,25 +653,6 @@ func (form *taxForm) Validate() bool {
return validator.AllOK()
}
func HandleDeleteCompanyTax(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
taxId, err := strconv.Atoi(params[0].Value)
if err != nil {
http.NotFound(w, r)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
conn := getConn(r)
conn.MustExec(r.Context(), "delete from tax where tax_id = $1", taxId)
if IsHTMxRequest(r) {
w.WriteHeader(http.StatusOK)
} else {
http.Redirect(w, r, companyURI(mustGetCompany(r), "/tax-details"), http.StatusSeeOther)
}
}
func HandleAddCompanyTax(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
locale := getLocale(r)
conn := getConn(r)
@ -637,24 +667,110 @@ func HandleAddCompanyTax(w http.ResponseWriter, r *http.Request, _ httprouter.Pa
return
}
if !form.Validate() {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
mustRenderTaxForm(w, r, form)
w.WriteHeader(http.StatusUnprocessableEntity)
page := newTaxesPageWithForm(r.Context(), conn, company, form)
page.MustRender(w, r)
return
}
conn.MustExec(r.Context(), "insert into tax (company_id, tax_class_id, name, rate) values ($1, $2, $3, $4 / 100::decimal)", company.Id, form.Class, form.Name, form.Rate.Integer())
if IsHTMxRequest(r) {
mustRenderTaxForm(w, r, newTaxForm(r.Context(), conn, company, locale))
} else {
http.Redirect(w, r, companyURI(company, "/tax-details"), http.StatusSeeOther)
htmxRedirect(w, r, companyURI(company, "/taxes"))
}
func HandleDeleteCompanyTax(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
taxId, err := strconv.Atoi(params[0].Value)
if err != nil {
http.NotFound(w, r)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
conn := getConn(r)
conn.MustExec(r.Context(), "delete from tax where tax_id = $1", taxId)
company := mustGetCompany(r)
htmxRedirect(w, r, companyURI(company, "/taxes"))
}
func servePaymentMethods(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
conn := getConn(r)
company := mustGetCompany(r)
locale := getLocale(r)
page := newPaymentMethodsPage(r.Context(), conn, company, locale)
page.MustRender(w, r)
}
type PaymentMethodsPage struct {
PaymentMethods []*PaymentMethod
UpdateDefaultError error
Form *paymentMethodForm
}
func newPaymentMethodsPage(ctx context.Context, conn *Conn, company *Company, locale *Locale) *PaymentMethodsPage {
form := newPaymentMethodForm(locale)
return newPaymentMethodsPageWithForm(ctx, conn, company, form)
}
func newPaymentMethodsPageWithForm(ctx context.Context, conn *Conn, company *Company, form *paymentMethodForm) *PaymentMethodsPage {
return &PaymentMethodsPage{
PaymentMethods: mustCollectPaymentMethods(ctx, conn, company),
Form: form,
}
}
func (page *PaymentMethodsPage) MustRender(w http.ResponseWriter, r *http.Request) {
mustRenderMainTemplate(w, r, "payments/methods/index.gohtml", page)
}
func mustCollectPaymentMethods(ctx context.Context, conn *Conn, company *Company) []*PaymentMethod {
return mustCollectPaymentMethodsWhere(ctx, conn, "company_id = $1", company.Id)
}
func mustCollectPaymentMethod(ctx context.Context, conn *Conn, paymentMethodId int) *PaymentMethod {
methods := mustCollectPaymentMethodsWhere(ctx, conn, "payment_method_id = $1", paymentMethodId)
if len(methods) == 0 {
return nil
}
return methods[0]
}
func mustCollectPaymentMethodsWhere(ctx context.Context, conn *Conn, where string, value any) []*PaymentMethod {
rows, err := conn.Query(ctx, fmt.Sprintf(`
select payment_method_id
, name
, instructions
, payment_method_id = default_payment_method_id
from payment_method
join company using (company_id)
where %s
order by name
`, where), value)
if err != nil {
panic(err)
}
defer rows.Close()
var methods []*PaymentMethod
for rows.Next() {
method := &PaymentMethod{}
err = rows.Scan(&method.Id, &method.Name, &method.Instructions, &method.IsDefault)
if err != nil {
panic(err)
}
methods = append(methods, method)
}
if rows.Err() != nil {
panic(rows.Err())
}
return methods
}
type paymentMethodForm struct {
locale *Locale
Name *InputField
Instructions *InputField
locale *Locale
PaymentMethodId int
Name *InputField
Instructions *InputField
}
func newPaymentMethodForm(locale *Locale) *paymentMethodForm {
@ -662,7 +778,7 @@ func newPaymentMethodForm(locale *Locale) *paymentMethodForm {
locale: locale,
Name: &InputField{
Name: "method_name",
Label: pgettext("input", "Payment method name", locale),
Label: pgettext("input", "Invoicing method name", locale),
Type: "text",
Required: true,
},
@ -675,6 +791,25 @@ func newPaymentMethodForm(locale *Locale) *paymentMethodForm {
}
}
func (form *paymentMethodForm) MustRender(w http.ResponseWriter, r *http.Request) {
mustRenderStandaloneTemplate(w, r, "payments/methods/edit.gohtml", form)
}
func (form *paymentMethodForm) MustFillFromDatabase(ctx context.Context, conn *Conn, paymentMethodId int) bool {
if notFoundErrorOrPanic(conn.QueryRow(ctx, `
select name
, instructions
from payment_method
where payment_method_id = $1
`, paymentMethodId).Scan(
form.Name,
form.Instructions)) {
return false
}
form.PaymentMethodId = paymentMethodId
return true
}
func (form *paymentMethodForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
@ -686,30 +821,11 @@ func (form *paymentMethodForm) Parse(r *http.Request) error {
func (form *paymentMethodForm) Validate() bool {
validator := newFormValidator()
validator.CheckRequiredInput(form.Name, gettext("Payment method name can not be empty.", form.locale))
validator.CheckRequiredInput(form.Name, gettext("Invoicing method name can not be empty.", form.locale))
validator.CheckRequiredInput(form.Instructions, gettext("Payment instructions can not be empty.", form.locale))
return validator.AllOK()
}
func HandleDeletePaymentMethod(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
paymentMethodId, err := strconv.Atoi(params[0].Value)
if err != nil {
http.NotFound(w, r)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
conn := getConn(r)
conn.MustExec(r.Context(), "delete from payment_method where payment_method_id = $1", paymentMethodId)
if IsHTMxRequest(r) {
w.WriteHeader(http.StatusOK)
} else {
http.Redirect(w, r, companyURI(mustGetCompany(r), "/tax-details"), http.StatusSeeOther)
}
}
func HandleAddPaymentMethod(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
locale := getLocale(r)
conn := getConn(r)
@ -724,16 +840,157 @@ func HandleAddPaymentMethod(w http.ResponseWriter, r *http.Request, _ httprouter
return
}
if !form.Validate() {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
mustRenderPaymentMethodForm(w, r, form)
w.WriteHeader(http.StatusUnprocessableEntity)
page := newPaymentMethodsPageWithForm(r.Context(), conn, company, form)
page.MustRender(w, r)
return
}
conn.MustExec(r.Context(), "insert into payment_method (company_id, name, instructions) values ($1, $2, $3)", company.Id, form.Name, form.Instructions)
if IsHTMxRequest(r) {
mustRenderPaymentMethodForm(w, r, newPaymentMethodForm(locale))
} else {
http.Redirect(w, r, companyURI(company, "/tax-details"), http.StatusSeeOther)
}
htmxRedirect(w, r, companyURI(company, "/payment-methods"))
}
func HandleUpdatePaymentMethod(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
if params[0].Value == "default" {
HandleUpdateDefaultPaymentMethod(w, r)
return
}
paymentMethodId, err := strconv.Atoi(params[0].Value)
if err != nil {
http.NotFound(w, r)
return
}
locale := getLocale(r)
conn := getConn(r)
form := newPaymentMethodForm(locale)
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if len(r.Form["cancel"]) == 0 {
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if !form.Validate() {
w.WriteHeader(http.StatusUnprocessableEntity)
form.MustRender(w, r)
return
}
newName := conn.MustGetText(r.Context(), "", "update payment_method set name = $1, instructions = $2 where payment_method_id = $3 returning name", form.Name, form.Instructions, paymentMethodId)
if newName == "" {
http.NotFound(w, r)
return
}
}
paymentMethod := mustCollectPaymentMethod(r.Context(), conn, paymentMethodId)
if paymentMethod == nil {
http.NotFound(w, r)
return
}
mustRenderStandaloneTemplateFragment(w, r, "payments/methods/index.gohtml", "row", paymentMethod)
}
func HandleUpdateDefaultPaymentMethod(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
locale := getLocale(r)
conn := getConn(r)
company := mustGetCompany(r)
defaultPaymentId, err := strconv.Atoi(r.FormValue("default_payment_id"))
if err != nil {
w.WriteHeader(http.StatusUnprocessableEntity)
page := newPaymentMethodsPage(r.Context(), conn, company, locale)
page.UpdateDefaultError = fmt.Errorf(gettext("Selected payment method is not valid.", locale))
page.MustRender(w, r)
return
}
conn.MustExec(r.Context(), "update company set default_payment_method_id = $1 where company_id = $2", defaultPaymentId, company.Id)
htmxRedirect(w, r, companyURI(company, "/payment-methods"))
}
func servePaymentMethodEditForm(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
paymentMethodId, err := strconv.Atoi(params[0].Value)
if err != nil {
http.NotFound(w, r)
return
}
locale := getLocale(r)
form := newPaymentMethodForm(locale)
conn := getConn(r)
if !form.MustFillFromDatabase(r.Context(), conn, paymentMethodId) {
http.NotFound(w, r)
return
}
form.MustRender(w, r)
}
func HandleDeletePaymentMethod(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
paymentMethodId, err := strconv.Atoi(params[0].Value)
if err != nil {
http.NotFound(w, r)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
conn := getConn(r)
conn.MustExec(r.Context(), "delete from payment_method where payment_method_id = $1", paymentMethodId)
company := mustGetCompany(r)
htmxRedirect(w, r, companyURI(company, "/payment-methods"))
}
func GetCompanySwitcher(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
page := &CompanySwitchPage{
Companies: mustCollectUserCompanies(r.Context(), getConn(r)),
}
page.MustRender(w, r)
}
type CompanySwitchPage struct {
Companies []*UserCompany
}
type UserCompany struct {
Name string
Slug string
}
func (page *CompanySwitchPage) MustRender(w http.ResponseWriter, r *http.Request) {
mustRenderModalTemplate(w, r, "company/switch.gohtml", page)
}
func mustCollectUserCompanies(ctx context.Context, conn *Conn) []*UserCompany {
rows, err := conn.Query(ctx, "select business_name::text, slug::text from company order by business_name")
if err != nil {
panic(err)
}
defer rows.Close()
var companies []*UserCompany
for rows.Next() {
company := &UserCompany{}
err = rows.Scan(&company.Name, &company.Slug)
if err != nil {
panic(err)
}
companies = append(companies, company)
}
if rows.Err() != nil {
panic(rows.Err())
}
return companies
}

View File

@ -53,6 +53,10 @@ func GetContactForm(w http.ResponseWriter, r *http.Request, params httprouter.Pa
mustRenderNewContactForm(w, r, form)
return
}
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
if !form.MustFillFromDatabase(r.Context(), conn, slug) {
http.NotFound(w, r)
return
@ -93,9 +97,7 @@ func HandleAddContact(w http.ResponseWriter, r *http.Request, _ httprouter.Param
return
}
if !form.Validate(r.Context(), conn) {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
w.WriteHeader(http.StatusUnprocessableEntity)
mustRenderNewContactForm(w, r, form)
return
}
@ -120,7 +122,12 @@ func HandleUpdateContact(w http.ResponseWriter, r *http.Request, params httprout
mustRenderEditContactForm(w, r, params[0].Value, form)
return
}
slug := conn.MustGetText(r.Context(), "", "select edit_contact($1, $2, $3, $4, $5, $6, $7, $8, $9)", params[0].Value, form.Name, form.Phone, form.Email, form.Web, form.TaxDetails(), form.IBAN, form.BIC, form.Tags)
slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
slug = conn.MustGetText(r.Context(), "", "select edit_contact($1, $2, $3, $4, $5, $6, $7, $8, $9)", slug, form.Name, form.Phone, form.Email, form.Web, form.TaxDetails(), form.IBAN, form.BIC, form.Tags)
if slug == "" {
http.NotFound(w, r)
}
@ -172,6 +179,11 @@ func (form *contactFilterForm) Parse(r *http.Request) error {
return nil
}
func (form *contactFilterForm) HasValue() bool {
return form.Name.HasValue() ||
form.Tags.HasValue()
}
func mustCollectContactEntries(ctx context.Context, conn *Conn, company *Company, filters *contactFilterForm) []*ContactEntry {
args := []interface{}{company.Id}
where := []string{"contact.company_id = $1"}
@ -477,36 +489,11 @@ func (form *contactForm) TaxDetails() *CustomerTaxDetails {
}
func ServeEditContactTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
conn := getConn(r)
locale := getLocale(r)
company := getCompany(r)
slug := params[0].Value
form := newTagsForm(companyURI(company, "/contacts/"+slug+"/tags"), slug, locale)
if notFoundErrorOrPanic(conn.QueryRow(r.Context(), `select tags from contact where slug = $1`, form.Slug).Scan(form.Tags)) {
http.NotFound(w, r)
return
}
mustRenderStandaloneTemplate(w, r, "tags/edit.gohtml", form)
serveTagsEditForm(w, r, params, "/contacts/", "select tags from contact where slug = $1")
}
func HandleUpdateContactTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
locale := getLocale(r)
conn := getConn(r)
company := getCompany(r)
slug := params[0].Value
form := newTagsForm(companyURI(company, "/contacts/"+slug+"/tags/edit"), slug, locale)
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if conn.MustGetText(r.Context(), "", "update contact set tags = $1 where slug = $2 returning slug", form.Tags, form.Slug) == "" {
http.NotFound(w, r)
}
mustRenderStandaloneTemplate(w, r, "tags/view.gohtml", form)
handleUpdateTags(w, r, params, "/contacts/", "update contact set tags = $1 where slug = $2 returning slug")
}
func ServeImportPage(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {

View File

@ -66,7 +66,7 @@ func ServeDashboard(w http.ResponseWriter, r *http.Request, _ httprouter.Params)
rows := conn.MustQuery(r.Context(), fmt.Sprintf(`
select to_price(0, decimal_digits) as sales
, to_price(coalesce(invoice.total, 0), decimal_digits) as income
, to_price(coalesce(expense.total, 0), decimal_digits) as expenses
, to_price(coalesce(expense.total, 0) + coalesce(expense_tax.vat, 0) + coalesce(expense_tax.irpf, 0), decimal_digits) as expenses
, to_price(coalesce(invoice_tax.vat, 0) - coalesce(expense_tax.vat, 0), decimal_digits) as vat
, to_price(coalesce(invoice_tax.irpf, 0) + coalesce(expense_tax.irpf, 0), decimal_digits) as irpf
, to_price(coalesce(invoice.total, 0) - coalesce(expense.total, 0) - (coalesce(invoice_tax.vat, 0) - coalesce(expense_tax.vat, 0)) + coalesce(expense_tax.irpf, 0), decimal_digits) as net_income
@ -201,9 +201,21 @@ func buildDashboardChart(ctx context.Context, conn *Conn, locale *Locale, compan
) as invoice
left join (
select to_char(date.invoice_date, '%[3]s')::integer as date
, sum(amount)::integer as total
, sum(subtotal + taxes)::integer as total
from generate_series(%[1]s, %[2]s, interval '1 day') as date(invoice_date)
left join expense on expense.invoice_date = date.invoice_date and company_id = $1
left join (
select expense_id
, invoice_date
, expense.amount as subtotal
, coalesce(sum(tax.amount)::integer, 0) as taxes
from expense
left join expense_tax_amount as tax using (expense_id)
where company_id = $1
group by expense_id
, invoice_date
, expense.amount
) as expense
on expense.invoice_date = date.invoice_date
group by date
) as expense using (date)
order by date

View File

@ -13,19 +13,28 @@ import (
)
type ExpenseEntry struct {
ID int
Slug string
InvoiceDate time.Time
InvoiceNumber string
Amount string
Taxes map[string]string
Total string
InvoicerName string
OriginalFileName string
Tags []string
Status string
StatusLabel string
}
type expensesIndexPage struct {
Expenses []*ExpenseEntry
TotalAmount string
Filters *expenseFilterForm
Expenses []*ExpenseEntry
SumAmount string
SumTaxes map[string]string
SumTotal string
Filters *expenseFilterForm
TaxClasses []string
ExpenseStatuses map[string]string
}
func IndexExpenses(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
@ -38,38 +47,66 @@ func IndexExpenses(w http.ResponseWriter, r *http.Request, _ httprouter.Params)
return
}
page := &expensesIndexPage{
Expenses: mustCollectExpenseEntries(r.Context(), conn, filters),
TotalAmount: mustComputeExpensesTotalAmount(r.Context(), conn, filters),
Filters: filters,
Expenses: mustCollectExpenseEntries(r.Context(), conn, locale, filters),
ExpenseStatuses: mustCollectExpenseStatuses(r.Context(), conn, locale),
TaxClasses: mustCollectTaxClasses(r.Context(), conn, company),
Filters: filters,
}
page.mustComputeExpensesTotalAmount(r.Context(), conn, filters)
mustRenderMainTemplate(w, r, "expenses/index.gohtml", page)
}
func mustCollectExpenseEntries(ctx context.Context, conn *Conn, filters *expenseFilterForm) []*ExpenseEntry {
where, args := filters.BuildQuery(nil)
func mustCollectExpenseEntries(ctx context.Context, conn *Conn, locale *Locale, filters *expenseFilterForm) []*ExpenseEntry {
where, args := filters.BuildQuery([]interface{}{locale.Language.String()})
rows := conn.MustQuery(ctx, fmt.Sprintf(`
select expense.slug
select expense_id
, expense.slug
, invoice_date
, invoice_number
, to_price(amount, decimal_digits)
, to_price(expense.amount, decimal_digits) as amount
, array_agg(array[tax_class.name, to_price(coalesce(expense_tax.amount, 0), decimal_digits)]) filter (where tax_class.name is not null)
, to_price(expense.amount + coalesce(sum(expense_tax.amount)::integer, 0), decimal_digits) as total
, contact.name
, coalesce(attachment.original_filename, '')
, expense.tags
, expense.expense_status
, esi18n.name
from expense
left join expense_attachment as attachment using (expense_id)
left join expense_tax_amount as expense_tax using (expense_id)
left join tax using (tax_id)
left join tax_class using (tax_class_id)
join contact using (contact_id)
join expense_status_i18n esi18n on expense.expense_status = esi18n.expense_status and esi18n.lang_tag = $1
join currency using (currency_code)
where (%s)
order by invoice_date
group by expense_id
, expense.slug
, invoice_date
, invoice_number
, expense.amount
, decimal_digits
, contact.name
, attachment.original_filename
, expense.tags
, expense.expense_status
, esi18n.name
order by invoice_date desc, contact.name, total desc
`, where), args...)
defer rows.Close()
var entries []*ExpenseEntry
for rows.Next() {
entry := &ExpenseEntry{}
if err := rows.Scan(&entry.Slug, &entry.InvoiceDate, &entry.InvoiceNumber, &entry.Amount, &entry.InvoicerName, &entry.OriginalFileName, &entry.Tags); err != nil {
entry := &ExpenseEntry{
Taxes: make(map[string]string),
}
var taxes [][]string
if err := rows.Scan(&entry.ID, &entry.Slug, &entry.InvoiceDate, &entry.InvoiceNumber, &entry.Amount, &taxes, &entry.Total, &entry.InvoicerName, &entry.OriginalFileName, &entry.Tags, &entry.Status, &entry.StatusLabel); err != nil {
panic(err)
}
for _, tax := range taxes {
entry.Taxes[tax[0]] = tax[1]
}
entries = append(entries, entry)
}
if rows.Err() != nil {
@ -79,15 +116,96 @@ func mustCollectExpenseEntries(ctx context.Context, conn *Conn, filters *expense
return entries
}
func mustComputeExpensesTotalAmount(ctx context.Context, conn *Conn, filters *expenseFilterForm) string {
func mustCollectExpenseStatuses(ctx context.Context, conn *Conn, locale *Locale) map[string]string {
rows := conn.MustQuery(ctx, `
select expense_status.expense_status
, esi18n.name
from expense_status
join expense_status_i18n esi18n using(expense_status)
where esi18n.lang_tag = $1
order by expense_status`, locale.Language.String())
defer rows.Close()
statuses := map[string]string{}
for rows.Next() {
var key, name string
if err := rows.Scan(&key, &name); err != nil {
panic(err)
}
statuses[key] = name
}
if rows.Err() != nil {
panic(rows.Err())
}
return statuses
}
func (page *expensesIndexPage) mustComputeExpensesTotalAmount(ctx context.Context, conn *Conn, filters *expenseFilterForm) {
where, args := filters.BuildQuery(nil)
return conn.MustGetText(ctx, "0", fmt.Sprintf(`
select to_price(sum(amount)::integer, decimal_digits)
from expense
row := conn.QueryRow(ctx, fmt.Sprintf(`
select to_price(sum(subtotal)::integer, decimal_digits)
, to_price(sum(subtotal + taxes)::integer, decimal_digits)
from (
select expense_id
, expense.amount as subtotal
, coalesce(sum(tax.amount)::integer, 0) as taxes
, currency_code
from expense
left join expense_tax_amount as tax using (expense_id)
where (%s)
group by expense_id
, expense.amount
, currency_code
) as expense
join currency using (currency_code)
where (%s)
group by decimal_digits
`, where), args...)
if notFoundErrorOrPanic(row.Scan(&page.SumAmount, &page.SumTotal)) {
page.SumAmount = "0.0"
page.SumTotal = "0.0"
}
row = conn.QueryRow(ctx, fmt.Sprintf(`
select array_agg(array[tax_class_name, to_price(coalesce(tax_amount, 0), decimal_digits)]) filter (where tax_class_name is not null)
from (
select tax_class.name as tax_class_name
, coalesce(sum(expense_tax.amount)::integer, 0) as tax_amount
, currency_code
from expense
left join expense_tax_amount as expense_tax using (expense_id)
left join tax using (tax_id)
left join tax_class using (tax_class_id)
where (%s)
group by tax_class.name
, currency_code
) as tax
join currency using (currency_code)
group by decimal_digits
`, where), args...)
var taxes [][]string
if notFoundErrorOrPanic(row.Scan(&taxes)) {
// well, nothing to do
}
page.SumTaxes = make(map[string]string)
for _, tax := range taxes {
page.SumTaxes[tax[0]] = tax[1]
}
}
func mustCollectTaxClasses(ctx context.Context, conn *Conn, company *Company) []string {
rows := conn.MustQuery(ctx, "select name from tax_class where company_id = $1", company.Id)
defer rows.Close()
var taxClasses []string
for rows.Next() {
var taxClass string
if err := rows.Scan(&taxClass); err != nil {
panic(err)
}
taxClasses = append(taxClasses, taxClass)
}
return taxClasses
}
func ServeExpenseForm(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
@ -102,6 +220,10 @@ func ServeExpenseForm(w http.ResponseWriter, r *http.Request, params httprouter.
mustRenderNewExpenseForm(w, r, form)
return
}
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
if !form.MustFillFromDatabase(r.Context(), conn, slug) {
http.NotFound(w, r)
return
@ -113,20 +235,40 @@ func ServeExpenseForm(w http.ResponseWriter, r *http.Request, params httprouter.
func mustRenderNewExpenseForm(w http.ResponseWriter, r *http.Request, form *expenseForm) {
locale := getLocale(r)
form.Invoicer.EmptyLabel = gettext("Select a contact.", locale)
mustRenderMainTemplate(w, r, "expenses/new.gohtml", form)
page := newNewExpensePage(form, r)
mustRenderMainTemplate(w, r, "expenses/new.gohtml", page)
}
type newExpensePage struct {
Form *expenseForm
Taxes [][]string
Total string
}
func newNewExpensePage(form *expenseForm, r *http.Request) *newExpensePage {
page := &newExpensePage{
Form: form,
}
conn := getConn(r)
company := mustGetCompany(r)
err := conn.QueryRow(r.Context(), "select taxes, total from compute_new_expense_amount($1, $2, $3)", company.Id, form.Amount, form.Tax.Selected).Scan(&page.Taxes, &page.Total)
if err != nil {
panic(err)
}
return page
}
func mustRenderEditExpenseForm(w http.ResponseWriter, r *http.Request, slug string, form *expenseForm) {
page := &editExpensePage{
Slug: slug,
Form: form,
newNewExpensePage(form, r),
slug,
}
mustRenderMainTemplate(w, r, "expenses/edit.gohtml", page)
}
type editExpensePage struct {
*newExpensePage
Slug string
Form *expenseForm
}
type expenseForm struct {
@ -142,6 +284,7 @@ type expenseForm struct {
}
func newExpenseForm(ctx context.Context, conn *Conn, locale *Locale, company *Company) *expenseForm {
triggerRecompute := template.HTMLAttr(`data-hx-on="change: this.dispatchEvent(new CustomEvent('recompute', {bubbles: true}))"`)
return &expenseForm{
locale: locale,
company: company,
@ -167,6 +310,9 @@ func newExpenseForm(ctx context.Context, conn *Conn, locale *Locale, company *Co
Label: pgettext("input", "Taxes", locale),
Multiple: true,
Options: mustGetTaxOptions(ctx, conn, company),
Attributes: []template.HTMLAttr{
triggerRecompute,
},
},
Amount: &InputField{
Name: "amount",
@ -174,6 +320,7 @@ func newExpenseForm(ctx context.Context, conn *Conn, locale *Locale, company *Co
Type: "number",
Required: true,
Attributes: []template.HTMLAttr{
triggerRecompute,
`min="0"`,
template.HTMLAttr(fmt.Sprintf(`step="%v"`, company.MinCents())),
},
@ -190,6 +337,16 @@ func newExpenseForm(ctx context.Context, conn *Conn, locale *Locale, company *Co
}
}
func mustGetExpenseStatusOptions(ctx context.Context, conn *Conn, locale *Locale) []*SelectOption {
return MustGetOptions(ctx, conn, `
select expense_status.expense_status
, esi18n.name
from expense_status
join expense_status_i18n esi18n using(expense_status)
where esi18n.lang_tag = $1
order by expense_status`, locale.Language.String())
}
func (form *expenseForm) Parse(r *http.Request) error {
if err := r.ParseMultipartForm(form.File.MaxSize); err != nil {
return err
@ -215,18 +372,16 @@ func (form *expenseForm) Validate() bool {
if validator.CheckRequiredInput(form.Amount, gettext("Amount can not be empty.", form.locale)) {
validator.CheckValidDecimal(form.Amount, form.company.MinCents(), math.MaxFloat64, gettext("Amount must be a number greater than zero.", form.locale))
}
validator.CheckValidSelectOption(form.Tax, gettext("Selected tax is not valid.", form.locale))
validator.CheckAtMostOneOfEachGroup(form.Tax, gettext("You can only select a tax of each class.", form.locale))
return validator.AllOK()
}
func (form *expenseForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool {
return !notFoundErrorOrPanic(conn.QueryRow(ctx, `
if notFoundErrorOrPanic(conn.QueryRow(ctx, `
select contact_id
, invoice_number
, invoice_date
, to_price(amount, decimal_digits)
, array_agg(tax_id)
, array_agg(tax_id) filter ( where tax_id is not null )
, tags
from expense
left join expense_tax using (expense_id)
@ -244,36 +399,14 @@ func (form *expenseForm) MustFillFromDatabase(ctx context.Context, conn *Conn, s
form.InvoiceDate,
form.Amount,
form.Tax,
form.Tags))
form.Tags)) {
return false
}
if len(form.Tax.Selected) == 1 && form.Tax.Selected[0] == "" {
form.Tax.Selected = nil
}
return true
}
func HandleAddExpense(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
conn := getConn(r)
locale := getLocale(r)
company := mustGetCompany(r)
form := newExpenseForm(r.Context(), conn, locale, company)
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if !form.Validate() {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
mustRenderNewExpenseForm(w, r, form)
return
}
taxes := mustSliceAtoi(form.Tax.Selected)
slug := conn.MustGetText(r.Context(), "", "select add_expense($1, $2, $3, $4, $5, $6, $7)", company.Id, form.InvoiceDate, form.Invoicer, form.InvoiceNumber, form.Amount, taxes, form.Tags)
if len(form.File.Content) > 0 {
conn.MustQuery(r.Context(), "select attach_to_expense($1, $2, $3, $4)", slug, form.File.OriginalFileName, form.File.ContentType, form.File.Content)
}
htmxRedirect(w, r, companyURI(company, "/expenses"))
}
func HandleUpdateExpense(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
conn := getConn(r)
locale := getLocale(r)
@ -288,10 +421,12 @@ func HandleUpdateExpense(w http.ResponseWriter, r *http.Request, params httprout
return
}
slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
if !form.Validate() {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
w.WriteHeader(http.StatusUnprocessableEntity)
mustRenderEditExpenseForm(w, r, slug, form)
return
}
@ -313,6 +448,7 @@ type expenseFilterForm struct {
InvoiceNumber *InputField
FromDate *InputField
ToDate *InputField
ExpenseStatus *SelectField
Tags *TagsField
TagsCondition *ToggleField
}
@ -346,6 +482,12 @@ func newExpenseFilterForm(ctx context.Context, conn *Conn, locale *Locale, compa
Name: "tags",
Label: pgettext("input", "Tags", locale),
},
ExpenseStatus: &SelectField{
Name: "expense_status",
Label: pgettext("input", "Expense Status", locale),
EmptyLabel: gettext("All status", locale),
Options: mustGetExpenseStatusOptions(ctx, conn, locale),
},
TagsCondition: &ToggleField{
Name: "tags_condition",
Label: pgettext("input", "Tags Condition", locale),
@ -372,11 +514,21 @@ func (form *expenseFilterForm) Parse(r *http.Request) error {
form.InvoiceNumber.FillValue(r)
form.FromDate.FillValue(r)
form.ToDate.FillValue(r)
form.ExpenseStatus.FillValue(r)
form.Tags.FillValue(r)
form.TagsCondition.FillValue(r)
return nil
}
func (form *expenseFilterForm) HasValue() bool {
return form.Contact.HasValue() ||
form.InvoiceNumber.HasValue() ||
form.FromDate.HasValue() ||
form.ToDate.HasValue() ||
form.ExpenseStatus.HasValue() ||
form.Tags.HasValue()
}
func (form *expenseFilterForm) BuildQuery(args []interface{}) (string, []interface{}) {
var where []string
appendWhere := func(expression string, value interface{}) {
@ -398,6 +550,7 @@ func (form *expenseFilterForm) BuildQuery(args []interface{}) (string, []interfa
customerId, _ := strconv.Atoi(form.Contact.Selected[0])
return customerId
})
maybeAppendWhere("expense.expense_status = $%d", form.ExpenseStatus.String(), nil)
maybeAppendWhere("invoice_number = $%d", form.InvoiceNumber.String(), nil)
maybeAppendWhere("invoice_date >= $%d", form.FromDate.String(), nil)
maybeAppendWhere("invoice_date <= $%d", form.ToDate.String(), nil)
@ -413,24 +566,51 @@ func (form *expenseFilterForm) BuildQuery(args []interface{}) (string, []interfa
}
func ServeEditExpenseTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
conn := getConn(r)
locale := getLocale(r)
company := getCompany(r)
slug := params[0].Value
form := newTagsForm(companyURI(company, "/expenses/"+slug+"/tags"), slug, locale)
if notFoundErrorOrPanic(conn.QueryRow(r.Context(), `select tags from expense where slug = $1`, form.Slug).Scan(form.Tags)) {
http.NotFound(w, r)
return
}
mustRenderStandaloneTemplate(w, r, "tags/edit.gohtml", form)
serveTagsEditForm(w, r, params, "/expenses/", "select tags from expense where slug = $1")
}
func HandleUpdateExpenseTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
handleUpdateTags(w, r, params, "/expenses/", "update expense set tags = $1 where slug = $2 returning slug")
}
func ServeExpenseAttachment(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
serveAttachment(w, r, params, `
select mime_type
, content
from expense
join expense_attachment using (expense_id)
where slug = $1
`)
}
func HandleEditExpenseAction(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
slug := params[0].Value
switch slug {
case "batch":
HandleBatchExpenseAction(w, r, params)
default:
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
actionUri := fmt.Sprintf("/invoices/%s/edit", slug)
handleExpenseAction(w, r, actionUri, func(w http.ResponseWriter, r *http.Request, form *expenseForm) {
mustRenderEditExpenseForm(w, r, slug, form)
})
}
}
func HandleNewExpenseAction(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
handleExpenseAction(w, r, "/expenses", mustRenderNewExpenseForm)
}
type renderExpenseFormFunc func(w http.ResponseWriter, r *http.Request, form *expenseForm)
func handleExpenseAction(w http.ResponseWriter, r *http.Request, action string, renderForm renderExpenseFormFunc) {
locale := getLocale(r)
conn := getConn(r)
company := getCompany(r)
slug := params[0].Value
form := newTagsForm(companyURI(company, "/expenses/"+slug+"/tags/edit"), slug, locale)
company := mustGetCompany(r)
form := newExpenseForm(r.Context(), conn, locale, company)
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@ -439,29 +619,117 @@ func HandleUpdateExpenseTags(w http.ResponseWriter, r *http.Request, params http
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if conn.MustGetText(r.Context(), "", "update expense set tags = $1 where slug = $2 returning slug", form.Tags, form.Slug) == "" {
http.NotFound(w, r)
actionField := r.Form.Get("action")
switch actionField {
case "update":
// Nothing else to do
w.WriteHeader(http.StatusOK)
renderForm(w, r, form)
case "add":
if !form.Validate() {
w.WriteHeader(http.StatusUnprocessableEntity)
renderForm(w, r, form)
return
}
taxes := mustSliceAtoi(form.Tax.Selected)
slug := conn.MustGetText(r.Context(), "", "select add_expense($1, $2, $3, $4, $5, $6, $7)", company.Id, form.InvoiceDate, form.Invoicer, form.InvoiceNumber, form.Amount, taxes, form.Tags)
if len(form.File.Content) > 0 {
conn.MustQuery(r.Context(), "select attach_to_expense($1, $2, $3, $4)", slug, form.File.OriginalFileName, form.File.ContentType, form.File.Content)
}
htmxRedirect(w, r, companyURI(company, action))
default:
http.Error(w, gettext("Invalid action", locale), http.StatusBadRequest)
}
mustRenderStandaloneTemplate(w, r, "tags/view.gohtml", form)
}
func ServeExpenseAttachment(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
func HandleBatchExpenseAction(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
locale := getLocale(r)
switch r.Form.Get("action") {
case "export":
conn := getConn(r)
company := getCompany(r)
filters := newExpenseFilterForm(r.Context(), conn, locale, company)
if err := filters.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
entries := mustCollectExpenseEntries(r.Context(), conn, locale, filters)
vatin := mustCollectExpenseEntriesVATIN(r.Context(), conn, entries)
lastPaymentDate := mustCollectExpenseEntriesLastPaymentDate(r.Context(), conn, entries)
taxes := mustCollectExpenseEntriesTaxes(r.Context(), conn, entries)
taxColumns := mustCollectTaxColumns(r.Context(), conn, company)
ods := mustWriteExpensesOds(entries, vatin, lastPaymentDate, taxes, taxColumns, locale, company)
writeOdsResponse(w, ods, gettext("expenses.ods", locale))
default:
http.Error(w, gettext("Invalid action", locale), http.StatusBadRequest)
}
}
func mustCollectExpenseEntriesTaxes(ctx context.Context, conn *Conn, entries []*ExpenseEntry) map[int]taxMap {
ids := mustMakeIDArray(entries, func(entry *ExpenseEntry) int {
return entry.ID
})
return mustMakeTaxMap(ctx, conn, ids, `
select expense_id
, tax_id
, to_price(tax.amount, decimal_digits)
from expense_tax_amount as tax
join expense using (expense_id)
join currency using (currency_code)
where expense_id = any ($1)
`)
}
func mustCollectExpenseEntriesVATIN(ctx context.Context, conn *Conn, entries []*ExpenseEntry) map[int]string {
ids := mustMakeIDArray(entries, func(entry *ExpenseEntry) int {
return entry.ID
})
return mustMakeVATINMap(ctx, conn, ids, `
select expense_id
, vatin::text
from contact_tax_details as tax
join expense using (contact_id)
where expense_id = any ($1)
`)
}
func mustCollectExpenseEntriesLastPaymentDate(ctx context.Context, conn *Conn, entries []*ExpenseEntry) map[int]time.Time {
ids := mustMakeIDArray(entries, func(entry *ExpenseEntry) int {
return entry.ID
})
return mustMakeDateMap(ctx, conn, ids, `
select expense_id
, max(payment_date)
from expense_payment
join payment using (payment_id)
where expense_id = any ($1)
group by expense_id
`)
}
func handleRemoveExpense(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
slug := params[0].Value
conn := getConn(r)
var contentType string
var content []byte
if notFoundErrorOrPanic(conn.QueryRow(r.Context(), `
select mime_type
, content
from expense
join expense_attachment using (expense_id)
where slug = $1
`, slug).Scan(&contentType, &content)) {
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(content)), 10))
w.WriteHeader(http.StatusOK)
w.Write(content)
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
conn := getConn(r)
conn.MustExec(r.Context(), "select remove_expense($1)", slug)
company := mustGetCompany(r)
htmxRedirect(w, r, companyURI(company, "/expenses"))
}

View File

@ -59,6 +59,10 @@ func (field *InputField) Value() (driver.Value, error) {
return field.Val, nil
}
func (field *InputField) HasValue() bool {
return field.Val != ""
}
func (field *InputField) FillValue(r *http.Request) {
field.Val = strings.TrimSpace(r.FormValue(field.Name))
}
@ -184,6 +188,10 @@ func (field *SelectField) Clear() {
field.Selected = []string{}
}
func (field *SelectField) HasValue() bool {
return len(field.Selected) > 0 && field.Selected[0] != ""
}
func MustGetOptions(ctx context.Context, conn *Conn, sql string, args ...interface{}) []*SelectOption {
rows, err := conn.Query(ctx, sql, args...)
if err != nil {
@ -287,6 +295,33 @@ func (field *RadioField) isValidOption(selected string) bool {
return field.FindOption(selected) != nil
}
func (field *RadioField) HasValidOption() bool {
return field.isValidOption(field.Selected)
}
func MustGetRadioOptions(ctx context.Context, conn *Conn, sql string, args ...interface{}) []*RadioOption {
rows, err := conn.Query(ctx, sql, args...)
if err != nil {
panic(err)
}
defer rows.Close()
var options []*RadioOption
for rows.Next() {
option := &RadioOption{}
err = rows.Scan(&option.Value, &option.Label)
if err != nil {
panic(err)
}
options = append(options, option)
}
if rows.Err() != nil {
panic(rows.Err())
}
return options
}
type CheckField struct {
Name string
Label string
@ -366,6 +401,10 @@ func (field *TagsField) Value() (driver.Value, error) {
return field.Tags, nil
}
func (field *TagsField) HasValue() bool {
return len(field.Tags) > 0 && field.Tags[0] != ""
}
func (field *TagsField) Scan(value interface{}) error {
if value == nil {
return nil
@ -416,6 +455,10 @@ func (field *ToggleField) FillValue(r *http.Request) {
}
}
func (field *ToggleField) String() string {
return field.Selected
}
type FormValidator struct {
Valid bool
}
@ -436,6 +479,10 @@ func (v *FormValidator) CheckInputMinLength(field *InputField, min int, message
return v.checkInput(field, len(field.Val) >= min, message)
}
func (v *FormValidator) CheckInputLength(field *InputField, length int, message string) bool {
return v.checkInput(field, len(field.Val) == length, message)
}
func (v *FormValidator) CheckValidEmailInput(field *InputField, message string) bool {
_, err := mail.ParseAddress(field.Val)
return v.checkInput(field, err == nil, message)
@ -465,6 +512,10 @@ func (v *FormValidator) CheckValidSelectOption(field *SelectField, message strin
return v.checkSelect(field, field.HasValidOptions(), message)
}
func (v *FormValidator) CheckValidRadioOption(field *RadioField, message string) bool {
return v.checkRadio(field, field.HasValidOption(), message)
}
func (v *FormValidator) CheckAtMostOneOfEachGroup(field *SelectField, message string) bool {
repeated := false
groups := map[string]bool{}
@ -523,3 +574,11 @@ func (v *FormValidator) checkSelect(field *SelectField, ok bool, message string)
}
return ok
}
func (v *FormValidator) checkRadio(field *RadioField, ok bool, message string) bool {
if !ok {
field.Errors = append(field.Errors, errors.New(message))
v.Valid = false
}
return ok
}

View File

@ -6,6 +6,7 @@ import (
"context"
"errors"
"fmt"
"github.com/jackc/pgtype"
"github.com/julienschmidt/httprouter"
"html/template"
"io"
@ -23,9 +24,11 @@ import (
const removedProductSuffix = ".removed"
type InvoiceEntry struct {
ID int
Slug string
Date time.Time
Number string
Subtotal string
Total string
CustomerName string
Tags []string
@ -61,13 +64,15 @@ func IndexInvoices(w http.ResponseWriter, r *http.Request, _ httprouter.Params)
func mustCollectInvoiceEntries(ctx context.Context, conn *Conn, locale *Locale, filters *invoiceFilterForm) []*InvoiceEntry {
where, args := filters.BuildQuery([]interface{}{locale.Language.String()})
rows := conn.MustQuery(ctx, fmt.Sprintf(`
select invoice.slug
select invoice_id
, invoice.slug
, invoice_date
, invoice_number
, contact.name
, invoice.tags
, invoice.invoice_status
, isi18n.name
, to_price(subtotal, decimal_digits)
, to_price(total, decimal_digits)
from invoice
join contact using (contact_id)
@ -83,7 +88,7 @@ func mustCollectInvoiceEntries(ctx context.Context, conn *Conn, locale *Locale,
var entries []*InvoiceEntry
for rows.Next() {
entry := &InvoiceEntry{}
if err := rows.Scan(&entry.Slug, &entry.Date, &entry.Number, &entry.CustomerName, &entry.Tags, &entry.Status, &entry.StatusLabel, &entry.Total); err != nil {
if err := rows.Scan(&entry.ID, &entry.Slug, &entry.Date, &entry.Number, &entry.CustomerName, &entry.Tags, &entry.Status, &entry.StatusLabel, &entry.Subtotal, &entry.Total); err != nil {
panic(err)
}
entries = append(entries, entry)
@ -152,7 +157,7 @@ func newInvoiceFilterForm(ctx context.Context, conn *Conn, locale *Locale, compa
Name: "invoice_status",
Label: pgettext("input", "Invoice Status", locale),
EmptyLabel: gettext("All status", locale),
Options: MustGetOptions(ctx, conn, "select invoice_status.invoice_status, isi18n.name from invoice_status join invoice_status_i18n isi18n using(invoice_status) where isi18n.lang_tag = $1 order by invoice_status", locale.Language.String()),
Options: mustGetInvoiceStatusOptions(ctx, conn, locale),
},
InvoiceNumber: &InputField{
Name: "number",
@ -205,6 +210,15 @@ func (form *invoiceFilterForm) Parse(r *http.Request) error {
return nil
}
func (form *invoiceFilterForm) HasValue() bool {
return form.Customer.HasValue() ||
form.InvoiceStatus.HasValue() ||
form.InvoiceNumber.HasValue() ||
form.FromDate.HasValue() ||
form.ToDate.HasValue() ||
form.Tags.HasValue()
}
func (form *invoiceFilterForm) BuildQuery(args []interface{}) (string, []interface{}) {
var where []string
appendWhere := func(expression string, value interface{}) {
@ -248,10 +262,9 @@ func ServeInvoice(w http.ResponseWriter, r *http.Request, params httprouter.Para
case "new":
locale := getLocale(r)
form := newInvoiceForm(r.Context(), conn, locale, company)
if invoiceToDuplicate := r.URL.Query().Get("duplicate"); invoiceToDuplicate != "" {
if invoiceToDuplicate := r.URL.Query().Get("duplicate"); ValidUuid(invoiceToDuplicate) {
form.MustFillFromDatabase(r.Context(), conn, invoiceToDuplicate)
form.InvoiceStatus.Selected = []string{"created"}
} else if quoteToInvoice := r.URL.Query().Get("quote"); quoteToInvoice != "" {
} else if quoteToInvoice := r.URL.Query().Get("quote"); ValidUuid(quoteToInvoice) {
form.MustFillFromQuote(r.Context(), conn, quoteToInvoice)
}
form.Date.Val = time.Now().Format("2006-01-02")
@ -280,6 +293,10 @@ func ServeInvoice(w http.ResponseWriter, r *http.Request, params httprouter.Para
pdf = true
slug = slug[:len(slug)-len(".pdf")]
}
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
inv := mustGetInvoice(r.Context(), conn, company, slug)
if inv == nil {
http.NotFound(w, r)
@ -349,6 +366,7 @@ type invoice struct {
HasDiscounts bool
Total string
LegalDisclaimer string
OriginalFileName string
}
type taxDetails struct {
@ -394,11 +412,13 @@ func mustGetInvoice(ctx context.Context, conn *Conn, company *Company, slug stri
, postal_code
, to_price(subtotal, decimal_digits)
, to_price(total, decimal_digits)
, coalesce(attachment.original_filename, '')
from invoice
join payment_method using (payment_method_id)
join contact_tax_details using (contact_id)
join invoice_amount using (invoice_id)
join currency using (currency_code)
left join invoice_attachment as attachment using (invoice_id)
where invoice.slug = $1`, slug).Scan(
&invoiceId,
&decimalDigits,
@ -413,16 +433,67 @@ func mustGetInvoice(ctx context.Context, conn *Conn, company *Company, slug stri
&inv.Invoicee.Province,
&inv.Invoicee.PostalCode,
&inv.Subtotal,
&inv.Total)) {
&inv.Total,
&inv.OriginalFileName)) {
return nil
}
if err := conn.QueryRow(ctx, "select business_name, vatin, phone, email, address, city, province, postal_code, legal_disclaimer from company where company_id = $1", company.Id).Scan(&inv.Invoicer.Name, &inv.Invoicer.VATIN, &inv.Invoicer.Phone, &inv.Invoicer.Email, &inv.Invoicer.Address, &inv.Invoicer.City, &inv.Invoicer.Province, &inv.Invoicer.PostalCode, &inv.LegalDisclaimer); err != nil {
if err := conn.QueryRow(ctx, `
select business_name
, vatin
, phone
, email
, address
, city
, province
, postal_code
, legal_disclaimer
from company
where company_id = $1
`, company.Id).Scan(
&inv.Invoicer.Name,
&inv.Invoicer.VATIN,
&inv.Invoicer.Phone,
&inv.Invoicer.Email,
&inv.Invoicer.Address,
&inv.Invoicer.City,
&inv.Invoicer.Province,
&inv.Invoicer.PostalCode,
&inv.LegalDisclaimer); err != nil {
panic(err)
}
if err := conn.QueryRow(ctx, "select array_agg(array[name, to_price(amount, $2)]) from invoice_tax_amount join tax using (tax_id) where invoice_id = $1", invoiceId, decimalDigits).Scan(&inv.Taxes); err != nil {
if err := conn.QueryRow(ctx, `
select array_agg(array[name, to_price(amount, $2)])
from invoice_tax_amount
join tax using (tax_id)
where invoice_id = $1
`, invoiceId, decimalDigits).Scan(&inv.Taxes); err != nil {
panic(err)
}
rows := conn.MustQuery(ctx, "select invoice_product.name, description, to_price(price, $2), (discount_rate * 100)::integer, quantity, to_price(subtotal, $2), to_price(total, $2), array_agg(array[tax_class.name, (tax_rate * 100)::integer::text]) filter (where tax_rate is not null) from invoice_product join invoice_product_amount using (invoice_product_id) left join invoice_product_tax using (invoice_product_id) left join tax using (tax_id) left join tax_class using (tax_class_id) where invoice_id = $1 group by invoice_product.name, description, discount_rate, price, quantity, subtotal, total", invoiceId, decimalDigits)
rows := conn.MustQuery(ctx, `
select invoice_product.name
, description
, to_price(price, $2)
, (discount_rate * 100)::integer
, quantity
, to_price(subtotal, $2)
, to_price(total, $2)
, array_agg(array[tax_class.name, (tax_rate * 100)::integer::text]) filter (where tax_rate is not null)
from invoice_product
join invoice_product_amount using (invoice_product_id)
left join invoice_product_tax using (invoice_product_id)
left join tax using (tax_id)
left join tax_class using (tax_class_id)
where invoice_id = $1
group by invoice_product_id
, invoice_product.name
, description
, discount_rate
, price
, quantity
, subtotal
, total
order by invoice_product_id
`, invoiceId, decimalDigits)
defer rows.Close()
taxClasses := map[string]bool{}
for rows.Next() {
@ -430,7 +501,15 @@ func mustGetInvoice(ctx context.Context, conn *Conn, company *Company, slug stri
Taxes: make(map[string]int),
}
var taxes [][]string
if err := rows.Scan(&product.Name, &product.Description, &product.Price, &product.Discount, &product.Quantity, &product.Subtotal, &product.Total, &taxes); err != nil {
if err := rows.Scan(
&product.Name,
&product.Description,
&product.Price,
&product.Discount,
&product.Quantity,
&product.Subtotal,
&product.Total,
&taxes); err != nil {
panic(err)
}
for _, tax := range taxes {
@ -540,13 +619,14 @@ func HandleAddInvoice(w http.ResponseWriter, r *http.Request, _ httprouter.Param
return
}
if !form.Validate() {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
w.WriteHeader(http.StatusUnprocessableEntity)
mustRenderNewInvoiceForm(w, r, form)
return
}
slug := conn.MustGetText(r.Context(), "", "select add_invoice($1, $2, $3, $4, $5, $6, $7)", company.Id, form.Date, form.Customer, form.Notes, form.PaymentMethod, form.Tags, NewInvoiceProductArray(form.Products))
if len(form.File.Content) > 0 {
conn.MustQuery(r.Context(), "select attach_to_invoice($1, $2, $3, $4)", slug, form.File.OriginalFileName, form.File.ContentType, form.File.Content)
}
htmxRedirect(w, r, companyURI(company, "/invoices/"+slug))
}
@ -570,14 +650,14 @@ func HandleBatchInvoiceAction(w http.ResponseWriter, r *http.Request, _ httprout
http.Error(w, err.Error(), http.StatusForbidden)
return
}
slugs := r.Form["invoice"]
if len(slugs) == 0 {
http.Redirect(w, r, companyURI(mustGetCompany(r), "/invoices"), http.StatusSeeOther)
return
}
locale := getLocale(r)
switch r.Form.Get("action") {
case "download":
slugs := r.Form["invoice"]
if len(slugs) == 0 {
http.Redirect(w, r, companyURI(mustGetCompany(r), "/invoices"), http.StatusSeeOther)
return
}
invoices := mustWriteInvoicesPdf(r, slugs)
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", gettext("invoices.zip", locale)))
@ -585,11 +665,187 @@ func HandleBatchInvoiceAction(w http.ResponseWriter, r *http.Request, _ httprout
if _, err := w.Write(invoices); err != nil {
panic(err)
}
case "export":
conn := getConn(r)
company := getCompany(r)
filters := newInvoiceFilterForm(r.Context(), conn, locale, company)
if err := filters.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
entries := mustCollectInvoiceEntries(r.Context(), conn, locale, filters)
vatin := mustCollectInvoiceEntriesVATIN(r.Context(), conn, entries)
lastCollectionDate := mustCollectInvoiceEntriesLastCollectionDate(r.Context(), conn, entries)
taxes := mustCollectInvoiceEntriesTaxes(r.Context(), conn, entries)
taxColumns := mustCollectTaxColumns(r.Context(), conn, company)
ods := mustWriteInvoicesOds(entries, vatin, lastCollectionDate, taxes, taxColumns, locale, company)
writeOdsResponse(w, ods, gettext("invoices.ods", locale))
default:
http.Error(w, gettext("Invalid action", locale), http.StatusBadRequest)
}
}
func mustCollectTaxColumns(ctx context.Context, conn *Conn, company *Company) map[int]string {
rows, err := conn.Query(ctx, `
select tax_id
, name
from tax
where company_id = $1
`, company.Id)
if err != nil {
panic(err)
}
defer rows.Close()
columns := make(map[int]string)
for rows.Next() {
var taxID int
var name string
err = rows.Scan(&taxID, &name)
if err != nil {
panic(err)
}
columns[taxID] = name
}
return columns
}
type taxMap map[int]string
func mustCollectInvoiceEntriesTaxes(ctx context.Context, conn *Conn, entries []*InvoiceEntry) map[int]taxMap {
ids := mustMakeIDArray(entries, func(entry *InvoiceEntry) int {
return entry.ID
})
return mustMakeTaxMap(ctx, conn, ids, `
select invoice_id
, tax_id
, to_price(amount, decimal_digits)
from invoice_tax_amount
join invoice using (invoice_id)
join currency using (currency_code)
where invoice_id = any ($1)
`)
}
func mustMakeIDArray[T any](entries []*T, id func(entry *T) int) *pgtype.Int4Array {
ids := make([]int, len(entries))
i := 0
for _, entry := range entries {
ids[i] = id(entry)
i++
}
idArray := &pgtype.Int4Array{}
if err := idArray.Set(ids); err != nil {
panic(err)
}
return idArray
}
func mustMakeTaxMap(ctx context.Context, conn *Conn, ids *pgtype.Int4Array, sql string) map[int]taxMap {
rows, err := conn.Query(ctx, sql, ids)
if err != nil {
panic(err)
}
defer rows.Close()
taxes := make(map[int]taxMap)
for rows.Next() {
var entryID int
var taxID int
var amount string
err := rows.Scan(&entryID, &taxID, &amount)
if err != nil {
panic(err)
}
entryTaxes := taxes[entryID]
if entryTaxes == nil {
entryTaxes = make(taxMap)
taxes[entryID] = entryTaxes
}
entryTaxes[taxID] = amount
}
if rows.Err() != nil {
panic(rows.Err())
}
return taxes
}
func mustCollectInvoiceEntriesVATIN(ctx context.Context, conn *Conn, entries []*InvoiceEntry) map[int]string {
ids := mustMakeIDArray(entries, func(entry *InvoiceEntry) int {
return entry.ID
})
return mustMakeVATINMap(ctx, conn, ids, `
select invoice_id
, vatin::text
from contact_tax_details
join invoice using (contact_id)
where invoice_id = any ($1)
`)
}
func mustMakeVATINMap(ctx context.Context, conn *Conn, ids *pgtype.Int4Array, sql string) map[int]string {
rows, err := conn.Query(ctx, sql, ids)
if err != nil {
panic(err)
}
defer rows.Close()
vatin := make(map[int]string)
for rows.Next() {
var entryID int
var number string
err := rows.Scan(&entryID, &number)
if err != nil {
panic(err)
}
vatin[entryID] = number
}
if rows.Err() != nil {
panic(rows.Err())
}
return vatin
}
func mustCollectInvoiceEntriesLastCollectionDate(ctx context.Context, conn *Conn, entries []*InvoiceEntry) map[int]time.Time {
ids := mustMakeIDArray(entries, func(entry *InvoiceEntry) int {
return entry.ID
})
return mustMakeDateMap(ctx, conn, ids, `
select invoice_id
, max(collection_date)
from invoice_collection
join collection using (collection_id)
where invoice_id = any ($1)
group by invoice_id
`)
}
func mustMakeDateMap(ctx context.Context, conn *Conn, ids *pgtype.Int4Array, sql string) map[int]time.Time {
rows, err := conn.Query(ctx, sql, ids)
if err != nil {
panic(err)
}
defer rows.Close()
dates := make(map[int]time.Time)
for rows.Next() {
var entryID int
var date time.Time
if err := rows.Scan(&entryID, &date); err != nil {
panic(err)
}
dates[entryID] = date
}
if rows.Err() != nil {
panic(rows.Err())
}
return dates
}
func mustWriteInvoicesPdf(r *http.Request, slugs []string) []byte {
conn := getConn(r)
company := mustGetCompany(r)
@ -600,7 +856,7 @@ func mustWriteInvoicesPdf(r *http.Request, slugs []string) []byte {
if inv == nil {
continue
}
f, err := w.Create(inv.Number + ".pdf")
f, err := w.Create(fmt.Sprintf("%s-%s.pdf", inv.Number, slugify(inv.Invoicee.Name)))
if err != nil {
panic(err)
}
@ -614,7 +870,6 @@ type invoiceForm struct {
locale *Locale
company *Company
Number string
InvoiceStatus *SelectField
Customer *SelectField
Date *InputField
Notes *InputField
@ -622,19 +877,13 @@ type invoiceForm struct {
Tags *TagsField
Products []*invoiceProductForm
RemovedProduct *invoiceProductForm
File *FileField
}
func newInvoiceForm(ctx context.Context, conn *Conn, locale *Locale, company *Company) *invoiceForm {
return &invoiceForm{
locale: locale,
company: company,
InvoiceStatus: &SelectField{
Name: "invoice_status",
Required: true,
Label: pgettext("input", "Invoice Status", locale),
Selected: []string{"created"},
Options: MustGetOptions(ctx, conn, "select invoice_status.invoice_status, isi18n.name from invoice_status join invoice_status_i18n isi18n using(invoice_status) where isi18n.lang_tag = $1 order by invoice_status", locale.Language.String()),
},
Customer: &SelectField{
Name: "customer",
Label: pgettext("input", "Customer", locale),
@ -659,23 +908,40 @@ func newInvoiceForm(ctx context.Context, conn *Conn, locale *Locale, company *Co
PaymentMethod: &SelectField{
Name: "payment_method",
Required: true,
Label: pgettext("input", "Payment Method", locale),
Label: pgettext("input", "Invoicing Method", locale),
Selected: []string{mustGetDefaultPaymentMethod(ctx, conn, company)},
Options: mustGetPaymentMethodOptions(ctx, conn, company),
},
File: &FileField{
Name: "file",
Label: pgettext("input", "File", locale),
MaxSize: 1 << 20,
},
}
}
func mustGetInvoiceStatusOptions(ctx context.Context, conn *Conn, locale *Locale) []*SelectOption {
return MustGetOptions(ctx, conn, `
select invoice_status.invoice_status
, isi18n.name
from invoice_status
join invoice_status_i18n isi18n using(invoice_status)
where isi18n.lang_tag = $1
order by invoice_status`, locale.Language.String())
}
func (form *invoiceForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
if err := r.ParseMultipartForm(form.File.MaxSize); err != nil {
return err
}
form.InvoiceStatus.FillValue(r)
form.Customer.FillValue(r)
form.Date.FillValue(r)
form.Notes.FillValue(r)
form.Tags.FillValue(r)
form.PaymentMethod.FillValue(r)
if err := form.File.FillValue(r); err != nil {
return err
}
if _, ok := r.Form["product.id.0"]; ok {
taxOptions := mustGetTaxOptions(r.Context(), getConn(r), form.company)
for index := 0; true; index++ {
@ -695,12 +961,11 @@ func (form *invoiceForm) Parse(r *http.Request) error {
func (form *invoiceForm) Validate() bool {
validator := newFormValidator()
validator.CheckValidSelectOption(form.InvoiceStatus, gettext("Selected invoice status is not valid.", form.locale))
validator.CheckValidSelectOption(form.Customer, gettext("Selected customer is not valid.", form.locale))
if validator.CheckRequiredInput(form.Date, gettext("Invoice date can not be empty.", form.locale)) {
validator.CheckValidDate(form.Date, gettext("Invoice date must be a valid date.", form.locale))
}
validator.CheckValidSelectOption(form.PaymentMethod, gettext("Selected payment method is not valid.", form.locale))
validator.CheckValidSelectOption(form.PaymentMethod, gettext("Selected invoicing method is not valid.", form.locale))
allOK := validator.AllOK()
for _, product := range form.Products {
@ -805,13 +1070,10 @@ func (form *invoiceForm) InsertProduct(product *invoiceProductForm) {
func (form *invoiceForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool {
var invoiceId int
selectedInvoiceStatus := form.InvoiceStatus.Selected
form.InvoiceStatus.Clear()
selectedPaymentMethod := form.PaymentMethod.Selected
form.PaymentMethod.Clear()
if notFoundErrorOrPanic(conn.QueryRow(ctx, `
select invoice_id
, invoice_status
, contact_id
, invoice_number
, invoice_date
@ -820,9 +1082,8 @@ func (form *invoiceForm) MustFillFromDatabase(ctx context.Context, conn *Conn, s
, tags
from invoice
where slug = $1
`, slug).Scan(&invoiceId, form.InvoiceStatus, form.Customer, &form.Number, form.Date, form.Notes, form.PaymentMethod, form.Tags)) {
`, slug).Scan(&invoiceId, form.Customer, &form.Number, form.Date, form.Notes, form.PaymentMethod, form.Tags)) {
form.PaymentMethod.Selected = selectedPaymentMethod
form.InvoiceStatus.Selected = selectedInvoiceStatus
return false
}
form.Products = []*invoiceProductForm{}
@ -851,7 +1112,7 @@ func (form *invoiceForm) MustFillFromQuote(ctx context.Context, conn *Conn, slug
return false
}
form.Products = []*invoiceProductForm{}
form.mustAddProductsFromQuery(ctx, conn, "select '', coalesce(product_id, 0), name, description, to_price(price, $2), quantity, (discount_rate * 100)::integer, array_remove(array_agg(tax_id), null) from quote_product left join quote_product_product using (quote_product_id) left join quote_product_tax using (quote_product_id) where quote_id = $1 group by quote_product_id, coalesce(product_id, 0), name, description, discount_rate, price, quantity", quoteId, form.company.DecimalDigits)
form.mustAddProductsFromQuery(ctx, conn, "select '', coalesce(product_id::text, ''), name, description, to_price(price, $2), quantity, (discount_rate * 100)::integer, array_remove(array_agg(tax_id), null) from quote_product left join quote_product_product using (quote_product_id) left join quote_product_tax using (quote_product_id) where quote_id = $1 group by quote_product_id, coalesce(product_id::text, ''), name, description, discount_rate, price, quantity", quoteId, form.company.DecimalDigits)
return true
}
@ -1058,28 +1319,25 @@ func HandleUpdateInvoice(w http.ResponseWriter, r *http.Request, params httprout
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if r.FormValue("quick") == "status" {
slug := conn.MustGetText(r.Context(), "", "update invoice set invoice_status = $1 where slug = $2 returning slug", form.InvoiceStatus, params[0].Value)
if slug == "" {
http.NotFound(w, r)
}
htmxRedirect(w, r, companyURI(mustGetCompany(r), "/invoices"))
} else {
slug := params[0].Value
if !form.Validate() {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
mustRenderEditInvoiceForm(w, r, slug, form)
return
}
slug = conn.MustGetText(r.Context(), "", "select edit_invoice($1, $2, $3, $4, $5, $6, $7)", slug, form.InvoiceStatus, form.Customer, form.Notes, form.PaymentMethod, form.Tags, EditedInvoiceProductArray(form.Products))
if slug == "" {
http.NotFound(w, r)
return
}
htmxRedirect(w, r, companyURI(company, "/invoices/"+slug))
slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
if !form.Validate() {
w.WriteHeader(http.StatusUnprocessableEntity)
mustRenderEditInvoiceForm(w, r, slug, form)
return
}
slug = conn.MustGetText(r.Context(), "", "select edit_invoice($1, $2, $3, $4, $5, $6)", slug, form.Customer, form.Notes, form.PaymentMethod, form.Tags, EditedInvoiceProductArray(form.Products))
if slug == "" {
http.NotFound(w, r)
return
}
if len(form.File.Content) > 0 {
conn.MustQuery(r.Context(), "select attach_to_invoice($1, $2, $3, $4)", slug, form.File.OriginalFileName, form.File.ContentType, form.File.Content)
}
htmxRedirect(w, r, companyURI(company, "/invoices/"+slug))
}
func htmxRedirect(w http.ResponseWriter, r *http.Request, uri string) {
@ -1098,6 +1356,10 @@ func ServeEditInvoice(w http.ResponseWriter, r *http.Request, params httprouter.
conn := getConn(r)
company := mustGetCompany(r)
slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
locale := getLocale(r)
form := newInvoiceForm(r.Context(), conn, locale, company)
if !form.MustFillFromDatabase(r.Context(), conn, slug) {
@ -1129,6 +1391,10 @@ func mustRenderEditInvoiceForm(w http.ResponseWriter, r *http.Request, slug stri
func HandleEditInvoiceAction(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
actionUri := fmt.Sprintf("/invoices/%s/edit", slug)
handleInvoiceAction(w, r, actionUri, func(w http.ResponseWriter, r *http.Request, form *invoiceForm) {
conn := getConn(r)
@ -1193,60 +1459,20 @@ func handleInvoiceAction(w http.ResponseWriter, r *http.Request, action string,
}
}
type tagsForm struct {
Action string
Slug string
Tags *TagsField
}
func newTagsForm(uri string, slug string, locale *Locale) *tagsForm {
return &tagsForm{
Action: uri,
Slug: slug,
Tags: &TagsField{
Name: "tags-" + slug,
Label: pgettext("input", "Tags", locale),
},
}
}
func (form *tagsForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
form.Tags.FillValue(r)
return nil
}
func ServeEditInvoiceTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
conn := getConn(r)
locale := getLocale(r)
company := getCompany(r)
slug := params[0].Value
form := newTagsForm(companyURI(company, "/invoices/"+slug+"/tags"), slug, locale)
if notFoundErrorOrPanic(conn.QueryRow(r.Context(), `select tags from invoice where slug = $1`, form.Slug).Scan(form.Tags)) {
http.NotFound(w, r)
return
}
mustRenderStandaloneTemplate(w, r, "tags/edit.gohtml", form)
serveTagsEditForm(w, r, params, "/invoices/", "select tags from invoice where slug = $1")
}
func HandleUpdateInvoiceTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
locale := getLocale(r)
conn := getConn(r)
company := getCompany(r)
slug := params[0].Value
form := newTagsForm(companyURI(company, "/invoices/"+slug+"/tags/edit"), slug, locale)
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if conn.MustGetText(r.Context(), "", "update invoice set tags = $1 where slug = $2 returning slug", form.Tags, form.Slug) == "" {
http.NotFound(w, r)
}
mustRenderStandaloneTemplate(w, r, "tags/view.gohtml", form)
handleUpdateTags(w, r, params, "/invoices/", "update invoice set tags = $1 where slug = $2 returning slug")
}
func ServeInvoiceAttachment(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
serveAttachment(w, r, params, `
select mime_type
, content
from invoice
join invoice_attachment using (invoice_id)
where slug = $1
`)
}

View File

@ -20,6 +20,7 @@ const (
sessionCookie = "numerus-session"
defaultRole = "guest"
csrfTokenField = "csfrToken"
csrfTokenHeader = "X-CSRFToken"
)
type loginForm struct {
@ -29,8 +30,8 @@ type loginForm struct {
Password *InputField
}
func newLoginForm(locale *Locale) *loginForm {
return &loginForm{
func newLoginForm(demo bool, locale *Locale) *loginForm {
form := &loginForm{
locale: locale,
Email: &InputField{
Name: "email",
@ -53,6 +54,11 @@ func newLoginForm(locale *Locale) *loginForm {
},
},
}
if demo {
form.Email.Val = "admin@numerus"
form.Password.Val = "admin"
}
return form
}
func (form *loginForm) Parse(r *http.Request) error {
@ -74,26 +80,26 @@ func (form *loginForm) Validate() bool {
return validator.AllOK()
}
func GetLoginForm(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
func GetLoginForm(w http.ResponseWriter, r *http.Request, demo bool) {
user := getUser(r)
if user.LoggedIn {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
locale := getLocale(r)
form := newLoginForm(locale)
form := newLoginForm(demo, locale)
w.WriteHeader(http.StatusOK)
mustRenderLoginForm(w, r, form)
}
func HandleLoginForm(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
func HandleLoginForm(w http.ResponseWriter, r *http.Request, demo bool) {
user := getUser(r)
if user.LoggedIn {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
locale := getLocale(r)
form := newLoginForm(locale)
form := newLoginForm(demo, locale)
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@ -200,7 +206,10 @@ func LoginChecker(db *Db, next http.Handler) http.Handler {
func verifyCsrfTokenValid(r *http.Request) error {
user := getUser(r)
token := r.FormValue(csrfTokenField)
token := r.Header.Get(csrfTokenHeader)
if token == "" {
token = r.FormValue(csrfTokenField)
}
if user.CsrfToken == token {
return nil
}

267
pkg/ods.go Normal file
View File

@ -0,0 +1,267 @@
package pkg
import (
"archive/zip"
"bytes"
"encoding/xml"
"fmt"
"net/http"
"sort"
"strings"
"time"
)
const (
mimetype = "application/vnd.oasis.opendocument.spreadsheet"
metaDashInfManifestXml = `<?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest
xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"
manifest:version="1.3">
<manifest:file-entry manifest:full-path="/" manifest:version="1.3" manifest:media-type="application/vnd.oasis.opendocument.spreadsheet"/>
<manifest:file-entry manifest:full-path="meta.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
</manifest:manifest>
`
metaXml = `<?xml version="1.0" encoding="UTF-8"?>
<office:document-meta
xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0"
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
office:version="1.3">
<office:meta>
<meta:creation-date></meta:creation-date>
<meta:generator>Numerus</meta:generator>
</office:meta>
</office:document-meta>
`
stylesXml = `<?xml version="1.0" encoding="UTF-8"?>
<office:document-styles
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
office:version="1.3">
</office:document-styles>
`
)
func extractTaxIDs(taxColumns map[int]string) []int {
taxIDs := make([]int, len(taxColumns))
i := 0
for k := range taxColumns {
taxIDs[i] = k
i++
}
sort.Ints(taxIDs[:])
return taxIDs
}
func mustWriteInvoicesOds(invoices []*InvoiceEntry, vatin map[int]string, lastCollectionDate map[int]time.Time, taxes map[int]taxMap, taxColumns map[int]string, locale *Locale, company *Company) []byte {
taxIDs := extractTaxIDs(taxColumns)
columns := make([]string, 9+len(taxIDs))
columns[0] = "Date"
columns[1] = "Invoice Num."
columns[2] = "Customer"
columns[3] = pgettext("title", "VAT number", locale)
columns[4] = "Payment Date"
columns[5] = "Status"
columns[6] = "Tax Base"
i := 7
for _, taxID := range taxIDs {
columns[i] = taxColumns[taxID]
i++
}
columns[i] = "Amount"
columns[i+1] = "Tags"
return mustWriteTableOds(invoices, columns, locale, func(sb *strings.Builder, invoice *InvoiceEntry) {
writeCellDate(sb, invoice.Date)
writeCellString(sb, invoice.Number)
writeCellString(sb, invoice.CustomerName)
writeCellString(sb, vatin[invoice.ID])
if date, ok := lastCollectionDate[invoice.ID]; ok {
writeCellDate(sb, date)
} else {
writeCellString(sb, "")
}
writeCellString(sb, invoice.StatusLabel)
writeCellFloat(sb, invoice.Subtotal, locale, company)
writeTaxes(sb, taxes[invoice.ID], taxIDs, locale, company)
writeCellFloat(sb, invoice.Total, locale, company)
writeCellString(sb, strings.Join(invoice.Tags, ","))
})
}
func mustWriteQuotesOds(quotes []*QuoteEntry, locale *Locale, company *Company) []byte {
columns := []string{
"Date",
"Quotation Num.",
"Customer",
"Status",
"Tags",
"Amount",
}
return mustWriteTableOds(quotes, columns, locale, func(sb *strings.Builder, quote *QuoteEntry) {
writeCellDate(sb, quote.Date)
writeCellString(sb, quote.Number)
writeCellString(sb, quote.CustomerName)
writeCellString(sb, quote.StatusLabel)
writeCellString(sb, strings.Join(quote.Tags, ","))
writeCellFloat(sb, quote.Total, locale, company)
})
}
func mustWriteExpensesOds(expenses []*ExpenseEntry, vatin map[int]string, lastPaymentDate map[int]time.Time, taxes map[int]taxMap, taxColumns map[int]string, locale *Locale, company *Company) []byte {
taxIDs := extractTaxIDs(taxColumns)
columns := make([]string, 9+len(taxIDs))
columns[0] = "Contact"
columns[1] = pgettext("title", "VAT number", locale)
columns[2] = "Invoice Date"
columns[3] = "Invoice Number"
columns[4] = "Payment Date"
columns[5] = "Status"
columns[6] = "Amount"
i := 7
for _, taxID := range taxIDs {
columns[i] = taxColumns[taxID]
i++
}
columns[i] = "Total"
columns[i+1] = "Tags"
return mustWriteTableOds(expenses, columns, locale, func(sb *strings.Builder, expense *ExpenseEntry) {
writeCellString(sb, expense.InvoicerName)
writeCellString(sb, vatin[expense.ID])
writeCellDate(sb, expense.InvoiceDate)
writeCellString(sb, expense.InvoiceNumber)
if date, ok := lastPaymentDate[expense.ID]; ok {
writeCellDate(sb, date)
} else {
writeCellString(sb, "")
}
writeCellString(sb, expense.StatusLabel)
writeCellFloat(sb, expense.Amount, locale, company)
writeTaxes(sb, taxes[expense.ID], taxIDs, locale, company)
writeCellFloat(sb, expense.Total, locale, company)
writeCellString(sb, strings.Join(expense.Tags, ","))
})
}
func mustWriteTableOds[K interface{}](rows []*K, columns []string, locale *Locale, writeRow func(*strings.Builder, *K)) []byte {
var sb strings.Builder
sb.WriteString(`<?xml version="1.0" encoding="UTF-8"?>
<office:document-content
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
xmlns:calcext="urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0"
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0"
office:version="1.3">
<office:scripts/>
<office:font-face-decls>
<style:font-face style:name="Liberation Sans" svg:font-family="'Liberation Sans'" style:font-family-generic="swiss" style:font-pitch="variable"/>
</office:font-face-decls>
<office:automatic-styles>
<style:style style:name="co1" style:family="table-column">
<style:table-column-properties fo:break-before="auto" style:column-width="0.889in"/>
</style:style>
<style:style style:name="ro1" style:family="table-row">
<style:table-row-properties style:row-height="0.178in" fo:break-before="auto" style:use-optimal-row-height="true"/>
</style:style>
<style:style style:name="ta1" style:family="table" style:master-page-name="Default">
<style:table-properties table:display="true" style:writing-mode="lr-tb"/>
</style:style>
<number:date-style style:name="N37" number:automatic-order="true">
<number:day number:style="long"/>
<number:text>/</number:text>
<number:month number:style="long"/>
<number:text>/</number:text>
<number:year/>
</number:date-style>
<style:style style:name="ce1" style:family="table-cell" style:parent-style-name="Default" style:data-style-name="N37"/>
</office:automatic-styles>
<office:body>
<office:spreadsheet>
<table:calculation-settings table:automatic-find-labels="false" table:use-regular-expressions="false" table:use-wildcards="true"/>
<table:table table:name="Sheet1" table:style-name="ta1">
`)
sb.WriteString(fmt.Sprintf(" <table:table-column table:style-name=\"co1\" table:number-columns-repeated=\"%d\" table:default-cell-style-name=\"Default\"/>\n", len(columns)))
sb.WriteString(` <table:table-row table:style-name="ro1">
`)
for _, t := range columns {
writeCellString(&sb, locale.GetC(t, "title"))
}
sb.WriteString(" </table:table-row>\n")
for _, row := range rows {
sb.WriteString(" <table:table-row table:style-name=\"ro1\">\n")
writeRow(&sb, row)
sb.WriteString(" </table:table-row>\n")
}
sb.WriteString(` </table:table>
<table:named-expressions/>
</office:spreadsheet>
</office:body>
</office:document-content>
`)
return mustWriteOds(sb.String())
}
func mustWriteOds(content string) []byte {
buf := new(bytes.Buffer)
ods := zip.NewWriter(buf)
mustWriteOdsFile(ods, "mimetype", mimetype, zip.Store)
mustWriteOdsFile(ods, "META-INF/manifest.xml", metaDashInfManifestXml, zip.Deflate)
mustWriteOdsFile(ods, "meta.xml", metaXml, zip.Deflate)
mustWriteOdsFile(ods, "styles.xml", stylesXml, zip.Deflate)
mustWriteOdsFile(ods, "content.xml", content, zip.Deflate)
mustClose(ods)
return buf.Bytes()
}
func mustWriteOdsFile(ods *zip.Writer, name string, content string, method uint16) {
f, err := ods.CreateHeader(&zip.FileHeader{
Name: name,
Method: method,
Modified: time.Now(),
})
if err != nil {
panic(err)
}
if _, err = f.Write([]byte(content)); err != nil {
panic(err)
}
}
func writeCellString(sb *strings.Builder, s string) {
sb.WriteString(` <table:table-cell office:value-type="string" calcext:value-type="string"><text:p>`)
if err := xml.EscapeText(sb, []byte(s)); err != nil {
panic(err)
}
sb.WriteString("</text:p></table:table-cell>\n")
}
func writeCellDate(sb *strings.Builder, t time.Time) {
sb.WriteString(fmt.Sprintf(" <table:table-cell table:style-name=\"ce1\" office:value-type=\"date\" office:date-value=\"%s\" calcext:value-type=\"date\"><text:p>%s</text:p></table:table-cell>\n", t.Format("2006-01-02"), t.Format("02/01/06")))
}
func writeCellFloat(sb *strings.Builder, s string, locale *Locale, company *Company) {
sb.WriteString(fmt.Sprintf(" <table:table-cell office:value-type=\"float\" office:value=\"%s\" calcext:value-type=\"float\"><text:p>%s</text:p></table:table-cell>\n", s, formatPrice(s, locale.Language, "%.[1]*[2]f", company.DecimalDigits, "")))
}
func writeOdsResponse(w http.ResponseWriter, ods []byte, filename string) {
w.Header().Set("Content-Type", "application/vnd.oasis.opendocument.spreadsheet")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
w.WriteHeader(http.StatusOK)
if _, err := w.Write(ods); err != nil {
panic(err)
}
}
func writeTaxes(sb *strings.Builder, taxes taxMap, taxIDs []int, locale *Locale, company *Company) {
for _, taxID := range taxIDs {
var amount string
if taxes != nil {
amount = taxes[taxID]
}
writeCellFloat(sb, amount, locale, company)
}
}

739
pkg/payments.go Normal file
View File

@ -0,0 +1,739 @@
package pkg
import (
"context"
"fmt"
"github.com/julienschmidt/httprouter"
"html/template"
"math"
"net/http"
"time"
)
const (
PaymentTypePayment = "P"
PaymentTypeCollection = "C"
)
func servePaymentIndex(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
conn := getConn(r)
company := mustGetCompany(r)
locale := getLocale(r)
page := NewPaymentIndexPage(r.Context(), conn, company, locale)
page.MustRender(w, r)
}
func serveExpensePaymentIndex(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
expenseSlug := params[0].Value
conn := getConn(r)
company := mustGetCompany(r)
locale := getLocale(r)
expense := mustGetPaymentExpense(r.Context(), conn, expenseSlug)
if expense == nil {
http.NotFound(w, r)
return
}
page := NewPaymentIndexPageForExpense(r.Context(), conn, company, locale, expense)
page.MustRender(w, r)
}
func serveInvoiceCollectionIndex(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
invoiceSlug := params[0].Value
conn := getConn(r)
company := mustGetCompany(r)
locale := getLocale(r)
invoice := mustGetCollectionInvoice(r.Context(), conn, invoiceSlug)
if invoice == nil {
http.NotFound(w, r)
return
}
page := NewPaymentIndexPageForInvoice(r.Context(), conn, company, locale, invoice)
page.MustRender(w, r)
}
type PaymentIndexPage struct {
Payments []*PaymentEntry
BaseURI string
Expense *PaymentExpense
Invoice *CollectionInvoice
}
func NewPaymentIndexPage(ctx context.Context, conn *Conn, company *Company, locale *Locale) *PaymentIndexPage {
return &PaymentIndexPage{
Payments: mustCollectPaymentEntries(ctx, conn, company, locale, "", 0),
BaseURI: companyURI(company, "/payments"),
}
}
func NewPaymentIndexPageForExpense(ctx context.Context, conn *Conn, company *Company, locale *Locale, expense *PaymentExpense) *PaymentIndexPage {
return &PaymentIndexPage{
Payments: mustCollectPaymentEntries(ctx, conn, company, locale, PaymentTypePayment, expense.Id),
BaseURI: expense.BaseURI(company),
Expense: expense,
}
}
func NewPaymentIndexPageForInvoice(ctx context.Context, conn *Conn, company *Company, locale *Locale, invoice *CollectionInvoice) *PaymentIndexPage {
return &PaymentIndexPage{
Payments: mustCollectPaymentEntries(ctx, conn, company, locale, PaymentTypeCollection, invoice.Id),
BaseURI: invoice.BaseURI(company),
Invoice: invoice,
}
}
func (page *PaymentIndexPage) MustRender(w http.ResponseWriter, r *http.Request) {
mustRenderMainTemplate(w, r, "payments/index.gohtml", page)
}
type PaymentExpense struct {
Id int
Slug string
InvoiceNumber string
}
func mustGetPaymentExpense(ctx context.Context, conn *Conn, expenseSlug string) *PaymentExpense {
if !ValidUuid(expenseSlug) {
return nil
}
expense := &PaymentExpense{}
if notFoundErrorOrPanic(conn.QueryRow(ctx, `
select expense_id
, slug
, coalesce(nullif(invoice_number, ''), slug::text)
from expense
where expense.slug = $1
`, expenseSlug).Scan(
&expense.Id,
&expense.Slug,
&expense.InvoiceNumber)) {
return nil
}
return expense
}
func (expense *PaymentExpense) BaseURI(company *Company) string {
return companyURI(company, "/expenses/"+expense.Slug+"/payments")
}
func (expense *PaymentExpense) calcRemainingPaymentAmount(ctx context.Context, conn *Conn) string {
return conn.MustGetText(ctx, "", `
select to_price(greatest(0, expense.amount + tax_amount - paid_amount)::int, decimal_digits)
from (
select coalesce (sum(payment.amount), 0) as paid_amount
from expense_payment
join payment using (payment_id)
where expense_payment.expense_id = $1
) as payment
cross join (
select coalesce (sum(amount), 0) as tax_amount
from expense_tax_amount
where expense_id = $1
) as tax
cross join (
select amount, decimal_digits
from expense
join currency using (currency_code)
where expense_id = $1
) as expense
`, expense.Id)
}
type CollectionInvoice struct {
Id int
Slug string
InvoiceNumber string
}
func mustGetCollectionInvoice(ctx context.Context, conn *Conn, invoiceSlug string) *CollectionInvoice {
if !ValidUuid(invoiceSlug) {
return nil
}
invoice := &CollectionInvoice{}
if notFoundErrorOrPanic(conn.QueryRow(ctx, `
select invoice_id
, slug
, invoice_number
from invoice
where invoice.slug = $1
`, invoiceSlug).Scan(
&invoice.Id,
&invoice.Slug,
&invoice.InvoiceNumber)) {
return nil
}
return invoice
}
func (invoice *CollectionInvoice) BaseURI(company *Company) string {
return companyURI(company, "/invoices/"+invoice.Slug+"/collections")
}
func (invoice *CollectionInvoice) calcRemainingPaymentAmount(ctx context.Context, conn *Conn) string {
return conn.MustGetText(ctx, "", `
select to_price(greatest(0, invoice_amount.total - collected_amount)::int, decimal_digits)
from (
select coalesce (sum(collection.amount), 0) as collected_amount
from invoice_collection
join collection using (collection_id)
where invoice_collection.invoice_id = $1
) as collection
cross join (
select total
, decimal_digits
from invoice_amount
join invoice using (invoice_id)
join currency using (currency_code)
where invoice_id = $1
) as invoice_amount
`, invoice.Id)
}
type PaymentEntry struct {
ID int
Type string
Slug string
PaymentDate time.Time
Description string
DocumentSlug string
InvoiceNumber string
Total string
OriginalFileName string
Tags []string
Status string
StatusLabel string
}
func mustCollectPaymentEntries(ctx context.Context, conn *Conn, company *Company, locale *Locale, paymentType string, documentId int) []*PaymentEntry {
rows := conn.MustQuery(ctx, `
select $5 as type
, payment_id
, payment.slug
, payment_date
, description
, to_price(payment.amount, decimal_digits) as total
, payment.tags
, payment.payment_status
, psi18n.name
, coalesce(attachment.original_filename, '')
, coalesce(expense.slug::text, '')
, coalesce(expense.invoice_number, '')
from payment
join payment_status_i18n psi18n on payment.payment_status = psi18n.payment_status and psi18n.lang_tag = $1
join currency using (currency_code)
left join payment_attachment as attachment using (payment_id)
left join expense_payment using (payment_id)
left join expense using (expense_id)
where payment.company_id = $2
and ($3 = '' or ($3 = $5 and expense_id = $4))
union all
select $6 as type
, collection_id
, collection.slug
, collection_date as payment_date
, description
, to_price(collection.amount, decimal_digits) as total
, collection.tags
, collection.payment_status
, psi18n.name
, coalesce(attachment.original_filename, '')
, coalesce(invoice.slug::text, '')
, coalesce(invoice.invoice_number, '')
from collection
join payment_status_i18n psi18n on collection.payment_status = psi18n.payment_status and psi18n.lang_tag = $1
join currency using (currency_code)
left join collection_attachment as attachment using (collection_id)
left join invoice_collection using (collection_id)
left join invoice using (invoice_id)
where collection.company_id = $2
and ($3 = '' or ($3 = $6 and invoice_id = $4))
order by payment_date desc, total desc
`, locale.Language, company.Id, paymentType, documentId, PaymentTypePayment, PaymentTypeCollection)
defer rows.Close()
var entries []*PaymentEntry
for rows.Next() {
entry := &PaymentEntry{}
if err := rows.Scan(&entry.Type, &entry.ID, &entry.Slug, &entry.PaymentDate, &entry.Description, &entry.Total, &entry.Tags, &entry.Status, &entry.StatusLabel, &entry.OriginalFileName, &entry.DocumentSlug, &entry.InvoiceNumber); err != nil {
panic(err)
}
entries = append(entries, entry)
}
if rows.Err() != nil {
panic(rows.Err())
}
return entries
}
func servePaymentForm(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
locale := getLocale(r)
conn := getConn(r)
company := mustGetCompany(r)
form := newPaymentForm(r.Context(), conn, locale, company)
slug := params[0].Value
if slug == "new" {
form.PaymentDate.Val = time.Now().Format("2006-01-02")
form.MustRender(w, r)
return
}
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
if !form.MustFillFromDatabase(r.Context(), conn, slug) {
http.NotFound(w, r)
return
}
form.MustRender(w, r)
}
func serveExpensePaymentForm(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
locale := getLocale(r)
conn := getConn(r)
company := mustGetCompany(r)
expenseSlug := params[0].Value
expense := mustGetPaymentExpense(r.Context(), conn, expenseSlug)
if expense == nil {
http.NotFound(w, r)
return
}
form := newPaymentFormForExpense(r.Context(), conn, locale, company, expense)
paymentSlug := params[1].Value
if paymentSlug == "new" {
form.PaymentDate.Val = time.Now().Format("2006-01-02")
form.Description.Val = fmt.Sprintf(gettext("Payment of %s", locale), form.Expense.InvoiceNumber)
form.Amount.Val = form.Expense.calcRemainingPaymentAmount(r.Context(), conn)
form.MustRender(w, r)
return
}
if !ValidUuid(paymentSlug) {
http.NotFound(w, r)
return
}
if !form.MustFillFromDatabase(r.Context(), conn, paymentSlug) {
http.NotFound(w, r)
return
}
form.MustRender(w, r)
}
func serveInvoiceCollectionForm(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
locale := getLocale(r)
conn := getConn(r)
company := mustGetCompany(r)
invoiceSlug := params[0].Value
invoice := mustGetCollectionInvoice(r.Context(), conn, invoiceSlug)
if invoice == nil {
http.NotFound(w, r)
return
}
form := newPaymentFormForInvoice(r.Context(), conn, locale, company, invoice)
paymentSlug := params[1].Value
if paymentSlug == "new" {
form.PaymentDate.Val = time.Now().Format("2006-01-02")
form.Description.Val = fmt.Sprintf(gettext("Collection of %s", locale), form.Invoice.InvoiceNumber)
form.Amount.Val = form.Invoice.calcRemainingPaymentAmount(r.Context(), conn)
form.MustRender(w, r)
return
}
if !ValidUuid(paymentSlug) {
http.NotFound(w, r)
return
}
if !form.MustFillFromDatabase(r.Context(), conn, paymentSlug) {
http.NotFound(w, r)
return
}
form.MustRender(w, r)
}
type PaymentForm struct {
locale *Locale
company *Company
Slug string
BaseURI string
Expense *PaymentExpense
Invoice *CollectionInvoice
Type *SelectField
Description *InputField
PaymentDate *InputField
PaymentAccount *SelectField
Amount *InputField
File *FileField
Tags *TagsField
}
func newPaymentForm(ctx context.Context, conn *Conn, locale *Locale, company *Company) *PaymentForm {
return &PaymentForm{
locale: locale,
company: company,
BaseURI: companyURI(company, "/payments"),
Type: &SelectField{
Name: "type",
Label: pgettext("input", "Type", locale),
Required: true,
Options: []*SelectOption{
{Value: PaymentTypePayment, Label: pgettext("payment type", "Payment", locale)},
{Value: PaymentTypeCollection, Label: pgettext("payment type", "Collection", locale)},
},
},
Description: &InputField{
Name: "description",
Label: pgettext("input", "Description", locale),
Required: true,
Type: "text",
},
PaymentDate: &InputField{
Name: "payment_date",
Label: pgettext("input", "Payment Date", locale),
Required: true,
Type: "date",
},
PaymentAccount: &SelectField{
Name: "payment_account",
Label: pgettext("input", "Account", locale),
Required: true,
Options: MustGetOptions(ctx, conn, "select payment_account_id::text, name from payment_account where company_id = $1 order by name", company.Id),
},
Amount: &InputField{
Name: "amount",
Label: pgettext("input", "Amount", locale),
Type: "number",
Required: true,
Attributes: []template.HTMLAttr{
`min="0"`,
template.HTMLAttr(fmt.Sprintf(`step="%v"`, company.MinCents())),
},
},
File: &FileField{
Name: "file",
Label: pgettext("input", "File", locale),
MaxSize: 1 << 20,
},
Tags: &TagsField{
Name: "tags",
Label: pgettext("input", "Tags", locale),
},
}
}
func newPaymentFormForExpense(ctx context.Context, conn *Conn, locale *Locale, company *Company, expense *PaymentExpense) *PaymentForm {
form := newPaymentForm(ctx, conn, locale, company)
form.Type.Selected = []string{PaymentTypePayment}
form.BaseURI = expense.BaseURI(company)
form.Expense = expense
return form
}
func newPaymentFormForInvoice(ctx context.Context, conn *Conn, locale *Locale, company *Company, invoice *CollectionInvoice) *PaymentForm {
form := newPaymentForm(ctx, conn, locale, company)
form.Type.Selected = []string{PaymentTypeCollection}
form.BaseURI = invoice.BaseURI(company)
form.Invoice = invoice
return form
}
func (f *PaymentForm) MustRender(w http.ResponseWriter, r *http.Request) {
if f.Slug == "" {
f.Type.EmptyLabel = gettext("Select a type.", f.locale)
f.PaymentAccount.EmptyLabel = gettext("Select an account.", f.locale)
mustRenderMainTemplate(w, r, "payments/new.gohtml", f)
} else {
mustRenderMainTemplate(w, r, "payments/edit.gohtml", f)
}
}
func (f *PaymentForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool {
selectedType := f.Type.Selected
selectedPaymentAccount := f.PaymentAccount.Selected
f.PaymentAccount.Clear()
if notFoundErrorOrPanic(conn.QueryRow(ctx, `
select $2 as type
, description
, payment_date
, payment_account_id::text
, to_price(amount, decimal_digits)
, tags
from payment
join currency using (currency_code)
where payment.slug = $1
union all
select $3 as type
, description
, collection_date
, payment_account_id::text
, to_price(amount, decimal_digits)
, tags
from collection
join currency using (currency_code)
where collection.slug = $1
`, slug, PaymentTypePayment, PaymentTypeCollection).Scan(
f.Type,
f.Description,
f.PaymentDate,
f.PaymentAccount,
f.Amount,
f.Tags)) {
f.Type.Selected = selectedType
f.PaymentAccount.Selected = selectedPaymentAccount
return false
}
f.Slug = slug
return true
}
func (f *PaymentForm) Parse(r *http.Request) error {
if err := r.ParseMultipartForm(f.File.MaxSize); err != nil {
return err
}
f.Type.FillValue(r)
f.Description.FillValue(r)
f.PaymentDate.FillValue(r)
f.PaymentAccount.FillValue(r)
f.Amount.FillValue(r)
if err := f.File.FillValue(r); err != nil {
return err
}
f.Tags.FillValue(r)
return nil
}
func (f *PaymentForm) Validate() bool {
validator := newFormValidator()
validator.CheckValidSelectOption(f.Type, gettext("Selected payment type is not valid.", f.locale))
validator.CheckRequiredInput(f.Description, gettext("Description can not be empty.", f.locale))
validator.CheckValidSelectOption(f.PaymentAccount, gettext("Selected payment account is not valid.", f.locale))
validator.CheckValidDate(f.PaymentDate, gettext("Payment date must be a valid date.", f.locale))
if validator.CheckRequiredInput(f.Amount, gettext("Amount can not be empty.", f.locale)) {
validator.CheckValidDecimal(f.Amount, f.company.MinCents(), math.MaxFloat64, gettext("Amount must be a number greater than zero.", f.locale))
}
return validator.AllOK()
}
func handleAddPayment(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
locale := getLocale(r)
conn := getConn(r)
company := mustGetCompany(r)
form := newPaymentForm(r.Context(), conn, locale, company)
handleAddPaymentForm(w, r, conn, company, form)
}
func handleAddPaymentForm(w http.ResponseWriter, r *http.Request, conn *Conn, company *Company, form *PaymentForm) {
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if !form.Validate() {
w.WriteHeader(http.StatusUnprocessableEntity)
form.MustRender(w, r)
return
}
var documentId any
if form.Type.String() == PaymentTypePayment {
if form.Expense != nil {
documentId = form.Expense.Id
}
slug := conn.MustGetText(r.Context(), "", "select add_payment($1, $2, $3, $4, $5, $6, $7)", company.Id, documentId, form.PaymentDate, form.PaymentAccount, form.Description, form.Amount, form.Tags)
if len(form.File.Content) > 0 {
conn.MustQuery(r.Context(), "select attach_to_payment($1, $2, $3, $4)", slug, form.File.OriginalFileName, form.File.ContentType, form.File.Content)
}
} else {
if form.Invoice != nil {
documentId = form.Invoice.Id
}
slug := conn.MustGetText(r.Context(), "", "select add_collection($1, $2, $3, $4, $5, $6, $7)", company.Id, documentId, form.PaymentDate, form.PaymentAccount, form.Description, form.Amount, form.Tags)
if len(form.File.Content) > 0 {
conn.MustQuery(r.Context(), "select attach_to_collection($1, $2, $3, $4)", slug, form.File.OriginalFileName, form.File.ContentType, form.File.Content)
}
}
htmxRedirect(w, r, form.BaseURI)
}
func handleAddExpensePayment(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
expenseSlug := params[0].Value
locale := getLocale(r)
conn := getConn(r)
company := mustGetCompany(r)
expense := mustGetPaymentExpense(r.Context(), conn, expenseSlug)
if expense == nil {
http.NotFound(w, r)
return
}
form := newPaymentFormForExpense(r.Context(), conn, locale, company, expense)
handleAddPaymentForm(w, r, conn, company, form)
}
func handleAddInvoiceCollection(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
invoiceSlug := params[0].Value
locale := getLocale(r)
conn := getConn(r)
company := mustGetCompany(r)
invoice := mustGetCollectionInvoice(r.Context(), conn, invoiceSlug)
if invoice == nil {
http.NotFound(w, r)
return
}
form := newPaymentFormForInvoice(r.Context(), conn, locale, company, invoice)
handleAddPaymentForm(w, r, conn, company, form)
}
func handleEditPayment(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
conn := getConn(r)
locale := getLocale(r)
company := mustGetCompany(r)
form := newPaymentForm(r.Context(), conn, locale, company)
form.Slug = params[0].Value
handleEditPaymentForm(w, r, conn, form)
}
func handleEditPaymentForm(w http.ResponseWriter, r *http.Request, conn *Conn, form *PaymentForm) {
if !ValidUuid(form.Slug) {
http.NotFound(w, r)
return
}
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if !form.Validate() {
w.WriteHeader(http.StatusUnprocessableEntity)
form.MustRender(w, r)
return
}
if form.Type.String() == PaymentTypePayment {
if found := conn.MustGetText(r.Context(), "", "select edit_payment($1, $2, $3, $4, $5, $6)", form.Slug, form.PaymentDate, form.PaymentAccount, form.Description, form.Amount, form.Tags); found == "" {
http.NotFound(w, r)
return
}
if len(form.File.Content) > 0 {
conn.MustQuery(r.Context(), "select attach_to_payment($1, $2, $3, $4)", form.Slug, form.File.OriginalFileName, form.File.ContentType, form.File.Content)
}
} else {
if found := conn.MustGetText(r.Context(), "", "select edit_collection($1, $2, $3, $4, $5, $6)", form.Slug, form.PaymentDate, form.PaymentAccount, form.Description, form.Amount, form.Tags); found == "" {
http.NotFound(w, r)
return
}
if len(form.File.Content) > 0 {
conn.MustQuery(r.Context(), "select attach_to_collection($1, $2, $3, $4)", form.Slug, form.File.OriginalFileName, form.File.ContentType, form.File.Content)
}
}
htmxRedirect(w, r, form.BaseURI)
}
func handleEditExpensePayment(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
conn := getConn(r)
locale := getLocale(r)
company := mustGetCompany(r)
expenseSlug := params[0].Value
expense := mustGetPaymentExpense(r.Context(), conn, expenseSlug)
if expense == nil {
http.NotFound(w, r)
return
}
form := newPaymentFormForExpense(r.Context(), conn, locale, company, expense)
form.Slug = params[1].Value
handleEditPaymentForm(w, r, conn, form)
}
func handleEditInvoiceCollection(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
conn := getConn(r)
locale := getLocale(r)
company := mustGetCompany(r)
invoiceSlug := params[0].Value
invoice := mustGetCollectionInvoice(r.Context(), conn, invoiceSlug)
if invoice == nil {
http.NotFound(w, r)
return
}
form := newPaymentFormForInvoice(r.Context(), conn, locale, company, invoice)
form.Slug = params[1].Value
handleEditPaymentForm(w, r, conn, form)
}
func handleRemovePayment(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
company := mustGetCompany(r)
removePayment(w, r, params[0].Value, companyURI(company, "/payments"))
}
func removePayment(w http.ResponseWriter, r *http.Request, slug string, backURI string) {
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
conn := getConn(r)
conn.MustExec(r.Context(), "select remove_payment($1), remove_collection($1)", slug)
htmxRedirect(w, r, backURI)
}
func handleRemoveExpensePayment(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
conn := getConn(r)
expenseSlug := params[0].Value
expense := mustGetPaymentExpense(r.Context(), conn, expenseSlug)
if expense == nil {
http.NotFound(w, r)
return
}
company := mustGetCompany(r)
removePayment(w, r, params[1].Value, expense.BaseURI(company))
}
func handleRemoveInvoiceCollection(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
conn := getConn(r)
invoiceSlug := params[0].Value
invoice := mustGetCollectionInvoice(r.Context(), conn, invoiceSlug)
if invoice == nil {
http.NotFound(w, r)
return
}
company := mustGetCompany(r)
removePayment(w, r, params[1].Value, invoice.BaseURI(company))
}
func servePaymentAttachment(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
serveAttachment(w, r, params, `
select mime_type
, content
from payment
join payment_attachment using (payment_id)
where slug = $1
union all
select mime_type
, content
from collection
join collection_attachment using (collection_id)
where slug = $1
`)
}
func servePaymentTagsEditForm(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
serveTagsEditForm(w, r, params, "/payments/", "select tags from payment where slug = $1 union all select tags from collection where slug = $1")
}
func handleUpdatePaymentTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
handleUpdateTags(w, r, params, "/payments/", "with p as (update payment set tags = $1 where slug = $2 returning slug), c as (update collection set tags = $1 where slug = $2 returning slug) select p.slug from p union all select c.slug from c")
}

View File

@ -7,6 +7,10 @@ import (
"github.com/jackc/pgx/v4"
)
var (
oidCache = make(map[string]uint32)
)
type CustomerTaxDetails struct {
BusinessName string
VATIN string
@ -325,8 +329,12 @@ func registerPgTypes(ctx context.Context, conn *pgx.Conn) error {
}
func registerPgType(ctx context.Context, conn *pgx.Conn, value pgtype.Value, name string) (oid uint32, err error) {
if err = conn.QueryRow(ctx, "select $1::regtype::oid", name).Scan(&oid); err != nil {
return
var found bool
if oid, found = oidCache[name]; !found {
if err = conn.QueryRow(ctx, "select $1::regtype::oid", name).Scan(&oid); err != nil {
return
}
oidCache[name] = oid
}
conn.ConnInfo().RegisterDataType(pgtype.DataType{Value: value, Name: name, OID: oid})
return

View File

@ -50,6 +50,10 @@ func GetProductForm(w http.ResponseWriter, r *http.Request, params httprouter.Pa
mustRenderNewProductForm(w, r, form)
return
}
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
if !form.MustFillFromDatabase(r.Context(), conn, slug) {
http.NotFound(w, r)
return
@ -91,9 +95,7 @@ func HandleAddProduct(w http.ResponseWriter, r *http.Request, _ httprouter.Param
return
}
if !form.Validate() {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
w.WriteHeader(http.StatusUnprocessableEntity)
mustRenderNewProductForm(w, r, form)
return
}
@ -136,10 +138,12 @@ func HandleUpdateProduct(w http.ResponseWriter, r *http.Request, params httprout
return
}
slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
if !form.Validate() {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
w.WriteHeader(http.StatusUnprocessableEntity)
mustRenderEditProductForm(w, r, slug, form)
return
}
@ -196,6 +200,11 @@ func (form *productFilterForm) Parse(r *http.Request) error {
return nil
}
func (form *productFilterForm) HasValue() bool {
return form.Name.HasValue() ||
form.Tags.HasValue()
}
func mustCollectProductEntries(ctx context.Context, conn *Conn, company *Company, filters *productFilterForm) []*ProductEntry {
args := []interface{}{company.Id}
where := []string{"product.company_id = $1"}
@ -354,34 +363,9 @@ func HandleProductSearch(w http.ResponseWriter, r *http.Request, _ httprouter.Pa
}
func ServeEditProductTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
conn := getConn(r)
locale := getLocale(r)
company := getCompany(r)
slug := params[0].Value
form := newTagsForm(companyURI(company, "/products/"+slug+"/tags"), slug, locale)
if notFoundErrorOrPanic(conn.QueryRow(r.Context(), `select tags from product where slug = $1`, form.Slug).Scan(form.Tags)) {
http.NotFound(w, r)
return
}
mustRenderStandaloneTemplate(w, r, "tags/edit.gohtml", form)
serveTagsEditForm(w, r, params, "/products/", "select tags from product where slug = $1")
}
func HandleUpdateProductTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
locale := getLocale(r)
conn := getConn(r)
company := getCompany(r)
slug := params[0].Value
form := newTagsForm(companyURI(company, "/products/"+slug+"/tags/edit"), slug, locale)
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if conn.MustGetText(r.Context(), "", "update product set tags = $1 where slug = $2 returning slug", form.Tags, form.Slug) == "" {
http.NotFound(w, r)
}
mustRenderStandaloneTemplate(w, r, "tags/view.gohtml", form)
handleUpdateTags(w, r, params, "/products/", "update product set tags = $1 where slug = $2 returning slug")
}

View File

@ -121,9 +121,7 @@ func HandleProfileForm(w http.ResponseWriter, r *http.Request, _ httprouter.Para
return
}
if ok := form.Validate(); !ok {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
w.WriteHeader(http.StatusUnprocessableEntity)
mustRenderProfileForm(w, r, form)
return
}

View File

@ -205,6 +205,15 @@ func (form *quoteFilterForm) Parse(r *http.Request) error {
return nil
}
func (form *quoteFilterForm) HasValue() bool {
return form.Customer.HasValue() ||
form.QuoteStatus.HasValue() ||
form.QuoteNumber.HasValue() ||
form.FromDate.HasValue() ||
form.ToDate.HasValue() ||
form.Tags.HasValue()
}
func (form *quoteFilterForm) BuildQuery(args []interface{}) (string, []interface{}) {
var where []string
appendWhere := func(expression string, value interface{}) {
@ -249,7 +258,7 @@ func ServeQuote(w http.ResponseWriter, r *http.Request, params httprouter.Params
case "new":
locale := getLocale(r)
form := newQuoteForm(r.Context(), conn, locale, company)
if quoteToDuplicate := r.URL.Query().Get("duplicate"); quoteToDuplicate != "" {
if quoteToDuplicate := r.URL.Query().Get("duplicate"); ValidUuid(quoteToDuplicate) {
form.MustFillFromDatabase(r.Context(), conn, quoteToDuplicate)
form.QuoteStatus.Selected = []string{"created"}
}
@ -279,6 +288,10 @@ func ServeQuote(w http.ResponseWriter, r *http.Request, params httprouter.Params
pdf = true
slug = slug[:len(slug)-len(".pdf")]
}
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
quo := mustGetQuote(r.Context(), conn, company, slug)
if quo == nil {
http.NotFound(w, r)
@ -410,13 +423,60 @@ func mustGetQuote(ctx context.Context, conn *Conn, company *Company, slug string
&quo.Total)) {
return nil
}
if err := conn.QueryRow(ctx, "select business_name, vatin, phone, email, address, city, province, postal_code, legal_disclaimer from company where company_id = $1", company.Id).Scan(&quo.Quoter.Name, &quo.Quoter.VATIN, &quo.Quoter.Phone, &quo.Quoter.Email, &quo.Quoter.Address, &quo.Quoter.City, &quo.Quoter.Province, &quo.Quoter.PostalCode, &quo.LegalDisclaimer); err != nil {
if err := conn.QueryRow(ctx, `
select business_name
, vatin, phone
, email
, address
, city
, province
, postal_code
, legal_disclaimer
from company
where company_id = $1
`, company.Id).Scan(
&quo.Quoter.Name,
&quo.Quoter.VATIN,
&quo.Quoter.Phone,
&quo.Quoter.Email,
&quo.Quoter.Address,
&quo.Quoter.City,
&quo.Quoter.Province,
&quo.Quoter.PostalCode,
&quo.LegalDisclaimer); err != nil {
panic(err)
}
if err := conn.QueryRow(ctx, "select array_agg(array[name, to_price(amount, $2)]) from quote_tax_amount join tax using (tax_id) where quote_id = $1", quoteId, decimalDigits).Scan(&quo.Taxes); err != nil {
if err := conn.QueryRow(ctx, `
select array_agg(array[name, to_price(amount, $2)]) from quote_tax_amount
join tax using (tax_id)
where quote_id = $1
`, quoteId, decimalDigits).Scan(&quo.Taxes); err != nil {
panic(err)
}
rows := conn.MustQuery(ctx, "select quote_product.name, description, to_price(price, $2), (discount_rate * 100)::integer, quantity, to_price(subtotal, $2), to_price(total, $2), array_agg(array[tax_class.name, (tax_rate * 100)::integer::text]) filter (where tax_rate is not null) from quote_product join quote_product_amount using (quote_product_id) left join quote_product_tax using (quote_product_id) left join tax using (tax_id) left join tax_class using (tax_class_id) where quote_id = $1 group by quote_product.name, description, discount_rate, price, quantity, subtotal, total", quoteId, decimalDigits)
rows := conn.MustQuery(ctx, `
select quote_product.name
, description
, to_price(price, $2)
, (discount_rate * 100)::integer
, quantity, to_price(subtotal, $2)
, to_price(total, $2)
, array_agg(array[tax_class.name, (tax_rate * 100)::integer::text]) filter (where tax_rate is not null)
from quote_product
join quote_product_amount using (quote_product_id)
left join quote_product_tax using (quote_product_id)
left join tax using (tax_id)
left join tax_class using (tax_class_id)
where quote_id = $1
group by quote_product_id
, quote_product.name
, description
, discount_rate
, price
, quantity
, subtotal
, total
order by quote_product_id
`, quoteId, decimalDigits)
defer rows.Close()
taxClasses := map[string]bool{}
for rows.Next() {
@ -424,7 +484,15 @@ func mustGetQuote(ctx context.Context, conn *Conn, company *Company, slug string
Taxes: make(map[string]int),
}
var taxes [][]string
if err := rows.Scan(&product.Name, &product.Description, &product.Price, &product.Discount, &product.Quantity, &product.Subtotal, &product.Total, &taxes); err != nil {
if err := rows.Scan(
&product.Name,
&product.Description,
&product.Price,
&product.Discount,
&product.Quantity,
&product.Subtotal,
&product.Total,
&taxes); err != nil {
panic(err)
}
for _, tax := range taxes {
@ -507,9 +575,7 @@ func HandleAddQuote(w http.ResponseWriter, r *http.Request, _ httprouter.Params)
return
}
if !form.Validate() {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
w.WriteHeader(http.StatusUnprocessableEntity)
mustRenderNewQuoteForm(w, r, form)
return
}
@ -537,14 +603,14 @@ func HandleBatchQuoteAction(w http.ResponseWriter, r *http.Request, _ httprouter
http.Error(w, err.Error(), http.StatusForbidden)
return
}
slugs := r.Form["quote"]
if len(slugs) == 0 {
http.Redirect(w, r, companyURI(mustGetCompany(r), "/quotes"), http.StatusSeeOther)
return
}
locale := getLocale(r)
switch r.Form.Get("action") {
case "download":
slugs := r.Form["quote"]
if len(slugs) == 0 {
http.Redirect(w, r, companyURI(mustGetCompany(r), "/quotes"), http.StatusSeeOther)
return
}
quotes := mustWriteQuotesPdf(r, slugs)
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", gettext("quotations.zip", locale)))
@ -552,6 +618,16 @@ func HandleBatchQuoteAction(w http.ResponseWriter, r *http.Request, _ httprouter
if _, err := w.Write(quotes); err != nil {
panic(err)
}
case "export":
conn := getConn(r)
company := getCompany(r)
filters := newQuoteFilterForm(r.Context(), conn, locale, company)
if err := filters.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ods := mustWriteQuotesOds(mustCollectQuoteEntries(r.Context(), conn, locale, filters), locale, company)
writeOdsResponse(w, ods, gettext("quotations.ods", locale))
default:
http.Error(w, gettext("Invalid action", locale), http.StatusBadRequest)
}
@ -631,8 +707,8 @@ func newQuoteForm(ctx context.Context, conn *Conn, locale *Locale, company *Comp
},
PaymentMethod: &SelectField{
Name: "payment_method",
Label: pgettext("input", "Payment Method", locale),
EmptyLabel: gettext("Select a payment method.", locale),
Label: pgettext("input", "Invoicing Method", locale),
EmptyLabel: gettext("Select a invoicing method.", locale),
Options: mustGetPaymentMethodOptions(ctx, conn, company),
},
}
@ -676,7 +752,7 @@ func (form *quoteForm) Validate() bool {
validator.CheckValidDate(form.Date, gettext("Quotation date must be a valid date.", form.locale))
}
if form.PaymentMethod.String() != "" {
validator.CheckValidSelectOption(form.PaymentMethod, gettext("Selected payment method is not valid.", form.locale))
validator.CheckValidSelectOption(form.PaymentMethod, gettext("Selected invoicing method is not valid.", form.locale))
}
allOK := validator.AllOK()
@ -972,18 +1048,21 @@ func HandleUpdateQuote(w http.ResponseWriter, r *http.Request, params httprouter
http.Error(w, err.Error(), http.StatusForbidden)
return
}
slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
if r.FormValue("quick") == "status" {
slug := conn.MustGetText(r.Context(), "", "update quote set quote_status = $1 where slug = $2 returning slug", form.QuoteStatus, params[0].Value)
slug = conn.MustGetText(r.Context(), "", "update quote set quote_status = $1 where slug = $2 returning slug", form.QuoteStatus, slug)
if slug == "" {
http.NotFound(w, r)
return
}
htmxRedirect(w, r, companyURI(mustGetCompany(r), "/quotes"))
} else {
slug := params[0].Value
if !form.Validate() {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
w.WriteHeader(http.StatusUnprocessableEntity)
mustRenderEditQuoteForm(w, r, slug, form)
return
}
@ -1000,6 +1079,10 @@ func ServeEditQuote(w http.ResponseWriter, r *http.Request, params httprouter.Pa
conn := getConn(r)
company := mustGetCompany(r)
slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
locale := getLocale(r)
form := newQuoteForm(r.Context(), conn, locale, company)
if !form.MustFillFromDatabase(r.Context(), conn, slug) {
@ -1031,6 +1114,10 @@ func mustRenderEditQuoteForm(w http.ResponseWriter, r *http.Request, slug string
func HandleEditQuoteAction(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
actionUri := fmt.Sprintf("/quotes/%s/edit", slug)
handleQuoteAction(w, r, actionUri, func(w http.ResponseWriter, r *http.Request, form *quoteForm) {
conn := getConn(r)
@ -1096,34 +1183,9 @@ func handleQuoteAction(w http.ResponseWriter, r *http.Request, action string, re
}
func ServeEditQuoteTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
conn := getConn(r)
locale := getLocale(r)
company := getCompany(r)
slug := params[0].Value
form := newTagsForm(companyURI(company, "/quotes/"+slug+"/tags"), slug, locale)
if notFoundErrorOrPanic(conn.QueryRow(r.Context(), `select tags from quote where slug = $1`, form.Slug).Scan(form.Tags)) {
http.NotFound(w, r)
return
}
mustRenderStandaloneTemplate(w, r, "tags/edit.gohtml", form)
serveTagsEditForm(w, r, params, "/quotes/", "select tags from quote where slug = $1")
}
func HandleUpdateQuoteTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
locale := getLocale(r)
conn := getConn(r)
company := getCompany(r)
slug := params[0].Value
form := newTagsForm(companyURI(company, "/quotes/"+slug+"/tags/edit"), slug, locale)
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if conn.MustGetText(r.Context(), "", "update quote set tags = $1 where slug = $2 returning slug", form.Tags, form.Slug) == "" {
http.NotFound(w, r)
}
mustRenderStandaloneTemplate(w, r, "tags/view.gohtml", form)
handleUpdateTags(w, r, params, "/quotes/", "update quote set tags = $1 where slug = $2 returning slug")
}

View File

@ -6,16 +6,23 @@ import (
"net/http"
)
func NewRouter(db *Db) http.Handler {
func NewRouter(db *Db, demo bool) http.Handler {
companyRouter := httprouter.New()
companyRouter.GET("/profile", GetProfileForm)
companyRouter.POST("/profile", HandleProfileForm)
companyRouter.GET("/tax-details", GetCompanyTaxDetailsForm)
companyRouter.POST("/tax-details", HandleCompanyTaxDetailsForm)
companyRouter.POST("/tax", HandleAddCompanyTax)
companyRouter.DELETE("/tax/:taxId", HandleDeleteCompanyTax)
companyRouter.POST("/payment-method", HandleAddPaymentMethod)
companyRouter.DELETE("/payment-method/:paymentMethodId", HandleDeletePaymentMethod)
companyRouter.GET("/invoicing", serveCompanyInvoicingForm)
companyRouter.POST("/invoicing", handleCompanyInvoicingForm)
companyRouter.GET("/switch-company", GetCompanySwitcher)
companyRouter.GET("/taxes", serveCompanyTaxes)
companyRouter.POST("/taxes", HandleAddCompanyTax)
companyRouter.DELETE("/taxes/:taxId", HandleDeleteCompanyTax)
companyRouter.GET("/payment-methods", servePaymentMethods)
companyRouter.POST("/payment-methods", HandleAddPaymentMethod)
companyRouter.PUT("/payment-methods/:paymentMethodId", HandleUpdatePaymentMethod)
companyRouter.DELETE("/payment-methods/:paymentMethodId", HandleDeletePaymentMethod)
companyRouter.GET("/payment-methods/:paymentMethodId/edit", servePaymentMethodEditForm)
companyRouter.GET("/contacts", IndexContacts)
companyRouter.POST("/contacts", HandleAddContact)
companyRouter.POST("/contacts/import", HandleImportContacts)
@ -38,6 +45,12 @@ func NewRouter(db *Db) http.Handler {
companyRouter.POST("/invoices/:slug/edit", HandleEditInvoiceAction)
companyRouter.PUT("/invoices/:slug/tags", HandleUpdateInvoiceTags)
companyRouter.GET("/invoices/:slug/tags/edit", ServeEditInvoiceTags)
companyRouter.GET("/invoices/:slug/download/:filename", ServeInvoiceAttachment)
companyRouter.GET("/invoices/:slug/collections", serveInvoiceCollectionIndex)
companyRouter.POST("/invoices/:slug/collections", handleAddInvoiceCollection)
companyRouter.GET("/invoices/:slug/collections/:slug", serveInvoiceCollectionForm)
companyRouter.PUT("/invoices/:slug/collections/:slug", handleEditInvoiceCollection)
companyRouter.DELETE("/invoices/:slug/collections/:slug", handleRemoveInvoiceCollection)
companyRouter.GET("/quotes", IndexQuotes)
companyRouter.POST("/quotes", HandleAddQuote)
companyRouter.GET("/quotes/:slug", ServeQuote)
@ -49,18 +62,41 @@ func NewRouter(db *Db) http.Handler {
companyRouter.GET("/quotes/:slug/tags/edit", ServeEditQuoteTags)
companyRouter.GET("/search/products", HandleProductSearch)
companyRouter.GET("/expenses", IndexExpenses)
companyRouter.POST("/expenses", HandleAddExpense)
companyRouter.POST("/expenses", HandleNewExpenseAction)
companyRouter.GET("/expenses/:slug", ServeExpenseForm)
companyRouter.POST("/expenses/:slug", HandleEditExpenseAction)
companyRouter.PUT("/expenses/:slug", HandleUpdateExpense)
companyRouter.DELETE("/expenses/:slug", handleRemoveExpense)
companyRouter.PUT("/expenses/:slug/tags", HandleUpdateExpenseTags)
companyRouter.GET("/expenses/:slug/tags/edit", ServeEditExpenseTags)
companyRouter.GET("/expenses/:slug/download/:filename", ServeExpenseAttachment)
companyRouter.GET("/expenses/:slug/payments", serveExpensePaymentIndex)
companyRouter.POST("/expenses/:slug/payments", handleAddExpensePayment)
companyRouter.GET("/expenses/:slug/payments/:slug", serveExpensePaymentForm)
companyRouter.PUT("/expenses/:slug/payments/:slug", handleEditExpensePayment)
companyRouter.DELETE("/expenses/:slug/payments/:slug", handleRemoveExpensePayment)
companyRouter.GET("/payments", servePaymentIndex)
companyRouter.POST("/payments", handleAddPayment)
companyRouter.GET("/payments/:slug", servePaymentForm)
companyRouter.PUT("/payments/:slug", handleEditPayment)
companyRouter.DELETE("/payments/:slug", handleRemovePayment)
companyRouter.PUT("/payments/:slug/tags", handleUpdatePaymentTags)
companyRouter.GET("/payments/:slug/tags/edit", servePaymentTagsEditForm)
companyRouter.GET("/payments/:slug/download/:filename", servePaymentAttachment)
companyRouter.GET("/payment-accounts", servePaymentAccountIndex)
companyRouter.POST("/payment-accounts", handleAddPaymentAccount)
companyRouter.GET("/payment-accounts/:slug", servePaymentAccountForm)
companyRouter.PUT("/payment-accounts/:slug", handleEditPaymentAccount)
companyRouter.GET("/", ServeDashboard)
router := httprouter.New()
router.ServeFiles("/static/*filepath", http.Dir("web/static"))
router.GET("/login", GetLoginForm)
router.POST("/login", HandleLoginForm)
router.GET("/login", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
GetLoginForm(w, r, demo)
})
router.POST("/login", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
HandleLoginForm(w, r, demo)
})
router.POST("/logout", Authenticated(HandleLogout))
companyHandler := Authenticated(CompanyHandler(companyRouter))
@ -69,6 +105,18 @@ func NewRouter(db *Db) http.Handler {
router.PUT("/company/:slug/*rest", companyHandler)
router.DELETE("/company/:slug/*rest", companyHandler)
router.GET("/legal", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
mustRenderWebTemplate(w, r, "legal.gohtml", nil)
})
router.GET("/privacy", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
mustRenderWebTemplate(w, r, "privacy.gohtml", nil)
})
router.GET("/cookies", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
mustRenderWebTemplate(w, r, "cookies.gohtml", nil)
})
router.GET("/", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
user := getUser(r)
if user.LoggedIn {

22
pkg/slug.go Normal file
View File

@ -0,0 +1,22 @@
package pkg
import (
"github.com/rainycape/unidecode"
"regexp"
"strings"
)
var (
nonValidChars = regexp.MustCompile("[^a-z0-9-_]")
multipleDashes = regexp.MustCompile("-+")
)
func slugify(s string) (slug string) {
slug = strings.TrimSpace(s)
slug = unidecode.Unidecode(slug)
slug = strings.ToLower(slug)
slug = nonValidChars.ReplaceAllString(slug, "-")
slug = multipleDashes.ReplaceAllString(slug, "-")
slug = strings.Trim(slug, "-_")
return slug
}

73
pkg/tags.go Normal file
View File

@ -0,0 +1,73 @@
package pkg
import (
"github.com/julienschmidt/httprouter"
"net/http"
)
func serveTagsEditForm(w http.ResponseWriter, r *http.Request, params httprouter.Params, prefix string, sql string) {
conn := getConn(r)
locale := getLocale(r)
company := getCompany(r)
slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
form := newTagsForm(companyURI(company, prefix+slug+"/tags"), slug, locale)
if notFoundErrorOrPanic(conn.QueryRow(r.Context(), sql, form.Slug).Scan(form.Tags)) {
http.NotFound(w, r)
return
}
mustRenderStandaloneTemplate(w, r, "tags/edit.gohtml", form)
}
type tagsForm struct {
Action string
Slug string
Tags *TagsField
}
func newTagsForm(uri string, slug string, locale *Locale) *tagsForm {
return &tagsForm{
Action: uri,
Slug: slug,
Tags: &TagsField{
Name: "tags-" + slug,
Label: pgettext("input", "Tags", locale),
},
}
}
func (form *tagsForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
form.Tags.FillValue(r)
return nil
}
func handleUpdateTags(w http.ResponseWriter, r *http.Request, params httprouter.Params, prefix string, sql string) {
locale := getLocale(r)
conn := getConn(r)
company := getCompany(r)
slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
form := newTagsForm(companyURI(company, prefix+slug+"/tags/edit"), slug, locale)
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if conn.MustGetText(r.Context(), "", sql, form.Tags, form.Slug) == "" {
http.NotFound(w, r)
return
}
mustRenderStandaloneTemplate(w, r, "tags/view.gohtml", form)
}

View File

@ -10,6 +10,7 @@ import (
"math"
"net/http"
"strconv"
"strings"
"time"
)
@ -20,6 +21,10 @@ func templateFile(name string) string {
}
func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename string, data interface{}) {
mustRenderTemplateFragment(wr, r, layout, filename, layout, data)
}
func mustRenderTemplateFragment(wr io.Writer, r *http.Request, layout string, filename string, fragment string, data interface{}) {
locale := getLocale(r)
company := getCompany(r)
user := getUser(r)
@ -30,6 +35,12 @@ func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename s
"currentLocale": func() string {
return locale.Language.String()
},
"requestURIMatches": func(uri string) bool {
return r.RequestURI == uri
},
"requestURIHasPrefix": func(uri string) bool {
return strings.HasPrefix(r.RequestURI, uri)
},
"companyURI": func(uri string) string {
return companyURI(company, uri)
},
@ -51,6 +62,9 @@ func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename s
"csrfToken": func() template.HTML {
return template.HTML(fmt.Sprintf(`<input type="hidden" name="%s" value="%s">`, csrfTokenField, user.CsrfToken))
},
"csrfHeader": func() string {
return fmt.Sprintf(`"%s": "%s"`, csrfTokenHeader, user.CsrfToken)
},
"addInputAttr": func(attr string, field *InputField) *InputField {
field.Attributes = append(field.Attributes, template.HTMLAttr(attr))
return field
@ -86,6 +100,12 @@ func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename s
sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}
return humanizeBytes(bytes, 1024, sizes)
},
"slugify": func(s string) string {
return slugify(s)
},
"numerusVersion": func() string {
return Version
},
})
if _, err := t.ParseFiles(templateFile(filename), templateFile(layout), templateFile("form.gohtml")); err != nil {
panic(err)
@ -93,7 +113,7 @@ func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename s
if w, ok := wr.(http.ResponseWriter); ok {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
}
if err := t.ExecuteTemplate(wr, layout, data); err != nil {
if err := t.ExecuteTemplate(wr, fragment, data); err != nil {
panic(err)
}
}
@ -129,7 +149,11 @@ func mustRenderMainTemplate(w io.Writer, r *http.Request, filename string, data
}
func mustRenderStandaloneTemplate(w io.Writer, r *http.Request, filename string, data interface{}) {
mustRenderTemplate(w, r, "standalone.gohtml", filename, data)
mustRenderStandaloneTemplateFragment(w, r, filename, "standalone.gohtml", data)
}
func mustRenderStandaloneTemplateFragment(w io.Writer, r *http.Request, filename string, fragment string, data interface{}) {
mustRenderTemplateFragment(w, r, "standalone.gohtml", filename, fragment, data)
}
func mustRenderWebTemplate(w io.Writer, r *http.Request, filename string, data interface{}) {

46
pkg/uuid.go Normal file
View File

@ -0,0 +1,46 @@
package pkg
func ValidUuid(s string) bool {
if len(s) != 36 {
return false
}
// it must be of the form xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
if s[8] != '-' || s[13] != '-' || s[18] != '-' || s[23] != '-' {
return false
}
for _, x := range [16]int{
0, 2, 4, 6,
9, 11,
14, 16,
19, 21,
24, 26, 28, 30, 32, 34} {
if !validHex(s[x], s[x+1]) {
return false
}
}
return true
}
// xvalues returns the value of a byte as a hexadecimal digit or 255.
var xvalues = [256]byte{
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 255, 255, 255, 255, 255, 255,
255, 10, 11, 12, 13, 14, 15, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 10, 11, 12, 13, 14, 15, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
}
func validHex(x1, x2 byte) bool {
return xvalues[x1] != 255 && xvalues[x2] != 255
}

33
pkg/uuid_test.go Normal file
View File

@ -0,0 +1,33 @@
package pkg
import (
"strings"
"testing"
)
type test struct {
in string
isUuid bool
}
var tests = []test{
{"f47ac10b-58cc-0372-8567-0e02b2c3d479", true},
{"2bc1be74-169d-4300-a239-49a1196a045d", true},
{"12bc1be74-169d-4300-a239-49a1196a045d", false},
{"2bc1be74-169d-4300-a239-49a1196a045", false},
{"2bc1be74-1x9d-4300-a239-49a1196a045d", false},
{"2bc1be74-169d-4300-a239-49a1196ag45d", false},
}
func testValidUuid(t *testing.T, in string, isUuid bool) {
if ok := ValidUuid(in); ok != isUuid {
t.Errorf("ValidUuid(%s) got %v expected %v", in, ok, isUuid)
}
}
func TestUUID(t *testing.T) {
for _, tt := range tests {
testValidUuid(t, tt.in, tt.isUuid)
testValidUuid(t, strings.ToUpper(tt.in), tt.isUuid)
}
}

1249
po/ca.po

File diff suppressed because it is too large Load Diff

1245
po/es.po

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
-- Revert numerus:add_collection from pg
begin;
drop function if exists numerus.add_collection(integer, integer, date, integer, text, text, numerus.tag_name[]);
commit;

View File

@ -1,7 +1,55 @@
-- Revert numerus:add_expense from pg
-- Deploy numerus:add_expense to pg
-- requires: schema_numerus
-- requires: expense
-- requires: expense_tax
-- requires: tax
-- requires: company
-- requires: currency
-- requires: parse_price
-- requires: tax
-- requires: tag_name
-- requires: expense_status
-- requires: expense_expense_status
begin;
drop function if exists numerus.add_expense(integer, date, integer, text, text, integer[], numerus.tag_name[]);
set search_path to numerus, public;
drop function if exists add_expense(integer, date, integer, text, text, integer[], tag_name[]);
create or replace function add_expense(company integer, status text, invoice_date date, contact_id integer, invoice_number text, amount text, taxes integer[], tags tag_name[]) returns uuid as
$$
declare
eid integer;
eslug uuid;
begin
insert into expense (company_id, contact_id, invoice_number, invoice_date, amount, currency_code, expense_status, tags)
select company_id
, contact_id
, invoice_number
, invoice_date
, parse_price(amount, currency.decimal_digits)
, currency_code
, status
, tags
from company
join currency using (currency_code)
where company.company_id = add_expense.company
returning expense_id, slug
into eid, eslug;
insert into expense_tax (expense_id, tax_id, tax_rate)
select eid, tax_id, tax.rate
from tax
join unnest(taxes) as etax(tax_id) using (tax_id);
return eslug;
end;
$$
language plpgsql;
revoke execute on function add_expense(integer, text, date, integer, text, text, integer[], tag_name[]) from public;
grant execute on function add_expense(integer, text, date, integer, text, text, integer[], tag_name[]) to invoicer;
grant execute on function add_expense(integer, text, date, integer, text, text, integer[], tag_name[]) to admin;
commit;

View File

@ -0,0 +1,7 @@
-- Revert numerus:add_expense from pg
begin;
drop function if exists numerus.add_expense(integer, date, integer, text, text, integer[], numerus.tag_name[]);
commit;

52
revert/add_expense@v2.sql Normal file
View File

@ -0,0 +1,52 @@
-- Deploy numerus:add_expense to pg
-- requires: schema_numerus
-- requires: expense
-- requires: expense_tax
-- requires: tax
-- requires: company
-- requires: currency
-- requires: parse_price
-- requires: tax
-- requires: tag_name
begin;
set search_path to numerus, public;
drop function if exists add_expense(integer, text, date, integer, text, text, integer[], tag_name[]);
create or replace function add_expense(company integer, invoice_date date, contact_id integer, invoice_number text, amount text, taxes integer[], tags tag_name[]) returns uuid as
$$
declare
eid integer;
eslug uuid;
begin
insert into expense (company_id, contact_id, invoice_number, invoice_date, amount, currency_code, tags)
select company_id
, contact_id
, invoice_number
, invoice_date
, parse_price(amount, currency.decimal_digits)
, currency_code
, tags
from company
join currency using (currency_code)
where company.company_id = add_expense.company
returning expense_id, slug
into eid, eslug;
insert into expense_tax (expense_id, tax_id, tax_rate)
select eid, tax_id, tax.rate
from tax
join unnest(taxes) as etax(tax_id) using (tax_id);
return eslug;
end;
$$
language plpgsql;
revoke execute on function add_expense(integer, date, integer, text, text, integer[], tag_name[]) from public;
grant execute on function add_expense(integer, date, integer, text, text, integer[], tag_name[]) to invoicer;
grant execute on function add_expense(integer, date, integer, text, text, integer[], tag_name[]) to admin;
commit;

7
revert/add_payment.sql Normal file
View File

@ -0,0 +1,7 @@
-- Revert numerus:add_payment from pg
begin;
drop function if exists numerus.add_payment(integer, integer, date, integer, text, text, numerus.tag_name[]);
commit;

View File

@ -0,0 +1,7 @@
-- Revert numerus:add_payment_account_bank from pg
begin;
drop function if exists numerus.add_payment_account_bank(integer, text, iban);
commit;

View File

@ -0,0 +1,7 @@
-- Revert numerus:add_payment_account_card from pg
begin;
drop function if exists numerus.add_payment_account_card(integer, text, text, date);
commit;

View File

@ -0,0 +1,7 @@
-- Revert numerus:add_payment_account_cash from pg
begin;
drop function if exists numerus.add_payment_account_cash(integer, text);
commit;

View File

@ -0,0 +1,7 @@
-- Revert numerus:add_payment_account_other from pg
begin;
drop function if exists numerus.add_payment_account_other(integer, text);
commit;

View File

@ -0,0 +1,7 @@
-- Revert numerus:attach_to_collection from pg
begin;
drop function if exists numerus.attach_to_collection(uuid, text, text, bytea);
commit;

View File

@ -0,0 +1,7 @@
-- Revert numerus:attach_to_invoice from pg
begin;
drop function if exists numerus.attach_to_invoice(uuid, text, text, bytea);
commit;

View File

@ -0,0 +1,7 @@
-- Revert numerus:attach_to_payment from pg
begin;
drop function if exists numerus.attach_to_payment(uuid, text, text, bytea);
commit;

View File

@ -0,0 +1,14 @@
-- Deploy numerus:available_expense_status to pg
-- requires: schema_numerus
-- requires: expense_status
-- requires: expense_status_i18n
begin;
set search_path to numerus;
update expense set expense_status = 'pending' where expense_status = 'partial';
delete from expense_status_i18n where expense_status = 'partial';
delete from expense_status where expense_status = 'partial';
commit;

View File

@ -0,0 +1,10 @@
-- Revert numerus:available_expense_status from pg
begin;
set search_path to numerus;
delete from expense_status_i18n;
delete from expense_status;
commit;

Some files were not shown because too many files have changed in this diff Show More