Compare commits

...

141 Commits
v0 ... master

Author SHA1 Message Date
jordi fita mas ebb073166a Subsume collections into payments, and record payments in negative
This is to handle refunds, which are invoices with negative amounts,
that can be both issued or received (i.e., an “expense”).

The API provided by PostgreSQL is mostly the same, and internally it
deals with negatives, so the Go package only had to change selects of
collection.
2025-01-30 23:24:16 +01:00
jordi fita mas 69076903e8 Tag version 3; bump version to 1.4 2025-01-30 11:42:28 +01:00
jordi fita mas 913612a179 Match error message for quote and invoice quantity field 2025-01-23 19:34:02 +01:00
jordi fita mas a0961f9da9 Fold download quotation link inside actions menus
It is not used as much as we though, and it uses valuable horizontal
space.
2025-01-23 19:31:44 +01:00
jordi fita mas 0ee64f1905 Allow negative prices and quantities in invoices and quotes
This is necessary for credit forms, because it is “just” an invoice with
a negative amount, usually the inverse of an invoice that has to be
rectified.  This is a requirement for electronic invoicing too.

However, it is not the only use case: sometimes it is necessary to have
some lines with a negative quantity, even if the total is positive, to
emphasize discounts or to register expenses, like bank commissions in
autoinvoices.

Part of #61.
2025-01-21 23:11:29 +01:00
jordi fita mas d4dc8e00e5 Make product description use the whole width in invoices and quotes
Using only the first column from the description means that most of the
time it is too narrow to be read comfortably.

This commit swaps the product name, usually shorter, and the
description, that now spans all columns.

Part of #106.
2025-01-20 18:12:02 +01:00
jordi fita mas 12b3b4ad12 Remove the type attribute from stylesheet link
It is, apparently, the current recommendation according to MDN[0].

[0]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#type
2025-01-17 12:42:57 +01:00
jordi fita mas f1e876d4fb Moved all “marketing material” on another repository
It made not much sense having the marketing / landing / whatever page
in the application, as it was intended only for numerus.cat, not fer
everyone that might want to install the application for themselves.
2025-01-17 12:41:15 +01:00
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
jordi fita mas 596120d84a Tag Sqitch with version 1 2023-07-03 11:33:09 +02:00
jordi fita mas ef8f40e734 Create validation function for SQL domains and for phones
When i wrote the functions to import contact, i already created a couple
of “temporary” functions to validate whether the input given from the
Excel files was correct according to the various domains used in the
relations, so i can know whether i can import that data.

I realized that i could do exactly the same when validating forms: check
that the value conforms to the domain, in the exact same way, so i can
make sure that the value will be accepted without duplicating the logic,
at the expense of a call to the database.

In an ideal world, i would use pg_input_is_valid, but this function is
only available in PostgreSQL 16 and Debian 12 uses PostgreSQL 15.

These functions are in the public schema because initially i wanted to
use them to also validate email, which is needed in the login form, but
then i recanted and kept the same email validation in Go, because
something felt off about using the database for that particular form,
but i do not know why.
2023-07-03 11:31:59 +02:00
jordi fita mas 2320cae3f4 Fix test for import_contact
Depending on the number of test ran before this test, the sequence could
overlap with the existing ids and fail because the on conflict do update
would update multiple rows.
2023-07-03 00:25:17 +02:00
jordi fita mas 183b8d3ed9 Allow importing contacts from Holded
This allows to import an Excel file exported from Holded, because it is
our own user case.  When we have more customers, we will give out an
Excel template file to fill out.

Why XLSX files instead of CSV, for instance? First, because this is the
output from Holded, but even then we would have more trouble with CSV
than with XLSX because of Microsoft: they royally fucked up
interoperability when decided that CSV files, the files that only other
applications or programmers see, should be “localized”, and use a comma
or a **semicolon** to separate a **comma** separated file depending on
the locale’s decimal separator.

This is ridiculous because it means that CSV files created with an Excel
in USA uses comma while the same Excel but with a French locale expects
the fields to be separated by semicolon.  And for no good reason,
either.

Since they fucked up so bad, decided to add a non-standard “meta” field
to specify the separator, writing a `sep=,` in the first line, but this
only works for reading, because saving the same file changes the
separator back to the locale-dependent character and removes the “meta”
field.

And since everyone expects to open spreadsheet with Excel, i can not
use CSV if i do not want a bunch of support tickets telling me that the
template is all in a single line.

I use an extremely old version of a xlsx reading library for golang[0]
because it is already available in Debian repositories, and the only
thing i want from it is to convert the convoluted XML file into a
string array.

Go is only responsible to read the file and dump its contents into a
temporary table, so that it can execute the PL/pgSQL function that will
actually move that data to the correct relations, much like add_contact
does but in batch.

In PostgreSQL version 16 they added a pg_input_is_valid function that
i would use to test whether input values really conform to domains,
but i will have to wait for Debian to pick up the new version.
Meanwhile, i use a couple of temporary functions, in lieu of nested
functions support in PostgreSQL.

Part of #45

[0]: https://github.com/tealeg/xlsx
2023-07-03 00:05:47 +02:00
jordi fita mas a068784a22 Remove unused company parameter from mustCollectExpenseEntries
The company is now in the filters form and there is no need for that
company parameter.
2023-07-02 20:06:45 +02:00
jordi fita mas f917ce84dd Replace call to deprecated ioutil.ReadAll with io.ReadAll
Starting from Go 1.16, ioutil.ReadAll simply calls io.ReadAll.
2023-07-02 20:04:45 +02:00
jordi fita mas eb845edf0a Change menu’s and profile icon
Eventually, we will allow people to upload their own profile images,
but until then we’ll use a “generic” profile icon instead of the closed
icon, that means nothing to users.

We wanted to have the same icon for that menu than for the Account item,
but the one we used until now did not look inside the round button for
the menu, thus we changed the two.

Related to #55.
2023-07-02 19:49:03 +02:00
jordi fita mas 2299ec9f8c Add empty IBAN and BIC to demo contacts 2023-07-02 02:26:35 +02:00
jordi fita mas 20827b2cfb Add IBAN and BIC fields to contacts
These two fields are just for information purposes, as Numerus does not
have any way to wire transfer using these, but people might want to keep
these in the contact’s info as a convenience.

Since not every contact should have an IBAN, e.g., customers, and inside
SEPA (European Union and some more countries) the BIC is not required,
they are in two different relations in order to be optional without
using NULL.

For the IBAN i found an already made PostgreSQL module, but for BIC i
had to write a regular expression based on the information i gathered
from Wikipedia, because the ISO standard is not free.

These two parameters for the add_contact and edit_contact functions are
TEXT because i realized that these functions are intended to be used
from the web application, that only deals with texts, so the
ValueOrNil() function was unnecessarily complex and PostreSQL’s
functions were better suited to “convert” from TEXT to IBAN or BIC.
The same is true for EMAIL and URI domains, so i changed their parameter
types to TEXT too.

Closes #54.
2023-07-02 02:08:45 +02:00
jordi fita mas 1c0f126c58 Split contact relation into tax_details, phone, web, and email
We need to have contacts with just a name: we need to assign
freelancer’s quote as expense linked the government, but of course we
do not have a phone or email for that “contact”, much less a VATIN or
other tax details.

It is also interesting for other expenses-only contacts to not have to
input all tax details, as we may not need to invoice then, thus are
useless for us, but sometimes it might be interesting to have them,
“just in case”.

Of course, i did not want to make nullable any of the tax details
required to generate an invoice, otherwise we could allow illegal
invoices.  Therefore, that data had to go in a different relation,
and invoice’s foreign key update to point to that relation, not just
customer, or we would again be able to create invalid invoices.

We replaced the contact’s trade name with just name, because we do not
need _three_ names for a contact, but we _do_ need two: the one we use
to refer to them and the business name for tax purposes.

The new contact_phone, contact_web, and contact_email relations could be
simply a nullable field, but i did not see the point, since there are
not that many instances where i need any of this data.

Now company.taxDetailsForm is no longer “the same as contactForm with
some extra fields”, because i have to add a check whether the user needs
to invoice the contact, to check that the required values are there.

I have an additional problem with the contact form when not using
JavaScript: i must set the required field to all tax details fields to
avoid the “(optional)” suffix, and because they _are_ required when
that checkbox is enabled, but i can not set them optional when the check
is unchecked.  My solution for now is to ignore the form validation,
and later i will add some JavaScript that adds the validation again,
so it will work in all cases.
2023-06-30 21:32:48 +02:00
jordi fita mas 30cd15ee89 Change IDs of demo SQL script to prevent coincidences
This is to avoid the problem that if i mistype `company_id = 1` instead
of `company_id = $1`, like i fixed in ee0b5d0b, at least it won’t work
in testing now, as all IDs have a three digits number.
2023-06-21 10:02:06 +02:00
jordi fita mas f40e4fdb2e Fix passing company ID to expenses chart query
By mistake, i was using 1 instead of $1, but i all was OK in testing
because there is only a single company with ID = 1.
2023-06-21 09:47:23 +02:00
jordi fita mas ee0b5d0bdc Rename Contact to Customer in quotes and invoices’ fields
In this case, the invoicee or quotee _is_ a (potential) customer, so
there is no point on calling them “contact”.
2023-06-20 11:37:02 +02:00
jordi fita mas de2a2f5912 Updated contacts’ table heading to read Contact instead of Customer 2023-06-20 11:34:00 +02:00
jordi fita mas 07c1071975 Add total amount for quotes, invoices, and expenses tables
We have shown the application to a potential user, and they told us that
it would be very useful to have a total in the table’s footer, so that
they can verify the amount with the bank’s extracts.
2023-06-20 11:33:28 +02:00
jordi fita mas 8a4f80783d Rename Customer expense filter to Contact
It would be very unusual to have an expense from a customer, and we do
not have (yet) a name for supplier or whatever it should be here, so i
used the same name we use for the column in the table.
2023-06-20 11:17:07 +02:00
jordi fita mas 1ad771b771 Update module dependencies to match the version of Debian 12 packages 2023-06-17 20:42:23 +02:00
jordi fita mas 055e92fb23 Internationalize and localize the home template
Had to add an `unsafe` function to be able to translate text with HTML
fragments in it, although the fragments are added back with printf
because the login link is actually not translatable.
2023-06-16 10:58:40 +02:00
oriol carbonell pujolàs 826741a381 Primer pas al frontal de visitants 2023-06-16 10:17:58 +02:00
jordi fita mas 3af40cc7bc Update weasyprint parameters for version 57.2
Debian 12 (bookworm) has upgraded its weasyprint version and it no
longer includes the --format parameter, because now it only can output
to PDF.
2023-06-15 23:16:53 +02:00
jordi fita mas 010e174de7 Change CURRENT_TIMESTAMP parameter for col_default_is
It turns out i have been **years** doing this wrong: you are supposed to
pass that value as a text, like 'CURRENT_TIMESTAMP', not like the
keyword so that it returns the current timestamp as a timestamptz.

However, i have been doing it wrong because of a bug in previous
versions of pgTAP[0], that did not take into account keywords such as
CURRENT_TIMESTAMP or CURRENT_DATE and was comparing their actual values,
not the names, therefore i thought that i misread the documentation.

Only now have discovered this because Debian 12 upgraded pgTAP version
to 1.2.0.

[0]: https://github.com/theory/pgtap/issues/244
2023-06-15 14:21:09 +02:00
jordi fita mas 73682462da debian: Update PostgreSQL version to 15, used in Debian 12 2023-06-15 12:19:08 +02:00
jordi fita mas 6732d654a4 Reduce the amount of useless data in request’s log
I tried to have a log line that uses the “common” format from Apache,
because i thought that it would help me reuse regexps i have defined for
fail2ban filters and such.

However, it makes no much sense.

For once, i was repeating the date and time: log.Printf already does
that for me.

And, second, i do not need that data in Numerus’ log because i always
run it behind a proxy that _has_ a “common”-formatted log file, so
there is no need for me to repeat all that data again.

What i need is the IP, to know whether remotedAdd() function works as
expected; the method, to check that the override does its job; the path,
to know what resource the browser requested; the response status code,
so that i do not need to open the browser console for that; the response
size, to keep on eye that i do not return a lot of data; and the
total response time, to realize how long my unoptimized SQL queries
slows the application down.

The rest, Apache should do its job and record it in its log file for
fail2ban and whatever i need the logs for in the future.
2023-06-13 16:05:40 +02:00
jordi fita mas eb207a01fc Log requests to stdout
This is so that i can have two output files: the one with accesses
(stdout) and the one with errors (stderr).
2023-06-13 15:08:23 +02:00
jordi fita mas 3c14447ef9 Debian: add service and post installation script to create user and group 2023-06-13 14:48:43 +02:00
jordi fita mas d79ddc6731 Log the remote address, and only trust localhost proxies
I need the actual remote address to add fail2ban rules for it, but i
also to not want everyone to be able to fake X-Forward-For HTTP headers.
Which can contain multiple ip addresses, by the way, so i have to get
only the first one, as the others will be the proxies that the request
has been (re)forwarded to.
2023-06-13 14:21:54 +02:00
398 changed files with 18433 additions and 2548 deletions

View File

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

18
debian/control vendored
View File

