2023-02-11 21:16:48 +00:00
package pkg
import (
2023-03-09 11:11:53 +00:00
"archive/zip"
Convert invoices to PDF with WeasyPrint
Although it is possible to just print the invoice from the browser, many
people will not even try an assume that they can not create a PDF for
the invoice.
I thought of using Groff or TeX to create the PDF, but it would mean
maintaining two templates in two different systems (HTML and whatever i
would use), and would probably look very different, because i do not
know Groff or TeX that well.
I wish there was a way to tell the browser to print to PDF, and it can
be done, but only with the Chrome Protocol to a server-side running
Chrome instance. This works, but i would need a Chrome running as a
daemon.
I also wrote a Qt application that uses QWebEngine to print the PDF,
much like wkhtmltopdf, but with support for more recent HTML and CSS
standards. Unfortunately, Qt 6.4’s embedded Chromium does not follow
break-page-inside as well as WeasyPrint does.
To use WeasyPrint, at first i wanted to reach the same URL as the user,
passing the cookie to WeasyPrint so that i can access the same invoice
as the user, something that can be done with wkhtmltopdf, but WeasyPrint
does not have such option. I did it with a custom Python script, but
then i need to package and install that script, that is not that much
work, but using the Debian-provided script is even less work, and less
likely to drift when WeasyPrint changes API.
Also, it is unnecessary to do a network round-trip from Go to Python
back to Go, because i can already write the invoice HTML as is to
WeasyPrint’s stdin.
2023-02-26 16:26:09 +00:00
"bytes"
2023-02-11 21:16:48 +00:00
"context"
2023-03-09 11:11:53 +00:00
"errors"
2023-02-11 21:16:48 +00:00
"fmt"
2024-01-26 01:29:51 +00:00
"github.com/jackc/pgtype"
2023-02-11 21:16:48 +00:00
"github.com/julienschmidt/httprouter"
"html/template"
Convert invoices to PDF with WeasyPrint
Although it is possible to just print the invoice from the browser, many
people will not even try an assume that they can not create a PDF for
the invoice.
I thought of using Groff or TeX to create the PDF, but it would mean
maintaining two templates in two different systems (HTML and whatever i
would use), and would probably look very different, because i do not
know Groff or TeX that well.
I wish there was a way to tell the browser to print to PDF, and it can
be done, but only with the Chrome Protocol to a server-side running
Chrome instance. This works, but i would need a Chrome running as a
daemon.
I also wrote a Qt application that uses QWebEngine to print the PDF,
much like wkhtmltopdf, but with support for more recent HTML and CSS
standards. Unfortunately, Qt 6.4’s embedded Chromium does not follow
break-page-inside as well as WeasyPrint does.
To use WeasyPrint, at first i wanted to reach the same URL as the user,
passing the cookie to WeasyPrint so that i can access the same invoice
as the user, something that can be done with wkhtmltopdf, but WeasyPrint
does not have such option. I did it with a custom Python script, but
then i need to package and install that script, that is not that much
work, but using the Debian-provided script is even less work, and less
likely to drift when WeasyPrint changes API.
Also, it is unnecessary to do a network round-trip from Go to Python
back to Go, because i can already write the invoice HTML as is to
WeasyPrint’s stdin.
2023-02-26 16:26:09 +00:00
"io"
"log"
2023-02-12 20:06:48 +00:00
"math"
2023-02-11 21:16:48 +00:00
"net/http"
2023-03-09 11:11:53 +00:00
"os"
Convert invoices to PDF with WeasyPrint
Although it is possible to just print the invoice from the browser, many
people will not even try an assume that they can not create a PDF for
the invoice.
I thought of using Groff or TeX to create the PDF, but it would mean
maintaining two templates in two different systems (HTML and whatever i
would use), and would probably look very different, because i do not
know Groff or TeX that well.
I wish there was a way to tell the browser to print to PDF, and it can
be done, but only with the Chrome Protocol to a server-side running
Chrome instance. This works, but i would need a Chrome running as a
daemon.
I also wrote a Qt application that uses QWebEngine to print the PDF,
much like wkhtmltopdf, but with support for more recent HTML and CSS
standards. Unfortunately, Qt 6.4’s embedded Chromium does not follow
break-page-inside as well as WeasyPrint does.
To use WeasyPrint, at first i wanted to reach the same URL as the user,
passing the cookie to WeasyPrint so that i can access the same invoice
as the user, something that can be done with wkhtmltopdf, but WeasyPrint
does not have such option. I did it with a custom Python script, but
then i need to package and install that script, that is not that much
work, but using the Debian-provided script is even less work, and less
likely to drift when WeasyPrint changes API.
Also, it is unnecessary to do a network round-trip from Go to Python
back to Go, because i can already write the invoice HTML as is to
WeasyPrint’s stdin.
2023-02-26 16:26:09 +00:00
"os/exec"
2023-03-01 13:08:12 +00:00
"sort"
2023-02-12 20:06:48 +00:00
"strconv"
Convert invoices to PDF with WeasyPrint
Although it is possible to just print the invoice from the browser, many
people will not even try an assume that they can not create a PDF for
the invoice.
I thought of using Groff or TeX to create the PDF, but it would mean
maintaining two templates in two different systems (HTML and whatever i
would use), and would probably look very different, because i do not
know Groff or TeX that well.
I wish there was a way to tell the browser to print to PDF, and it can
be done, but only with the Chrome Protocol to a server-side running
Chrome instance. This works, but i would need a Chrome running as a
daemon.
I also wrote a Qt application that uses QWebEngine to print the PDF,
much like wkhtmltopdf, but with support for more recent HTML and CSS
standards. Unfortunately, Qt 6.4’s embedded Chromium does not follow
break-page-inside as well as WeasyPrint does.
To use WeasyPrint, at first i wanted to reach the same URL as the user,
passing the cookie to WeasyPrint so that i can access the same invoice
as the user, something that can be done with wkhtmltopdf, but WeasyPrint
does not have such option. I did it with a custom Python script, but
then i need to package and install that script, that is not that much
work, but using the Debian-provided script is even less work, and less
likely to drift when WeasyPrint changes API.
Also, it is unnecessary to do a network round-trip from Go to Python
back to Go, because i can already write the invoice HTML as is to
WeasyPrint’s stdin.
2023-02-26 16:26:09 +00:00
"strings"
2023-02-11 21:16:48 +00:00
"time"
)
2023-05-28 22:01:11 +00:00
const removedProductSuffix = ".removed"
2023-02-12 20:06:48 +00:00
type InvoiceEntry struct {
2024-01-26 01:29:51 +00:00
ID int
2023-02-12 20:06:48 +00:00
Slug string
Date time . Time
Number string
2023-02-22 13:39:38 +00:00
Total string
2023-02-12 20:06:48 +00:00
CustomerName string
2023-03-10 13:02:55 +00:00
Tags [ ] string
2023-02-12 20:06:48 +00:00
Status string
StatusLabel string
}
2023-02-11 21:16:48 +00:00
type InvoicesIndexPage struct {
2023-03-07 10:52:09 +00:00
Invoices [ ] * InvoiceEntry
2023-06-20 09:33:28 +00:00
TotalAmount string
Add filters form for invoices
Instead of using links in the invoice tags, that we will replace with a
“click-to-edit field”, with Oriol agreed to add a form with filters that
includes not only the tags but also dates, customer, status, and the
invoice number.
This means i now need dynamic SQL, and i do not think this belongs to
the database (i.e., no PL/pgSQL function for that). I have looked at
query builder libraries for Golang, and did not find anything that
suited me: either they wanted to manage not only the SQL query but also
all structs, or they managed to confuse Goland’s SQL analyzer.
For now, at least, i am using a very simple approach with arrays, that
still confuses Goland’s analyzer, but just in a very specific part,
which i find tolerable—not that their analyzer is that great to begin
with, but that’s a story for another day.
2023-03-29 14:16:31 +00:00
Filters * invoiceFilterForm
2023-03-07 10:52:09 +00:00
InvoiceStatuses map [ string ] string
2023-02-11 21:16:48 +00:00
}
func IndexInvoices ( w http . ResponseWriter , r * http . Request , _ httprouter . Params ) {
2023-03-07 10:52:09 +00:00
conn := getConn ( r )
locale := getLocale ( r )
Add filters form for invoices
Instead of using links in the invoice tags, that we will replace with a
“click-to-edit field”, with Oriol agreed to add a form with filters that
includes not only the tags but also dates, customer, status, and the
invoice number.
This means i now need dynamic SQL, and i do not think this belongs to
the database (i.e., no PL/pgSQL function for that). I have looked at
query builder libraries for Golang, and did not find anything that
suited me: either they wanted to manage not only the SQL query but also
all structs, or they managed to confuse Goland’s SQL analyzer.
For now, at least, i am using a very simple approach with arrays, that
still confuses Goland’s analyzer, but just in a very specific part,
which i find tolerable—not that their analyzer is that great to begin
with, but that’s a story for another day.
2023-03-29 14:16:31 +00:00
company := mustGetCompany ( r )
filters := newInvoiceFilterForm ( r . Context ( ) , conn , locale , company )
if err := filters . Parse ( r ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusBadRequest )
return
}
2023-02-12 20:06:48 +00:00
page := & InvoicesIndexPage {
2023-06-20 09:33:28 +00:00
Invoices : mustCollectInvoiceEntries ( r . Context ( ) , conn , locale , filters ) ,
TotalAmount : mustComputeInvoicesTotalAmount ( r . Context ( ) , conn , filters ) ,
Add filters form for invoices
Instead of using links in the invoice tags, that we will replace with a
“click-to-edit field”, with Oriol agreed to add a form with filters that
includes not only the tags but also dates, customer, status, and the
invoice number.
This means i now need dynamic SQL, and i do not think this belongs to
the database (i.e., no PL/pgSQL function for that). I have looked at
query builder libraries for Golang, and did not find anything that
suited me: either they wanted to manage not only the SQL query but also
all structs, or they managed to confuse Goland’s SQL analyzer.
For now, at least, i am using a very simple approach with arrays, that
still confuses Goland’s analyzer, but just in a very specific part,
which i find tolerable—not that their analyzer is that great to begin
with, but that’s a story for another day.
2023-03-29 14:16:31 +00:00
Filters : filters ,
2023-03-07 10:52:09 +00:00
InvoiceStatuses : mustCollectInvoiceStatuses ( r . Context ( ) , conn , locale ) ,
2023-02-12 20:06:48 +00:00
}
2023-03-23 09:55:02 +00:00
mustRenderMainTemplate ( w , r , "invoices/index.gohtml" , page )
2023-02-11 21:16:48 +00:00
}
2023-06-20 09:33:28 +00:00
func mustCollectInvoiceEntries ( ctx context . Context , conn * Conn , locale * Locale , filters * invoiceFilterForm ) [ ] * InvoiceEntry {
where , args := filters . BuildQuery ( [ ] interface { } { locale . Language . String ( ) } )
Add filters form for invoices
Instead of using links in the invoice tags, that we will replace with a
“click-to-edit field”, with Oriol agreed to add a form with filters that
includes not only the tags but also dates, customer, status, and the
invoice number.
This means i now need dynamic SQL, and i do not think this belongs to
the database (i.e., no PL/pgSQL function for that). I have looked at
query builder libraries for Golang, and did not find anything that
suited me: either they wanted to manage not only the SQL query but also
all structs, or they managed to confuse Goland’s SQL analyzer.
For now, at least, i am using a very simple approach with arrays, that
still confuses Goland’s analyzer, but just in a very specific part,
which i find tolerable—not that their analyzer is that great to begin
with, but that’s a story for another day.
2023-03-29 14:16:31 +00:00
rows := conn . MustQuery ( ctx , fmt . Sprintf ( `
2024-01-26 01:29:51 +00:00
select invoice_id
, invoice . slug
2023-03-10 13:02:55 +00:00
, invoice_date
, invoice_number
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 19:32:48 +00:00
, contact . name
Replace tag relations with array attributes
It all started when i wanted to try to filter invoices by multiple tags
using an “AND”, instead of “OR” as it was doing until now. But
something felt off and seemed to me that i was doing thing much more
complex than needed, all to be able to list the tags as a suggestion
in the input field—which i am not doing yet.
I found this article series[0] exploring different approaches for
tagging, which includes the one i was using, and comparing their
performance. I have not actually tested it, but it seems that i have
chosen the worst option, in both query time and storage.
I attempted to try using an array attribute to each table, which is more
or less the same they did in the articles but without using a separate
relation for tags, and i found out that all the queries were way easier
to write, and needed two joins less, so it was a no-brainer.
[0]: http://www.databasesoup.com/2015/01/tag-all-things.html
2023-04-07 19:31:35 +00:00
, invoice . tags
2023-03-10 13:02:55 +00:00
, invoice . invoice_status
, isi18n . name
, to_price ( total , decimal_digits )
from invoice
join contact using ( contact_id )
Add filters form for invoices
Instead of using links in the invoice tags, that we will replace with a
“click-to-edit field”, with Oriol agreed to add a form with filters that
includes not only the tags but also dates, customer, status, and the
invoice number.
This means i now need dynamic SQL, and i do not think this belongs to
the database (i.e., no PL/pgSQL function for that). I have looked at
query builder libraries for Golang, and did not find anything that
suited me: either they wanted to manage not only the SQL query but also
all structs, or they managed to confuse Goland’s SQL analyzer.
For now, at least, i am using a very simple approach with arrays, that
still confuses Goland’s analyzer, but just in a very specific part,
which i find tolerable—not that their analyzer is that great to begin
with, but that’s a story for another day.
2023-03-29 14:16:31 +00:00
join invoice_status_i18n isi18n on invoice . invoice_status = isi18n . invoice_status and isi18n . lang_tag = $ 1
2023-03-10 13:02:55 +00:00
join invoice_amount using ( invoice_id )
join currency using ( currency_code )
Add filters form for invoices
Instead of using links in the invoice tags, that we will replace with a
“click-to-edit field”, with Oriol agreed to add a form with filters that
includes not only the tags but also dates, customer, status, and the
invoice number.
This means i now need dynamic SQL, and i do not think this belongs to
the database (i.e., no PL/pgSQL function for that). I have looked at
query builder libraries for Golang, and did not find anything that
suited me: either they wanted to manage not only the SQL query but also
all structs, or they managed to confuse Goland’s SQL analyzer.
For now, at least, i am using a very simple approach with arrays, that
still confuses Goland’s analyzer, but just in a very specific part,
which i find tolerable—not that their analyzer is that great to begin
with, but that’s a story for another day.
2023-03-29 14:16:31 +00:00
where ( % s )
2023-03-10 13:02:55 +00:00
order by invoice_date desc
, invoice_number desc
2023-06-20 09:33:28 +00:00
` , where ) , args ... )
2023-02-12 20:06:48 +00:00
defer rows . Close ( )
var entries [ ] * InvoiceEntry
for rows . Next ( ) {
entry := & InvoiceEntry { }
2024-01-26 01:29:51 +00:00
if err := rows . Scan ( & entry . ID , & entry . Slug , & entry . Date , & entry . Number , & entry . CustomerName , & entry . Tags , & entry . Status , & entry . StatusLabel , & entry . Total ) ; err != nil {
2023-02-12 20:06:48 +00:00
panic ( err )
}
entries = append ( entries , entry )
}
if rows . Err ( ) != nil {
panic ( rows . Err ( ) )
}
return entries
}
2023-06-20 09:33:28 +00:00
func mustComputeInvoicesTotalAmount ( ctx context . Context , conn * Conn , filters * invoiceFilterForm ) string {
where , args := filters . BuildQuery ( nil )
return conn . MustGetText ( ctx , "0" , fmt . Sprintf ( `
select to_price ( sum ( total ) : : integer , decimal_digits )
from invoice
join invoice_amount using ( invoice_id )
join currency using ( currency_code )
where ( % s )
group by decimal_digits
` , where ) , args ... )
}
2023-03-07 10:52:09 +00:00
func mustCollectInvoiceStatuses ( ctx context . Context , conn * Conn , locale * Locale ) map [ string ] string {
rows := conn . MustQuery ( ctx , "select invoice_status.invoice_status, isi18n.name from invoice_status join invoice_status_i18n isi18n using(invoice_status) where isi18n.lang_tag = $1 order by invoice_status" , locale . Language . String ( ) )
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
}
Add filters form for invoices
Instead of using links in the invoice tags, that we will replace with a
“click-to-edit field”, with Oriol agreed to add a form with filters that
includes not only the tags but also dates, customer, status, and the
invoice number.
This means i now need dynamic SQL, and i do not think this belongs to
the database (i.e., no PL/pgSQL function for that). I have looked at
query builder libraries for Golang, and did not find anything that
suited me: either they wanted to manage not only the SQL query but also
all structs, or they managed to confuse Goland’s SQL analyzer.
For now, at least, i am using a very simple approach with arrays, that
still confuses Goland’s analyzer, but just in a very specific part,
which i find tolerable—not that their analyzer is that great to begin
with, but that’s a story for another day.
2023-03-29 14:16:31 +00:00
type invoiceFilterForm struct {
locale * Locale
company * Company
Customer * SelectField
InvoiceStatus * SelectField
InvoiceNumber * InputField
FromDate * InputField
ToDate * InputField
Tags * TagsField
2023-04-15 02:05:59 +00:00
TagsCondition * ToggleField
Add filters form for invoices
Instead of using links in the invoice tags, that we will replace with a
“click-to-edit field”, with Oriol agreed to add a form with filters that
includes not only the tags but also dates, customer, status, and the
invoice number.
This means i now need dynamic SQL, and i do not think this belongs to
the database (i.e., no PL/pgSQL function for that). I have looked at
query builder libraries for Golang, and did not find anything that
suited me: either they wanted to manage not only the SQL query but also
all structs, or they managed to confuse Goland’s SQL analyzer.
For now, at least, i am using a very simple approach with arrays, that
still confuses Goland’s analyzer, but just in a very specific part,
which i find tolerable—not that their analyzer is that great to begin
with, but that’s a story for another day.
2023-03-29 14:16:31 +00:00
}
func newInvoiceFilterForm ( ctx context . Context , conn * Conn , locale * Locale , company * Company ) * invoiceFilterForm {
return & invoiceFilterForm {
locale : locale ,
company : company ,
Customer : & SelectField {
Name : "customer" ,
2023-06-20 09:37:02 +00:00
Label : pgettext ( "input" , "Customer" , locale ) ,
Add filters form for invoices
Instead of using links in the invoice tags, that we will replace with a
“click-to-edit field”, with Oriol agreed to add a form with filters that
includes not only the tags but also dates, customer, status, and the
invoice number.
This means i now need dynamic SQL, and i do not think this belongs to
the database (i.e., no PL/pgSQL function for that). I have looked at
query builder libraries for Golang, and did not find anything that
suited me: either they wanted to manage not only the SQL query but also
all structs, or they managed to confuse Goland’s SQL analyzer.
For now, at least, i am using a very simple approach with arrays, that
still confuses Goland’s analyzer, but just in a very specific part,
which i find tolerable—not that their analyzer is that great to begin
with, but that’s a story for another day.
2023-03-29 14:16:31 +00:00
EmptyLabel : gettext ( "All customers" , locale ) ,
2023-05-03 10:40:07 +00:00
Options : mustGetContactOptions ( ctx , conn , company ) ,
Add filters form for invoices
Instead of using links in the invoice tags, that we will replace with a
“click-to-edit field”, with Oriol agreed to add a form with filters that
includes not only the tags but also dates, customer, status, and the
invoice number.
This means i now need dynamic SQL, and i do not think this belongs to
the database (i.e., no PL/pgSQL function for that). I have looked at
query builder libraries for Golang, and did not find anything that
suited me: either they wanted to manage not only the SQL query but also
all structs, or they managed to confuse Goland’s SQL analyzer.
For now, at least, i am using a very simple approach with arrays, that
still confuses Goland’s analyzer, but just in a very specific part,
which i find tolerable—not that their analyzer is that great to begin
with, but that’s a story for another day.
2023-03-29 14:16:31 +00:00
} ,
InvoiceStatus : & SelectField {
Name : "invoice_status" ,
Label : pgettext ( "input" , "Invoice Status" , locale ) ,
EmptyLabel : gettext ( "All status" , locale ) ,
2023-07-11 13:26:23 +00:00
Options : mustGetInvoiceStatusOptions ( ctx , conn , locale ) ,
Add filters form for invoices
Instead of using links in the invoice tags, that we will replace with a
“click-to-edit field”, with Oriol agreed to add a form with filters that
includes not only the tags but also dates, customer, status, and the
invoice number.
This means i now need dynamic SQL, and i do not think this belongs to
the database (i.e., no PL/pgSQL function for that). I have looked at
query builder libraries for Golang, and did not find anything that
suited me: either they wanted to manage not only the SQL query but also
all structs, or they managed to confuse Goland’s SQL analyzer.
For now, at least, i am using a very simple approach with arrays, that
still confuses Goland’s analyzer, but just in a very specific part,
which i find tolerable—not that their analyzer is that great to begin
with, but that’s a story for another day.
2023-03-29 14:16:31 +00:00
} ,
InvoiceNumber : & InputField {
Name : "number" ,
Label : pgettext ( "input" , "Invoice Number" , locale ) ,
Trigger filter form on change and search, as well as submit as before
Changed the invoice number field’s type to search to add the delete icon
on Chromium. Firefox does not add that icon, but i do not care; it is
still better that type="text".
Had to emit the change event to the numerus-tag field, otherwise the
form would not detect the change.
I also can not use keyup as a trigger because the changed modifier can
not be used in the <form>, as nothing ever changes, i do not know how to
trigger the form from children (i.e., data-hx-trigger on the <input>
does nothing), and i can not trigger for just any keyup, or i would
make the request even if they only moved the cursor with the arrow keys,
which is very confusing as Firefox resets the position (this may be due
the fact that i reload the whole <main>, but still).
2023-04-03 10:45:15 +00:00
Type : "search" ,
Add filters form for invoices
Instead of using links in the invoice tags, that we will replace with a
“click-to-edit field”, with Oriol agreed to add a form with filters that
includes not only the tags but also dates, customer, status, and the
invoice number.
This means i now need dynamic SQL, and i do not think this belongs to
the database (i.e., no PL/pgSQL function for that). I have looked at
query builder libraries for Golang, and did not find anything that
suited me: either they wanted to manage not only the SQL query but also
all structs, or they managed to confuse Goland’s SQL analyzer.
For now, at least, i am using a very simple approach with arrays, that
still confuses Goland’s analyzer, but just in a very specific part,
which i find tolerable—not that their analyzer is that great to begin
with, but that’s a story for another day.
2023-03-29 14:16:31 +00:00
} ,
FromDate : & InputField {
Name : "from_date" ,
Label : pgettext ( "input" , "From Date" , locale ) ,
Type : "date" ,
} ,
ToDate : & InputField {
Name : "to_date" ,
Label : pgettext ( "input" , "To Date" , locale ) ,
Type : "date" ,
} ,
Tags : & TagsField {
Name : "tags" ,
Label : pgettext ( "input" , "Tags" , locale ) ,
} ,
2023-04-15 02:05:59 +00:00
TagsCondition : & ToggleField {
2023-04-14 00:40:48 +00:00
Name : "tags_condition" ,
Label : pgettext ( "input" , "Tags Condition" , locale ) ,
2023-04-15 02:05:59 +00:00
Selected : "and" ,
2023-04-16 17:01:11 +00:00
FirstOption : & ToggleOption {
Value : "and" ,
Label : pgettext ( "tag condition" , "All" , locale ) ,
Description : gettext ( "Invoices must have all the specified labels." , locale ) ,
2023-04-15 02:05:59 +00:00
} ,
2023-04-16 17:01:11 +00:00
SecondOption : & ToggleOption {
Value : "or" ,
Label : pgettext ( "tag condition" , "Any" , locale ) ,
Description : gettext ( "Invoices must have at least one of the specified labels." , locale ) ,
2023-04-14 00:40:48 +00:00
} ,
} ,
Add filters form for invoices
Instead of using links in the invoice tags, that we will replace with a
“click-to-edit field”, with Oriol agreed to add a form with filters that
includes not only the tags but also dates, customer, status, and the
invoice number.
This means i now need dynamic SQL, and i do not think this belongs to
the database (i.e., no PL/pgSQL function for that). I have looked at
query builder libraries for Golang, and did not find anything that
suited me: either they wanted to manage not only the SQL query but also
all structs, or they managed to confuse Goland’s SQL analyzer.
For now, at least, i am using a very simple approach with arrays, that
still confuses Goland’s analyzer, but just in a very specific part,
which i find tolerable—not that their analyzer is that great to begin
with, but that’s a story for another day.
2023-03-29 14:16:31 +00:00
}
}
func ( form * invoiceFilterForm ) Parse ( r * http . Request ) error {
if err := r . ParseForm ( ) ; err != nil {
return err
}
form . Customer . FillValue ( r )
form . InvoiceStatus . FillValue ( r )
form . InvoiceNumber . FillValue ( r )
form . FromDate . FillValue ( r )
form . ToDate . FillValue ( r )
form . Tags . FillValue ( r )
2023-04-14 00:40:48 +00:00
form . TagsCondition . FillValue ( r )
Add filters form for invoices
Instead of using links in the invoice tags, that we will replace with a
“click-to-edit field”, with Oriol agreed to add a form with filters that
includes not only the tags but also dates, customer, status, and the
invoice number.
This means i now need dynamic SQL, and i do not think this belongs to
the database (i.e., no PL/pgSQL function for that). I have looked at
query builder libraries for Golang, and did not find anything that
suited me: either they wanted to manage not only the SQL query but also
all structs, or they managed to confuse Goland’s SQL analyzer.
For now, at least, i am using a very simple approach with arrays, that
still confuses Goland’s analyzer, but just in a very specific part,
which i find tolerable—not that their analyzer is that great to begin
with, but that’s a story for another day.
2023-03-29 14:16:31 +00:00
return nil
}
2023-07-16 18:56:11 +00:00
func ( form * invoiceFilterForm ) HasValue ( ) bool {
return form . Customer . HasValue ( ) ||
form . InvoiceStatus . HasValue ( ) ||
form . InvoiceNumber . HasValue ( ) ||
form . FromDate . HasValue ( ) ||
form . ToDate . HasValue ( ) ||
form . Tags . HasValue ( )
}
2023-06-20 09:33:28 +00:00
func ( form * invoiceFilterForm ) 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 ) ) )
}
maybeAppendWhere := func ( expression string , value string , conv func ( string ) interface { } ) {
if value != "" {
if conv == nil {
appendWhere ( expression , value )
} else {
appendWhere ( expression , conv ( value ) )
}
}
}
appendWhere ( "invoice.company_id = $%d" , form . company . Id )
maybeAppendWhere ( "contact_id = $%d" , form . Customer . String ( ) , func ( v string ) interface { } {
customerId , _ := strconv . Atoi ( form . Customer . Selected [ 0 ] )
return customerId
} )
maybeAppendWhere ( "invoice.invoice_status = $%d" , form . InvoiceStatus . 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 ( "invoice.tags @> $%d" , form . Tags )
} else {
appendWhere ( "invoice.tags && $%d" , form . Tags )
}
}
return strings . Join ( where , ") AND (" ) , args
}
Convert invoices to PDF with WeasyPrint
Although it is possible to just print the invoice from the browser, many
people will not even try an assume that they can not create a PDF for
the invoice.
I thought of using Groff or TeX to create the PDF, but it would mean
maintaining two templates in two different systems (HTML and whatever i
would use), and would probably look very different, because i do not
know Groff or TeX that well.
I wish there was a way to tell the browser to print to PDF, and it can
be done, but only with the Chrome Protocol to a server-side running
Chrome instance. This works, but i would need a Chrome running as a
daemon.
I also wrote a Qt application that uses QWebEngine to print the PDF,
much like wkhtmltopdf, but with support for more recent HTML and CSS
standards. Unfortunately, Qt 6.4’s embedded Chromium does not follow
break-page-inside as well as WeasyPrint does.
To use WeasyPrint, at first i wanted to reach the same URL as the user,
passing the cookie to WeasyPrint so that i can access the same invoice
as the user, something that can be done with wkhtmltopdf, but WeasyPrint
does not have such option. I did it with a custom Python script, but
then i need to package and install that script, that is not that much
work, but using the Debian-provided script is even less work, and less
likely to drift when WeasyPrint changes API.
Also, it is unnecessary to do a network round-trip from Go to Python
back to Go, because i can already write the invoice HTML as is to
WeasyPrint’s stdin.
2023-02-26 16:26:09 +00:00
func ServeInvoice ( w http . ResponseWriter , r * http . Request , params httprouter . Params ) {
2023-02-11 21:16:48 +00:00
conn := getConn ( r )
company := mustGetCompany ( r )
slug := params [ 0 ] . Value
2023-04-24 00:00:38 +00:00
switch slug {
case "new" :
2023-03-13 14:00:35 +00:00
locale := getLocale ( r )
form := newInvoiceForm ( r . Context ( ) , conn , locale , company )
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 09:46:11 +00:00
if invoiceToDuplicate := r . URL . Query ( ) . Get ( "duplicate" ) ; ValidUuid ( invoiceToDuplicate ) {
2023-03-08 10:26:02 +00:00
form . MustFillFromDatabase ( r . Context ( ) , conn , invoiceToDuplicate )
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 09:46:11 +00:00
} else if quoteToInvoice := r . URL . Query ( ) . Get ( "quote" ) ; ValidUuid ( quoteToInvoice ) {
2023-06-10 18:46:03 +00:00
form . MustFillFromQuote ( r . Context ( ) , conn , quoteToInvoice )
2023-03-08 10:26:02 +00:00
}
2023-02-11 21:16:48 +00:00
form . Date . Val = time . Now ( ) . Format ( "2006-01-02" )
w . WriteHeader ( http . StatusOK )
mustRenderNewInvoiceForm ( w , r , form )
2023-04-24 00:00:38 +00:00
case "product-form" :
query := r . URL . Query ( )
index , _ := strconv . Atoi ( query . Get ( "index" ) )
form := newInvoiceProductForm ( index , company , getLocale ( r ) , mustGetTaxOptions ( r . Context ( ) , conn , company ) )
2023-05-26 11:30:45 +00:00
productSlug := query . Get ( "slug" )
if len ( productSlug ) > 0 {
if ! form . MustFillFromDatabase ( r . Context ( ) , conn , productSlug ) {
2023-04-24 00:00:38 +00:00
http . NotFound ( w , r )
return
}
quantity , _ := strconv . Atoi ( query . Get ( "product.quantity." + strconv . Itoa ( index ) ) )
if quantity > 0 {
form . Quantity . Val = strconv . Itoa ( quantity )
}
w . Header ( ) . Set ( HxTriggerAfterSettle , "recompute" )
}
mustRenderStandaloneTemplate ( w , r , "invoices/product-form.gohtml" , form )
default :
pdf := false
if strings . HasSuffix ( slug , ".pdf" ) {
pdf = true
slug = slug [ : len ( slug ) - len ( ".pdf" ) ]
}
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 09:46:11 +00:00
if ! ValidUuid ( slug ) {
http . NotFound ( w , r )
return
}
2023-04-24 00:00:38 +00:00
inv := mustGetInvoice ( r . Context ( ) , conn , company , slug )
if inv == nil {
http . NotFound ( w , r )
return
}
if pdf {
w . Header ( ) . Set ( "Content-Type" , "application/pdf" )
mustWriteInvoicePdf ( w , r , inv )
} else {
mustRenderMainTemplate ( w , r , "invoices/view.gohtml" , inv )
}
2023-03-09 11:11:53 +00:00
}
}
func mustWriteInvoicePdf ( w io . Writer , r * http . Request , inv * invoice ) {
2023-06-15 21:16:53 +00:00
cmd := exec . Command ( "weasyprint" , "--stylesheet" , "web/static/invoice.css" , "-" , "-" )
2023-03-09 11:11:53 +00:00
var stderr bytes . Buffer
cmd . Stderr = & stderr
stdin , err := cmd . StdinPipe ( )
if err != nil {
panic ( err )
}
stdout , err := cmd . StdoutPipe ( )
if err != nil {
panic ( err )
}
defer func ( ) {
err := stdout . Close ( )
if ! errors . Is ( err , os . ErrClosed ) {
Convert invoices to PDF with WeasyPrint
Although it is possible to just print the invoice from the browser, many
people will not even try an assume that they can not create a PDF for
the invoice.
I thought of using Groff or TeX to create the PDF, but it would mean
maintaining two templates in two different systems (HTML and whatever i
would use), and would probably look very different, because i do not
know Groff or TeX that well.
I wish there was a way to tell the browser to print to PDF, and it can
be done, but only with the Chrome Protocol to a server-side running
Chrome instance. This works, but i would need a Chrome running as a
daemon.
I also wrote a Qt application that uses QWebEngine to print the PDF,
much like wkhtmltopdf, but with support for more recent HTML and CSS
standards. Unfortunately, Qt 6.4’s embedded Chromium does not follow
break-page-inside as well as WeasyPrint does.
To use WeasyPrint, at first i wanted to reach the same URL as the user,
passing the cookie to WeasyPrint so that i can access the same invoice
as the user, something that can be done with wkhtmltopdf, but WeasyPrint
does not have such option. I did it with a custom Python script, but
then i need to package and install that script, that is not that much
work, but using the Debian-provided script is even less work, and less
likely to drift when WeasyPrint changes API.
Also, it is unnecessary to do a network round-trip from Go to Python
back to Go, because i can already write the invoice HTML as is to
WeasyPrint’s stdin.
2023-02-26 16:26:09 +00:00
panic ( err )
}
2023-03-09 11:11:53 +00:00
} ( )
if err = cmd . Start ( ) ; err != nil {
panic ( err )
}
go func ( ) {
defer mustClose ( stdin )
mustRenderAppTemplate ( stdin , r , "invoices/view.gohtml" , inv )
} ( )
if _ , err = io . Copy ( w , stdout ) ; err != nil {
panic ( err )
}
if err := cmd . Wait ( ) ; err != nil {
log . Printf ( "ERR - %v\n" , stderr . String ( ) )
panic ( err )
Convert invoices to PDF with WeasyPrint
Although it is possible to just print the invoice from the browser, many
people will not even try an assume that they can not create a PDF for
the invoice.
I thought of using Groff or TeX to create the PDF, but it would mean
maintaining two templates in two different systems (HTML and whatever i
would use), and would probably look very different, because i do not
know Groff or TeX that well.
I wish there was a way to tell the browser to print to PDF, and it can
be done, but only with the Chrome Protocol to a server-side running
Chrome instance. This works, but i would need a Chrome running as a
daemon.
I also wrote a Qt application that uses QWebEngine to print the PDF,
much like wkhtmltopdf, but with support for more recent HTML and CSS
standards. Unfortunately, Qt 6.4’s embedded Chromium does not follow
break-page-inside as well as WeasyPrint does.
To use WeasyPrint, at first i wanted to reach the same URL as the user,
passing the cookie to WeasyPrint so that i can access the same invoice
as the user, something that can be done with wkhtmltopdf, but WeasyPrint
does not have such option. I did it with a custom Python script, but
then i need to package and install that script, that is not that much
work, but using the Debian-provided script is even less work, and less
likely to drift when WeasyPrint changes API.
Also, it is unnecessary to do a network round-trip from Go to Python
back to Go, because i can already write the invoice HTML as is to
WeasyPrint’s stdin.
2023-02-26 16:26:09 +00:00
}
2023-02-24 11:22:15 +00:00
}
2023-02-27 11:55:18 +00:00
func mustClose ( closer io . Closer ) {
if err := closer . Close ( ) ; err != nil {
panic ( err )
}
}
2023-02-24 11:22:15 +00:00
type invoice struct {
2023-03-05 17:50:57 +00:00
Number string
Slug string
Date time . Time
Invoicer taxDetails
Invoicee taxDetails
Notes string
PaymentInstructions string
Products [ ] * invoiceProduct
Subtotal string
Taxes [ ] [ ] string
TaxClasses [ ] string
HasDiscounts bool
Total string
LegalDisclaimer string
2023-07-12 18:06:53 +00:00
OriginalFileName string
2023-02-24 11:22:15 +00:00
}
type taxDetails struct {
Name string
VATIN string
Address string
City string
PostalCode string
Province string
Email string
Phone string
}
type invoiceProduct struct {
Name string
Description string
Price string
2023-03-01 13:08:12 +00:00
Discount int
2023-02-24 11:22:15 +00:00
Quantity int
2023-03-01 13:08:12 +00:00
Taxes map [ string ] int
Subtotal string
2023-02-24 11:22:15 +00:00
Total string
}
func mustGetInvoice ( ctx context . Context , conn * Conn , company * Company , slug string ) * invoice {
Convert invoices to PDF with WeasyPrint
Although it is possible to just print the invoice from the browser, many
people will not even try an assume that they can not create a PDF for
the invoice.
I thought of using Groff or TeX to create the PDF, but it would mean
maintaining two templates in two different systems (HTML and whatever i
would use), and would probably look very different, because i do not
know Groff or TeX that well.
I wish there was a way to tell the browser to print to PDF, and it can
be done, but only with the Chrome Protocol to a server-side running
Chrome instance. This works, but i would need a Chrome running as a
daemon.
I also wrote a Qt application that uses QWebEngine to print the PDF,
much like wkhtmltopdf, but with support for more recent HTML and CSS
standards. Unfortunately, Qt 6.4’s embedded Chromium does not follow
break-page-inside as well as WeasyPrint does.
To use WeasyPrint, at first i wanted to reach the same URL as the user,
passing the cookie to WeasyPrint so that i can access the same invoice
as the user, something that can be done with wkhtmltopdf, but WeasyPrint
does not have such option. I did it with a custom Python script, but
then i need to package and install that script, that is not that much
work, but using the Debian-provided script is even less work, and less
likely to drift when WeasyPrint changes API.
Also, it is unnecessary to do a network round-trip from Go to Python
back to Go, because i can already write the invoice HTML as is to
WeasyPrint’s stdin.
2023-02-26 16:26:09 +00:00
inv := & invoice {
Slug : slug ,
}
2023-02-24 11:22:15 +00:00
var invoiceId int
var decimalDigits int
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 19:32:48 +00:00
if notFoundErrorOrPanic ( conn . QueryRow ( ctx , `
select invoice_id
, decimal_digits
, invoice_number
, invoice_date
, notes
, instructions
, business_name
, vatin
, address
, city
, province
, postal_code
, to_price ( subtotal , decimal_digits )
, to_price ( total , decimal_digits )
2023-07-12 18:06:53 +00:00
, coalesce ( attachment . original_filename , ' ' )
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 19:32:48 +00:00
from invoice
join payment_method using ( payment_method_id )
join contact_tax_details using ( contact_id )
join invoice_amount using ( invoice_id )
join currency using ( currency_code )
2023-07-12 18:06:53 +00:00
left join invoice_attachment as attachment using ( invoice_id )
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 19:32:48 +00:00
where invoice . slug = $ 1 ` , slug ) . Scan (
& invoiceId ,
& decimalDigits ,
& inv . Number ,
& inv . Date ,
& inv . Notes ,
& inv . PaymentInstructions ,
& inv . Invoicee . Name ,
& inv . Invoicee . VATIN ,
& inv . Invoicee . Address ,
& inv . Invoicee . City ,
& inv . Invoicee . Province ,
& inv . Invoicee . PostalCode ,
& inv . Subtotal ,
2023-07-12 18:06:53 +00:00
& inv . Total ,
& inv . OriginalFileName ) ) {
2023-02-24 11:22:15 +00:00
return nil
}
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 08:39:22 +00:00
if err := conn . QueryRow ( ctx , `
select business_name
, vatin
, phone
, email
, address
, city
, province
, postal_code
, legal_disclaimer
from company
where company_id = $ 1
` , company . Id ) . Scan (
& inv . Invoicer . Name ,
& inv . Invoicer . VATIN ,
& inv . Invoicer . Phone ,
& inv . Invoicer . Email ,
& inv . Invoicer . Address ,
& inv . Invoicer . City ,
& inv . Invoicer . Province ,
& inv . Invoicer . PostalCode ,
& inv . LegalDisclaimer ) ; err != nil {
2023-02-24 11:22:15 +00:00
panic ( err )
}
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 08:39:22 +00:00
if err := conn . QueryRow ( ctx , `
select array_agg ( array [ name , to_price ( amount , $ 2 ) ] )
from invoice_tax_amount
join tax using ( tax_id )
where invoice_id = $ 1
` , invoiceId , decimalDigits ) . Scan ( & inv . Taxes ) ; err != nil {
2023-02-24 11:22:15 +00:00
panic ( err )
}
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 08:39:22 +00:00
rows := conn . MustQuery ( ctx , `
select invoice_product . name
, description
, to_price ( price , $ 2 )
, ( discount_rate * 100 ) : : integer
, quantity
, to_price ( subtotal , $ 2 )
, to_price ( total , $ 2 )
, array_agg ( array [ tax_class . name , ( tax_rate * 100 ) : : integer : : text ] ) filter ( where tax_rate is not null )
from invoice_product
join invoice_product_amount using ( invoice_product_id )
left join invoice_product_tax using ( invoice_product_id )
left join tax using ( tax_id )
left join tax_class using ( tax_class_id )
where invoice_id = $ 1
group by invoice_product_id
, invoice_product . name
, description
, discount_rate
, price
, quantity
, subtotal
, total
order by invoice_product_id
` , invoiceId , decimalDigits )
2023-02-24 11:22:15 +00:00
defer rows . Close ( )
2023-03-01 13:08:12 +00:00
taxClasses := map [ string ] bool { }
2023-02-24 11:22:15 +00:00
for rows . Next ( ) {
2023-03-01 13:08:12 +00:00
product := & invoiceProduct {
Taxes : make ( map [ string ] int ) ,
}
var taxes [ ] [ ] string
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 08:39:22 +00:00
if err := rows . Scan (
& product . Name ,
& product . Description ,
& product . Price ,
& product . Discount ,
& product . Quantity ,
& product . Subtotal ,
& product . Total ,
& taxes ) ; err != nil {
2023-02-24 11:22:15 +00:00
panic ( err )
}
2023-03-01 13:08:12 +00:00
for _ , tax := range taxes {
taxClass := tax [ 0 ]
taxClasses [ taxClass ] = true
product . Taxes [ taxClass ] , _ = strconv . Atoi ( tax [ 1 ] )
}
if product . Discount > 0 {
inv . HasDiscounts = true
}
2023-02-24 11:22:15 +00:00
inv . Products = append ( inv . Products , product )
}
2023-03-01 13:08:12 +00:00
for taxClass := range taxClasses {
inv . TaxClasses = append ( inv . TaxClasses , taxClass )
}
sort . Strings ( inv . TaxClasses )
2023-02-24 11:22:15 +00:00
if rows . Err ( ) != nil {
panic ( rows . Err ( ) )
}
return inv
2023-02-11 21:16:48 +00:00
}
2023-02-23 14:31:57 +00:00
type newInvoicePage struct {
Form * invoiceForm
Subtotal string
Taxes [ ] [ ] string
Total string
}
func newNewInvoicePage ( form * invoiceForm , r * http . Request ) * newInvoicePage {
page := & newInvoicePage {
Form : form ,
}
conn := getConn ( r )
company := mustGetCompany ( r )
err := conn . QueryRow ( r . Context ( ) , "select subtotal, taxes, total from compute_new_invoice_amount($1, $2)" , company . Id , NewInvoiceProductArray ( form . Products ) ) . Scan ( & page . Subtotal , & page . Taxes , & page . Total )
if err != nil {
panic ( err )
}
2023-05-26 11:33:49 +00:00
if len ( form . Products ) == 0 {
form . Products = append ( form . Products , newInvoiceProductForm ( 0 , company , getLocale ( r ) , mustGetTaxOptions ( r . Context ( ) , conn , company ) ) )
}
2023-02-23 14:31:57 +00:00
return page
}
2023-02-11 21:16:48 +00:00
func mustRenderNewInvoiceForm ( w http . ResponseWriter , r * http . Request , form * invoiceForm ) {
2023-02-14 11:55:19 +00:00
locale := getLocale ( r )
form . Customer . EmptyLabel = gettext ( "Select a customer to bill." , locale )
2023-02-23 14:31:57 +00:00
page := newNewInvoicePage ( form , r )
2023-04-25 13:28:55 +00:00
mustRenderMainTemplate ( w , r , "invoices/new.gohtml" , page )
2023-02-11 21:16:48 +00:00
}
2023-03-13 14:00:35 +00:00
func mustRenderNewInvoiceProductsForm ( w http . ResponseWriter , r * http . Request , action string , form * invoiceForm ) {
2023-02-12 20:06:48 +00:00
conn := getConn ( r )
company := mustGetCompany ( r )
page := newInvoiceProductsPage {
2023-03-13 14:00:35 +00:00
Action : companyURI ( company , action ) ,
2023-02-12 20:06:48 +00:00
Form : form ,
Products : mustGetProductChoices ( r . Context ( ) , conn , company ) ,
}
2023-04-25 13:28:55 +00:00
mustRenderMainTemplate ( w , r , "invoices/products.gohtml" , page )
2023-02-12 20:06:48 +00:00
}
func mustGetProductChoices ( ctx context . Context , conn * Conn , company * Company ) [ ] * productChoice {
2023-04-26 11:50:02 +00:00
rows := conn . MustQuery ( ctx , "select product.slug, product.name, to_price(price, decimal_digits) from product join company using (company_id) join currency using (currency_code) where company_id = $1 order by name" , company . Id )
2023-02-12 20:06:48 +00:00
defer rows . Close ( )
var choices [ ] * productChoice
for rows . Next ( ) {
entry := & productChoice { }
2023-04-26 11:50:02 +00:00
if err := rows . Scan ( & entry . Slug , & entry . Name , & entry . Price ) ; err != nil {
2023-02-12 20:06:48 +00:00
panic ( err )
}
choices = append ( choices , entry )
}
if rows . Err ( ) != nil {
panic ( rows . Err ( ) )
}
return choices
}
type newInvoiceProductsPage struct {
2023-03-13 14:00:35 +00:00
Action string
2023-02-12 20:06:48 +00:00
Form * invoiceForm
Products [ ] * productChoice
}
type productChoice struct {
2023-04-26 11:50:02 +00:00
Slug string
2023-02-12 20:06:48 +00:00
Name string
Price string
}
2023-02-11 21:16:48 +00:00
func HandleAddInvoice ( w http . ResponseWriter , r * http . Request , _ httprouter . Params ) {
2023-02-12 20:06:48 +00:00
locale := getLocale ( r )
conn := getConn ( r )
company := mustGetCompany ( r )
form := newInvoiceForm ( 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
}
2023-02-27 12:13:28 +00:00
if ! form . Validate ( ) {
2024-08-27 09:07:39 +00:00
w . WriteHeader ( http . StatusUnprocessableEntity )
2023-02-12 20:06:48 +00:00
mustRenderNewInvoiceForm ( w , r , form )
2023-02-27 12:13:28 +00:00
return
2023-02-12 20:06:48 +00:00
}
Remove the number field from new invoice form
Initially, this field was meant to be left almost always blank, except
for when we deleted invoiced and had to “replace” its number with a new
invoice; using the automatic numbering in this cas would not “fill in”
the missing number in the sequence.
However, we decide to not allow removing invoicer not edit their
numbers, therefore, if everything goes as planned, there should not be
any gap in the sequence, and that field is rendered useless.
Oriol suggested making it a read-only field, both for new and edit
forms, but i do not think it makes sense to have a field if you can not
edit it at all, specially in the new invoice dialog, where it would
always be blank. In the edit form we already show the number in the
title and breadcrumbs, thus no need for the read-only field as
reference.
I still keep a Number member to the form struct, but is now a string
(kind of “a read-only field”, in a way) and just to be written in the
title or breadcrumbs. I did not like the idea of adding a new SQL
query just for that value.
2023-04-01 13:57:56 +00:00
slug := conn . MustGetText ( r . Context ( ) , "" , "select add_invoice($1, $2, $3, $4, $5, $6, $7)" , company . Id , form . Date , form . Customer , form . Notes , form . PaymentMethod , form . Tags , NewInvoiceProductArray ( form . Products ) )
2023-07-12 18:06:53 +00:00
if len ( form . File . Content ) > 0 {
conn . MustQuery ( r . Context ( ) , "select attach_to_invoice($1, $2, $3, $4)" , slug , form . File . OriginalFileName , form . File . ContentType , form . File . Content )
}
2023-04-25 13:28:55 +00:00
htmxRedirect ( w , r , companyURI ( company , "/invoices/" + slug ) )
2023-02-11 21:16:48 +00:00
}
2023-03-13 14:00:35 +00:00
func HandleNewInvoiceAction ( w http . ResponseWriter , r * http . Request , params httprouter . Params ) {
switch params [ 0 ] . Value {
case "new" :
handleInvoiceAction ( w , r , "/invoices/new" , mustRenderNewInvoiceForm )
case "batch" :
HandleBatchInvoiceAction ( w , r , params )
2023-02-27 12:13:28 +00:00
default :
2023-03-13 14:00:35 +00:00
http . Error ( w , "Method Not Allowed" , http . StatusMethodNotAllowed )
2023-02-12 20:06:48 +00:00
}
2023-02-11 21:16:48 +00:00
}
2023-03-09 11:11:53 +00:00
func HandleBatchInvoiceAction ( w http . ResponseWriter , r * http . Request , _ httprouter . Params ) {
if err := r . ParseForm ( ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusBadRequest )
return
}
if err := verifyCsrfTokenValid ( r ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusForbidden )
return
}
locale := getLocale ( r )
switch r . Form . Get ( "action" ) {
case "download" :
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 11:29:36 +00:00
slugs := r . Form [ "invoice" ]
if len ( slugs ) == 0 {
http . Redirect ( w , r , companyURI ( mustGetCompany ( r ) , "/invoices" ) , http . StatusSeeOther )
return
}
2023-03-09 11:11:53 +00:00
invoices := mustWriteInvoicesPdf ( r , slugs )
w . Header ( ) . Set ( "Content-Type" , "application/zip" )
w . Header ( ) . Set ( "Content-Disposition" , fmt . Sprintf ( "attachment; filename=%s" , gettext ( "invoices.zip" , locale ) ) )
w . WriteHeader ( http . StatusOK )
if _ , err := w . Write ( invoices ) ; err != nil {
panic ( err )
}
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 11:29:36 +00:00
case "export" :
conn := getConn ( r )
company := getCompany ( r )
filters := newInvoiceFilterForm ( r . Context ( ) , conn , locale , company )
if err := filters . Parse ( r ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusBadRequest )
return
}
2024-01-26 01:29:51 +00:00
entries := mustCollectInvoiceEntries ( r . Context ( ) , conn , locale , filters )
2024-07-20 20:42:36 +00:00
vatin := mustCollectInvoiceEntriesVATIN ( r . Context ( ) , conn , entries )
2024-01-26 01:29:51 +00:00
taxes := mustCollectInvoiceEntriesTaxes ( r . Context ( ) , conn , entries )
taxColumns := mustCollectTaxColumns ( r . Context ( ) , conn , company )
2024-07-20 20:42:36 +00:00
ods := mustWriteInvoicesOds ( entries , vatin , taxes , taxColumns , locale , company )
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 11:29:36 +00:00
writeOdsResponse ( w , ods , gettext ( "invoices.ods" , locale ) )
2023-03-09 11:11:53 +00:00
default :
http . Error ( w , gettext ( "Invalid action" , locale ) , http . StatusBadRequest )
}
}
2024-01-26 01:29:51 +00:00
func mustCollectTaxColumns ( ctx context . Context , conn * Conn , company * Company ) map [ int ] string {
rows , err := conn . Query ( ctx , `
select tax_id
, name
from tax
where company_id = $ 1
` , company . Id )
if err != nil {
panic ( err )
}
defer rows . Close ( )
columns := make ( map [ int ] string )
for rows . Next ( ) {
var taxID int
var name string
err = rows . Scan ( & taxID , & name )
if err != nil {
panic ( err )
}
columns [ taxID ] = name
}
return columns
}
type taxMap map [ int ] string
func mustCollectInvoiceEntriesTaxes ( ctx context . Context , conn * Conn , entries [ ] * InvoiceEntry ) map [ int ] taxMap {
ids := mustMakeIDArray ( entries , func ( entry * InvoiceEntry ) int {
return entry . ID
} )
return mustMakeTaxMap ( ctx , conn , ids , `
select invoice_id
, tax_id
, to_price ( amount , decimal_digits )
from invoice_tax_amount
join invoice using ( invoice_id )
join currency using ( currency_code )
where invoice_id = any ( $ 1 )
` )
}
func mustMakeIDArray [ T any ] ( entries [ ] * T , id func ( entry * T ) int ) * pgtype . Int4Array {
ids := make ( [ ] int , len ( entries ) )
i := 0
for _ , entry := range entries {
ids [ i ] = id ( entry )
i ++
}
idArray := & pgtype . Int4Array { }
if err := idArray . Set ( ids ) ; err != nil {
panic ( err )
}
return idArray
}
func mustMakeTaxMap ( ctx context . Context , conn * Conn , ids * pgtype . Int4Array , sql string ) map [ int ] taxMap {
rows , err := conn . Query ( ctx , sql , ids )
if err != nil {
panic ( err )
}
defer rows . Close ( )
taxes := make ( map [ int ] taxMap )
for rows . Next ( ) {
var entryID int
var taxID int
var amount string
err := rows . Scan ( & entryID , & taxID , & amount )
if err != nil {
panic ( err )
}
entryTaxes := taxes [ entryID ]
if entryTaxes == nil {
entryTaxes = make ( taxMap )
taxes [ entryID ] = entryTaxes
}
entryTaxes [ taxID ] = amount
}
if rows . Err ( ) != nil {
panic ( rows . Err ( ) )
}
return taxes
}
2024-07-20 20:42:36 +00:00
func mustCollectInvoiceEntriesVATIN ( ctx context . Context , conn * Conn , entries [ ] * InvoiceEntry ) map [ int ] string {
ids := mustMakeIDArray ( entries , func ( entry * InvoiceEntry ) int {
return entry . ID
} )
2024-07-20 20:52:00 +00:00
return mustMakeVATINMap ( ctx , conn , ids , `
2024-07-20 20:42:36 +00:00
select invoice_id
, vatin : : text
from contact_tax_details
join invoice using ( contact_id )
where invoice_id = any ( $ 1 )
2024-07-20 20:52:00 +00:00
` )
}
func mustMakeVATINMap ( ctx context . Context , conn * Conn , ids * pgtype . Int4Array , sql string ) map [ int ] string {
2024-07-20 20:42:36 +00:00
rows , err := conn . Query ( ctx , sql , ids )
if err != nil {
panic ( err )
}
defer rows . Close ( )
vatin := make ( map [ int ] string )
for rows . Next ( ) {
var entryID int
var number string
err := rows . Scan ( & entryID , & number )
if err != nil {
panic ( err )
}
vatin [ entryID ] = number
}
if rows . Err ( ) != nil {
panic ( rows . Err ( ) )
}
return vatin
}
2023-03-09 11:11:53 +00:00
func mustWriteInvoicesPdf ( r * http . Request , slugs [ ] string ) [ ] byte {
conn := getConn ( r )
company := mustGetCompany ( r )
buf := new ( bytes . Buffer )
w := zip . NewWriter ( buf )
for _ , slug := range slugs {
inv := mustGetInvoice ( r . Context ( ) , conn , company , slug )
if inv == nil {
continue
}
2023-07-07 09:32:59 +00:00
f , err := w . Create ( fmt . Sprintf ( "%s-%s.pdf" , inv . Number , slugify ( inv . Invoicee . Name ) ) )
2023-03-09 11:11:53 +00:00
if err != nil {
panic ( err )
}
mustWriteInvoicePdf ( f , r , inv )
}
mustClose ( w )
return buf . Bytes ( )
}
2023-02-11 21:16:48 +00:00
type invoiceForm struct {
2023-05-28 22:01:11 +00:00
locale * Locale
company * Company
Number string
Customer * SelectField
Date * InputField
Notes * InputField
PaymentMethod * SelectField
Tags * TagsField
Products [ ] * invoiceProductForm
RemovedProduct * invoiceProductForm
2023-07-12 18:06:53 +00:00
File * FileField
2023-02-11 21:16:48 +00:00
}
func newInvoiceForm ( ctx context . Context , conn * Conn , locale * Locale , company * Company ) * invoiceForm {
return & invoiceForm {
locale : locale ,
company : company ,
Customer : & SelectField {
Name : "customer" ,
2023-06-20 09:37:02 +00:00
Label : pgettext ( "input" , "Customer" , locale ) ,
2023-02-11 21:16:48 +00:00
Required : true ,
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 19:32:48 +00:00
Options : mustGetCustomerOptions ( ctx , conn , company ) ,
2023-02-11 21:16:48 +00:00
} ,
Date : & InputField {
Name : "date" ,
Label : pgettext ( "input" , "Invoice Date" , locale ) ,
Type : "date" ,
Required : true ,
} ,
Notes : & InputField {
2023-03-04 21:15:52 +00:00
Name : "notes" ,
2023-02-11 21:16:48 +00:00
Label : pgettext ( "input" , "Notes" , locale ) ,
Type : "textarea" ,
} ,
Add tags for contacts too
With Oriol we agreed that contacts should have tags, too, and that the
“tag pool”, as it were, should be shared with the one for invoices (and
all future tags we might add).
I added the contact_tag relation and tag_contact function, just like
with invoices, and then realized that the SQL queries that Go had to
execute were becoming “complex” enough: i had to get not only the slug,
but the contact id to call tag_contact, and all inside a transaction.
Therefore, i opted to create the add_contact and edit_contact functions,
that mirror those for invoice and products, so now each “major” section
has these functions. They also simplified a bit the handling of the
VATIN and phone numbers, because it is now encapsuled inside the
PL/pgSQL function and Go does not know how to assemble the parts.
2023-03-26 00:32:53 +00:00
Tags : & TagsField {
2023-03-10 13:02:55 +00:00
Name : "tags" ,
Label : pgettext ( "input" , "Tags" , locale ) ,
} ,
2023-03-04 21:15:52 +00:00
PaymentMethod : & SelectField {
Name : "payment_method" ,
Required : true ,
2024-09-06 10:13:27 +00:00
Label : pgettext ( "input" , "Invoicing Method" , locale ) ,
2023-06-07 14:35:31 +00:00
Selected : [ ] string { mustGetDefaultPaymentMethod ( ctx , conn , company ) } ,
Options : mustGetPaymentMethodOptions ( ctx , conn , company ) ,
2023-02-11 21:16:48 +00:00
} ,
2023-07-12 18:06:53 +00:00
File : & FileField {
Name : "file" ,
Label : pgettext ( "input" , "File" , locale ) ,
MaxSize : 1 << 20 ,
} ,
2023-02-11 21:16:48 +00:00
}
}
2023-07-11 13:26:23 +00:00
func mustGetInvoiceStatusOptions ( ctx context . Context , conn * Conn , locale * Locale ) [ ] * SelectOption {
return MustGetOptions ( ctx , conn , `
select invoice_status . invoice_status
, isi18n . name
from invoice_status
join invoice_status_i18n isi18n using ( invoice_status )
where isi18n . lang_tag = $ 1
order by invoice_status ` , locale . Language . String ( ) )
}
2023-02-12 20:06:48 +00:00
func ( form * invoiceForm ) Parse ( r * http . Request ) error {
2023-07-12 18:06:53 +00:00
if err := r . ParseMultipartForm ( form . File . MaxSize ) ; err != nil {
2023-02-12 20:06:48 +00:00
return err
}
form . Customer . FillValue ( r )
form . Date . FillValue ( r )
form . Notes . FillValue ( r )
2023-03-10 13:02:55 +00:00
form . Tags . FillValue ( r )
2023-03-08 10:19:59 +00:00
form . PaymentMethod . FillValue ( r )
2023-07-12 18:06:53 +00:00
if err := form . File . FillValue ( r ) ; err != nil {
return err
}
2023-02-13 09:32:26 +00:00
if _ , ok := r . Form [ "product.id.0" ] ; ok {
2023-03-04 21:15:52 +00:00
taxOptions := mustGetTaxOptions ( r . Context ( ) , getConn ( r ) , form . company )
2023-02-13 09:32:26 +00:00
for index := 0 ; true ; index ++ {
if _ , ok := r . Form [ "product.id." + strconv . Itoa ( index ) ] ; ! ok {
break
}
2023-03-04 21:15:52 +00:00
productForm := newInvoiceProductForm ( index , form . company , form . locale , taxOptions )
2023-02-12 20:06:48 +00:00
if err := productForm . Parse ( r ) ; err != nil {
return err
}
form . Products = append ( form . Products , productForm )
}
}
return nil
}
func ( form * invoiceForm ) Validate ( ) bool {
validator := newFormValidator ( )
2023-03-13 14:00:35 +00:00
validator . CheckValidSelectOption ( form . Customer , gettext ( "Selected customer is not valid." , form . locale ) )
2023-02-12 20:06:48 +00:00
if validator . CheckRequiredInput ( form . Date , gettext ( "Invoice date can not be empty." , form . locale ) ) {
validator . CheckValidDate ( form . Date , gettext ( "Invoice date must be a valid date." , form . locale ) )
}
2024-09-06 10:13:27 +00:00
validator . CheckValidSelectOption ( form . PaymentMethod , gettext ( "Selected invoicing method is not valid." , form . locale ) )
2023-02-12 20:06:48 +00:00
allOK := validator . AllOK ( )
for _ , product := range form . Products {
allOK = product . Validate ( ) && allOK
}
return allOK
}
func ( form * invoiceForm ) Update ( ) {
products := form . Products
form . Products = nil
2023-02-21 12:57:40 +00:00
for n , product := range products {
2023-02-12 20:06:48 +00:00
if product . Quantity . Val != "0" {
2023-04-24 18:40:10 +00:00
product . Update ( )
2023-02-21 12:57:40 +00:00
if n != len ( form . Products ) {
2023-04-24 00:00:38 +00:00
product . Index = len ( form . Products )
product . Rename ( )
2023-02-21 12:57:40 +00:00
}
2023-02-12 20:06:48 +00:00
form . Products = append ( form . Products , product )
}
}
}
2023-05-28 22:01:11 +00:00
func ( form * invoiceForm ) RemoveProduct ( index int ) {
products := form . Products
form . Products = nil
for n , product := range products {
if n == index {
form . RemovedProduct = product
} else {
if n != len ( form . Products ) {
product . Index = len ( form . Products )
product . Rename ( )
}
form . Products = append ( form . Products , product )
}
}
if form . RemovedProduct != nil {
form . RemovedProduct . RenameWithSuffix ( removedProductSuffix )
}
}
2023-04-26 11:50:02 +00:00
const selectProductBySlug = `
select ' '
, product_id
, name
, description
, to_price ( price , decimal_digits )
, 1 as quantity
, 0 as discount
, array_remove ( array_agg ( tax_id ) , null )
from product
join company using ( company_id )
join currency using ( currency_code )
left join product_tax using ( product_id )
where product . slug = any ( $ 1 )
group by product_id
, name
, description
, price
, decimal_digits
`
func ( form * invoiceForm ) AddProducts ( ctx context . Context , conn * Conn , productsSlug [ ] string ) {
form . mustAddProductsFromQuery ( ctx , conn , selectProductBySlug , productsSlug )
2023-03-08 10:26:02 +00:00
}
func ( form * invoiceForm ) mustAddProductsFromQuery ( ctx context . Context , conn * Conn , sql string , args ... interface { } ) {
2023-02-27 12:13:28 +00:00
index := len ( form . Products )
2023-03-05 17:43:22 +00:00
taxOptions := mustGetTaxOptions ( ctx , conn , form . company )
2023-03-08 10:26:02 +00:00
rows := conn . MustQuery ( ctx , sql , args ... )
2023-02-27 12:13:28 +00:00
defer rows . Close ( )
for rows . Next ( ) {
2023-03-04 21:15:52 +00:00
product := newInvoiceProductForm ( index , form . company , form . locale , taxOptions )
2023-03-13 14:00:35 +00:00
if err := rows . Scan ( product . InvoiceProductId , product . ProductId , product . Name , product . Description , product . Price , product . Quantity , product . Discount , product . Tax ) ; err != nil {
2023-02-27 12:13:28 +00:00
panic ( err )
}
form . Products = append ( form . Products , product )
index ++
}
if rows . Err ( ) != nil {
panic ( rows . Err ( ) )
}
}
2023-05-28 22:01:11 +00:00
func ( form * invoiceForm ) InsertProduct ( product * invoiceProductForm ) {
replaced := false
for n , existing := range form . Products {
if existing . Quantity . Val == "" || existing . Quantity . Val == "0" {
product . Index = n
form . Products [ n ] = product
replaced = true
break
}
}
if ! replaced {
product . Index = len ( form . Products )
form . Products = append ( form . Products , product )
}
product . Rename ( )
}
2023-03-13 14:00:35 +00:00
func ( form * invoiceForm ) MustFillFromDatabase ( ctx context . Context , conn * Conn , slug string ) bool {
2023-03-08 10:26:02 +00:00
var invoiceId int
selectedPaymentMethod := form . PaymentMethod . Selected
form . PaymentMethod . Clear ( )
2023-03-10 13:02:55 +00:00
if notFoundErrorOrPanic ( conn . QueryRow ( ctx , `
select invoice_id
, contact_id
, invoice_number
, invoice_date
, notes
, payment_method_id
2023-05-27 19:36:10 +00:00
, tags
2023-03-10 13:02:55 +00:00
from invoice
Replace tag relations with array attributes
It all started when i wanted to try to filter invoices by multiple tags
using an “AND”, instead of “OR” as it was doing until now. But
something felt off and seemed to me that i was doing thing much more
complex than needed, all to be able to list the tags as a suggestion
in the input field—which i am not doing yet.
I found this article series[0] exploring different approaches for
tagging, which includes the one i was using, and comparing their
performance. I have not actually tested it, but it seems that i have
chosen the worst option, in both query time and storage.
I attempted to try using an array attribute to each table, which is more
or less the same they did in the articles but without using a separate
relation for tags, and i found out that all the queries were way easier
to write, and needed two joins less, so it was a no-brainer.
[0]: http://www.databasesoup.com/2015/01/tag-all-things.html
2023-04-07 19:31:35 +00:00
where slug = $ 1
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]: ac0143b2b0b772e155ef8525e147786700403578
2024-08-26 08:42:38 +00:00
` , slug ) . Scan ( & invoiceId , form . Customer , & form . Number , form . Date , form . Notes , form . PaymentMethod , form . Tags ) ) {
2023-03-08 10:26:02 +00:00
form . PaymentMethod . Selected = selectedPaymentMethod
2023-03-13 14:00:35 +00:00
return false
2023-03-08 10:26:02 +00:00
}
form . Products = [ ] * invoiceProductForm { }
Move the product_id field from invoice_product to a separate table
We are going to allow invoices with products that are not (yet) inserted
into the products table.
We always allowed to have products in invoices with a totally different
name, description, price, and whatnot, but until now we had the product
id in these invoice lines for statistics purposes.
However, Oriol raised the concern that this requires for the products
to be inserted before we can create an invoice with them, and we do not
plan to have a “create product while invoicing” feature, thus it would
mean that people would need to cancel the new invoice, create the new
product, and then start the invoice again from scratch.
The compromise is to allow products in the invoice that do not have a
product_id, meaning that at the time the invoice was created they were
not (yet) in the products table. Oriol sees this stop-invoice-create-
product issue more important than the accurate statistics of product
sales, as it will probably be only one or two units off, anyway.
I did not want to allow NULL values to the invoice product’s product_id
field, because NULL means “dunno” instead of “no product”, so i had to
split that field to a separate table that relates an invoice product
with a registered product.
2023-04-19 17:30:12 +00:00
form . mustAddProductsFromQuery ( ctx , conn , "select invoice_product_id::text, coalesce(product_id, 0), name, description, to_price(price, $2), quantity, (discount_rate * 100)::integer, array_remove(array_agg(tax_id), null) from invoice_product left join invoice_product_product using (invoice_product_id) left join invoice_product_tax using (invoice_product_id) where invoice_id = $1 group by invoice_product_id, coalesce(product_id, 0), name, description, discount_rate, price, quantity" , invoiceId , form . company . DecimalDigits )
2023-03-13 14:00:35 +00:00
return true
2023-03-08 10:26:02 +00:00
}
2023-06-10 18:46:03 +00:00
func ( form * invoiceForm ) MustFillFromQuote ( ctx context . Context , conn * Conn , slug string ) bool {
var quoteId int
selectedPaymentMethod := form . PaymentMethod . Selected
note := gettext ( "Re: quotation #%s of %s" , form . locale )
dateFormat := pgettext ( "to_char" , "MM/DD/YYYY" , form . locale )
form . PaymentMethod . Clear ( )
if notFoundErrorOrPanic ( conn . QueryRow ( ctx , `
select quote_id
, coalesce ( contact_id : : text , ' ' )
, ( case when length ( trim ( notes ) ) = 0 then ' ' else notes || E ' \ n \ n ' end ) || format ( $ 2 , quote_number , to_char ( quote_date , $ 3 ) )
, coalesce ( payment_method_id : : text , $ 4 )
, tags
from quote
left join quote_contact using ( quote_id )
left join quote_payment_method using ( quote_id )
where slug = $ 1
` , slug , note , dateFormat , selectedPaymentMethod [ 0 ] ) . Scan ( & quoteId , form . Customer , form . Notes , form . PaymentMethod , form . Tags ) ) {
form . PaymentMethod . Selected = selectedPaymentMethod
return false
}
form . Products = [ ] * invoiceProductForm { }
2023-08-11 17:47:10 +00:00
form . mustAddProductsFromQuery ( ctx , conn , "select '', coalesce(product_id::text, ''), name, description, to_price(price, $2), quantity, (discount_rate * 100)::integer, array_remove(array_agg(tax_id), null) from quote_product left join quote_product_product using (quote_product_id) left join quote_product_tax using (quote_product_id) where quote_id = $1 group by quote_product_id, coalesce(product_id::text, ''), name, description, discount_rate, price, quantity" , quoteId , form . company . DecimalDigits )
2023-06-10 18:46:03 +00:00
return true
}
2023-02-12 20:06:48 +00:00
func mustGetTaxOptions ( ctx context . Context , conn * Conn , company * Company ) [ ] * SelectOption {
2023-03-01 10:40:23 +00:00
return MustGetGroupedOptions ( ctx , conn , "select tax_id::text, tax.name, tax_class.name from tax join tax_class using (tax_class_id) where tax.company_id = $1 order by tax_class.name, tax.name" , company . Id )
2023-02-12 20:06:48 +00:00
}
2023-05-03 10:40:07 +00:00
func mustGetContactOptions ( ctx context . Context , conn * Conn , company * Company ) [ ] * SelectOption {
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 19:32:48 +00:00
return MustGetOptions ( ctx , conn , "select contact_id::text, name from contact where company_id = $1 order by name" , company . Id )
}
func mustGetCustomerOptions ( ctx context . Context , conn * Conn , company * Company ) [ ] * SelectOption {
return MustGetOptions ( ctx , conn , "select contact_id::text, name from contact join contact_tax_details using (contact_id) where company_id = $1 order by name" , company . Id )
2023-05-03 10:40:07 +00:00
}
2023-06-07 14:35:31 +00:00
func mustGetDefaultPaymentMethod ( ctx context . Context , conn * Conn , company * Company ) string {
return conn . MustGetText ( ctx , "" , "select default_payment_method_id::text from company where company_id = $1" , company . Id )
}
func mustGetPaymentMethodOptions ( ctx context . Context , conn * Conn , company * Company ) [ ] * SelectOption {
return MustGetOptions ( ctx , conn , "select payment_method_id::text, name from payment_method where company_id = $1" , company . Id )
}
2023-02-12 20:06:48 +00:00
type invoiceProductForm struct {
2023-03-13 14:00:35 +00:00
locale * Locale
company * Company
2023-04-24 00:00:38 +00:00
Index int
2023-03-13 14:00:35 +00:00
InvoiceProductId * InputField
ProductId * InputField
Name * InputField
Description * InputField
Price * InputField
Quantity * InputField
Discount * InputField
Tax * SelectField
2023-02-12 20:06:48 +00:00
}
func newInvoiceProductForm ( index int , company * Company , locale * Locale , taxOptions [ ] * SelectOption ) * invoiceProductForm {
2023-04-27 22:22:28 +00:00
triggerRecompute := template . HTMLAttr ( ` data-hx-on="change: this.dispatchEvent(new CustomEvent('recompute', { bubbles: true}))" ` )
2023-02-21 12:57:40 +00:00
form := & invoiceProductForm {
2023-02-12 20:06:48 +00:00
locale : locale ,
company : company ,
2023-04-24 00:00:38 +00:00
Index : index ,
2023-03-13 14:00:35 +00:00
InvoiceProductId : & InputField {
Label : pgettext ( "input" , "Id" , locale ) ,
Type : "hidden" ,
Required : true ,
} ,
2023-02-11 21:16:48 +00:00
ProductId : & InputField {
Label : pgettext ( "input" , "Id" , locale ) ,
Type : "hidden" ,
Required : true ,
} ,
Name : & InputField {
Label : pgettext ( "input" , "Name" , locale ) ,
Type : "text" ,
Required : true ,
2023-04-24 00:00:38 +00:00
Is : "numerus-product-search" ,
Attributes : [ ] template . HTMLAttr {
` autocomplete="off" ` ,
` data-hx-trigger="keyup changed delay:200" ` ,
` data-hx-target="next .options" ` ,
` data-hx-indicator="closest div" ` ,
` data-hx-swap="innerHTML" ` ,
template . HTMLAttr ( fmt . Sprintf ( ` data-hx-get="%v" ` , companyURI ( company , "/search/products" ) ) ) ,
} ,
2023-02-11 21:16:48 +00:00
} ,
Description : & InputField {
Label : pgettext ( "input" , "Description" , locale ) ,
Type : "textarea" ,
} ,
Price : & InputField {
Label : pgettext ( "input" , "Price" , locale ) ,
Type : "number" ,
Required : true ,
Attributes : [ ] template . HTMLAttr {
2023-04-27 22:22:28 +00:00
triggerRecompute ,
2023-02-11 21:16:48 +00:00
` min="0" ` ,
template . HTMLAttr ( fmt . Sprintf ( ` step="%v" ` , company . MinCents ( ) ) ) ,
} ,
} ,
Quantity : & InputField {
Label : pgettext ( "input" , "Quantity" , locale ) ,
Type : "number" ,
Required : true ,
Attributes : [ ] template . HTMLAttr {
2023-04-27 22:22:28 +00:00
triggerRecompute ,
2023-02-11 21:16:48 +00:00
` min="0" ` ,
} ,
} ,
Discount : & InputField {
Label : pgettext ( "input" , "Discount (%)" , locale ) ,
Type : "number" ,
Required : true ,
Attributes : [ ] template . HTMLAttr {
2023-04-27 22:22:28 +00:00
triggerRecompute ,
2023-02-11 21:16:48 +00:00
` min="0" ` ,
` max="100" ` ,
} ,
} ,
Tax : & SelectField {
Label : pgettext ( "input" , "Taxes" , locale ) ,
Multiple : true ,
2023-02-12 20:06:48 +00:00
Options : taxOptions ,
2023-04-27 22:22:28 +00:00
Attributes : [ ] template . HTMLAttr {
triggerRecompute ,
} ,
2023-02-11 21:16:48 +00:00
} ,
}
2023-04-24 00:00:38 +00:00
form . Rename ( )
2023-02-21 12:57:40 +00:00
return form
}
2023-04-24 00:00:38 +00:00
func ( form * invoiceProductForm ) Rename ( ) {
2023-05-28 22:01:11 +00:00
form . RenameWithSuffix ( "." + strconv . Itoa ( form . Index ) )
}
func ( form * invoiceProductForm ) RenameWithSuffix ( suffix string ) {
2023-03-13 14:00:35 +00:00
form . InvoiceProductId . Name = "product.invoice_product_id" + suffix
2023-02-21 12:57:40 +00:00
form . ProductId . Name = "product.id" + suffix
form . Name . Name = "product.name" + suffix
form . Description . Name = "product.description" + suffix
form . Price . Name = "product.price" + suffix
form . Quantity . Name = "product.quantity" + suffix
form . Discount . Name = "product.discount" + suffix
form . Tax . Name = "product.tax" + suffix
2023-02-11 21:16:48 +00:00
}
2023-02-12 20:06:48 +00:00
func ( form * invoiceProductForm ) Parse ( r * http . Request ) error {
if err := r . ParseForm ( ) ; err != nil {
return err
}
2023-03-13 14:00:35 +00:00
form . InvoiceProductId . FillValue ( r )
2023-02-12 20:06:48 +00:00
form . ProductId . FillValue ( r )
form . Name . FillValue ( r )
form . Description . FillValue ( r )
form . Price . FillValue ( r )
form . Quantity . FillValue ( r )
form . Discount . FillValue ( r )
form . Tax . FillValue ( r )
return nil
}
func ( form * invoiceProductForm ) Validate ( ) bool {
validator := newFormValidator ( )
2023-04-24 18:40:10 +00:00
if form . InvoiceProductId . Val != "" {
2023-05-22 09:16:21 +00:00
validator . CheckValidInteger ( form . InvoiceProductId , 1 , math . MaxInt32 , gettext ( "Invoice product ID must be a number greater than zero." , form . locale ) )
}
if form . ProductId . Val != "" {
validator . CheckValidInteger ( form . ProductId , 0 , math . MaxInt32 , gettext ( "Product ID must be a positive number or zero." , form . locale ) )
2023-04-24 18:40:10 +00:00
}
2023-02-12 20:06:48 +00:00
validator . CheckRequiredInput ( form . Name , gettext ( "Name can not be empty." , form . locale ) )
if validator . CheckRequiredInput ( form . Price , gettext ( "Price can not be empty." , form . locale ) ) {
validator . CheckValidDecimal ( form . Price , form . company . MinCents ( ) , math . MaxFloat64 , gettext ( "Price must be a number greater than zero." , form . locale ) )
}
if validator . CheckRequiredInput ( form . Quantity , gettext ( "Quantity can not be empty." , form . locale ) ) {
2023-02-13 09:32:26 +00:00
validator . CheckValidInteger ( form . Quantity , 1 , math . MaxInt32 , gettext ( "Quantity must be a number greater than zero." , form . locale ) )
2023-02-12 20:06:48 +00:00
}
if validator . CheckRequiredInput ( form . Discount , gettext ( "Discount can not be empty." , form . locale ) ) {
validator . CheckValidInteger ( form . Discount , 0 , 100 , gettext ( "Discount must be a percentage between 0 and 100." , form . locale ) )
}
validator . CheckValidSelectOption ( form . Tax , gettext ( "Selected tax is not valid." , form . locale ) )
2023-03-01 10:55:26 +00:00
validator . CheckAtMostOneOfEachGroup ( form . Tax , gettext ( "You can only select a tax of each class." , form . locale ) )
2023-02-12 20:06:48 +00:00
return validator . AllOK ( )
}
2023-03-07 10:52:09 +00:00
2023-04-24 18:40:10 +00:00
func ( form * invoiceProductForm ) Update ( ) {
validator := newFormValidator ( )
if ! validator . CheckValidDecimal ( form . Price , form . company . MinCents ( ) , math . MaxFloat64 , "" ) {
form . Price . Val = "0.0"
form . Price . Errors = nil
}
if ! validator . CheckValidInteger ( form . Quantity , 0 , math . MaxInt32 , "" ) {
form . Quantity . Val = "1"
form . Quantity . Errors = nil
}
if ! validator . CheckValidInteger ( form . Discount , 0 , 100 , "" ) {
form . Discount . Val = "0"
form . Discount . Errors = nil
}
}
2023-04-24 00:00:38 +00:00
func ( form * invoiceProductForm ) MustFillFromDatabase ( ctx context . Context , conn * Conn , slug string ) bool {
2023-04-26 11:50:02 +00:00
return ! notFoundErrorOrPanic ( conn . QueryRow ( ctx , selectProductBySlug , [ ] string { slug } ) . Scan (
form . InvoiceProductId ,
2023-04-24 00:00:38 +00:00
form . ProductId ,
form . Name ,
form . Description ,
form . Price ,
form . Quantity ,
form . Discount ,
form . Tax ) )
}
2023-03-07 10:52:09 +00:00
func HandleUpdateInvoice ( w http . ResponseWriter , r * http . Request , params httprouter . Params ) {
2023-03-13 14:00:35 +00:00
locale := getLocale ( r )
2023-03-07 10:52:09 +00:00
conn := getConn ( r )
2023-03-13 14:00:35 +00:00
company := mustGetCompany ( r )
form := newInvoiceForm ( r . Context ( ) , conn , locale , company )
if err := form . Parse ( r ) ; err != nil {
2023-03-07 10:52:09 +00:00
http . Error ( w , err . Error ( ) , http . StatusBadRequest )
return
}
if err := verifyCsrfTokenValid ( r ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusForbidden )
return
}
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 09:46:11 +00:00
slug := params [ 0 ] . Value
if ! ValidUuid ( slug ) {
http . NotFound ( w , r )
return
}
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]: ac0143b2b0b772e155ef8525e147786700403578
2024-08-26 08:42:38 +00:00
if ! form . Validate ( ) {
2024-08-27 09:07:39 +00:00
w . WriteHeader ( http . StatusUnprocessableEntity )
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]: ac0143b2b0b772e155ef8525e147786700403578
2024-08-26 08:42:38 +00:00
mustRenderEditInvoiceForm ( w , r , slug , form )
return
}
slug = conn . MustGetText ( r . Context ( ) , "" , "select edit_invoice($1, $2, $3, $4, $5, $6)" , slug , form . Customer , form . Notes , form . PaymentMethod , form . Tags , EditedInvoiceProductArray ( form . Products ) )
if slug == "" {
http . NotFound ( w , r )
return
}
if len ( form . File . Content ) > 0 {
conn . MustQuery ( r . Context ( ) , "select attach_to_invoice($1, $2, $3, $4)" , slug , form . File . OriginalFileName , form . File . ContentType , form . File . Content )
2023-03-13 14:00:35 +00:00
}
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]: ac0143b2b0b772e155ef8525e147786700403578
2024-08-26 08:42:38 +00:00
htmxRedirect ( w , r , companyURI ( company , "/invoices/" + slug ) )
2023-04-06 10:20:40 +00:00
}
func htmxRedirect ( w http . ResponseWriter , r * http . Request , uri string ) {
if IsHTMxRequest ( r ) {
w . Header ( ) . Set ( HxLocation , MustMarshalHTMxLocation ( & HTMxLocation {
Path : uri ,
Target : "main" ,
} ) )
w . WriteHeader ( http . StatusNoContent )
} else {
http . Redirect ( w , r , uri , http . StatusSeeOther )
}
2023-03-13 14:00:35 +00:00
}
func ServeEditInvoice ( w http . ResponseWriter , r * http . Request , params httprouter . Params ) {
conn := getConn ( r )
company := mustGetCompany ( r )
slug := params [ 0 ] . Value
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 09:46:11 +00:00
if ! ValidUuid ( slug ) {
http . NotFound ( w , r )
return
}
2023-03-13 14:00:35 +00:00
locale := getLocale ( r )
form := newInvoiceForm ( r . Context ( ) , conn , locale , company )
if ! form . MustFillFromDatabase ( r . Context ( ) , conn , slug ) {
2023-03-07 10:52:09 +00:00
http . NotFound ( w , r )
2023-03-13 14:00:35 +00:00
return
}
w . WriteHeader ( http . StatusOK )
mustRenderEditInvoiceForm ( w , r , slug , form )
}
type editInvoicePage struct {
* newInvoicePage
Slug string
Number string
}
func newEditInvoicePage ( slug string , form * invoiceForm , r * http . Request ) * editInvoicePage {
return & editInvoicePage {
newNewInvoicePage ( form , r ) ,
slug ,
Remove the number field from new invoice form
Initially, this field was meant to be left almost always blank, except
for when we deleted invoiced and had to “replace” its number with a new
invoice; using the automatic numbering in this cas would not “fill in”
the missing number in the sequence.
However, we decide to not allow removing invoicer not edit their
numbers, therefore, if everything goes as planned, there should not be
any gap in the sequence, and that field is rendered useless.
Oriol suggested making it a read-only field, both for new and edit
forms, but i do not think it makes sense to have a field if you can not
edit it at all, specially in the new invoice dialog, where it would
always be blank. In the edit form we already show the number in the
title and breadcrumbs, thus no need for the read-only field as
reference.
I still keep a Number member to the form struct, but is now a string
(kind of “a read-only field”, in a way) and just to be written in the
title or breadcrumbs. I did not like the idea of adding a new SQL
query just for that value.
2023-04-01 13:57:56 +00:00
form . Number ,
2023-03-13 14:00:35 +00:00
}
}
func mustRenderEditInvoiceForm ( w http . ResponseWriter , r * http . Request , slug string , form * invoiceForm ) {
page := newEditInvoicePage ( slug , form , r )
2023-04-25 13:28:55 +00:00
mustRenderMainTemplate ( w , r , "invoices/edit.gohtml" , page )
2023-03-13 14:00:35 +00:00
}
func HandleEditInvoiceAction ( w http . ResponseWriter , r * http . Request , params httprouter . Params ) {
slug := params [ 0 ] . Value
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 09:46:11 +00:00
if ! ValidUuid ( slug ) {
http . NotFound ( w , r )
return
}
2023-03-13 14:00:35 +00:00
actionUri := fmt . Sprintf ( "/invoices/%s/edit" , slug )
handleInvoiceAction ( w , r , actionUri , func ( w http . ResponseWriter , r * http . Request , form * invoiceForm ) {
2023-05-26 12:02:39 +00:00
conn := getConn ( r )
form . Number = conn . MustGetText ( r . Context ( ) , "" , "select invoice_number from invoice where slug = $1" , slug )
2023-03-13 14:00:35 +00:00
mustRenderEditInvoiceForm ( w , r , slug , form )
} )
}
2023-06-07 14:35:31 +00:00
type renderInvoiceFormFunc func ( w http . ResponseWriter , r * http . Request , form * invoiceForm )
2023-03-13 14:00:35 +00:00
2023-06-07 14:35:31 +00:00
func handleInvoiceAction ( w http . ResponseWriter , r * http . Request , action string , renderForm renderInvoiceFormFunc ) {
2023-03-13 14:00:35 +00:00
locale := getLocale ( r )
conn := getConn ( r )
company := mustGetCompany ( r )
form := newInvoiceForm ( 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
}
2023-05-28 22:01:11 +00:00
actionField := r . Form . Get ( "action" )
switch actionField {
2023-03-13 14:00:35 +00:00
case "update" :
form . Update ( )
w . WriteHeader ( http . StatusOK )
renderForm ( w , r , form )
case "select-products" :
w . WriteHeader ( http . StatusOK )
mustRenderNewInvoiceProductsForm ( w , r , action , form )
case "add-products" :
2023-04-26 11:50:02 +00:00
form . AddProducts ( r . Context ( ) , conn , r . Form [ "slug" ] )
2023-03-13 14:00:35 +00:00
w . WriteHeader ( http . StatusOK )
renderForm ( w , r , form )
2023-05-28 22:01:11 +00:00
case "restore-product" :
restoredProduct := newInvoiceProductForm ( 0 , company , locale , mustGetTaxOptions ( r . Context ( ) , conn , company ) )
restoredProduct . RenameWithSuffix ( removedProductSuffix )
if err := restoredProduct . Parse ( r ) ; err != nil {
panic ( err )
}
form . InsertProduct ( restoredProduct )
form . Update ( )
w . WriteHeader ( http . StatusOK )
renderForm ( w , r , form )
2023-03-13 14:00:35 +00:00
default :
2023-05-28 22:01:11 +00:00
prefix := "remove-product."
if strings . HasPrefix ( actionField , prefix ) {
index , err := strconv . Atoi ( actionField [ len ( prefix ) : ] )
if err != nil {
http . Error ( w , gettext ( "Invalid action" , locale ) , http . StatusBadRequest )
} else {
form . RemoveProduct ( index )
form . Update ( )
w . WriteHeader ( http . StatusOK )
renderForm ( w , r , form )
}
} else {
http . Error ( w , gettext ( "Invalid action" , locale ) , http . StatusBadRequest )
}
2023-03-07 10:52:09 +00:00
}
}
Allow editing invoice tags inline from the index table
I use the same pattern as HTMx’s “Click to Edit” example[0], except that
my edit form is triggered by submit and by focus out of the tags input.
I could not, however, use the standard focus out event because it would
also trigger when removing a tag with the mouse, as for a moment the
remove button has the focus and the search input dispatches a bubbling
focusout. I had to resort to a custom event for that, but i am not
happy with it.
The autofocus attribute seems to do nothing in this case, so i need to
manually change the focus to the new input with JavaScript. However,
this means that i can not use the same input ID for all the forms
because getElementById would always return the first in document order,
changing the focus to that same element and automatically submit the
form due to focus out. That’s why in this form i append the invoice’s
slug to the input’s ID.
Finally, this is the first time i am using an HTMx-only solution and i
needed a way to return back just the HTML for the <td>, without <title>,
breadcrumbs, or <dialog>. In principle, the template would be the
“layout”, but then i would need to modify everything to check whether
the template file is empty, or something to that effect, so instead i
created a “standalone” template for these cases.
[0]: https://htmx.org/examples/click-to-edit/
2023-04-11 08:46:27 +00:00
func ServeEditInvoiceTags ( w http . ResponseWriter , r * http . Request , params httprouter . Params ) {
2024-08-15 02:18:18 +00:00
serveTagsEditForm ( w , r , params , "/invoices/" , "select tags from invoice where slug = $1" )
Allow editing invoice tags inline from the index table
I use the same pattern as HTMx’s “Click to Edit” example[0], except that
my edit form is triggered by submit and by focus out of the tags input.
I could not, however, use the standard focus out event because it would
also trigger when removing a tag with the mouse, as for a moment the
remove button has the focus and the search input dispatches a bubbling
focusout. I had to resort to a custom event for that, but i am not
happy with it.
The autofocus attribute seems to do nothing in this case, so i need to
manually change the focus to the new input with JavaScript. However,
this means that i can not use the same input ID for all the forms
because getElementById would always return the first in document order,
changing the focus to that same element and automatically submit the
form due to focus out. That’s why in this form i append the invoice’s
slug to the input’s ID.
Finally, this is the first time i am using an HTMx-only solution and i
needed a way to return back just the HTML for the <td>, without <title>,
breadcrumbs, or <dialog>. In principle, the template would be the
“layout”, but then i would need to modify everything to check whether
the template file is empty, or something to that effect, so instead i
created a “standalone” template for these cases.
[0]: https://htmx.org/examples/click-to-edit/
2023-04-11 08:46:27 +00:00
}
func HandleUpdateInvoiceTags ( w http . ResponseWriter , r * http . Request , params httprouter . Params ) {
2024-08-15 02:18:18 +00:00
handleUpdateTags ( w , r , params , "/invoices/" , "update invoice set tags = $1 where slug = $2 returning slug" )
Allow editing invoice tags inline from the index table
I use the same pattern as HTMx’s “Click to Edit” example[0], except that
my edit form is triggered by submit and by focus out of the tags input.
I could not, however, use the standard focus out event because it would
also trigger when removing a tag with the mouse, as for a moment the
remove button has the focus and the search input dispatches a bubbling
focusout. I had to resort to a custom event for that, but i am not
happy with it.
The autofocus attribute seems to do nothing in this case, so i need to
manually change the focus to the new input with JavaScript. However,
this means that i can not use the same input ID for all the forms
because getElementById would always return the first in document order,
changing the focus to that same element and automatically submit the
form due to focus out. That’s why in this form i append the invoice’s
slug to the input’s ID.
Finally, this is the first time i am using an HTMx-only solution and i
needed a way to return back just the HTML for the <td>, without <title>,
breadcrumbs, or <dialog>. In principle, the template would be the
“layout”, but then i would need to modify everything to check whether
the template file is empty, or something to that effect, so instead i
created a “standalone” template for these cases.
[0]: https://htmx.org/examples/click-to-edit/
2023-04-11 08:46:27 +00:00
}
2023-07-12 18:06:53 +00:00
func ServeInvoiceAttachment ( w http . ResponseWriter , r * http . Request , params httprouter . Params ) {
2024-08-11 22:07:30 +00:00
serveAttachment ( w , r , params , `
2023-07-12 18:06:53 +00:00
select mime_type
, content
from invoice
join invoice_attachment using ( invoice_id )
where slug = $ 1
2024-08-11 22:07:30 +00:00
` )
2023-07-12 18:06:53 +00:00
}