From 4d2379555e3547a20c6647183a5fd4f0c276af71 Mon Sep 17 00:00:00 2001
From: jordi fita mas
Date: Sun, 26 Feb 2023 17:26:09 +0100
Subject: [PATCH] Convert invoices to PDF with WeasyPrint
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
debian/control | 3 +-
pkg/invoices.go | 49 ++++++++++++++++++--
pkg/router.go | 2 +-
po/ca.po | 73 ++++++++++++++++--------------
po/es.po | 73 ++++++++++++++++--------------
web/static/numerus.css | 9 ++++
web/template/invoices/index.gohtml | 5 +-
web/template/invoices/view.gohtml | 6 +++
8 files changed, 146 insertions(+), 74 deletions(-)
diff --git a/debian/control b/debian/control
index 9f3c3fe..2ede999 100644
--- a/debian/control
+++ b/debian/control
@@ -41,7 +41,8 @@ Package: numerus
Architecture: any
Depends:
${shlibs:Depends},
- ${misc:Depends}
+ ${misc:Depends},
+ weasyprint
Built-Using: ${misc:Built-Using}
Description: Simple invoicing and accounting web application
A simple web application to keep invoice and accouting records, intended for
diff --git a/pkg/invoices.go b/pkg/invoices.go
index eb6eb3b..f68e665 100644
--- a/pkg/invoices.go
+++ b/pkg/invoices.go
@@ -1,13 +1,18 @@
package pkg
import (
+ "bytes"
"context"
"fmt"
"github.com/julienschmidt/httprouter"
"html/template"
+ "io"
+ "log"
"math"
"net/http"
+ "os/exec"
"strconv"
+ "strings"
"time"
)
@@ -52,7 +57,7 @@ func mustCollectInvoiceEntries(ctx context.Context, conn *Conn, company *Company
return entries
}
-func GetInvoiceForm(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
+func ServeInvoice(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
locale := getLocale(r)
conn := getConn(r)
company := mustGetCompany(r)
@@ -65,16 +70,52 @@ func GetInvoiceForm(w http.ResponseWriter, r *http.Request, params httprouter.Pa
return
}
+ pdf := false
+ if strings.HasSuffix(slug, ".pdf") {
+ pdf = true
+ slug = slug[:len(slug)-len(".pdf")]
+ }
invoice := mustGetInvoice(r.Context(), conn, company, slug)
if invoice == nil {
http.NotFound(w, r)
return
}
- mustRenderAppTemplate(w, r, "invoices/view.gohtml", invoice)
+ if pdf {
+ 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 stdout.Close()
+ if err = cmd.Start(); err != nil {
+ panic(err)
+ }
+ go func() {
+ defer stdin.Close()
+ mustRenderAppTemplate(stdin, r, "invoices/view.gohtml", invoice)
+ }()
+ w.Header().Set("Content-Type", "application/pdf")
+ 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)
+ }
+ } else {
+ mustRenderAppTemplate(w, r, "invoices/view.gohtml", invoice)
+ }
}
type invoice struct {
Number string
+ Slug string
Date time.Time
Invoicer taxDetails
Invoicee taxDetails
@@ -105,7 +146,9 @@ type invoiceProduct struct {
}
func mustGetInvoice(ctx context.Context, conn *Conn, company *Company, slug string) *invoice {
- inv := &invoice{}
+ inv := &invoice{
+ Slug: slug,
+ }
var invoiceId int
var decimalDigits int
if notFoundErrorOrPanic(conn.QueryRow(ctx, "select invoice_id, decimal_digits, invoice_number, invoice_date, notes, business_name, vatin, phone, email, address, city, province, postal_code, to_price(subtotal, decimal_digits), to_price(total, decimal_digits) from invoice join contact using (contact_id) join invoice_amount using (invoice_id) join currency using (currency_code) where invoice.slug = $1", slug).Scan(&invoiceId, &decimalDigits, &inv.Number, &inv.Date, &inv.Notes, &inv.Invoicee.Name, &inv.Invoicee.VATIN, &inv.Invoicee.Phone, &inv.Invoicee.Email, &inv.Invoicee.Address, &inv.Invoicee.City, &inv.Invoicee.Province, &inv.Invoicee.PostalCode, &inv.Subtotal, &inv.Total)) {
diff --git a/pkg/router.go b/pkg/router.go
index 3c56ede..eabc6d3 100644
--- a/pkg/router.go
+++ b/pkg/router.go
@@ -24,7 +24,7 @@ func NewRouter(db *Db) http.Handler {
companyRouter.PUT("/products/:slug", HandleUpdateProduct)
companyRouter.GET("/invoices", IndexInvoices)
companyRouter.POST("/invoices", HandleAddInvoice)
- companyRouter.GET("/invoices/:slug", GetInvoiceForm)
+ companyRouter.GET("/invoices/:slug", ServeInvoice)
companyRouter.POST("/invoices/new/products", HandleAddProductsToInvoice)
companyRouter.GET("/", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
mustRenderAppTemplate(w, r, "dashboard.gohtml", nil)
diff --git a/po/ca.po b/po/ca.po
index c6cc89e..194fffb 100644
--- a/po/ca.po
+++ b/po/ca.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: numerus\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n"
-"POT-Creation-Date: 2023-02-24 11:52+0100\n"
+"POT-Creation-Date: 2023-02-26 17:11+0100\n"
"PO-Revision-Date: 2023-01-18 17:08+0100\n"
"Last-Translator: jordi fita mas \n"
"Language-Team: Catalan \n"
@@ -59,7 +59,7 @@ msgid "Name"
msgstr "Nom"
#: web/template/invoices/products.gohtml:43
-#: web/template/invoices/view.gohtml:42 web/template/products/index.gohtml:23
+#: web/template/invoices/view.gohtml:59 web/template/products/index.gohtml:23
msgctxt "title"
msgid "Price"
msgstr "Preu"
@@ -74,13 +74,13 @@ msgctxt "action"
msgid "Add products"
msgstr "Afegeix productes"
-#: web/template/invoices/new.gohtml:41 web/template/invoices/view.gohtml:44
-#: web/template/invoices/view.gohtml:71
+#: web/template/invoices/new.gohtml:41 web/template/invoices/view.gohtml:61
+#: web/template/invoices/view.gohtml:87
msgctxt "title"
msgid "Subtotal"
msgstr "Subtotal"
-#: web/template/invoices/new.gohtml:51 web/template/invoices/view.gohtml:81
+#: web/template/invoices/new.gohtml:51 web/template/invoices/view.gohtml:97
msgctxt "title"
msgid "Total"
msgstr "Total"
@@ -100,7 +100,7 @@ msgctxt "invoice"
msgid "All"
msgstr "Totes"
-#: web/template/invoices/index.gohtml:22 web/template/invoices/view.gohtml:18
+#: web/template/invoices/index.gohtml:22 web/template/invoices/view.gohtml:25
msgctxt "title"
msgid "Date"
msgstr "Data"
@@ -135,21 +135,26 @@ msgctxt "title"
msgid "Download"
msgstr "Descàrrega"
-#: web/template/invoices/index.gohtml:47
+#: web/template/invoices/index.gohtml:50
msgid "No invoices added yet."
msgstr "No hi ha cap factura."
-#: web/template/invoices/view.gohtml:2 web/template/invoices/view.gohtml:17
+#: web/template/invoices/view.gohtml:2 web/template/invoices/view.gohtml:24
msgctxt "title"
msgid "Invoice %s"
msgstr "Factura %s"
-#: web/template/invoices/view.gohtml:41
+#: web/template/invoices/view.gohtml:16
+msgctxt "action"
+msgid "Download invoice"
+msgstr "Descarrega factura"
+
+#: web/template/invoices/view.gohtml:58
msgctxt "title"
msgid "Concept"
msgstr "Concepte"
-#: web/template/invoices/view.gohtml:43
+#: web/template/invoices/view.gohtml:60
msgctxt "title"
msgid "Units"
msgstr "Unitats"
@@ -364,40 +369,40 @@ msgstr "No podeu deixar la contrasenya en blanc."
msgid "Invalid user or password."
msgstr "Nom d’usuari o contrasenya incorrectes."
-#: pkg/products.go:165 pkg/invoices.go:392
+#: pkg/products.go:165 pkg/invoices.go:435
msgctxt "input"
msgid "Name"
msgstr "Nom"
-#: pkg/products.go:171 pkg/invoices.go:397
+#: pkg/products.go:171 pkg/invoices.go:440
msgctxt "input"
msgid "Description"
msgstr "Descripció"
-#: pkg/products.go:176 pkg/invoices.go:401
+#: pkg/products.go:176 pkg/invoices.go:444
msgctxt "input"
msgid "Price"
msgstr "Preu"
-#: pkg/products.go:186 pkg/invoices.go:307 pkg/invoices.go:427
+#: pkg/products.go:186 pkg/invoices.go:350 pkg/invoices.go:470
msgctxt "input"
msgid "Taxes"
msgstr "Imposts"
-#: pkg/products.go:206 pkg/profile.go:92 pkg/invoices.go:340
-#: pkg/invoices.go:463
+#: pkg/products.go:206 pkg/profile.go:92 pkg/invoices.go:383
+#: pkg/invoices.go:506
msgid "Name can not be empty."
msgstr "No podeu deixar el nom en blanc."
-#: pkg/products.go:207 pkg/invoices.go:464
+#: pkg/products.go:207 pkg/invoices.go:507
msgid "Price can not be empty."
msgstr "No podeu deixar el preu en blanc."
-#: pkg/products.go:208 pkg/invoices.go:465
+#: pkg/products.go:208 pkg/invoices.go:508
msgid "Price must be a number greater than zero."
msgstr "El preu ha de ser un número major a zero."
-#: pkg/products.go:210 pkg/invoices.go:344 pkg/invoices.go:473
+#: pkg/products.go:210 pkg/invoices.go:387 pkg/invoices.go:516
msgid "Selected tax is not valid."
msgstr "Heu seleccionat un impost que no és vàlid."
@@ -460,70 +465,70 @@ msgstr "La confirmació no és igual a la contrasenya."
msgid "Selected language is not valid."
msgstr "Heu seleccionat un idioma que no és vàlid."
-#: pkg/invoices.go:158
+#: pkg/invoices.go:201
msgid "Select a customer to bill."
msgstr "Escolliu un client a facturar."
-#: pkg/invoices.go:233
+#: pkg/invoices.go:276
msgid "Invalid action"
msgstr "Acció invàlida."
-#: pkg/invoices.go:284
+#: pkg/invoices.go:327
msgctxt "input"
msgid "Customer"
msgstr "Client"
-#: pkg/invoices.go:290
+#: pkg/invoices.go:333
msgctxt "input"
msgid "Number"
msgstr "Número"
-#: pkg/invoices.go:296
+#: pkg/invoices.go:339
msgctxt "input"
msgid "Invoice Date"
msgstr "Data de factura"
-#: pkg/invoices.go:302
+#: pkg/invoices.go:345
msgctxt "input"
msgid "Notes"
msgstr "Notes"
-#: pkg/invoices.go:341
+#: pkg/invoices.go:384
msgid "Invoice date can not be empty."
msgstr "No podeu deixar la data de la factura en blanc."
-#: pkg/invoices.go:342
+#: pkg/invoices.go:385
msgid "Invoice date must be a valid date."
msgstr "La data de facturació ha de ser vàlida."
-#: pkg/invoices.go:387
+#: pkg/invoices.go:430
msgctxt "input"
msgid "Id"
msgstr "Identificador"
-#: pkg/invoices.go:410
+#: pkg/invoices.go:453
msgctxt "input"
msgid "Quantity"
msgstr "Quantitat"
-#: pkg/invoices.go:418
+#: pkg/invoices.go:461
msgctxt "input"
msgid "Discount (%)"
msgstr "Descompte (%)"
-#: pkg/invoices.go:467
+#: pkg/invoices.go:510
msgid "Quantity can not be empty."
msgstr "No podeu deixar la quantitat en blanc."
-#: pkg/invoices.go:468
+#: pkg/invoices.go:511
msgid "Quantity must be a number greater than zero."
msgstr "La quantitat ha de ser un número major a zero."
-#: pkg/invoices.go:470
+#: pkg/invoices.go:513
msgid "Discount can not be empty."
msgstr "No podeu deixar el descompte en blanc."
-#: pkg/invoices.go:471
+#: pkg/invoices.go:514
msgid "Discount must be a percentage between 0 and 100."
msgstr "El descompte ha de ser un percentatge entre 0 i 100."
diff --git a/po/es.po b/po/es.po
index d3a2596..41577b6 100644
--- a/po/es.po
+++ b/po/es.po
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: numerus\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n"
-"POT-Creation-Date: 2023-02-24 11:52+0100\n"
+"POT-Creation-Date: 2023-02-26 17:11+0100\n"
"PO-Revision-Date: 2023-01-18 17:45+0100\n"
"Last-Translator: jordi fita mas \n"
"Language-Team: Spanish \n"
@@ -59,7 +59,7 @@ msgid "Name"
msgstr "Nombre"
#: web/template/invoices/products.gohtml:43
-#: web/template/invoices/view.gohtml:42 web/template/products/index.gohtml:23
+#: web/template/invoices/view.gohtml:59 web/template/products/index.gohtml:23
msgctxt "title"
msgid "Price"
msgstr "Precio"
@@ -74,13 +74,13 @@ msgctxt "action"
msgid "Add products"
msgstr "Añadir productos"
-#: web/template/invoices/new.gohtml:41 web/template/invoices/view.gohtml:44
-#: web/template/invoices/view.gohtml:71
+#: web/template/invoices/new.gohtml:41 web/template/invoices/view.gohtml:61
+#: web/template/invoices/view.gohtml:87
msgctxt "title"
msgid "Subtotal"
msgstr "Subtotal"
-#: web/template/invoices/new.gohtml:51 web/template/invoices/view.gohtml:81
+#: web/template/invoices/new.gohtml:51 web/template/invoices/view.gohtml:97
msgctxt "title"
msgid "Total"
msgstr "Total"
@@ -100,7 +100,7 @@ msgctxt "invoice"
msgid "All"
msgstr "Todas"
-#: web/template/invoices/index.gohtml:22 web/template/invoices/view.gohtml:18
+#: web/template/invoices/index.gohtml:22 web/template/invoices/view.gohtml:25
msgctxt "title"
msgid "Date"
msgstr "Fecha"
@@ -135,21 +135,26 @@ msgctxt "title"
msgid "Download"
msgstr "Descargar"
-#: web/template/invoices/index.gohtml:47
+#: web/template/invoices/index.gohtml:50
msgid "No invoices added yet."
msgstr "No hay facturas."
-#: web/template/invoices/view.gohtml:2 web/template/invoices/view.gohtml:17
+#: web/template/invoices/view.gohtml:2 web/template/invoices/view.gohtml:24
msgctxt "title"
msgid "Invoice %s"
msgstr "Factura %s"
-#: web/template/invoices/view.gohtml:41
+#: web/template/invoices/view.gohtml:16
+msgctxt "action"
+msgid "Download invoice"
+msgstr "Descargar factura"
+
+#: web/template/invoices/view.gohtml:58
msgctxt "title"
msgid "Concept"
msgstr "Concepto"
-#: web/template/invoices/view.gohtml:43
+#: web/template/invoices/view.gohtml:60
msgctxt "title"
msgid "Units"
msgstr "Unidades"
@@ -364,40 +369,40 @@ msgstr "No podéis dejar la contraseña en blanco."
msgid "Invalid user or password."
msgstr "Nombre de usuario o contraseña inválido."
-#: pkg/products.go:165 pkg/invoices.go:392
+#: pkg/products.go:165 pkg/invoices.go:435
msgctxt "input"
msgid "Name"
msgstr "Nombre"
-#: pkg/products.go:171 pkg/invoices.go:397
+#: pkg/products.go:171 pkg/invoices.go:440
msgctxt "input"
msgid "Description"
msgstr "Descripción"
-#: pkg/products.go:176 pkg/invoices.go:401
+#: pkg/products.go:176 pkg/invoices.go:444
msgctxt "input"
msgid "Price"
msgstr "Precio"
-#: pkg/products.go:186 pkg/invoices.go:307 pkg/invoices.go:427
+#: pkg/products.go:186 pkg/invoices.go:350 pkg/invoices.go:470
msgctxt "input"
msgid "Taxes"
msgstr "Impuestos"
-#: pkg/products.go:206 pkg/profile.go:92 pkg/invoices.go:340
-#: pkg/invoices.go:463
+#: pkg/products.go:206 pkg/profile.go:92 pkg/invoices.go:383
+#: pkg/invoices.go:506
msgid "Name can not be empty."
msgstr "No podéis dejar el nombre en blanco."
-#: pkg/products.go:207 pkg/invoices.go:464
+#: pkg/products.go:207 pkg/invoices.go:507
msgid "Price can not be empty."
msgstr "No podéis dejar el precio en blanco."
-#: pkg/products.go:208 pkg/invoices.go:465
+#: pkg/products.go:208 pkg/invoices.go:508
msgid "Price must be a number greater than zero."
msgstr "El precio tiene que ser un número mayor a cero."
-#: pkg/products.go:210 pkg/invoices.go:344 pkg/invoices.go:473
+#: pkg/products.go:210 pkg/invoices.go:387 pkg/invoices.go:516
msgid "Selected tax is not valid."
msgstr "Habéis escogido un impuesto que no es válido."
@@ -460,70 +465,70 @@ msgstr "La confirmación no corresponde con la contraseña."
msgid "Selected language is not valid."
msgstr "Habéis escogido un idioma que no es válido."
-#: pkg/invoices.go:158
+#: pkg/invoices.go:201
msgid "Select a customer to bill."
msgstr "Escoged un cliente a facturar."
-#: pkg/invoices.go:233
+#: pkg/invoices.go:276
msgid "Invalid action"
msgstr "Acción inválida."
-#: pkg/invoices.go:284
+#: pkg/invoices.go:327
msgctxt "input"
msgid "Customer"
msgstr "Cliente"
-#: pkg/invoices.go:290
+#: pkg/invoices.go:333
msgctxt "input"
msgid "Number"
msgstr "Número"
-#: pkg/invoices.go:296
+#: pkg/invoices.go:339
msgctxt "input"
msgid "Invoice Date"
msgstr "Fecha de factura"
-#: pkg/invoices.go:302
+#: pkg/invoices.go:345
msgctxt "input"
msgid "Notes"
msgstr "Notas"
-#: pkg/invoices.go:341
+#: pkg/invoices.go:384
msgid "Invoice date can not be empty."
msgstr "No podéis dejar la fecha de la factura en blanco."
-#: pkg/invoices.go:342
+#: pkg/invoices.go:385
msgid "Invoice date must be a valid date."
msgstr "La fecha de factura debe ser válida."
-#: pkg/invoices.go:387
+#: pkg/invoices.go:430
msgctxt "input"
msgid "Id"
msgstr "Identificador"
-#: pkg/invoices.go:410
+#: pkg/invoices.go:453
msgctxt "input"
msgid "Quantity"
msgstr "Cantidad"
-#: pkg/invoices.go:418
+#: pkg/invoices.go:461
msgctxt "input"
msgid "Discount (%)"
msgstr "Descuento (%)"
-#: pkg/invoices.go:467
+#: pkg/invoices.go:510
msgid "Quantity can not be empty."
msgstr "No podéis dejar la cantidad en blanco."
-#: pkg/invoices.go:468
+#: pkg/invoices.go:511
msgid "Quantity must be a number greater than zero."
msgstr "La cantidad tiene que ser un número mayor a cero."
-#: pkg/invoices.go:470
+#: pkg/invoices.go:513
msgid "Discount can not be empty."
msgstr "No podéis dejar el descuento en blanco."
-#: pkg/invoices.go:471
+#: pkg/invoices.go:514
msgid "Discount must be a percentage between 0 and 100."
msgstr "El descuento tiene que ser un percentage entre 0 y 100."
diff --git a/web/static/numerus.css b/web/static/numerus.css
index 11c07d2..233e392 100644
--- a/web/static/numerus.css
+++ b/web/static/numerus.css
@@ -508,6 +508,15 @@ main > nav {
text-align: right;
}
+.invoice-download {
+ text-align: center;
+}
+
+.invoice-download a {
+ color: inherit;
+ text-decoration: none;
+}
+
/* Remix Icon */
@font-face {
diff --git a/web/template/invoices/index.gohtml b/web/template/invoices/index.gohtml
index 2838866..986b667 100644
--- a/web/template/invoices/index.gohtml
+++ b/web/template/invoices/index.gohtml
@@ -39,7 +39,10 @@