@ -10,14 +10,17 @@ Build-Depends:
golang-github-jackc-pgx-v4-dev, golang-github-jackc-pgx-v4-dev,
golang-github-julienschmidt-httprouter-dev, golang-github-julienschmidt-httprouter-dev,
golang-github-leonelquinteros-gotext-dev, golang-github-leonelquinteros-gotext-dev,
golang-github-rainycape-unidecode-dev,
golang-github-tealeg-xlsx-dev,
golang-golang-x-text-dev, golang-golang-x-text-dev,
postgresql-all (>= 217~), postgresql-all (>= 217~),
sqitch, sqitch,
pgtap, pgtap,
postgresql-13-pg-libphonenumber, postgresql-15-pg-libphonenumber,
postgresql-13-pgtap, postgresql-15-pgtap,
postgresql-13-pguri, postgresql-15-pguri,
postgresql-13-vat postgresql-15-vat,
postgresql-15-iban
Standards-Version: 4.6.0 Standards-Version: 4.6.0
XS-Go-Import-Path: dev.tandem.ws/tandem/numerus XS-Go-Import-Path: dev.tandem.ws/tandem/numerus
Vcs-Browser: https://dev.tandem.ws/tandem/numerus Vcs-Browser: https://dev.tandem.ws/tandem/numerus
@ -52,9 +55,10 @@ Package: numerus-sqitch
Architecture: all Architecture: all
Depends: Depends:
${misc:Depends}, ${misc:Depends},
postgresql-13-pg-libphonenumber, postgresql-15-pg-libphonenumber,
postgresql-13-pguri, postgresql-15-pguri,
postgresql-13-vat, postgresql-15-vat,
postgresql-15-iban,
sqitch sqitch
Description: Simple invoicing and accounting web application Description: Simple invoicing and accounting web application
A simple web application to keep invoice and accouting records, intended for A simple web application to keep invoice and accouting records, intended for

40
debian/numerus.postinst vendored Normal file
View File

@ -0,0 +1,40 @@
#!/bin/sh
set -e
. /usr/share/debconf/confmodule
case "$1" in
configure)
# Create numerus user and group
if ! getent group numerus >/dev/null; then
addgroup --system --quiet numerus
fi
if ! getent passwd numerus >/dev/null; then
adduser --quiet \
--system \
--disabled-login \
--no-create-home \
--shell /bin/bash \
--ingroup numerus \
--home /usr/share/numerus \
--gecos "Numerus Daemon" \
numerus
fi
# Make sure log directory has correct permissions set
dpkg-statoverride --list "/var/log/numerus" >/dev/null || \
dpkg-statoverride --add --force --quiet --update numerus adm 0750 /var/log/numerus
;;
abort-upgrade|abort-remove|abort-deconfigure)
;;
*)
echo "postinst called with unknown argument \`$1'" >&2
exit 1
;;
esac
#DEBHELPER#
exit 0

15
debian/numerus.service vendored Normal file
View File

@ -0,0 +1,15 @@
[Unit]
Description=Numerus application server
Requires=postgresql.service
After=postgresql.service
[Service]
Type=simple
User=numerus
Group=numerus
WorkingDirectory=/usr/share/numerus
EnvironmentFile=-/etc/default/numerus
ExecStart=/usr/bin/numerus
Restart=always
StandardOutput=append:/var/log/numerus/access.log
StandardError=append:/var/log/numerus/error.log

6
debian/rules vendored
View File

@ -1,10 +1,14 @@
#!/usr/bin/make -f #!/usr/bin/make -f
include /usr/share/dpkg/pkg-info.mk
%: %:
dh $@ --builddirectory=_build --buildsystem=golang --with=golang dh $@ --builddirectory=_build --buildsystem=golang --with=golang
execute_before_dh_auto_build: 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 make
execute_after_dh_auto_test: execute_after_dh_auto_test:
pg_virtualenv -v 13 make test-deploy pg_virtualenv -v 15 make test-deploy

View File

