numerus/pkg/invoices.go

1537 lines
45 KiB
Go
Raw Normal View History

2023-02-11 21:16:48 +00:00
package pkg
import (
"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"
"errors"
2023-02-11 21:16:48 +00:00
"fmt"
"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"
"math"
2023-02-11 21:16:48 +00:00
"net/http"
"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"
"sort"
"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"
)
const removedProductSuffix = ".removed"
type InvoiceEntry struct {
ID int
Slug string
Date time.Time
Number string
Total string
CustomerName string
Tags []string
Status string
StatusLabel string
}
2023-02-11 21:16:48 +00:00
type InvoicesIndexPage struct {
Invoices []*InvoiceEntry
TotalAmount string
Filters *invoiceFilterForm
InvoiceStatuses map[string]string
2023-02-11 21:16:48 +00:00
}
func IndexInvoices(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
conn := getConn(r)
locale := getLocale(r)
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
}
page := &InvoicesIndexPage{
Invoices: mustCollectInvoiceEntries(r.Context(), conn, locale, filters),
TotalAmount: mustComputeInvoicesTotalAmount(r.Context(), conn, filters),
Filters: filters,
InvoiceStatuses: mustCollectInvoiceStatuses(r.Context(), conn, locale),
}
mustRenderMainTemplate(w, r, "invoices/index.gohtml", page)
2023-02-11 21:16:48 +00:00
}
func mustCollectInvoiceEntries(ctx context.Context, conn *Conn, locale *Locale, filters *invoiceFilterForm) []*InvoiceEntry {
where, args := filters.BuildQuery([]interface{}{locale.Language.String()})
rows := conn.MustQuery(ctx, fmt.Sprintf(`
select invoice_id
, invoice.slug
, 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
, invoice.tags
, invoice.invoice_status
, isi18n.name
, to_price(total, decimal_digits)
from invoice
join contact using (contact_id)
join invoice_status_i18n isi18n on invoice.invoice_status = isi18n.invoice_status and isi18n.lang_tag = $1
join invoice_amount using (invoice_id)
join currency using (currency_code)
where (%s)
order by invoice_date desc
, invoice_number desc
`, where), args...)
defer rows.Close()
var entries []*InvoiceEntry
for rows.Next() {
entry := &InvoiceEntry{}
if err := rows.Scan(&entry.ID, &entry.Slug, &entry.Date, &entry.Number, &entry.CustomerName, &entry.Tags, &entry.Status, &entry.StatusLabel, &entry.Total); err != nil {
panic(err)
}
entries = append(entries, entry)
}
if rows.Err() != nil {
panic(rows.Err())
}
return entries
}
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...)
}
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
}
type invoiceFilterForm struct {
locale *Locale
company *Company
Customer *SelectField
InvoiceStatus *SelectField
InvoiceNumber *InputField
FromDate *InputField
ToDate *InputField
Tags *TagsField
TagsCondition *ToggleField
}
func newInvoiceFilterForm(ctx context.Context, conn *Conn, locale *Locale, company *Company) *invoiceFilterForm {
return &invoiceFilterForm{
locale: locale,
company: company,
Customer: &SelectField{
Name: "customer",
Label: pgettext("input", "Customer", locale),
EmptyLabel: gettext("All customers", locale),
Options: mustGetContactOptions(ctx, conn, company),
},
InvoiceStatus: &SelectField{
Name: "invoice_status",
Label: pgettext("input", "Invoice Status", locale),
EmptyLabel: gettext("All status", locale),
Options: mustGetInvoiceStatusOptions(ctx, conn, locale),
},
InvoiceNumber: &InputField{
Name: "number",
Label: pgettext("input", "Invoice Number", locale),
Type: "search",
},
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),
},
TagsCondition: &ToggleField{
Name: "tags_condition",
Label: pgettext("input", "Tags Condition", locale),
Selected: "and",
FirstOption: &ToggleOption{
Value: "and",
Label: pgettext("tag condition", "All", locale),
Description: gettext("Invoices must have all the specified labels.", locale),
},
SecondOption: &ToggleOption{
Value: "or",
Label: pgettext("tag condition", "Any", locale),
Description: gettext("Invoices must have at least one of the specified labels.", locale),
},
},
}
}
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)
form.TagsCondition.FillValue(r)
return nil
}
func (form *invoiceFilterForm) HasValue() bool {
return form.Customer.HasValue() ||
form.InvoiceStatus.HasValue() ||
form.InvoiceNumber.HasValue() ||
form.FromDate.HasValue() ||
form.ToDate.HasValue() ||
form.Tags.HasValue()
}
func (form *invoiceFilterForm) BuildQuery(args []interface{}) (string, []interface{}) {
var where []string
appendWhere := func(expression string, value interface{}) {
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
switch slug {
case "new":
locale := getLocale(r)
form := newInvoiceForm(r.Context(), conn, locale, company)
if invoiceToDuplicate := r.URL.Query().Get("duplicate"); ValidUuid(invoiceToDuplicate) {
form.MustFillFromDatabase(r.Context(), conn, invoiceToDuplicate)
form.InvoiceStatus.Selected = []string{"created"}
} else if quoteToInvoice := r.URL.Query().Get("quote"); ValidUuid(quoteToInvoice) {
form.MustFillFromQuote(r.Context(), conn, quoteToInvoice)
}
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)
case "product-form":
query := r.URL.Query()
index, _ := strconv.Atoi(query.Get("index"))
form := newInvoiceProductForm(index, company, getLocale(r), mustGetTaxOptions(r.Context(), conn, company))
productSlug := query.Get("slug")
if len(productSlug) > 0 {
if !form.MustFillFromDatabase(r.Context(), conn, productSlug) {
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")]
}
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
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)
}
}
}
func mustWriteInvoicePdf(w io.Writer, r *http.Request, inv *invoice) {
cmd := exec.Command("weasyprint", "--stylesheet", "web/static/invoice.css", "-", "-")
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)
}
}()
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
}
}
func mustClose(closer io.Closer) {
if err := closer.Close(); err != nil {
panic(err)
}
}
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
OriginalFileName string
}
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
Discount int
Quantity int
Taxes map[string]int
Subtotal string
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,
}
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)
, 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)
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,
&inv.Total,
&inv.OriginalFileName)) {
return nil
}
if err := conn.QueryRow(ctx, `
select business_name
, vatin
, phone
, email
, address
, city
, province
, postal_code
, legal_disclaimer
from company
where company_id = $1
`, company.Id).Scan(
&inv.Invoicer.Name,
&inv.Invoicer.VATIN,
&inv.Invoicer.Phone,
&inv.Invoicer.Email,
&inv.Invoicer.Address,
&inv.Invoicer.City,
&inv.Invoicer.Province,
&inv.Invoicer.PostalCode,
&inv.LegalDisclaimer); err != nil {
panic(err)
}
if err := conn.QueryRow(ctx, `
select array_agg(array[name, to_price(amount, $2)])
from invoice_tax_amount
join tax using (tax_id)
where invoice_id = $1
`, invoiceId, decimalDigits).Scan(&inv.Taxes); err != nil {
panic(err)
}
rows := conn.MustQuery(ctx, `
select invoice_product.name
, description
, to_price(price, $2)
, (discount_rate * 100)::integer
, quantity
, to_price(subtotal, $2)
, to_price(total, $2)
, array_agg(array[tax_class.name, (tax_rate * 100)::integer::text]) filter (where tax_rate is not null)
from invoice_product
join invoice_product_amount using (invoice_product_id)
left join invoice_product_tax using (invoice_product_id)
left join tax using (tax_id)
left join tax_class using (tax_class_id)
where invoice_id = $1
group by invoice_product_id
, invoice_product.name
, description
, discount_rate
, price
, quantity
, subtotal
, total
order by invoice_product_id
`, invoiceId, decimalDigits)
defer rows.Close()
taxClasses := map[string]bool{}
for rows.Next() {
product := &invoiceProduct{
Taxes: make(map[string]int),
}
var taxes [][]string
if err := rows.Scan(
&product.Name,
&product.Description,
&product.Price,
&product.Discount,
&product.Quantity,
&product.Subtotal,
&product.Total,
&taxes); err != nil {
panic(err)
}
for _, tax := range taxes {
taxClass := tax[0]
taxClasses[taxClass] = true
product.Taxes[taxClass], _ = strconv.Atoi(tax[1])
}
if product.Discount > 0 {
inv.HasDiscounts = true
}
inv.Products = append(inv.Products, product)
}
for taxClass := range taxClasses {
inv.TaxClasses = append(inv.TaxClasses, taxClass)
}
sort.Strings(inv.TaxClasses)
if rows.Err() != nil {
panic(rows.Err())
}
return inv
2023-02-11 21:16:48 +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)
}
if len(form.Products) == 0 {
form.Products = append(form.Products, newInvoiceProductForm(0, company, getLocale(r), mustGetTaxOptions(r.Context(), conn, company)))
}
return page
}
2023-02-11 21:16:48 +00:00
func mustRenderNewInvoiceForm(w http.ResponseWriter, r *http.Request, form *invoiceForm) {
locale := getLocale(r)
form.Customer.EmptyLabel = gettext("Select a customer to bill.", locale)
page := newNewInvoicePage(form, r)
mustRenderMainTemplate(w, r, "invoices/new.gohtml", page)
2023-02-11 21:16:48 +00:00
}
func mustRenderNewInvoiceProductsForm(w http.ResponseWriter, r *http.Request, action string, form *invoiceForm) {
conn := getConn(r)
company := mustGetCompany(r)
page := newInvoiceProductsPage{
Action: companyURI(company, action),
Form: form,
Products: mustGetProductChoices(r.Context(), conn, company),
}
mustRenderMainTemplate(w, r, "invoices/products.gohtml", page)
}
func mustGetProductChoices(ctx context.Context, conn *Conn, company *Company) []*productChoice {
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)
defer rows.Close()
var choices []*productChoice
for rows.Next() {
entry := &productChoice{}
if err := rows.Scan(&entry.Slug, &entry.Name, &entry.Price); err != nil {
panic(err)
}
choices = append(choices, entry)
}
if rows.Err() != nil {
panic(rows.Err())
}
return choices
}
type newInvoiceProductsPage struct {
Action string
Form *invoiceForm
Products []*productChoice
}
type productChoice struct {
Slug string
Name string
Price string
}
2023-02-11 21:16:48 +00:00
func HandleAddInvoice(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
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
}
if !form.Validate() {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
mustRenderNewInvoiceForm(w, r, form)
return
}
slug := conn.MustGetText(r.Context(), "", "select add_invoice($1, $2, $3, $4, $5, $6, $7)", company.Id, form.Date, form.Customer, form.Notes, form.PaymentMethod, form.Tags, NewInvoiceProductArray(form.Products))
if len(form.File.Content) > 0 {
conn.MustQuery(r.Context(), "select attach_to_invoice($1, $2, $3, $4)", slug, form.File.OriginalFileName, form.File.ContentType, form.File.Content)
}
htmxRedirect(w, r, companyURI(company, "/invoices/"+slug))
2023-02-11 21:16:48 +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)
default:
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}
2023-02-11 21:16:48 +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
}
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
}
entries := mustCollectInvoiceEntries(r.Context(), conn, locale, filters)
vatin := mustCollectInvoiceEntriesVATIN(r.Context(), conn, entries)
taxes := mustCollectInvoiceEntriesTaxes(r.Context(), conn, entries)
taxColumns := mustCollectTaxColumns(r.Context(), conn, company)
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))
default:
http.Error(w, gettext("Invalid action", locale), http.StatusBadRequest)
}
}
func mustCollectTaxColumns(ctx context.Context, conn *Conn, company *Company) map[int]string {
rows, err := conn.Query(ctx, `
select tax_id
, name
from tax
where company_id = $1
`, company.Id)
if err != nil {
panic(err)
}
defer rows.Close()
columns := make(map[int]string)
for rows.Next() {
var taxID int
var name string
err = rows.Scan(&taxID, &name)
if err != nil {
panic(err)
}
columns[taxID] = name
}
return columns
}
type taxMap map[int]string
func mustCollectInvoiceEntriesTaxes(ctx context.Context, conn *Conn, entries []*InvoiceEntry) map[int]taxMap {
ids := mustMakeIDArray(entries, func(entry *InvoiceEntry) int {
return entry.ID
})
return mustMakeTaxMap(ctx, conn, ids, `
select invoice_id
, tax_id
, to_price(amount, decimal_digits)
from invoice_tax_amount
join invoice using (invoice_id)
join currency using (currency_code)
where invoice_id = any ($1)
`)
}
func mustMakeIDArray[T any](entries []*T, id func(entry *T) int) *pgtype.Int4Array {
ids := make([]int, len(entries))
i := 0
for _, entry := range entries {
ids[i] = id(entry)
i++
}
idArray := &pgtype.Int4Array{}
if err := idArray.Set(ids); err != nil {
panic(err)
}
return idArray
}
func mustMakeTaxMap(ctx context.Context, conn *Conn, ids *pgtype.Int4Array, sql string) map[int]taxMap {
rows, err := conn.Query(ctx, sql, ids)
if err != nil {
panic(err)
}
defer rows.Close()
taxes := make(map[int]taxMap)
for rows.Next() {
var entryID int
var taxID int
var amount string
err := rows.Scan(&entryID, &taxID, &amount)
if err != nil {
panic(err)
}
entryTaxes := taxes[entryID]
if entryTaxes == nil {
entryTaxes = make(taxMap)
taxes[entryID] = entryTaxes
}
entryTaxes[taxID] = amount
}
if rows.Err() != nil {
panic(rows.Err())
}
return taxes
}
func mustCollectInvoiceEntriesVATIN(ctx context.Context, conn *Conn, entries []*InvoiceEntry) map[int]string {
ids := mustMakeIDArray(entries, func(entry *InvoiceEntry) int {
return entry.ID
})
sql := `
select invoice_id
, vatin::text
from contact_tax_details
join invoice using (contact_id)
where invoice_id = any ($1)
`
rows, err := conn.Query(ctx, sql, ids)
if err != nil {
panic(err)
}
defer rows.Close()
vatin := make(map[int]string)
for rows.Next() {
var entryID int
var number string
err := rows.Scan(&entryID, &number)
if err != nil {
panic(err)
}
vatin[entryID] = number
}
if rows.Err() != nil {
panic(rows.Err())
}
return vatin
}
func 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
}
f, err := w.Create(fmt.Sprintf("%s-%s.pdf", inv.Number, slugify(inv.Invoicee.Name)))
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 {
locale *Locale
company *Company
Number string
InvoiceStatus *SelectField
Customer *SelectField
Date *InputField
Notes *InputField
PaymentMethod *SelectField
Tags *TagsField
Products []*invoiceProductForm
RemovedProduct *invoiceProductForm
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,
InvoiceStatus: &SelectField{
Name: "invoice_status",
Required: true,
Label: pgettext("input", "Invoice Status", locale),
Selected: []string{"created"},
Options: mustGetInvoiceStatusOptions(ctx, conn, locale),
},
2023-02-11 21:16:48 +00:00
Customer: &SelectField{
Name: "customer",
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{
Name: "notes",
2023-02-11 21:16:48 +00:00
Label: pgettext("input", "Notes", locale),
Type: "textarea",
},
Tags: &TagsField{
Name: "tags",
Label: pgettext("input", "Tags", locale),
},
PaymentMethod: &SelectField{
Name: "payment_method",
Required: true,
Label: pgettext("input", "Payment Method", locale),
Selected: []string{mustGetDefaultPaymentMethod(ctx, conn, company)},
Options: mustGetPaymentMethodOptions(ctx, conn, company),
2023-02-11 21:16:48 +00:00
},
File: &FileField{
Name: "file",
Label: pgettext("input", "File", locale),
MaxSize: 1 << 20,
},
2023-02-11 21:16:48 +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())
}
func (form *invoiceForm) Parse(r *http.Request) error {
if err := r.ParseMultipartForm(form.File.MaxSize); err != nil {
return err
}
form.InvoiceStatus.FillValue(r)
form.Customer.FillValue(r)
form.Date.FillValue(r)
form.Notes.FillValue(r)
form.Tags.FillValue(r)
form.PaymentMethod.FillValue(r)
if err := form.File.FillValue(r); err != nil {
return err
}
if _, ok := r.Form["product.id.0"]; ok {
taxOptions := mustGetTaxOptions(r.Context(), getConn(r), form.company)
for index := 0; true; index++ {
if _, ok := r.Form["product.id."+strconv.Itoa(index)]; !ok {
break
}
productForm := newInvoiceProductForm(index, form.company, form.locale, taxOptions)
if err := productForm.Parse(r); err != nil {
return err
}
form.Products = append(form.Products, productForm)
}
}
return nil
}
func (form *invoiceForm) Validate() bool {
validator := newFormValidator()
validator.CheckValidSelectOption(form.InvoiceStatus, gettext("Selected invoice status is not valid.", form.locale))
validator.CheckValidSelectOption(form.Customer, gettext("Selected customer is not valid.", form.locale))
if validator.CheckRequiredInput(form.Date, gettext("Invoice date can not be empty.", form.locale)) {
validator.CheckValidDate(form.Date, gettext("Invoice date must be a valid date.", form.locale))
}
validator.CheckValidSelectOption(form.PaymentMethod, gettext("Selected payment method is not valid.", form.locale))
allOK := validator.AllOK()
for _, product := range form.Products {
allOK = product.Validate() && allOK
}
return allOK
}
func (form *invoiceForm) Update() {
products := form.Products
form.Products = nil
for n, product := range products {
if product.Quantity.Val != "0" {
product.Update()
if n != len(form.Products) {
product.Index = len(form.Products)
product.Rename()
}
form.Products = append(form.Products, product)
}
}
}
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)
}
}
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)
}
func (form *invoiceForm) mustAddProductsFromQuery(ctx context.Context, conn *Conn, sql string, args ...interface{}) {
index := len(form.Products)
taxOptions := mustGetTaxOptions(ctx, conn, form.company)
rows := conn.MustQuery(ctx, sql, args...)
defer rows.Close()
for rows.Next() {
product := newInvoiceProductForm(index, form.company, form.locale, taxOptions)
if err := rows.Scan(product.InvoiceProductId, product.ProductId, product.Name, product.Description, product.Price, product.Quantity, product.Discount, product.Tax); err != nil {
panic(err)
}
form.Products = append(form.Products, product)
index++
}
if rows.Err() != nil {
panic(rows.Err())
}
}
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()
}
func (form *invoiceForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool {
var invoiceId int
selectedInvoiceStatus := form.InvoiceStatus.Selected
form.InvoiceStatus.Clear()
selectedPaymentMethod := form.PaymentMethod.Selected
form.PaymentMethod.Clear()
if notFoundErrorOrPanic(conn.QueryRow(ctx, `
select invoice_id
, invoice_status
, contact_id
, invoice_number
, invoice_date
, notes
, payment_method_id
, tags
from invoice
where slug = $1
`, slug).Scan(&invoiceId, form.InvoiceStatus, form.Customer, &form.Number, form.Date, form.Notes, form.PaymentMethod, form.Tags)) {
form.PaymentMethod.Selected = selectedPaymentMethod
form.InvoiceStatus.Selected = selectedInvoiceStatus
return false
}
form.Products = []*invoiceProductForm{}
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)
return true
}
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{}
form.mustAddProductsFromQuery(ctx, conn, "select '', coalesce(product_id::text, ''), name, description, to_price(price, $2), quantity, (discount_rate * 100)::integer, array_remove(array_agg(tax_id), null) from quote_product left join quote_product_product using (quote_product_id) left join quote_product_tax using (quote_product_id) where quote_id = $1 group by quote_product_id, coalesce(product_id::text, ''), name, description, discount_rate, price, quantity", quoteId, form.company.DecimalDigits)
return true
}
func mustGetTaxOptions(ctx context.Context, conn *Conn, company *Company) []*SelectOption {
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)
}
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)
}
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)
}
type invoiceProductForm struct {
locale *Locale
company *Company
Index int
InvoiceProductId *InputField
ProductId *InputField
Name *InputField
Description *InputField
Price *InputField
Quantity *InputField
Discount *InputField
Tax *SelectField
}
func newInvoiceProductForm(index int, company *Company, locale *Locale, taxOptions []*SelectOption) *invoiceProductForm {
triggerRecompute := template.HTMLAttr(`data-hx-on="change: this.dispatchEvent(new CustomEvent('recompute', {bubbles: true}))"`)
form := &invoiceProductForm{
locale: locale,
company: company,
Index: index,
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,
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{
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{
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{
triggerRecompute,
2023-02-11 21:16:48 +00:00
`min="0"`,
`max="100"`,
},
},
Tax: &SelectField{
Label: pgettext("input", "Taxes", locale),
Multiple: true,
Options: taxOptions,
Attributes: []template.HTMLAttr{
triggerRecompute,
},
2023-02-11 21:16:48 +00:00
},
}
form.Rename()
return form
}
func (form *invoiceProductForm) Rename() {
form.RenameWithSuffix("." + strconv.Itoa(form.Index))
}
func (form *invoiceProductForm) RenameWithSuffix(suffix string) {
form.InvoiceProductId.Name = "product.invoice_product_id" + suffix
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
}
func (form *invoiceProductForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
form.InvoiceProductId.FillValue(r)
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()
if form.InvoiceProductId.Val != "" {
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))
}
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)) {
validator.CheckValidInteger(form.Quantity, 1, math.MaxInt32, gettext("Quantity must be a number greater than zero.", form.locale))
}
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))
validator.CheckAtMostOneOfEachGroup(form.Tax, gettext("You can only select a tax of each class.", form.locale))
return validator.AllOK()
}
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
}
}
func (form *invoiceProductForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool {
return !notFoundErrorOrPanic(conn.QueryRow(ctx, selectProductBySlug, []string{slug}).Scan(
form.InvoiceProductId,
form.ProductId,
form.Name,
form.Description,
form.Price,
form.Quantity,
form.Discount,
form.Tax))
}
func HandleUpdateInvoice(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
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
}
slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
if r.FormValue("quick") == "status" {
slug = conn.MustGetText(r.Context(), "", "update invoice set invoice_status = $1 where slug = $2 returning slug", form.InvoiceStatus, slug)
if slug == "" {
http.NotFound(w, r)
return
}
htmxRedirect(w, r, companyURI(mustGetCompany(r), "/invoices"))
} else {
if !form.Validate() {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
mustRenderEditInvoiceForm(w, r, slug, form)
return
}
slug = conn.MustGetText(r.Context(), "", "select edit_invoice($1, $2, $3, $4, $5, $6, $7)", slug, form.InvoiceStatus, form.Customer, form.Notes, form.PaymentMethod, form.Tags, EditedInvoiceProductArray(form.Products))
if slug == "" {
http.NotFound(w, r)
return
}
if len(form.File.Content) > 0 {
conn.MustQuery(r.Context(), "select attach_to_invoice($1, $2, $3, $4)", slug, form.File.OriginalFileName, form.File.ContentType, form.File.Content)
}
htmxRedirect(w, r, companyURI(company, "/invoices/"+slug))
}
}
func htmxRedirect(w http.ResponseWriter, r *http.Request, uri string) {
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)
}
}
func ServeEditInvoice(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
conn := getConn(r)
company := mustGetCompany(r)
slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
locale := getLocale(r)
form := newInvoiceForm(r.Context(), conn, locale, company)
if !form.MustFillFromDatabase(r.Context(), conn, slug) {
http.NotFound(w, r)
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,
form.Number,
}
}
func mustRenderEditInvoiceForm(w http.ResponseWriter, r *http.Request, slug string, form *invoiceForm) {
page := newEditInvoicePage(slug, form, r)
mustRenderMainTemplate(w, r, "invoices/edit.gohtml", page)
}
func HandleEditInvoiceAction(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
actionUri := fmt.Sprintf("/invoices/%s/edit", slug)
handleInvoiceAction(w, r, actionUri, func(w http.ResponseWriter, r *http.Request, form *invoiceForm) {
conn := getConn(r)
form.Number = conn.MustGetText(r.Context(), "", "select invoice_number from invoice where slug = $1", slug)
mustRenderEditInvoiceForm(w, r, slug, form)
})
}
type renderInvoiceFormFunc func(w http.ResponseWriter, r *http.Request, form *invoiceForm)
func handleInvoiceAction(w http.ResponseWriter, r *http.Request, action string, renderForm renderInvoiceFormFunc) {
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
}
actionField := r.Form.Get("action")
switch actionField {
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":
form.AddProducts(r.Context(), conn, r.Form["slug"])
w.WriteHeader(http.StatusOK)
renderForm(w, r, form)
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)
default:
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)
}
}
}
type tagsForm struct {
2023-05-08 10:58:54 +00:00
Action string
Slug string
Tags *TagsField
}
2023-05-08 10:58:54 +00:00
func newTagsForm(uri string, slug string, locale *Locale) *tagsForm {
return &tagsForm{
2023-05-08 10:58:54 +00:00
Action: uri,
Slug: slug,
Tags: &TagsField{
Name: "tags-" + slug,
Label: pgettext("input", "Tags", locale),
},
}
}
func (form *tagsForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
form.Tags.FillValue(r)
return nil
}
func ServeEditInvoiceTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
conn := getConn(r)
locale := getLocale(r)
2023-05-08 10:58:54 +00:00
company := getCompany(r)
slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
2023-05-08 10:58:54 +00:00
form := newTagsForm(companyURI(company, "/invoices/"+slug+"/tags"), slug, locale)
if notFoundErrorOrPanic(conn.QueryRow(r.Context(), `select tags from invoice where slug = $1`, form.Slug).Scan(form.Tags)) {
http.NotFound(w, r)
return
}
mustRenderStandaloneTemplate(w, r, "tags/edit.gohtml", form)
}
func HandleUpdateInvoiceTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
locale := getLocale(r)
conn := getConn(r)
2023-05-08 10:58:54 +00:00
company := getCompany(r)
slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
2023-05-08 10:58:54 +00:00
form := newTagsForm(companyURI(company, "/invoices/"+slug+"/tags/edit"), slug, locale)
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
2023-05-08 10:58:54 +00:00
if conn.MustGetText(r.Context(), "", "update invoice set tags = $1 where slug = $2 returning slug", form.Tags, form.Slug) == "" {
http.NotFound(w, r)
}
mustRenderStandaloneTemplate(w, r, "tags/view.gohtml", form)
}
func ServeInvoiceAttachment(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
conn := getConn(r)
var contentType string
var content []byte
if notFoundErrorOrPanic(conn.QueryRow(r.Context(), `
select mime_type
, content
from invoice
join invoice_attachment using (invoice_id)
where slug = $1
`, slug).Scan(&contentType, &content)) {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(content)), 10))
w.WriteHeader(http.StatusOK)
w.Write(content)
}