@ -2,7 +2,9 @@ begin;
set search_path to auth, numerus, public; set search_path to auth, numerus, public;
alter sequence user_user_id_seq restart; 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) insert into auth."user" (email, name, password, role)
values ('demo@numerus', 'Demo User', 'demo', 'invoicer') values ('demo@numerus', 'Demo User', 'demo', 'invoicer')
, ('admin@numerus', 'Demo Admin', 'admin', 'admin') , ('admin@numerus', 'Demo Admin', 'admin', 'admin')
@ -10,65 +12,188 @@ values ('demo@numerus', 'Demo User', 'demo', 'invoicer')
set constraints "company_default_payment_method_id_fkey" deferred; set constraints "company_default_payment_method_id_fkey" deferred;
alter sequence company_company_id_seq restart; 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) 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', 2, '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; alter sequence payment_method_payment_method_id_seq restart with 123;
insert into payment_method (company_id, name, instructions) insert into payment_method (company_id, name, instructions)
values (1, 'Efectiu', 'Pagament en efectiu al comptat.') values (123, 'Efectiu', 'Pagament en efectiu al comptat.')
, (1, 'Transferència', E'Pagament per transferència bancària al compte:\n\nES0123456789012345678901\n\nBIC AAAABBCCDD') , (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; set constraints "company_default_payment_method_id_fkey" immediate;
insert into company_user (company_id, user_id) insert into company_user (company_id, user_id)
values (1, 1) values (123, 123)
, (1, 2) , (123, 124)
, (124, 123)
, (124, 124)
; ;
alter sequence tax_class_tax_class_id_seq restart; alter sequence tax_class_tax_class_id_seq restart with 123;
insert into tax_class (company_id, name) insert into tax_class (company_id, name)
values (1, 'IRPF') values (123, 'IRPF')
, (1, 'IVA') , (123, 'IVA')
, (124, 'IRPF')
, (124, 'IVA')
; ;
alter sequence tax_tax_id_seq restart; alter sequence tax_tax_id_seq restart with 123;
insert into tax (company_id, tax_class_id, name, rate) insert into tax (company_id, tax_class_id, name, rate)
values (1, 1, 'Retenció 15 %', -0.15) values (123, 123, 'Retenció 15 %', -0.15)
, (1, 2, 'IVA 21 %', 0.21) , (123, 124, 'IVA 21 %', 0.21)
, (1, 2, 'IVA 10 %', 0.10) , (123, 124, 'IVA 10 %', 0.10)
, (1, 2, 'IVA 4 %', 0.04) , (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; alter sequence contact_contact_id_seq restart with 123;
select add_contact (1, 'Melcior', '1', 'Rei Blanc', '0732621', 'melcio@reismags.cat', '', 'C/ Principal, 1', 'Shiraz', 'Fars', '1', 'IR', array['pesebre', 'mag']); -- customers
select add_contact (1, 'Gaspar', '2', 'Rei Ros', '111', 'gaspar@reismags.cat', '', 'C/ Principal, 2', 'Nova Delhi', 'Delhi', '2', 'IN', array['pesebre', 'mag']); 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 (1, 'Baltasar', '3', 'Rei Negre', '1-111-111', 'baltasar@reismags.cat', '', 'C/ Principal, 3', 'Sanaa', 'Sanaa', '3', 'YE', 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', 'client']);
select add_contact (1, 'Caganera', '41414141L', '', '222 222 222', 'caganera@pesebre.cat', '', 'C/ De lHort, 4', 'Olot', 'Girona', '17800', 'ES', array['pesebre', 'persona']); 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 (1, 'Bou', '41414142C', '', '333 333 333', 'bou@pesebre.cat', '', 'C/ De la Palla, 5', 'Sant Climent Sescebes', 'Girona', '17751', 'ES', array['pesebre', 'bestia']); 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 (1, 'Rabadà', '41414143K', '', '444 444 444', 'rabada@pesebre.cat', '', 'C/ De les Ovelles, 6', 'Fornells de la Selva', 'Girona', '17458', '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', '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; alter sequence product_product_id_seq restart with 123;
select add_product(1, '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[2], array['metall']); 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']);
select add_product(1, 'Encens', 'Goma resina fragrant que desprèn una olor característica quan es crema.', '2.15', array[2], array['resina']); select add_product(123, 'Encens', 'Goma resina fragrant que desprèn una olor característica quan es crema.', '2.15', array[124], array['resina']);
select add_product(1, 'Mirra', 'Goma resinosa aromàtica de color gris groguenc i gust amargant.', '6.90', array[2], array['resina']); select add_product(123, 'Mirra', 'Goma resinosa aromàtica de color gris groguenc i gust amargant.', '6.90', array[124], array['resina']);
select add_product(1, 'Paper higiènic (pack de 32 U)', 'Paper que susa per mantenir la higiene personal després de defecar o orinar.', '7.99', array[4], array['necessitat']); select add_product(123, 'Paper higiènic (pack de 32 U)', 'Paper que susa per mantenir la higiene personal després de defecar o orinar.', '7.99', array[126], array['necessitat']);
select add_product(1, 'Cavall Fort', 'Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.', '3.64', array[2], array['revista']); select add_product(123, 'Cavall Fort', 'Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.', '3.64', array[124], array['revista']);
select add_product(1, 'Palla', 'Tija seca dels cereals després que el gra o llavor ha estat separat mitjançant la trilla.', '25.00', array[3], array['necessitat']); 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(1, 'Teia', 'Fusta resinosa de pi i daltres arbres, provinent sobretot del cor de larbre, que crema amb molta facilitat.', '7.00', array[2], array['obsolet']); 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 sequence invoice_invoice_id_seq restart; alter table payment_account alter column payment_account_id restart with 123;
alter sequence invoice_product_invoice_product_id_seq restart; select add_payment_account_bank(123, 'Guardiola', 'ES2820958297603648596978');
select add_invoice(1, (current_date - '28 days'::interval)::date, 6, 'Vol esmorzar!', 1, '{producte}','{"(1,Teia,\"Fusta resinosa de pi i daltres arbres, provinent sobretot del cor de larbre, que crema amb molta facilitat.\",7.00,1,0.0,{2})","(5,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{2})"}'); select add_payment_account_cash(123, 'Matalàs');
select add_invoice(1, (current_date - '24 days'::interval)::date, 5, '', 1, '{producte,bestia}','{"(1,Palla,Tija seca dels cereals després que el gra o llavor ha estat separat mitjançant la trilla.,25.00,25,0.0,{3})","(5,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{2})"}');
select add_invoice(1, (current_date - '17 days'::interval)::date, 4, '', 1, '{producte,higiene}','{"(1,\"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,{4})","(5,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{2})"}');
select add_invoice(1, (current_date - '7 days'::interval)::date, 3, '', 1, '{producte,mag}','{"(1,Mirra,Goma resinosa aromàtica de color gris groguenc i gust amargant.,7.22,144,0.05,{2})","(5,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.45,1,0.0,{2})"}');
select add_invoice(1, (current_date - '4 days'::interval)::date, 2, '', 1, '{producte,mag}','{"(1,Encens,Goma resina fragrant que desprèn una olor característica quan es crema.,2.26,460,0.05,{2})","(5,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.53,1,0.0,{2})"}');
select add_invoice(1, (current_date - '1 days'::interval)::date, 1, '', 1, '{producte,mag}','{"(1,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,{2})","(5,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.43,1,0.0,{2})"}');
update invoice set invoice_status = 'paid' where invoice_id in (1, 5); alter sequence invoice_invoice_id_seq restart with 123;
update invoice set invoice_status = 'unpaid' where invoice_id = 3; alter sequence invoice_product_invoice_product_id_seq restart with 123;
update invoice set invoice_status = 'sent' where invoice_id = 4; 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})"}');
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; commit;

View File

@ -0,0 +1,45 @@
-- Deploy numerus:_merge_collection_into_payment to pg
-- requires: collection
-- requires: collection_attachment
-- requires: invoice_collection
-- requires: payment
begin;
set search_path to numerus, public;
alter table payment
drop constraint if exists payment_amount_positive
, add constraint payment_amount_not_zero check (amount <> 0)
;
update payment
set amount = -amount
;
insert into payment (company_id, slug, description, payment_date, payment_account_id, amount, currency_code, tags, payment_status, created_at)
select company_id, slug, description, collection_date, payment_account_id, amount, currency_code, tags, payment_status, created_at
from collection
;
insert into payment_attachment (payment_id, original_filename, mime_type, content)
select payment_id, original_filename, mime_type, content
from collection_attachment
join collection using (collection_id)
join payment using (slug)
;
drop table collection_attachment;
insert into invoice_payment (invoice_id, payment_id)
select invoice_id, payment_id
from invoice_collection
join collection using (collection_id)
join payment using (slug)
;
drop table invoice_collection;
drop table collection;
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: payment
-- requires: invoice_payment
-- 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 payment
( company_id
, payment_account_id
, description
, payment_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 payment_id, slug, payment.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_payment (invoice_id, payment_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

@ -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

@ -7,21 +7,65 @@
-- requires: country_code -- requires: country_code
-- requires: contact -- requires: contact
-- requires: tag_name -- requires: tag_name
-- requires: tax_details
-- requires: contact_web
-- requires: contact_phone
-- requires: contact_tax_details
-- requires: contact_iban
-- requires: contact_bic
begin; begin;
set search_path to numerus, public; set search_path to numerus, public;
create or replace function add_contact(company_id integer, business_name text, vatin text, trade_name text, phone text, email email, web uri, address text, city text, province text, postal_code text, country_code country_code, tags tag_name[]) returns uuid as create or replace function add_contact(company_id integer, name text, phone text, email text, web text, tax_details tax_details, iban text, bic text, tags tag_name[]) returns uuid as
$$ $$
declare declare
cid integer; cid integer;
cslug uuid; cslug uuid;
begin begin
insert into contact (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, tags) insert into contact (company_id, name, tags)
values (add_contact.company_id, add_contact.business_name, (add_contact.country_code || add_contact.vatin)::vatin, add_contact.trade_name, parse_packed_phone_number(add_contact.phone, add_contact.country_code), add_contact.email, add_contact.web, add_contact.address, add_contact.city, add_contact.province, add_contact.postal_code, add_contact.country_code, add_contact.tags) values (add_contact.company_id, add_contact.name, add_contact.tags)
returning contact_id, slug returning contact_id, slug
into cid, cslug; into cid, cslug
;
if tax_details is not null then
insert into contact_tax_details (contact_id, business_name, vatin, address, city, province, postal_code, country_code)
values (cid, tax_details.business_name, (tax_details.country_code || tax_details.vatin)::vatin, tax_details.address, tax_details.city, tax_details.province, tax_details.postal_code, tax_details.country_code)
;
end if;
if phone is not null and trim(phone) <> '' then
insert into contact_phone (contact_id, phone)
values (cid, parse_packed_phone_number(add_contact.phone, coalesce(tax_details.country_code, 'ES')))
;
end if;
if email is not null and trim(email) <> '' then
insert into contact_email (contact_id, email)
values (cid, add_contact.email)
;
end if;
if web is not null and trim(web) <> '' then
insert into contact_web (contact_id, uri)
values (cid, add_contact.web)
;
end if;
if iban is not null and trim(iban) <> '' then
insert into contact_iban (contact_id, iban)
values (cid, add_contact.iban)
;
end if;
if bic is not null and trim(bic) <> '' then
insert into contact_swift (contact_id, bic)
values (cid, add_contact.bic)
;
end if;
return cslug; return cslug;
end end
@ -29,8 +73,10 @@ $$
language plpgsql language plpgsql
; ;
revoke execute on function add_contact(integer, text, text, text, text, email, uri, text, text, text, text, country_code, tag_name[]) from public; revoke execute on function add_contact(integer, text, text, text, text, tax_details, text, text, tag_name[]) from public;
grant execute on function add_contact(integer, text, text, text, text, email, uri, text, text, text, text, country_code, tag_name[]) to invoicer; grant execute on function add_contact(integer, text, text, text, text, tax_details, text, text, tag_name[]) to invoicer;
grant execute on function add_contact(integer, text, text, text, text, email, uri, text, text, text, text, country_code, tag_name[]) to admin; grant execute on function add_contact(integer, text, text, text, text, tax_details, text, text, tag_name[]) to admin;
drop function if exists add_contact(integer, text, text, text, text, email, uri, text, text, text, text, country_code, tag_name[]);
commit; commit;

36
deploy/add_contact@v0.sql Normal file
View File

@ -0,0 +1,36 @@
-- Deploy numerus:add_contact to pg
-- requires: schema_numerus
-- requires: extension_vat
-- requires: email
-- requires: extension_pg_libphonenumber
-- requires: extension_uri
-- requires: country_code
-- requires: contact
-- requires: tag_name
begin;
set search_path to numerus, public;
create or replace function add_contact(company_id integer, business_name text, vatin text, trade_name text, phone text, email email, web uri, address text, city text, province text, postal_code text, country_code country_code, tags tag_name[]) returns uuid as
$$
declare
cid integer;
cslug uuid;
begin
insert into contact (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, tags)
values (add_contact.company_id, add_contact.business_name, (add_contact.country_code || add_contact.vatin)::vatin, add_contact.trade_name, parse_packed_phone_number(add_contact.phone, add_contact.country_code), add_contact.email, add_contact.web, add_contact.address, add_contact.city, add_contact.province, add_contact.postal_code, add_contact.country_code, add_contact.tags)
returning contact_id, slug
into cid, cslug;
return cslug;
end
$$
language plpgsql
;
revoke execute on function add_contact(integer, text, text, text, text, email, uri, text, text, text, text, country_code, tag_name[]) from public;
grant execute on function add_contact(integer, text, text, text, text, email, uri, text, text, text, text, country_code, tag_name[]) to invoicer;
grant execute on function add_contact(integer, text, text, text, text, email, uri, text, text, text, text, country_code, tag_name[]) to admin;
commit;

View File

@ -8,11 +8,15 @@
-- requires: parse_price -- requires: parse_price
-- requires: tax -- requires: tax
-- requires: tag_name -- requires: tag_name
-- requires: expense_status
-- requires: expense_expense_status
begin; begin;
set search_path to numerus, public; 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 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 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;

67
deploy/add_payment@v3.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,20 @@
-- Deploy numerus:attach_to_collection to pg
-- requires: roles
-- requires: attach_to_payment
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
$$
select attach_to_payment(collection_slug, original_filename, mime_type, 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_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) insert into invoice_status (invoice_status, name)
values ('created', 'Created') values ('created', 'Created')
, ('sent', 'Sent') , ('sent', 'Sent')
, ('partial', 'Partial')
, ('paid', 'Paid') , ('paid', 'Paid')
, ('unpaid', 'Unpaid') , ('unpaid', 'Unpaid')
on conflict (invoice_status) do nothing
; ;
insert into invoice_status_i18n (invoice_status, lang_tag, name) insert into invoice_status_i18n (invoice_status, lang_tag, name)
values ('created', 'ca', 'Creada') values ('created', 'ca', 'Creada')
, ('sent', 'ca', 'Enviada') , ('sent', 'ca', 'Enviada')
, ('partial', 'ca', 'Parcial')
, ('paid', 'ca', 'Cobrada') , ('paid', 'ca', 'Cobrada')
, ('unpaid', 'ca', 'No cobrada') , ('unpaid', 'ca', 'No cobrada')
, ('created', 'es', 'Creada') , ('created', 'es', 'Creada')
, ('sent', 'es', 'Enviada') , ('sent', 'es', 'Enviada')
, ('partial', 'es', 'Parcial')
, ('paid', 'es', 'Cobrada') , ('paid', 'es', 'Cobrada')
, ('unpaid', 'es', 'No 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; 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;

14
deploy/bic.sql Normal file
View File

@ -0,0 +1,14 @@
-- Deploy numerus:bic to pg
-- requires: schema_numerus
begin;
set search_path to numerus, public;
create domain bic as text
check ( value ~ '^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$' )
;
comment on domain bic is 'A valid, but not necessarily existent, Business Identifier Code (ISO 9362)';
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;

39
deploy/contact_email.sql Normal file
View File

@ -0,0 +1,39 @@
-- Deploy numerus:contact_email to pg
-- requires: roles
-- requires: schema_numerus
-- requires: email
-- requires: contact
begin;
set search_path to numerus, public;
create table contact_email (
contact_id integer primary key references contact,
email email not null
);
grant select, insert, update, delete on table contact_email to invoicer;
grant select, insert, update, delete on table contact_email to admin;
alter table contact_email enable row level security;
create policy company_policy
on contact_email
using (
exists(
select 1
from contact
where contact.contact_id = contact_email.contact_id
)
);
insert into contact_email
select contact_id, email
from contact;
alter table contact
drop column email
;
commit;

31
deploy/contact_iban.sql Normal file
View File

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

38
deploy/contact_phone.sql Normal file
View File

@ -0,0 +1,38 @@
-- Deploy numerus:contact_phone to pg
-- requires: roles
-- requires: schema_numerus
-- requires: extension_pg_libphonenumber
begin;
set search_path to numerus, public;
create table contact_phone (
contact_id integer primary key references contact,
phone packed_phone_number not null
);
grant select, insert, update, delete on table contact_phone to invoicer;
grant select, insert, update, delete on table contact_phone to admin;
alter table contact_phone enable row level security;
create policy company_policy
on contact_phone
using (
exists(
select 1
from contact
where contact.contact_id = contact_phone.contact_id
)
);
insert into contact_phone
select contact_id, phone
from contact;
alter table contact
drop column phone
;
commit;

31
deploy/contact_swift.sql Normal file
View File

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

View File

@ -0,0 +1,60 @@
-- Deploy numerus:contact_tax_details to pg
-- requires: roles
-- requires: schema_numerus
-- requires: contact
-- requires: extension_vat
-- requires: country_code
-- requires: country
begin;
set search_path to numerus, public;
create table contact_tax_details (
contact_id integer primary key references contact,
business_name text not null constraint business_name_not_empty check(length(trim(business_name)) > 1),
vatin vatin not null,
address text not null,
city text not null,
province text not null,
postal_code text not null,
country_code country_code not null references country
);
alter table contact_tax_details enable row level security;
create policy company_policy
on contact_tax_details
using (
exists(
select 1
from contact
where contact.contact_id = contact_tax_details.contact_id
)
);
grant select, insert, update, delete on table contact_tax_details to invoicer;
grant select, insert, update, delete on table contact_tax_details to admin;
insert into contact_tax_details
select contact_id, business_name, vatin, address, city, province, postal_code, country_code
from contact;
update contact set trade_name = business_name where trade_name = '';
alter table contact
rename column trade_name to name
;
alter table contact
drop column business_name
, drop column vatin
, drop column address
, drop column city
, drop column province
, drop column postal_code
, drop column country_code
, add constraint name_not_empty check(length(trim(name)) > 1)
;
commit;

39
deploy/contact_web.sql Normal file
View File

@ -0,0 +1,39 @@
-- Deploy numerus:contact_web to pg
-- requires: roles
-- requires: schema_numerus
-- requires: extension_uri
-- requires: contact
begin;
set search_path to numerus, public;
create table contact_web (
contact_id integer primary key references contact,
uri uri not null
);
grant select, insert, update, delete on table contact_web to invoicer;
grant select, insert, update, delete on table contact_web to admin;
alter table contact_web enable row level security;
create policy company_policy
on contact_web
using (
exists(
select 1
from contact
where contact.contact_id = contact_web.contact_id
)
);
insert into contact_web
select contact_id, web
from contact;
alter table contact
drop column web
;
commit;

View File

@ -0,0 +1,53 @@
-- Deploy numerus:edit_collection to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment
-- requires: invoice_payment
-- 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 payment
set payment_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 = payment.currency_code
returning payment_id, payment.amount
into cid, amount_cents
;
select invoice_id into iid
from invoice_payment
where payment_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

@ -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

@ -7,29 +7,25 @@
-- requires: contact -- requires: contact
-- requires: extension_vat -- requires: extension_vat
-- requires: extension_pg_libphonenumber -- requires: extension_pg_libphonenumber
-- requires: tax_details
-- requires: contact_web
-- requires: contact_phone
-- requires: contact_tax_details
-- requires: contact_iban
-- requires: contact_bic
begin; begin;
set search_path to numerus, public; set search_path to numerus, public;
create or replace function edit_contact(contact_slug uuid, business_name text, vatin text, trade_name text, phone text, email email, web uri, address text, city text, province text, postal_code text, country_code country_code, tags tag_name[]) returns uuid as create or replace function edit_contact(contact_slug uuid, name text, phone text, email text, web text, tax_details tax_details, iban text, bic text, tags tag_name[]) returns uuid as
$$ $$
declare declare
cid integer; cid integer;
company integer; company integer;
begin begin
update contact update contact
set business_name = edit_contact.business_name set name = edit_contact.name
, vatin = (edit_contact.country_code || edit_contact.vatin)::vatin
, trade_name = edit_contact.trade_name
, phone = parse_packed_phone_number( edit_contact.phone, edit_contact.country_code)
, email = edit_contact.email
, web = edit_contact.web
, address = edit_contact.address
, city = edit_contact.city
, province = edit_contact.province
, postal_code = edit_contact.postal_code
, country_code = edit_contact.country_code
, tags = edit_contact.tags , tags = edit_contact.tags
where slug = contact_slug where slug = contact_slug
returning contact_id, company_id returning contact_id, company_id
@ -40,14 +36,96 @@ begin
return null; return null;
end if; end if;
if tax_details is null then
delete
from contact_tax_details
where contact_id = cid
;
else
insert into contact_tax_details (contact_id, business_name, vatin, address, city, province, postal_code, country_code)
values (cid, tax_details.business_name, (tax_details.country_code || tax_details.vatin)::vatin, tax_details.address, tax_details.city, tax_details.province, tax_details.postal_code, tax_details.country_code)
on conflict (contact_id) do update
set business_name = excluded.business_name
, vatin = excluded.vatin
, address = excluded.address
, city = excluded.city
, province = excluded.province
, postal_code = excluded.postal_code
, country_code = excluded.country_code
;
end if;
if phone is null or trim(phone) = '' then
delete from contact_phone
where contact_id = cid
;
else
insert into contact_phone (contact_id, phone)
values (cid, parse_packed_phone_number(edit_contact.phone, coalesce(tax_details.country_code, 'ES')))
on conflict (contact_id) do update
set phone = excluded.phone
;
end if;
if email is null or trim(email) = '' then
delete from contact_email
where contact_id = cid
;
else
insert into contact_email (contact_id, email)
values (cid, edit_contact.email)
on conflict (contact_id) do update
set email = excluded.email
;
end if;
if web is null or trim(web) = '' then
delete from contact_web
where contact_id = cid
;
else
insert into contact_web (contact_id, uri)
values (cid, edit_contact.web)
on conflict (contact_id) do update
set uri = excluded.uri
;
end if;
if iban is null or trim(iban) = '' then
delete from contact_iban
where contact_id = cid
;
else
insert into contact_iban (contact_id, iban)
values (cid, edit_contact.iban)
on conflict (contact_id) do update
set iban = excluded.iban
;
end if;
if bic is null or trim(bic) = '' then
delete from contact_swift
where contact_id = cid
;
else
insert into contact_swift (contact_id, bic)
values (cid, edit_contact.bic)
on conflict (contact_id) do update
set bic = excluded.bic
;
end if;
return contact_slug; return contact_slug;
end end
$$ $$
language plpgsql language plpgsql
; ;
revoke execute on function edit_contact(uuid, text, text, text, text, email, uri, text, text, text, text, country_code, tag_name[]) from public; revoke execute on function edit_contact(uuid, text, text, text, text, tax_details, text, text, tag_name[]) from public;
grant execute on function edit_contact(uuid, text, text, text, text, email, uri, text, text, text, text, country_code, tag_name[]) to invoicer; grant execute on function edit_contact(uuid, text, text, text, text, tax_details, text, text, tag_name[]) to invoicer;
grant execute on function edit_contact(uuid, text, text, text, text, email, uri, text, text, text, text, country_code, tag_name[]) to admin; grant execute on function edit_contact(uuid, text, text, text, text, tax_details, text, text, tag_name[]) to admin;
drop function if exists edit_contact(uuid, text, text, text, text, email, uri, text, text, text, text, country_code, tag_name[]);
commit; commit;

View File

@ -0,0 +1,53 @@
-- Deploy numerus:edit_contact to pg
-- requires: schema_numerus
-- requires: email
-- requires: extension_uri
-- requires: country_code
-- requires: tag_name
-- requires: contact
-- requires: extension_vat
-- requires: extension_pg_libphonenumber
begin;
set search_path to numerus, public;
create or replace function edit_contact(contact_slug uuid, business_name text, vatin text, trade_name text, phone text, email email, web uri, address text, city text, province text, postal_code text, country_code country_code, tags tag_name[]) returns uuid as
$$
declare
cid integer;
company integer;
begin
update contact
set business_name = edit_contact.business_name
, vatin = (edit_contact.country_code || edit_contact.vatin)::vatin
, trade_name = edit_contact.trade_name
, phone = parse_packed_phone_number( edit_contact.phone, edit_contact.country_code)
, email = edit_contact.email
, web = edit_contact.web
, address = edit_contact.address
, city = edit_contact.city
, province = edit_contact.province
, postal_code = edit_contact.postal_code
, country_code = edit_contact.country_code
, tags = edit_contact.tags
where slug = contact_slug
returning contact_id, company_id
into cid, company
;
if cid is null then
return null;
end if;
return contact_slug;
end
$$
language plpgsql
;
revoke execute on function edit_contact(uuid, text, text, text, text, email, uri, text, text, text, text, country_code, tag_name[]) from public;
grant execute on function edit_contact(uuid, text, text, text, text, email, uri, text, text, text, text, country_code, tag_name[]) to invoicer;
grant execute on function edit_contact(uuid, text, text, text, text, email, uri, text, text, text, text, country_code, tag_name[]) to admin;
commit;

View File

@ -5,11 +5,15 @@
-- requires: parse_price -- requires: parse_price
-- requires: tax -- requires: tax
-- requires: tag_name -- requires: tag_name
-- requires: expense_status
-- requires: expense_expense_status
begin; begin;
set search_path to numerus, public; 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 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 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; 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 declare
iid integer; iid integer;
@ -27,7 +29,6 @@ declare
begin begin
update invoice update invoice
set contact_id = edit_invoice.contact_id set contact_id = edit_invoice.contact_id
, invoice_status = edit_invoice.invoice_status
, notes = edit_invoice.notes , notes = edit_invoice.notes
, payment_method_id = edit_invoice.payment_method_id , payment_method_id = edit_invoice.payment_method_id
, tags = edit_invoice.tags , tags = edit_invoice.tags
@ -103,9 +104,9 @@ end;
$$ $$
language plpgsql; language plpgsql;
revoke execute on function edit_invoice(uuid, text, integer, text, integer, tag_name[], edited_invoice_product[]) from public; revoke execute on function edit_invoice(uuid, 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, 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; grant execute on function edit_invoice(uuid, integer, text, integer, tag_name[], edited_invoice_product[]) to admin;
commit; 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,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,8 @@
-- Deploy numerus:extension_iban to pg
-- requires: schema_numerus
begin;
create extension if not exists iban;
commit;

165
deploy/import_contact.sql Normal file
View File

@ -0,0 +1,165 @@
-- Deploy numerus:import_contact to pg
-- requires: schema_numerus
-- requires: roles
-- requires: contact
-- requires: contact_web
-- requires: contact_phone
-- requires: contact_email
-- requires: contact_iban
-- requires: contact_swift
-- requires: contact_tax_details
-- requires: input_is_valid
-- requires: input_is_valid_phone
begin;
set search_path to numerus, public;
create or replace function begin_import_contacts() returns name as
$$
create temporary table imported_contact (
contact_id integer
, name text not null default ''
, vatin text not null default ''
, email text not null default ''
, phone text not null default ''
, web text not null default ''
, address text not null default ''
, city text not null default ''
, province text not null default ''
, postal_code text not null default ''
, country_code text not null default ''
, iban text not null default ''
, bic text not null default ''
, tags text not null default ''
);
select 'imported_contact'::name;
$$
language sql;
revoke execute on function begin_import_contacts() from public;
grant execute on function begin_import_contacts() to invoicer;
grant execute on function begin_import_contacts() to admin;
create or replace function end_import_contacts(company_id integer) returns integer as
$$
declare
imported integer;
begin
update imported_contact
set country_code = upper(trim(country_code))
, name = trim(name)
, vatin = trim(vatin)
, email = trim(email)
, phone = trim(phone)
, web = trim(web)
, address = trim(address)
, city = trim(city)
, province = trim(province)
, postal_code = trim(postal_code)
, iban = trim(iban)
, bic = trim(bic)
, tags = lower(trim(regexp_replace(regexp_replace(tags, '[^\sA-Za-z0-9-]', '', 'g'), '\s\s+', ' ', 'g')))
;
update imported_contact
set contact_id = tax_details.contact_id
from contact_tax_details as tax_details
join contact using (contact_id)
where contact.company_id = end_import_contacts.company_id
and tax_details.vatin::text = imported_contact.country_code || imported_contact.vatin
;
update imported_contact
set contact_id = nextval('contact_contact_id_seq'::regclass)
where length(trim(name)) > 1
and contact_id is null
;
insert into contact (contact_id, company_id, name, tags)
select contact_id, end_import_contacts.company_id, name, string_to_array(tags, ' ')::tag_name[]
from imported_contact
where contact_id is not null
on conflict (contact_id) do update
set tags = array_cat(contact.tags, excluded.tags)
;
insert into contact_tax_details (contact_id, business_name, vatin, address, city, province, postal_code, country_code)
select contact_id, imported_contact.name, (country_code || vatin)::vatin, address, city, province, postal_code, country_code
from imported_contact
join country using (country_code)
where contact_id is not null
and length(address) > 1
and length(city) > 1
and length(province) > 1
and postal_code ~ postal_code_regex
and input_is_valid(country_code || vatin, 'vatin')
on conflict (contact_id) do update
set business_name = excluded.business_name
, vatin = excluded.vatin
, address = excluded.address
, city = excluded.city
, province = excluded.province
, postal_code = excluded.postal_code
, country_code = excluded.country_code
;
insert into contact_email (contact_id, email)
select contact_id, email::email
from imported_contact
where contact_id is not null
and input_is_valid(email, 'email')
on conflict (contact_id) do update
set email = excluded.email
;
insert into contact_web (contact_id, uri)
select contact_id, web::uri
from imported_contact
where contact_id is not null
and input_is_valid(web, 'uri')
and length(web) > 1
on conflict (contact_id) do update
set uri = excluded.uri
;
insert into contact_iban (contact_id, iban)
select contact_id, iban::iban
from imported_contact
where contact_id is not null
and input_is_valid(iban, 'iban')
on conflict (contact_id) do update
set iban = excluded.iban
;
insert into contact_swift (contact_id, bic)
select contact_id, bic::bic
from imported_contact
where contact_id is not null
and input_is_valid(bic, 'bic')
on conflict (contact_id) do update
set bic = excluded.bic
;
insert into contact_phone (contact_id, phone)
select contact_id, parse_packed_phone_number(phone, case when country_code = '' then 'ES' else country_code end)
from imported_contact
where contact_id is not null
and input_is_valid_phone(phone, case when country_code = '' then 'ES' else country_code end)
on conflict (contact_id) do update
set phone = excluded.phone
;
select count(*) from imported_contact where contact_id is not null into imported;
return imported;
drop table imported_contact;
end
$$
language plpgsql;
revoke execute on function end_import_contacts(integer) from public;
grant execute on function end_import_contacts(integer) to invoicer;
grant execute on function end_import_contacts(integer) to admin;
commit;

23
deploy/input_is_valid.sql Normal file
View File

@ -0,0 +1,23 @@
-- Deploy numerus:input_is_valid to pg
-- requires: schema_numerus
-- requires: roles
begin;
set search_path to public;
create or replace function input_is_valid(input text, domname text) returns boolean as
$$
begin
begin
execute format('select %L::%s', input, domname);
return true;
exception when others then
return false;
end;
end;
$$
language plpgsql
stable;
commit;

View File

@ -0,0 +1,24 @@
-- Deploy numerus:input_is_valid_phone to pg
-- requires: schema_numerus
-- requires: roles
-- requires: extension_pg_libphonenumber
begin;
set search_path to public;
create or replace function input_is_valid_phone(phone text, country text) returns boolean as
$$
begin
begin
perform parse_packed_phone_number(phone, country);
return true;
exception when others then
return false;
end;
end;
$$
language plpgsql
stable;
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:invoice_contact_id_fkey to pg
-- requires: schema_numerus
-- requires: invoice
-- requires: contact_tax_details
begin;
alter table numerus.invoice
drop constraint invoice_contact_id_fkey
, add foreign key (contact_id) references numerus.contact_tax_details (contact_id)
;
commit;

View File

@ -0,0 +1,32 @@
-- Deploy numerus:invoice_payment to pg
-- requires: roles
-- requires: schema_numerus
-- requires: invoice
-- requires: payment
begin;
set search_path to numerus, public;
create table invoice_payment (
invoice_id integer not null references invoice,
payment_id integer not null references payment,
primary key (invoice_id, payment_id)
);
grant select, insert, update, delete on table invoice_payment to invoicer;
grant select, insert, update, delete on table invoice_payment to admin;
alter table invoice_payment enable row level security;
create policy company_policy
on invoice_payment
using (
exists(
select 1
from invoice
where invoice.invoice_id = invoice_payment.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; end if;
result := parts[1]::integer; 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 for d in 1..decimal_digits loop
result := result * 10; result := result * 10;
end loop; 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_payment
-- requires: payment
-- requires: payment_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 payment_id into cid from payment where slug = collection_slug;
if not found then
return;
end if;
delete from invoice_payment where payment_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 payment_attachment where payment_id = cid;
delete from payment where payment_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;

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;

20
deploy/tax_details.sql Normal file
View File

@ -0,0 +1,20 @@
-- Deploy numerus:tax_details to pg
-- requires: schema_numerus
-- requires: extension_vat
-- requires: country_code
begin;
set search_path to numerus, public;
create type tax_details as (
business_name text,
vatin text,
address text,
city text,
province text,
postal_code text,
country_code country_code
);
commit;

View File

@ -0,0 +1,53 @@
-- 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 > 0 and expense.amount + coalesce(tax.amount, 0) > -amount_cents) or (expense.amount < 0 and 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 (expense.amount > 0 and -paid_amount >= expense.amount + tax_amount) or (expense.amount < 0 and -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,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,51 @@
-- Deploy numerus:update_invoice_collection_status to pg
-- requires: roles
-- requires: schema_numerus
-- requires: invoice
-- requires: payment
-- requires: invoice_payment
-- 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 payment
set payment_status = case
when (invoice_amount.total > 0 and invoice_amount.total > amount_cents) or (invoice_amount.total < 0 and invoice_amount.total < amount_cents) or exists (select 1 from invoice_payment as ic where ic.invoice_id = invoice_amount.invoice_id and payment_id <> cid) then 'partial'
else 'complete' end
from invoice_amount
where invoice_id = iid
and payment_id = cid
;
update invoice
set invoice_status = case
when (total_amount > 0 and collected_amount >= total_amount) or (total_amount < 0 and collected_amount <= total_amount) then 'paid'
when collected_amount = 0 then 'created'
else 'partial' end
from (
select coalesce(sum(payment.amount), 0) as collected_amount
from invoice_payment
join payment using (payment_id)
where invoice_payment.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;

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;

19
go.mod
View File

@ -1,15 +1,24 @@
module dev.tandem.ws/tandem/numerus module dev.tandem.ws/tandem/numerus
go 1.16 go 1.19
require ( require (
github.com/jackc/pgx/v4 v4.17.2 github.com/jackc/pgtype v1.10.0
github.com/jackc/pgx/v4 v4.15.0
github.com/julienschmidt/httprouter v1.3.0 github.com/julienschmidt/httprouter v1.3.0
github.com/leonelquinteros/gotext v1.5.1 github.com/leonelquinteros/gotext v1.5.0
golang.org/x/text v0.3.8 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
) )
require ( require (
github.com/jackc/pgtype v1.12.0 github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgconn v1.13.0 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.3.1 // indirect
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
github.com/jackc/puddle v1.3.0 // indirect
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 // indirect golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 // indirect
) )

45
go.sum
View File

@ -14,7 +14,6 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me
github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw=
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
@ -25,6 +24,7 @@ github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsU
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY= github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
github.com/jackc/pgconn v1.11.0/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
github.com/jackc/pgconn v1.13.0 h1:3L1XMNV2Zvca/8BYhzcRFS70Lr0WlDg16Di6SFGAbys= github.com/jackc/pgconn v1.13.0 h1:3L1XMNV2Zvca/8BYhzcRFS70Lr0WlDg16Di6SFGAbys=
github.com/jackc/pgconn v1.13.0/go.mod h1:AnowpAqO4CMIIJNZl2VJp+KrkAZciAkhEl0W0JIobpI= github.com/jackc/pgconn v1.13.0/go.mod h1:AnowpAqO4CMIIJNZl2VJp+KrkAZciAkhEl0W0JIobpI=
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
@ -35,7 +35,6 @@ github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5W
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
@ -43,6 +42,7 @@ github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvW
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.2.0/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.3.1 h1:nwj7qwf0S+Q7ISFfBndqeLwSwxs+4DPsbRFjECT1Y4Y= github.com/jackc/pgproto3/v2 v2.3.1 h1:nwj7qwf0S+Q7ISFfBndqeLwSwxs+4DPsbRFjECT1Y4Y=
github.com/jackc/pgproto3/v2 v2.3.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= github.com/jackc/pgproto3/v2 v2.3.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg= github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=
@ -51,17 +51,18 @@ github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01C
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=
github.com/jackc/pgtype v1.12.0 h1:Dlq8Qvcch7kiehm8wPGIW0W3KsCCHJnRacKW0UM8n5w= github.com/jackc/pgtype v1.10.0 h1:ILnBWrRMSXGczYvmkYD6PsYyVFUNLTnIUJHHDLmqk38=
github.com/jackc/pgtype v1.12.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= github.com/jackc/pgtype v1.10.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
github.com/jackc/pgx/v4 v4.17.2 h1:0Ut0rpeKwvIVbMQ1KbMBU4h6wxehBI535LK6Flheh8E= github.com/jackc/pgx/v4 v4.15.0 h1:B7dTkXsdILD3MF987WGGCcg+tvLW6bZJdEcqVFeU//w=
github.com/jackc/pgx/v4 v4.17.2/go.mod h1:lcxIZN44yMIrWI78a5CpucdD14hX0SBDbNRvjDBItsw= github.com/jackc/pgx/v4 v4.15.0/go.mod h1:D/zyOyXiaM1TmVWnOM18p0xdDtdakRBa0RsVGI3U3bw=
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.2.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.3.0 h1:eHK/5clGOatcjX3oWGBO/MpxpbHzSwud5EWTSCI+MX0= github.com/jackc/puddle v1.3.0 h1:eHK/5clGOatcjX3oWGBO/MpxpbHzSwud5EWTSCI+MX0=
github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
@ -69,12 +70,14 @@ github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/leonelquinteros/gotext v1.5.1 h1:vmddRn3gHp67YFjZLZE2AZsgYMT4IBTJhua4yfe7/4Q= github.com/leonelquinteros/gotext v1.5.0 h1:ODY7LzLpZWWSJdAHnzhreOr6cwLXTAmc914FOauSkBM=
github.com/leonelquinteros/gotext v1.5.1/go.mod h1:/A4Y7BvIsf5JHO60E43ZQDVkV3qO+7eP8HjeqD6ChIA= github.com/leonelquinteros/gotext v1.5.0/go.mod h1:OCiUVHuhP9LGFBQ1oAmdtNCHJCiHiQA8lf4nAifHkr0=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
@ -89,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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/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/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
@ -111,8 +116,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/tealeg/xlsx v0.0.0-20181024002044-dbf71b6a931e h1:0AoAjM/7iqEZwTsWhk3nm9+H5mocFnh6dCGUaIOSTDQ=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/tealeg/xlsx v0.0.0-20181024002044-dbf71b6a931e/go.mod h1:uxu5UY2ovkuRPWKQ8Q7JG0JbSivrISjdPzZQKeo74mA=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
@ -134,26 +139,19 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 h1:Y/gsMcFOcR+6S6f3YeMKl5g+dZMEWqcz5Czj/GWYbkM= golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 h1:Y/gsMcFOcR+6S6f3YeMKl5g+dZMEWqcz5Czj/GWYbkM=
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -167,20 +165,16 @@ golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
@ -188,16 +182,15 @@ golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgw
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= golang.org/x/tools v0.0.0-20200221224223-e1da425f72fd/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=

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.4~git"

View File

@ -3,6 +3,7 @@ package pkg
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"html/template" "html/template"
"math" "math"
@ -82,21 +83,115 @@ type PaymentMethod struct {
Id int Id int
Name string Name string
Instructions string Instructions string
IsDefault bool
} }
type taxDetailsForm struct { type taxDetailsForm struct {
*contactForm 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 Currency *SelectField
InvoiceNumberFormat *InputField
NextInvoiceNumber *InputField
QuoteNumberFormat *InputField
NextQuoteNumber *InputField
LegalDisclaimer *InputField
} }
func newTaxDetailsForm(ctx context.Context, conn *Conn, locale *Locale) *taxDetailsForm { func newTaxDetailsForm(ctx context.Context, conn *Conn, locale *Locale) *taxDetailsForm {
return &taxDetailsForm{ return &taxDetailsForm{
contactForm: newContactForm(ctx, conn, locale), locale: locale,
TradeName: &InputField{
Name: "trade_name",
Label: pgettext("input", "Trade name", locale),
Type: "text",
},
Phone: &InputField{
Name: "phone",
Label: pgettext("input", "Phone", locale),
Type: "tel",
Required: true,
Attributes: []template.HTMLAttr{
`autocomplete="tel"`,
},
},
Email: &InputField{
Name: "email",
Label: pgettext("input", "Email", locale),
Type: "email",
Required: true,
Attributes: []template.HTMLAttr{
`autocomplete="email"`,
},
},
Web: &InputField{
Name: "web",
Label: pgettext("input", "Web", locale),
Type: "url",
Attributes: []template.HTMLAttr{
`autocomplete="url"`,
},
},
BusinessName: &InputField{
Name: "business_name",
Label: pgettext("input", "Business name", locale),
Type: "text",
Required: true,
Attributes: []template.HTMLAttr{
`autocomplete="organization"`,
`minlength="2"`,
},
},
VATIN: &InputField{
Name: "vatin",
Label: pgettext("input", "VAT number", locale),
Type: "text",
Required: true,
},
Address: &InputField{
Name: "address",
Label: pgettext("input", "Address", locale),
Type: "text",
Required: true,
Attributes: []template.HTMLAttr{
`autocomplete="address-line1"`,
},
},
City: &InputField{
Name: "city",
Label: pgettext("input", "City", locale),
Type: "text",
Required: true,
},
Province: &InputField{
Name: "province",
Label: pgettext("input", "Province", locale),
Type: "text",
Required: true,
},
PostalCode: &InputField{
Name: "postal_code",
Label: pgettext("input", "Postal code", locale),
Type: "text",
Required: true,
Attributes: []template.HTMLAttr{
`autocomplete="postal-code"`,
},
},
Country: &SelectField{
Name: "country",
Label: pgettext("input", "Country", locale),
Options: mustGetCountryOptions(ctx, conn, locale),
Required: true,
Selected: []string{"ES"},
Attributes: []template.HTMLAttr{
`autocomplete="country"`,
},
},
Currency: &SelectField{ Currency: &SelectField{
Name: "currency", Name: "currency",
Label: pgettext("input", "Currency", locale), Label: pgettext("input", "Currency", locale),
@ -104,6 +199,204 @@ func newTaxDetailsForm(ctx context.Context, conn *Conn, locale *Locale) *taxDeta
Required: true, Required: true,
Selected: []string{"EUR"}, 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{ InvoiceNumberFormat: &InputField{
Name: "invoice_number_format", Name: "invoice_number_format",
Label: pgettext("input", "Invoice number format", locale), Label: pgettext("input", "Invoice number format", locale),
@ -142,44 +435,15 @@ func newTaxDetailsForm(ctx context.Context, conn *Conn, locale *Locale) *taxDeta
} }
} }
func (form *taxDetailsForm) Parse(r *http.Request) error { func newInvoicingFormFromDatabase(ctx context.Context, conn *Conn, company *Company, locale *Locale) *InvoicingForm {
if err := form.contactForm.Parse(r); err != nil { form := newInvoicingForm(locale)
return err form.mustFillFromDatabase(ctx, conn, company)
} return form
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 (form *taxDetailsForm) Validate(ctx context.Context, conn *Conn) bool { func (form *InvoicingForm) mustFillFromDatabase(ctx context.Context, conn *Conn, company *Company) {
validator := newFormValidator()
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 form.contactForm.Validate(ctx, conn) && validator.AllOK()
}
func (form *taxDetailsForm) mustFillFromDatabase(ctx context.Context, conn *Conn, company *Company) *taxDetailsForm {
err := conn.QueryRow(ctx, ` err := conn.QueryRow(ctx, `
select business_name select invoice_number_format
, substr(vatin::text, 3)
, trade_name
, phone
, email
, web
, address
, city
, province
, postal_code
, country_code
, currency_code
, invoice_number_format
, quote_number_format , quote_number_format
, legal_disclaimer , legal_disclaimer
, coalesce(invoice_number_counter.currval, 0) + 1 , coalesce(invoice_number_counter.currval, 0) + 1
@ -192,18 +456,6 @@ func (form *taxDetailsForm) mustFillFromDatabase(ctx context.Context, conn *Conn
on quote_number_counter.company_id = company.company_id on quote_number_counter.company_id = company.company_id
and quote_number_counter.year = date_part('year', current_date) and quote_number_counter.year = date_part('year', current_date)
where company.company_id = $1`, company.Id).Scan( 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.InvoiceNumberFormat,
form.QuoteNumberFormat, form.QuoteNumberFormat,
form.LegalDisclaimer, form.LegalDisclaimer,
@ -213,36 +465,39 @@ func (form *taxDetailsForm) mustFillFromDatabase(ctx context.Context, conn *Conn
if err != nil { if err != nil {
panic(err) panic(err)
} }
return form
} }
type TaxDetailsPage struct { func (form *InvoicingForm) MustRender(w http.ResponseWriter, r *http.Request) {
DetailsForm *taxDetailsForm mustRenderMainTemplate(w, r, "company/invoicing.gohtml", form)
NewTaxForm *taxForm
Taxes []*Tax
NewPaymentMethodForm *paymentMethodForm
PaymentMethods []*PaymentMethod
} }
func GetCompanyTaxDetailsForm(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { func (form *InvoicingForm) Parse(r *http.Request) error {
mustRenderTaxDetailsForm(w, r, newTaxDetailsFormFromDatabase(r)) 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) locale := getLocale(r)
conn := getConn(r) conn := getConn(r)
form := newTaxDetailsForm(r.Context(), conn, locale) form := newInvoicingForm(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 { if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@ -251,11 +506,9 @@ func HandleCompanyTaxDetailsForm(w http.ResponseWriter, r *http.Request, _ httpr
http.Error(w, err.Error(), http.StatusForbidden) http.Error(w, err.Error(), http.StatusForbidden)
return return
} }
if ok := form.Validate(r.Context(), conn); !ok { if ok := form.Validate(); !ok {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
} form.MustRender(w, r)
mustRenderTaxDetailsForm(w, r, form)
return return
} }
company := mustGetCompany(r) company := mustGetCompany(r)
@ -263,35 +516,11 @@ func HandleCompanyTaxDetailsForm(w http.ResponseWriter, r *http.Request, _ httpr
defer tx.MustRollback(r.Context()) defer tx.MustRollback(r.Context())
tx.MustExec(r.Context(), ` tx.MustExec(r.Context(), `
update company update company
set business_name = $1 set invoice_number_format = $1
, vatin = ($11 || $2)::vatin , quote_number_format = $2
, trade_name = $3 , legal_disclaimer = $3
, phone = parse_packed_phone_number($4, $11) where company_id = $4
, 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
`, `,
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.InvoiceNumberFormat,
form.QuoteNumberFormat, form.QuoteNumberFormat,
form.LegalDisclaimer, form.LegalDisclaimer,
@ -313,60 +542,39 @@ func HandleCompanyTaxDetailsForm(w http.ResponseWriter, r *http.Request, _ httpr
company.Id, company.Id,
form.NextQuoteNumber.Integer()-1) form.NextQuoteNumber.Integer()-1)
tx.MustCommit(r.Context()) tx.MustCommit(r.Context())
if IsHTMxRequest(r) { htmxRedirect(w, r, companyURI(company, "/invoicing"))
w.Header().Set(HxTrigger, "closeModal")
w.WriteHeader(http.StatusNoContent)
} else {
http.Redirect(w, r, companyURI(company, "/tax-details"), http.StatusSeeOther)
}
} }
func mustRenderTaxDetailsForm(w http.ResponseWriter, r *http.Request, form *taxDetailsForm) { func serveCompanyTaxes(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
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) {
conn := getConn(r) conn := getConn(r)
company := mustGetCompany(r) company := mustGetCompany(r)
page.Taxes = mustGetTaxes(r.Context(), conn, company) locale := getLocale(r)
page.PaymentMethods = mustCollectPaymentMethods(r.Context(), conn, company) page := newTaxesPage(r.Context(), conn, company, locale)
mustRenderModalTemplate(w, r, "tax-details.gohtml", page) page.MustRender(w, r)
} }
func mustGetCompany(r *http.Request) *Company { type TaxesPage struct {
company := getCompany(r) Taxes []*Tax
if company == nil { Form *taxForm
panic(errors.New("company: required but not found")) }
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) 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 { if err != nil {
panic(err) panic(err)
@ -389,29 +597,6 @@ func mustGetTaxes(ctx context.Context, conn *Conn, company *Company) []*Tax {
return taxes 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 { type taxForm struct {
locale *Locale locale *Locale
Name *InputField Name *InputField
@ -468,25 +653,6 @@ func (form *taxForm) Validate() bool {
return validator.AllOK() 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) { func HandleAddCompanyTax(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
locale := getLocale(r) locale := getLocale(r)
conn := getConn(r) conn := getConn(r)
@ -501,22 +667,108 @@ func HandleAddCompanyTax(w http.ResponseWriter, r *http.Request, _ httprouter.Pa
return return
} }
if !form.Validate() { if !form.Validate() {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
} page := newTaxesPageWithForm(r.Context(), conn, company, form)
mustRenderTaxForm(w, r, form) page.MustRender(w, r)
return 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()) 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) { htmxRedirect(w, r, companyURI(company, "/taxes"))
mustRenderTaxForm(w, r, newTaxForm(r.Context(), conn, company, locale)) }
} else {
http.Redirect(w, r, companyURI(company, "/tax-details"), http.StatusSeeOther) 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 { type paymentMethodForm struct {
locale *Locale locale *Locale
PaymentMethodId int
Name *InputField Name *InputField
Instructions *InputField Instructions *InputField
} }
@ -526,7 +778,7 @@ func newPaymentMethodForm(locale *Locale) *paymentMethodForm {
locale: locale, locale: locale,
Name: &InputField{ Name: &InputField{
Name: "method_name", Name: "method_name",
Label: pgettext("input", "Payment method name", locale), Label: pgettext("input", "Invoicing method name", locale),
Type: "text", Type: "text",
Required: true, Required: true,
}, },
@ -539,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 { func (form *paymentMethodForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
return err return err
@ -550,30 +821,11 @@ func (form *paymentMethodForm) Parse(r *http.Request) error {
func (form *paymentMethodForm) Validate() bool { func (form *paymentMethodForm) Validate() bool {
validator := newFormValidator() 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)) validator.CheckRequiredInput(form.Instructions, gettext("Payment instructions can not be empty.", form.locale))
return validator.AllOK() 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) { func HandleAddPaymentMethod(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
locale := getLocale(r) locale := getLocale(r)
conn := getConn(r) conn := getConn(r)
@ -588,16 +840,157 @@ func HandleAddPaymentMethod(w http.ResponseWriter, r *http.Request, _ httprouter
return return
} }
if !form.Validate() { if !form.Validate() {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
} page := newPaymentMethodsPageWithForm(r.Context(), conn, company, form)
mustRenderPaymentMethodForm(w, r, form) page.MustRender(w, r)
return return
} }
conn.MustExec(r.Context(), "insert into payment_method (company_id, name, instructions) values ($1, $2, $3)", company.Id, form.Name, form.Instructions) 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) { htmxRedirect(w, r, companyURI(company, "/payment-methods"))
mustRenderPaymentMethodForm(w, r, newPaymentMethodForm(locale)) }
} else {
http.Redirect(w, r, companyURI(company, "/tax-details"), http.StatusSeeOther) 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

@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"github.com/tealeg/xlsx"
"html/template" "html/template"
"net/http" "net/http"
"strings" "strings"
@ -41,13 +42,21 @@ func IndexContacts(w http.ResponseWriter, r *http.Request, _ httprouter.Params)
func GetContactForm(w http.ResponseWriter, r *http.Request, params httprouter.Params) { func GetContactForm(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
locale := getLocale(r) locale := getLocale(r)
conn := getConn(r) conn := getConn(r)
form := newContactForm(r.Context(), conn, locale)
slug := params[0].Value slug := params[0].Value
if slug == "import" {
ServeImportPage(w, r, params)
return
}
form := newContactForm(r.Context(), conn, locale)
if slug == "new" { if slug == "new" {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
mustRenderNewContactForm(w, r, form) mustRenderNewContactForm(w, r, form)
return return
} }
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
if !form.MustFillFromDatabase(r.Context(), conn, slug) { if !form.MustFillFromDatabase(r.Context(), conn, slug) {
http.NotFound(w, r) http.NotFound(w, r)
return return
@ -69,7 +78,7 @@ type editContactPage struct {
func mustRenderEditContactForm(w http.ResponseWriter, r *http.Request, slug string, form *contactForm) { func mustRenderEditContactForm(w http.ResponseWriter, r *http.Request, slug string, form *contactForm) {
page := &editContactPage{ page := &editContactPage{
Slug: slug, Slug: slug,
ContactName: form.BusinessName.Val, ContactName: form.Name.String(),
Form: form, Form: form,
} }
mustRenderMainTemplate(w, r, "contacts/edit.gohtml", page) mustRenderMainTemplate(w, r, "contacts/edit.gohtml", page)
@ -88,14 +97,12 @@ func HandleAddContact(w http.ResponseWriter, r *http.Request, _ httprouter.Param
return return
} }
if !form.Validate(r.Context(), conn) { if !form.Validate(r.Context(), conn) {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
}
mustRenderNewContactForm(w, r, form) mustRenderNewContactForm(w, r, form)
return return
} }
company := mustGetCompany(r) company := mustGetCompany(r)
conn.MustExec(r.Context(), "select add_contact($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)", company.Id, form.BusinessName, form.VATIN, form.TradeName, form.Phone, form.Email, form.Web, form.Address, form.City, form.Province, form.PostalCode, form.Country, form.Tags) conn.MustExec(r.Context(), "select add_contact($1, $2, $3, $4, $5, $6, $7, $8, $9)", company.Id, form.Name, form.Phone, form.Email, form.Web, form.TaxDetails(), form.IBAN, form.BIC, form.Tags)
htmxRedirect(w, r, companyURI(company, "/contacts")) htmxRedirect(w, r, companyURI(company, "/contacts"))
} }
@ -115,7 +122,12 @@ func HandleUpdateContact(w http.ResponseWriter, r *http.Request, params httprout
mustRenderEditContactForm(w, r, params[0].Value, form) mustRenderEditContactForm(w, r, params[0].Value, form)
return return
} }
slug := conn.MustGetText(r.Context(), "", "select edit_contact($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)", params[0].Value, form.BusinessName, form.VATIN, form.TradeName, form.Phone, form.Email, form.Web, form.Address, form.City, form.Province, form.PostalCode, form.Country, 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 == "" { if slug == "" {
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -167,6 +179,11 @@ func (form *contactFilterForm) Parse(r *http.Request) error {
return nil 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 { func mustCollectContactEntries(ctx context.Context, conn *Conn, company *Company, filters *contactFilterForm) []*ContactEntry {
args := []interface{}{company.Id} args := []interface{}{company.Id}
where := []string{"contact.company_id = $1"} where := []string{"contact.company_id = $1"}
@ -177,7 +194,7 @@ func mustCollectContactEntries(ctx context.Context, conn *Conn, company *Company
if filters != nil { if filters != nil {
name := strings.TrimSpace(filters.Name.String()) name := strings.TrimSpace(filters.Name.String())
if name != "" { if name != "" {
appendWhere("contact.business_name ilike $%d", "%"+name+"%") appendWhere("contact.name ilike $%d", "%"+name+"%")
} }
if len(filters.Tags.Tags) > 0 { if len(filters.Tags.Tags) > 0 {
if filters.TagsCondition.Selected == "and" { if filters.TagsCondition.Selected == "and" {
@ -189,13 +206,15 @@ func mustCollectContactEntries(ctx context.Context, conn *Conn, company *Company
} }
rows := conn.MustQuery(ctx, fmt.Sprintf(` rows := conn.MustQuery(ctx, fmt.Sprintf(`
select slug select slug
, business_name , name
, email , coalesce(email::text, '')
, phone , coalesce(phone::text, '')
, tags , tags
from contact from contact
left join contact_email using (contact_id)
left join contact_phone using (contact_id)
where (%s) where (%s)
order by business_name order by name
`, strings.Join(where, ") AND (")), args...) `, strings.Join(where, ") AND (")), args...)
defer rows.Close() defer rows.Close()
@ -216,9 +235,10 @@ func mustCollectContactEntries(ctx context.Context, conn *Conn, company *Company
type contactForm struct { type contactForm struct {
locale *Locale locale *Locale
Name *InputField
HasTaxDetails *CheckField
BusinessName *InputField BusinessName *InputField
VATIN *InputField VATIN *InputField
TradeName *InputField
Phone *InputField Phone *InputField
Email *InputField Email *InputField
Web *InputField Web *InputField
@ -227,12 +247,48 @@ type contactForm struct {
Province *InputField Province *InputField
PostalCode *InputField PostalCode *InputField
Country *SelectField Country *SelectField
IBAN *InputField
BIC *InputField
Tags *TagsField Tags *TagsField
} }
func newContactForm(ctx context.Context, conn *Conn, locale *Locale) *contactForm { func newContactForm(ctx context.Context, conn *Conn, locale *Locale) *contactForm {
return &contactForm{ return &contactForm{
locale: locale, locale: locale,
Name: &InputField{
Name: "name",
Label: pgettext("input", "Name", locale),
Type: "text",
Required: true,
},
Phone: &InputField{
Name: "phone",
Label: pgettext("input", "Phone", locale),
Type: "tel",
Attributes: []template.HTMLAttr{
`autocomplete="tel"`,
},
},
Email: &InputField{
Name: "email",
Label: pgettext("input", "Email", locale),
Type: "email",
Attributes: []template.HTMLAttr{
`autocomplete="email"`,
},
},
Web: &InputField{
Name: "web",
Label: pgettext("input", "Web", locale),
Type: "url",
Attributes: []template.HTMLAttr{
`autocomplete="url"`,
},
},
HasTaxDetails: &CheckField{
Name: "has_tax_details",
Label: pgettext("input", "Need to input tax details", locale),
},
BusinessName: &InputField{ BusinessName: &InputField{
Name: "business_name", Name: "business_name",
Label: pgettext("input", "Business name", locale), Label: pgettext("input", "Business name", locale),
@ -249,37 +305,6 @@ func newContactForm(ctx context.Context, conn *Conn, locale *Locale) *contactFor
Type: "text", Type: "text",
Required: true, Required: true,
}, },
TradeName: &InputField{
Name: "trade_name",
Label: pgettext("input", "Trade name", locale),
Type: "text",
},
Phone: &InputField{
Name: "phone",
Label: pgettext("input", "Phone", locale),
Type: "tel",
Required: true,
Attributes: []template.HTMLAttr{
`autocomplete="tel"`,
},
},
Email: &InputField{
Name: "email",
Label: pgettext("input", "Email", locale),
Type: "email",
Required: true,
Attributes: []template.HTMLAttr{
`autocomplete="email"`,
},
},
Web: &InputField{
Name: "web",
Label: pgettext("input", "Web", locale),
Type: "url",
Attributes: []template.HTMLAttr{
`autocomplete="url"`,
},
},
Address: &InputField{ Address: &InputField{
Name: "address", Name: "address",
Label: pgettext("input", "Address", locale), Label: pgettext("input", "Address", locale),
@ -320,6 +345,16 @@ func newContactForm(ctx context.Context, conn *Conn, locale *Locale) *contactFor
`autocomplete="country"`, `autocomplete="country"`,
}, },
}, },
IBAN: &InputField{
Name: "iban",
Label: pgettext("input", "IBAN", locale),
Type: "text",
},
BIC: &InputField{
Name: "bic",
Label: pgettext("bic", "BIC", locale),
Type: "text",
},
Tags: &TagsField{ Tags: &TagsField{
Name: "tags", Name: "tags",
Label: pgettext("input", "Tags", locale), Label: pgettext("input", "Tags", locale),
@ -331,9 +366,10 @@ func (form *contactForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
return err return err
} }
form.Name.FillValue(r)
form.HasTaxDetails.FillValue(r)
form.BusinessName.FillValue(r) form.BusinessName.FillValue(r)
form.VATIN.FillValue(r) form.VATIN.FillValue(r)
form.TradeName.FillValue(r)
form.Phone.FillValue(r) form.Phone.FillValue(r)
form.Email.FillValue(r) form.Email.FillValue(r)
form.Web.FillValue(r) form.Web.FillValue(r)
@ -342,6 +378,8 @@ func (form *contactForm) Parse(r *http.Request) error {
form.Province.FillValue(r) form.Province.FillValue(r)
form.PostalCode.FillValue(r) form.PostalCode.FillValue(r)
form.Country.FillValue(r) form.Country.FillValue(r)
form.IBAN.FillValue(r)
form.BIC.FillValue(r)
form.Tags.FillValue(r) form.Tags.FillValue(r)
return nil return nil
} }
@ -349,54 +387,79 @@ func (form *contactForm) Parse(r *http.Request) error {
func (form *contactForm) Validate(ctx context.Context, conn *Conn) bool { func (form *contactForm) Validate(ctx context.Context, conn *Conn) bool {
validator := newFormValidator() validator := newFormValidator()
country := "" country := "ES"
if form.HasTaxDetails.Checked {
if validator.CheckValidSelectOption(form.Country, gettext("Selected country is not valid.", form.locale)) { if validator.CheckValidSelectOption(form.Country, gettext("Selected country is not valid.", form.locale)) {
country = form.Country.Selected[0] country = form.Country.Selected[0]
} }
if validator.CheckRequiredInput(form.BusinessName, gettext("Business name can not be empty.", form.locale)) {
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)) 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)) { if validator.CheckRequiredInput(form.VATIN, gettext("VAT number can not be empty.", form.locale)) {
validator.CheckValidVATINInput(form.VATIN, country, gettext("This value is not a valid VAT number.", 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.CheckRequiredInput(form.Address, gettext("Address can not be empty.", form.locale))
validator.CheckValidPhoneInput(form.Phone, country, gettext("This value is not a valid phone number.", 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))
} }
if validator.CheckRequiredInput(form.Email, gettext("Email can not be empty.", form.locale)) { }
if validator.CheckRequiredInput(form.Name, gettext("Name can not be empty.", form.locale)) {
validator.CheckInputMinLength(form.Name, 2, gettext("Name must have at least two letters.", form.locale))
}
if form.Phone.Val != "" {
validator.CheckValidPhoneInput(ctx, conn, form.Phone, country, gettext("This value is not a valid phone number.", form.locale))
}
if form.Email.Val != "" {
validator.CheckValidEmailInput(form.Email, gettext("This value is not a valid email. It should be like name@domain.com.", 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 != "" { 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.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)) if form.IBAN.Val != "" {
validator.CheckRequiredInput(form.City, gettext("City can not be empty.", form.locale)) validator.CheckValidIBANInput(ctx, conn, form.IBAN, gettext("This values is not a valid IBAN.", 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))
} }
if form.BIC.Val != "" {
validator.CheckValidBICInput(ctx, conn, form.BIC, gettext("This values is not a valid BIC.", form.locale))
}
return validator.AllOK() return validator.AllOK()
} }
func (form *contactForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool { func (form *contactForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool {
return !notFoundErrorOrPanic(conn.QueryRow(ctx, ` return !notFoundErrorOrPanic(conn.QueryRow(ctx, `
select business_name select name
, vatin is not null
, business_name
, substr(vatin::text, 3) , substr(vatin::text, 3)
, trade_name
, phone , phone
, email , email
, web , uri
, address , address
, city , city
, province , province
, postal_code , postal_code
, country_code , country_code
, iban
, bic
, tags , tags
from contact from contact
left join contact_email using (contact_id)
left join contact_phone using (contact_id)
left join contact_web using (contact_id)
left join contact_iban using (contact_id)
left join contact_swift using (contact_id)
left join contact_tax_details using (contact_id)
where slug = $1 where slug = $1
`, slug).Scan( `, slug).Scan(
form.Name,
form.HasTaxDetails,
form.BusinessName, form.BusinessName,
form.VATIN, form.VATIN,
form.TradeName,
form.Phone, form.Phone,
form.Email, form.Email,
form.Web, form.Web,
@ -405,28 +468,72 @@ func (form *contactForm) MustFillFromDatabase(ctx context.Context, conn *Conn, s
form.Province, form.Province,
form.PostalCode, form.PostalCode,
form.Country, form.Country,
form.IBAN,
form.BIC,
form.Tags)) form.Tags))
} }
func ServeEditContactTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) { func (form *contactForm) TaxDetails() *CustomerTaxDetails {
conn := getConn(r) if !form.HasTaxDetails.Checked {
locale := getLocale(r) return nil
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) return &CustomerTaxDetails{
BusinessName: form.BusinessName.String(),
VATIN: form.VATIN.String(),
Address: form.Address.String(),
City: form.City.String(),
Province: form.Province.String(),
PostalCode: form.PostalCode.String(),
CountryCode: form.Country.String(),
}
}
func ServeEditContactTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
serveTagsEditForm(w, r, params, "/contacts/", "select tags from contact where slug = $1")
} }
func HandleUpdateContactTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) { func HandleUpdateContactTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
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) {
form := newContactImportForm(getLocale(r), getCompany(r))
mustRenderMainTemplate(w, r, "contacts/import.gohtml", form)
}
type contactImportForm struct {
locale *Locale
company *Company
File *FileField
}
func newContactImportForm(locale *Locale, company *Company) *contactImportForm {
return &contactImportForm{
locale: locale,
company: company,
File: &FileField{
Name: "file",
Label: pgettext("input", "Holded Excel file", locale),
MaxSize: 1 << 20,
Required: true,
},
}
}
func (form *contactImportForm) Parse(r *http.Request) error {
if err := r.ParseMultipartForm(form.File.MaxSize); err != nil {
return err
}
if err := form.File.FillValue(r); err != nil {
return err
}
return nil
}
func HandleImportContacts(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
locale := getLocale(r) locale := getLocale(r)
conn := getConn(r) company := mustGetCompany(r)
company := getCompany(r) form := newContactImportForm(locale, company)
slug := params[0].Value
form := newTagsForm(companyURI(company, "/contacts/"+slug+"/tags/edit"), slug, locale)
if err := form.Parse(r); err != nil { if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@ -435,8 +542,45 @@ func HandleUpdateContactTags(w http.ResponseWriter, r *http.Request, params http
http.Error(w, err.Error(), http.StatusForbidden) http.Error(w, err.Error(), http.StatusForbidden)
return return
} }
if conn.MustGetText(r.Context(), "", "update contact set tags = $1 where slug = $2 returning slug", form.Tags, form.Slug) == "" { workbook, err := xlsx.OpenBinary(form.File.Content)
http.NotFound(w, r) if err != nil {
panic(err)
} }
mustRenderStandaloneTemplate(w, r, "tags/view.gohtml", form) conn := getConn(r)
tx := conn.MustBegin(r.Context())
defer tx.MustRollback(r.Context())
relation := tx.MustGetText(r.Context(), "select begin_import_contacts()")
columns := []string{"name", "vatin", "email", "phone", "address", "city", "postal_code", "province", "country_code", "iban", "bic", "tags"}
for _, sheet := range workbook.Sheets {
tx.MustCopyFrom(r.Context(), relation, columns, len(sheet.Rows)-4, func(idx int) ([]interface{}, error) {
row := sheet.Rows[idx+4]
var values []interface{}
if len(row.Cells) < 23 {
values = []interface{}{"", "", "", "", "", "", "", "", "", "", "", ""}
} else {
phone := row.Cells[5].String() // mobile
if phone == "" {
phone = row.Cells[4].String() // landline
}
values = []interface{}{
row.Cells[1].String(),
row.Cells[2].String(),
row.Cells[3].String(),
phone,
row.Cells[6].String(),
row.Cells[7].String(),
row.Cells[8].String(),
row.Cells[9].String(),
row.Cells[11].String(),
row.Cells[19].String(),
row.Cells[20].String(),
row.Cells[22].String(),
}
}
return values, nil
})
}
tx.MustExec(r.Context(), "select end_import_contacts($1)", company.Id)
tx.MustCommit(r.Context())
htmxRedirect(w, r, companyURI(company, "/contacts"))
} }

View File

@ -66,7 +66,7 @@ func ServeDashboard(w http.ResponseWriter, r *http.Request, _ httprouter.Params)
rows := conn.MustQuery(r.Context(), fmt.Sprintf(` rows := conn.MustQuery(r.Context(), fmt.Sprintf(`
select to_price(0, decimal_digits) as sales select to_price(0, decimal_digits) as sales
, to_price(coalesce(invoice.total, 0), decimal_digits) as income , 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.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_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 , 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 ) as invoice
left join ( left join (
select to_char(date.invoice_date, '%[3]s')::integer as date 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) 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 group by date
) as expense using (date) ) as expense using (date)
order by date order by date

View File

@ -143,6 +143,14 @@ func (tx *Tx) MustExec(ctx context.Context, sql string, args ...interface{}) {
} }
} }
func (tx *Tx) MustGetText(ctx context.Context, sql string, args ...interface{}) string {
var result string
if err := tx.QueryRow(ctx, sql, args...).Scan(&result); err != nil {
panic(err)
}
return result
}
func (tx *Tx) MustGetInteger(ctx context.Context, sql string, args ...interface{}) int { func (tx *Tx) MustGetInteger(ctx context.Context, sql string, args ...interface{}) int {
var result int var result int
if err := tx.QueryRow(ctx, sql, args...).Scan(&result); err != nil { if err := tx.QueryRow(ctx, sql, args...).Scan(&result); err != nil {
@ -159,8 +167,8 @@ func (tx *Tx) MustGetIntegerOrDefault(ctx context.Context, def int, sql string,
return result return result
} }
func (tx *Tx) MustCopyFrom(ctx context.Context, tableName string, columns []string, rows [][]interface{}) int64 { func (tx *Tx) MustCopyFrom(ctx context.Context, tableName string, columns []string, length int, next func(int) ([]interface{}, error)) int64 {
copied, err := tx.CopyFrom(ctx, pgx.Identifier{tableName}, columns, pgx.CopyFromRows(rows)) copied, err := tx.CopyFrom(ctx, pgx.Identifier{tableName}, columns, pgx.CopyFromSlice(length, next))
if err != nil { if err != nil {
panic(err) panic(err)
} }

View File

@ -13,18 +13,28 @@ import (
) )
type ExpenseEntry struct { type ExpenseEntry struct {
ID int
Slug string Slug string
InvoiceDate time.Time InvoiceDate time.Time
InvoiceNumber string InvoiceNumber string
Amount string Amount string
Taxes map[string]string
Total string
InvoicerName string InvoicerName string
OriginalFileName string OriginalFileName string
Tags []string Tags []string
Status string
StatusLabel string
} }
type expensesIndexPage struct { type expensesIndexPage struct {
Expenses []*ExpenseEntry Expenses []*ExpenseEntry
SumAmount string
SumTaxes map[string]string
SumTotal string
Filters *expenseFilterForm Filters *expenseFilterForm
TaxClasses []string
ExpenseStatuses map[string]string
} }
func IndexExpenses(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { func IndexExpenses(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
@ -37,65 +47,66 @@ func IndexExpenses(w http.ResponseWriter, r *http.Request, _ httprouter.Params)
return return
} }
page := &expensesIndexPage{ page := &expensesIndexPage{
Expenses: mustCollectExpenseEntries(r.Context(), conn, company, filters), Expenses: mustCollectExpenseEntries(r.Context(), conn, locale, filters),
ExpenseStatuses: mustCollectExpenseStatuses(r.Context(), conn, locale),
TaxClasses: mustCollectTaxClasses(r.Context(), conn, company),
Filters: filters, Filters: filters,
} }
page.mustComputeExpensesTotalAmount(r.Context(), conn, filters)
mustRenderMainTemplate(w, r, "expenses/index.gohtml", page) mustRenderMainTemplate(w, r, "expenses/index.gohtml", page)
} }
func mustCollectExpenseEntries(ctx context.Context, conn *Conn, company *Company, filters *expenseFilterForm) []*ExpenseEntry { func mustCollectExpenseEntries(ctx context.Context, conn *Conn, locale *Locale, filters *expenseFilterForm) []*ExpenseEntry {
args := []interface{}{company.Id} where, args := filters.BuildQuery([]interface{}{locale.Language.String()})
where := []string{"expense.company_id = $1"}
appendWhere := func(expression string, value interface{}) {
args = append(args, value)
where = append(where, fmt.Sprintf(expression, len(args)))
}
maybeAppendWhere := func(expression string, value string, conv func(string) interface{}) {
if value != "" {
if conv == nil {
appendWhere(expression, value)
} else {
appendWhere(expression, conv(value))
}
}
}
maybeAppendWhere("contact_id = $%d", filters.Customer.String(), func(v string) interface{} {
customerId, _ := strconv.Atoi(filters.Customer.Selected[0])
return customerId
})
maybeAppendWhere("invoice_number = $%d", filters.InvoiceNumber.String(), nil)
maybeAppendWhere("invoice_date >= $%d", filters.FromDate.String(), nil)
maybeAppendWhere("invoice_date <= $%d", filters.ToDate.String(), nil)
if len(filters.Tags.Tags) > 0 {
if filters.TagsCondition.Selected == "and" {
appendWhere("expense.tags @> $%d", filters.Tags)
} else {
appendWhere("expense.tags && $%d", filters.Tags)
}
}
rows := conn.MustQuery(ctx, fmt.Sprintf(` rows := conn.MustQuery(ctx, fmt.Sprintf(`
select expense.slug select expense_id
, expense.slug
, invoice_date , invoice_date
, invoice_number , invoice_number
, to_price(amount, decimal_digits) , to_price(expense.amount, decimal_digits) as amount
, contact.business_name , 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, '') , coalesce(attachment.original_filename, '')
, expense.tags , expense.tags
, expense.expense_status
, esi18n.name
from expense from expense
left join expense_attachment as attachment using (expense_id) 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 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) join currency using (currency_code)
where (%s) where (%s)
order by invoice_date group by expense_id
`, strings.Join(where, ") AND (")), args...) , 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() defer rows.Close()
var entries []*ExpenseEntry var entries []*ExpenseEntry
for rows.Next() { for rows.Next() {
entry := &ExpenseEntry{} entry := &ExpenseEntry{
if err := rows.Scan(&entry.Slug, &entry.InvoiceDate, &entry.InvoiceNumber, &entry.Amount, &entry.InvoicerName, &entry.OriginalFileName, &entry.Tags); err != nil { 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) panic(err)
} }
for _, tax := range taxes {
entry.Taxes[tax[0]] = tax[1]
}
entries = append(entries, entry) entries = append(entries, entry)
} }
if rows.Err() != nil { if rows.Err() != nil {
@ -104,6 +115,99 @@ func mustCollectExpenseEntries(ctx context.Context, conn *Conn, company *Company
return entries return entries
} }
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)
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)
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) { func ServeExpenseForm(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
locale := getLocale(r) locale := getLocale(r)
conn := getConn(r) conn := getConn(r)
@ -116,6 +220,10 @@ func ServeExpenseForm(w http.ResponseWriter, r *http.Request, params httprouter.
mustRenderNewExpenseForm(w, r, form) mustRenderNewExpenseForm(w, r, form)
return return
} }
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
if !form.MustFillFromDatabase(r.Context(), conn, slug) { if !form.MustFillFromDatabase(r.Context(), conn, slug) {
http.NotFound(w, r) http.NotFound(w, r)
return return
@ -127,20 +235,40 @@ func ServeExpenseForm(w http.ResponseWriter, r *http.Request, params httprouter.
func mustRenderNewExpenseForm(w http.ResponseWriter, r *http.Request, form *expenseForm) { func mustRenderNewExpenseForm(w http.ResponseWriter, r *http.Request, form *expenseForm) {
locale := getLocale(r) locale := getLocale(r)
form.Invoicer.EmptyLabel = gettext("Select a contact.", locale) 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) { func mustRenderEditExpenseForm(w http.ResponseWriter, r *http.Request, slug string, form *expenseForm) {
page := &editExpensePage{ page := &editExpensePage{
Slug: slug, newNewExpensePage(form, r),
Form: form, slug,
} }
mustRenderMainTemplate(w, r, "expenses/edit.gohtml", page) mustRenderMainTemplate(w, r, "expenses/edit.gohtml", page)
} }
type editExpensePage struct { type editExpensePage struct {
*newExpensePage
Slug string Slug string
Form *expenseForm
} }
type expenseForm struct { type expenseForm struct {
@ -156,6 +284,7 @@ type expenseForm struct {
} }
func newExpenseForm(ctx context.Context, conn *Conn, locale *Locale, company *Company) *expenseForm { 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{ return &expenseForm{
locale: locale, locale: locale,
company: company, company: company,
@ -181,6 +310,9 @@ func newExpenseForm(ctx context.Context, conn *Conn, locale *Locale, company *Co
Label: pgettext("input", "Taxes", locale), Label: pgettext("input", "Taxes", locale),
Multiple: true, Multiple: true,
Options: mustGetTaxOptions(ctx, conn, company), Options: mustGetTaxOptions(ctx, conn, company),
Attributes: []template.HTMLAttr{
triggerRecompute,
},
}, },
Amount: &InputField{ Amount: &InputField{
Name: "amount", Name: "amount",
@ -188,7 +320,7 @@ func newExpenseForm(ctx context.Context, conn *Conn, locale *Locale, company *Co
Type: "number", Type: "number",
Required: true, Required: true,
Attributes: []template.HTMLAttr{ Attributes: []template.HTMLAttr{
`min="0"`, triggerRecompute,
template.HTMLAttr(fmt.Sprintf(`step="%v"`, company.MinCents())), template.HTMLAttr(fmt.Sprintf(`step="%v"`, company.MinCents())),
}, },
}, },
@ -204,6 +336,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 { func (form *expenseForm) Parse(r *http.Request) error {
if err := r.ParseMultipartForm(form.File.MaxSize); err != nil { if err := r.ParseMultipartForm(form.File.MaxSize); err != nil {
return err return err
@ -227,20 +369,18 @@ func (form *expenseForm) Validate() bool {
validator.CheckValidSelectOption(form.Tax, gettext("Selected tax is not valid.", 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)) validator.CheckAtMostOneOfEachGroup(form.Tax, gettext("You can only select a tax of each class.", form.locale))
if validator.CheckRequiredInput(form.Amount, gettext("Amount can not be empty.", form.locale)) { 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.CheckValidDecimal(form.Amount, -math.MaxFloat64, math.MaxFloat64, gettext("Amount must be a decimal number.", 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() return validator.AllOK()
} }
func (form *expenseForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool { 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 select contact_id
, invoice_number , invoice_number
, invoice_date , invoice_date
, to_price(amount, decimal_digits) , to_price(amount, decimal_digits)
, array_agg(tax_id) , array_agg(tax_id) filter ( where tax_id is not null )
, tags , tags
from expense from expense
left join expense_tax using (expense_id) left join expense_tax using (expense_id)
@ -258,36 +398,14 @@ func (form *expenseForm) MustFillFromDatabase(ctx context.Context, conn *Conn, s
form.InvoiceDate, form.InvoiceDate,
form.Amount, form.Amount,
form.Tax, 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) { func HandleUpdateExpense(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
conn := getConn(r) conn := getConn(r)
locale := getLocale(r) locale := getLocale(r)
@ -302,10 +420,12 @@ func HandleUpdateExpense(w http.ResponseWriter, r *http.Request, params httprout
return return
} }
slug := params[0].Value slug := params[0].Value
if !form.Validate() { if !ValidUuid(slug) {
if !IsHTMxRequest(r) { http.NotFound(w, r)
w.WriteHeader(http.StatusUnprocessableEntity) return
} }
if !form.Validate() {
w.WriteHeader(http.StatusUnprocessableEntity)
mustRenderEditExpenseForm(w, r, slug, form) mustRenderEditExpenseForm(w, r, slug, form)
return return
} }
@ -323,10 +443,11 @@ func HandleUpdateExpense(w http.ResponseWriter, r *http.Request, params httprout
type expenseFilterForm struct { type expenseFilterForm struct {
locale *Locale locale *Locale
company *Company company *Company
Customer *SelectField Contact *SelectField
InvoiceNumber *InputField InvoiceNumber *InputField
FromDate *InputField FromDate *InputField
ToDate *InputField ToDate *InputField
ExpenseStatus *SelectField
Tags *TagsField Tags *TagsField
TagsCondition *ToggleField TagsCondition *ToggleField
} }
@ -335,10 +456,10 @@ func newExpenseFilterForm(ctx context.Context, conn *Conn, locale *Locale, compa
return &expenseFilterForm{ return &expenseFilterForm{
locale: locale, locale: locale,
company: company, company: company,
Customer: &SelectField{ Contact: &SelectField{
Name: "customer", Name: "contact",
Label: pgettext("input", "Customer", locale), Label: pgettext("input", "Contact", locale),
EmptyLabel: gettext("All customers", locale), EmptyLabel: gettext("All contacts", locale),
Options: mustGetContactOptions(ctx, conn, company), Options: mustGetContactOptions(ctx, conn, company),
}, },
InvoiceNumber: &InputField{ InvoiceNumber: &InputField{
@ -360,6 +481,12 @@ func newExpenseFilterForm(ctx context.Context, conn *Conn, locale *Locale, compa
Name: "tags", Name: "tags",
Label: pgettext("input", "Tags", locale), 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{ TagsCondition: &ToggleField{
Name: "tags_condition", Name: "tags_condition",
Label: pgettext("input", "Tags Condition", locale), Label: pgettext("input", "Tags Condition", locale),
@ -382,34 +509,107 @@ func (form *expenseFilterForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
return err return err
} }
form.Customer.FillValue(r) form.Contact.FillValue(r)
form.InvoiceNumber.FillValue(r) form.InvoiceNumber.FillValue(r)
form.FromDate.FillValue(r) form.FromDate.FillValue(r)
form.ToDate.FillValue(r) form.ToDate.FillValue(r)
form.ExpenseStatus.FillValue(r)
form.Tags.FillValue(r) form.Tags.FillValue(r)
form.TagsCondition.FillValue(r) form.TagsCondition.FillValue(r)
return nil return nil
} }
func ServeEditExpenseTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) { func (form *expenseFilterForm) HasValue() bool {
conn := getConn(r) return form.Contact.HasValue() ||
locale := getLocale(r) form.InvoiceNumber.HasValue() ||
company := getCompany(r) form.FromDate.HasValue() ||
slug := params[0].Value form.ToDate.HasValue() ||
form := newTagsForm(companyURI(company, "/expenses/"+slug+"/tags"), slug, locale) form.ExpenseStatus.HasValue() ||
if notFoundErrorOrPanic(conn.QueryRow(r.Context(), `select tags from expense where slug = $1`, form.Slug).Scan(form.Tags)) { form.Tags.HasValue()
http.NotFound(w, r) }
return
func (form *expenseFilterForm) BuildQuery(args []interface{}) (string, []interface{}) {
var where []string
appendWhere := func(expression string, value interface{}) {
args = append(args, value)
where = append(where, fmt.Sprintf(expression, len(args)))
} }
mustRenderStandaloneTemplate(w, r, "tags/edit.gohtml", form) maybeAppendWhere := func(expression string, value string, conv func(string) interface{}) {
if value != "" {
if conv == nil {
appendWhere(expression, value)
} else {
appendWhere(expression, conv(value))
}
}
}
appendWhere("expense.company_id = $%d", form.company.Id)
maybeAppendWhere("contact_id = $%d", form.Contact.String(), func(v string) interface{} {
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)
if len(form.Tags.Tags) > 0 {
if form.TagsCondition.Selected == "and" {
appendWhere("expense.tags @> $%d", form.Tags)
} else {
appendWhere("expense.tags && $%d", form.Tags)
}
}
return strings.Join(where, ") AND ("), args
}
func ServeEditExpenseTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
serveTagsEditForm(w, r, params, "/expenses/", "select tags from expense where slug = $1")
} }
func HandleUpdateExpenseTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) { 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) locale := getLocale(r)
conn := getConn(r) conn := getConn(r)
company := getCompany(r) company := mustGetCompany(r)
slug := params[0].Value form := newExpenseForm(r.Context(), conn, locale, company)
form := newTagsForm(companyURI(company, "/expenses/"+slug+"/tags/edit"), slug, locale)
if err := form.Parse(r); err != nil { if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@ -418,29 +618,117 @@ func HandleUpdateExpenseTags(w http.ResponseWriter, r *http.Request, params http
http.Error(w, err.Error(), http.StatusForbidden) http.Error(w, err.Error(), http.StatusForbidden)
return return
} }
if conn.MustGetText(r.Context(), "", "update expense set tags = $1 where slug = $2 returning slug", form.Tags, form.Slug) == "" { actionField := r.Form.Get("action")
http.NotFound(w, r) 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) {
slug := params[0].Value 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) conn := getConn(r)
var contentType string company := getCompany(r)
var content []byte filters := newExpenseFilterForm(r.Context(), conn, locale, company)
if notFoundErrorOrPanic(conn.QueryRow(r.Context(), ` if err := filters.Parse(r); err != nil {
select mime_type http.Error(w, err.Error(), http.StatusBadRequest)
, content return
from expense }
join expense_attachment using (expense_id) entries := mustCollectExpenseEntries(r.Context(), conn, locale, filters)
where slug = $1 vatin := mustCollectExpenseEntriesVATIN(r.Context(), conn, entries)
`, slug).Scan(&contentType, &content)) { 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
if !ValidUuid(slug) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(content)), 10)) if err := verifyCsrfTokenValid(r); err != nil {
w.WriteHeader(http.StatusOK) http.Error(w, err.Error(), http.StatusForbidden)
w.Write(content) return
}
conn := getConn(r)
conn.MustExec(r.Context(), "select remove_expense($1)", slug)
company := mustGetCompany(r)
htmxRedirect(w, r, companyURI(company, "/expenses"))
} }

View File

@ -8,7 +8,7 @@ import (
"fmt" "fmt"
"github.com/jackc/pgtype" "github.com/jackc/pgtype"
"html/template" "html/template"
"io/ioutil" "io"
"net/http" "net/http"
"net/mail" "net/mail"
"net/url" "net/url"
@ -59,6 +59,10 @@ func (field *InputField) Value() (driver.Value, error) {
return field.Val, nil return field.Val, nil
} }
func (field *InputField) HasValue() bool {
return field.Val != ""
}
func (field *InputField) FillValue(r *http.Request) { func (field *InputField) FillValue(r *http.Request) {
field.Val = strings.TrimSpace(r.FormValue(field.Name)) field.Val = strings.TrimSpace(r.FormValue(field.Name))
} }
@ -184,6 +188,10 @@ func (field *SelectField) Clear() {
field.Selected = []string{} 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 { func MustGetOptions(ctx context.Context, conn *Conn, sql string, args ...interface{}) []*SelectOption {
rows, err := conn.Query(ctx, sql, args...) rows, err := conn.Query(ctx, sql, args...)
if err != nil { if err != nil {
@ -287,6 +295,64 @@ func (field *RadioField) isValidOption(selected string) bool {
return field.FindOption(selected) != nil 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
Checked bool
Attributes []template.HTMLAttr
Required bool
Errors []error
}
func (field *CheckField) FillValue(r *http.Request) {
field.Checked = len(r.Form[field.Name]) > 0
}
func (field *CheckField) Scan(value interface{}) error {
if value == nil {
field.Checked = false
return nil
}
switch v := value.(type) {
case bool:
field.Checked = v
default:
field.Checked, _ = strconv.ParseBool(fmt.Sprintf("%v", v))
}
return nil
}
func (field *CheckField) Value() (driver.Value, error) {
return field.Checked, nil
}
type FileField struct { type FileField struct {
Name string Name string
Label string Label string
@ -294,6 +360,7 @@ type FileField struct {
OriginalFileName string OriginalFileName string
ContentType string ContentType string
Content []byte Content []byte
Required bool
Errors []error Errors []error
} }
@ -306,7 +373,7 @@ func (field *FileField) FillValue(r *http.Request) error {
return err return err
} }
defer file.Close() defer file.Close()
field.Content, err = ioutil.ReadAll(file) field.Content, err = io.ReadAll(file)
if err != nil { if err != nil {
return err return err
} }
@ -334,6 +401,10 @@ func (field *TagsField) Value() (driver.Value, error) {
return field.Tags, nil return field.Tags, nil
} }
func (field *TagsField) HasValue() bool {
return len(field.Tags) > 0 && field.Tags[0] != ""
}
func (field *TagsField) Scan(value interface{}) error { func (field *TagsField) Scan(value interface{}) error {
if value == nil { if value == nil {
return nil return nil
@ -384,6 +455,10 @@ func (field *ToggleField) FillValue(r *http.Request) {
} }
} }
func (field *ToggleField) String() string {
return field.Selected
}
type FormValidator struct { type FormValidator struct {
Valid bool Valid bool
} }
@ -404,19 +479,29 @@ func (v *FormValidator) CheckInputMinLength(field *InputField, min int, message
return v.checkInput(field, len(field.Val) >= min, 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 { func (v *FormValidator) CheckValidEmailInput(field *InputField, message string) bool {
_, err := mail.ParseAddress(field.Val) _, err := mail.ParseAddress(field.Val)
return v.checkInput(field, err == nil, message) return v.checkInput(field, err == nil, message)
} }
func (v *FormValidator) CheckValidVATINInput(field *InputField, country string, message string) bool { func (v *FormValidator) CheckValidVATINInput(ctx context.Context, conn *Conn, field *InputField, country string, message string) bool {
// TODO: actual VATIN validation return v.checkInput(field, conn.MustGetBool(ctx, "select input_is_valid($1 || $2, 'vatin')", country, field.Val), message)
return v.checkInput(field, true, message)
} }
func (v *FormValidator) CheckValidPhoneInput(field *InputField, country string, message string) bool { func (v *FormValidator) CheckValidPhoneInput(ctx context.Context, conn *Conn, field *InputField, country string, message string) bool {
// TODO: actual phone validation return v.checkInput(field, conn.MustGetBool(ctx, "select input_is_valid_phone($1, $2)", field.Val, country), message)
return v.checkInput(field, true, message) }
func (v *FormValidator) CheckValidIBANInput(ctx context.Context, conn *Conn, field *InputField, message string) bool {
return v.checkInput(field, conn.MustGetBool(ctx, "select input_is_valid($1, 'iban')", field.Val), message)
}
func (v *FormValidator) CheckValidBICInput(ctx context.Context, conn *Conn, field *InputField, message string) bool {
return v.checkInput(field, conn.MustGetBool(ctx, "select input_is_valid($1, 'bic')", field.Val), message)
} }
func (v *FormValidator) CheckPasswordConfirmation(password *InputField, confirm *InputField, message string) bool { func (v *FormValidator) CheckPasswordConfirmation(password *InputField, confirm *InputField, message string) bool {
@ -427,6 +512,10 @@ func (v *FormValidator) CheckValidSelectOption(field *SelectField, message strin
return v.checkSelect(field, field.HasValidOptions(), message) 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 { func (v *FormValidator) CheckAtMostOneOfEachGroup(field *SelectField, message string) bool {
repeated := false repeated := false
groups := map[string]bool{} groups := map[string]bool{}
@ -485,3 +574,11 @@ func (v *FormValidator) checkSelect(field *SelectField, ok bool, message string)
} }
return ok 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
}

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