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/
985 lines
32 KiB
Go
985 lines
32 KiB
Go
package pkg
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"github.com/julienschmidt/httprouter"
|
|
"html/template"
|
|
"io"
|
|
"log"
|
|
"math"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type InvoiceEntry struct {
|
|
Slug string
|
|
Date time.Time
|
|
Number string
|
|
Total string
|
|
CustomerName string
|
|
Tags []string
|
|
Status string
|
|
StatusLabel string
|
|
}
|
|
|
|
type InvoicesIndexPage struct {
|
|
Invoices []*InvoiceEntry
|
|
Filters *invoiceFilterForm
|
|
InvoiceStatuses map[string]string
|
|
}
|
|
|
|
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, company, locale, filters),
|
|
Filters: filters,
|
|
InvoiceStatuses: mustCollectInvoiceStatuses(r.Context(), conn, locale),
|
|
}
|
|
mustRenderMainTemplate(w, r, "invoices/index.gohtml", page)
|
|
}
|
|
|
|
func mustCollectInvoiceEntries(ctx context.Context, conn *Conn, company *Company, locale *Locale, filters *invoiceFilterForm) []*InvoiceEntry {
|
|
args := []interface{}{locale.Language.String(), company.Id}
|
|
where := []string{"invoice.company_id = $2"}
|
|
appendWhere := func(expression string, value interface{}) {
|
|
args = append(args, value)
|
|
where = append(where, fmt.Sprintf(expression, len(args)))
|
|
}
|
|
maybeAppendWhere := func(expression string, value string, conv func(string) interface{}) {
|
|
if value != "" {
|
|
if conv == nil {
|
|
appendWhere(expression, value)
|
|
} else {
|
|
appendWhere(expression, conv(value))
|
|
}
|
|
}
|
|
}
|
|
maybeAppendWhere("contact_id = $%d", filters.Customer.String(), func(v string) interface{} {
|
|
customerId, _ := strconv.Atoi(filters.Customer.Selected[0])
|
|
return customerId
|
|
})
|
|
maybeAppendWhere("invoice.invoice_status = $%d", filters.InvoiceStatus.String(), nil)
|
|
maybeAppendWhere("invoice_number = $%d", filters.InvoiceNumber.String(), nil)
|
|
maybeAppendWhere("invoice_date >= $%d", filters.FromDate.String(), nil)
|
|
maybeAppendWhere("invoice_date <= $%d", filters.ToDate.String(), nil)
|
|
if len(filters.Tags.Tags) > 0 {
|
|
appendWhere("invoice.tags && $%d", filters.Tags)
|
|
}
|
|
rows := conn.MustQuery(ctx, fmt.Sprintf(`
|
|
select invoice.slug
|
|
, invoice_date
|
|
, invoice_number
|
|
, contact.business_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
|
|
`, strings.Join(where, ") AND (")), args...)
|
|
defer rows.Close()
|
|
|
|
var entries []*InvoiceEntry
|
|
for rows.Next() {
|
|
entry := &InvoiceEntry{}
|
|
if err := rows.Scan(&entry.Slug, &entry.Date, &entry.Number, &entry.CustomerName, &entry.Tags, &entry.Status, &entry.StatusLabel, &entry.Total); err != nil {
|
|
panic(err)
|
|
}
|
|
entries = append(entries, entry)
|
|
}
|
|
if rows.Err() != nil {
|
|
panic(rows.Err())
|
|
}
|
|
|
|
return entries
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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: MustGetOptions(ctx, conn, "select contact_id::text, business_name from contact where company_id = $1 order by business_name", company.Id),
|
|
},
|
|
InvoiceStatus: &SelectField{
|
|
Name: "invoice_status",
|
|
Label: pgettext("input", "Invoice Status", locale),
|
|
EmptyLabel: gettext("All status", locale),
|
|
Options: MustGetOptions(ctx, conn, "select invoice_status.invoice_status, isi18n.name from invoice_status join invoice_status_i18n isi18n using(invoice_status) where isi18n.lang_tag = $1 order by invoice_status", locale.Language.String()),
|
|
},
|
|
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),
|
|
},
|
|
}
|
|
}
|
|
|
|
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)
|
|
return nil
|
|
}
|
|
|
|
func ServeInvoice(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
|
conn := getConn(r)
|
|
company := mustGetCompany(r)
|
|
slug := params[0].Value
|
|
if slug == "new" {
|
|
locale := getLocale(r)
|
|
form := newInvoiceForm(r.Context(), conn, locale, company)
|
|
if invoiceToDuplicate := r.URL.Query().Get("duplicate"); invoiceToDuplicate != "" {
|
|
form.MustFillFromDatabase(r.Context(), conn, invoiceToDuplicate)
|
|
form.InvoiceStatus.Selected = []string{"created"}
|
|
form.Location.Val = r.URL.Query().Get("location")
|
|
}
|
|
form.Date.Val = time.Now().Format("2006-01-02")
|
|
w.WriteHeader(http.StatusOK)
|
|
mustRenderNewInvoiceForm(w, r, form)
|
|
return
|
|
}
|
|
|
|
pdf := false
|
|
if strings.HasSuffix(slug, ".pdf") {
|
|
pdf = true
|
|
slug = slug[:len(slug)-len(".pdf")]
|
|
}
|
|
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", "--format", "pdf", "--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) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
func mustClose(closer io.Closer) {
|
|
if err := closer.Close(); err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
type invoice struct {
|
|
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
|
|
}
|
|
|
|
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 {
|
|
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, instructions, business_name, vatin, phone, email, address, city, province, postal_code, to_price(subtotal, decimal_digits), to_price(total, decimal_digits) from invoice join payment_method using (payment_method_id) 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.PaymentInstructions, &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)) {
|
|
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.name, description, discount_rate, price, quantity, subtotal, total", 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
|
|
}
|
|
|
|
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)
|
|
}
|
|
return page
|
|
}
|
|
|
|
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)
|
|
mustRenderModalTemplate(w, r, "invoices/new.gohtml", page)
|
|
}
|
|
|
|
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),
|
|
}
|
|
mustRenderModalTemplate(w, r, "invoices/products.gohtml", page)
|
|
}
|
|
|
|
func mustGetProductChoices(ctx context.Context, conn *Conn, company *Company) []*productChoice {
|
|
rows := conn.MustQuery(ctx, "select product.product_id, 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.Id, &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 {
|
|
Id int
|
|
Name string
|
|
Price string
|
|
}
|
|
|
|
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() {
|
|
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))
|
|
closeModalAndRedirect(w, r, form.Location.Val, "/invoices/"+slug, "/invoices")
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
slugs := r.Form["invoice"]
|
|
if len(slugs) == 0 {
|
|
http.Redirect(w, r, companyURI(mustGetCompany(r), "/invoices"), http.StatusSeeOther)
|
|
return
|
|
}
|
|
locale := getLocale(r)
|
|
switch r.Form.Get("action") {
|
|
case "download":
|
|
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)
|
|
}
|
|
default:
|
|
http.Error(w, gettext("Invalid action", locale), http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
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(inv.Number + ".pdf")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
mustWriteInvoicePdf(f, r, inv)
|
|
}
|
|
mustClose(w)
|
|
return buf.Bytes()
|
|
}
|
|
|
|
type invoiceForm struct {
|
|
locale *Locale
|
|
company *Company
|
|
Number string
|
|
Location *InputField
|
|
InvoiceStatus *SelectField
|
|
Customer *SelectField
|
|
Date *InputField
|
|
Notes *InputField
|
|
PaymentMethod *SelectField
|
|
Tags *TagsField
|
|
Products []*invoiceProductForm
|
|
}
|
|
|
|
func newInvoiceForm(ctx context.Context, conn *Conn, locale *Locale, company *Company) *invoiceForm {
|
|
return &invoiceForm{
|
|
locale: locale,
|
|
company: company,
|
|
Location: &InputField{
|
|
Name: "redirect",
|
|
Type: "hidden",
|
|
},
|
|
InvoiceStatus: &SelectField{
|
|
Name: "invoice_status",
|
|
Required: true,
|
|
Label: pgettext("input", "Invoice Status", locale),
|
|
Selected: []string{"created"},
|
|
Options: MustGetOptions(ctx, conn, "select invoice_status.invoice_status, isi18n.name from invoice_status join invoice_status_i18n isi18n using(invoice_status) where isi18n.lang_tag = $1 order by invoice_status", locale.Language.String()),
|
|
},
|
|
Customer: &SelectField{
|
|
Name: "customer",
|
|
Label: pgettext("input", "Customer", locale),
|
|
Required: true,
|
|
Options: MustGetOptions(ctx, conn, "select contact_id::text, business_name from contact where company_id = $1 order by business_name", company.Id),
|
|
},
|
|
Date: &InputField{
|
|
Name: "date",
|
|
Label: pgettext("input", "Invoice Date", locale),
|
|
Type: "date",
|
|
Required: true,
|
|
},
|
|
Notes: &InputField{
|
|
Name: "notes",
|
|
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{conn.MustGetText(ctx, "", "select default_payment_method_id::text from company where company_id = $1", company.Id)},
|
|
Options: MustGetOptions(ctx, conn, "select payment_method_id::text, name from payment_method where company_id = $1", company.Id),
|
|
},
|
|
}
|
|
}
|
|
|
|
func (form *invoiceForm) Parse(r *http.Request) error {
|
|
if err := r.ParseForm(); err != nil {
|
|
return err
|
|
}
|
|
form.Location.FillValue(r)
|
|
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 _, 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" {
|
|
if n != len(form.Products) {
|
|
product.Reindex(len(form.Products))
|
|
}
|
|
form.Products = append(form.Products, product)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (form *invoiceForm) AddProducts(ctx context.Context, conn *Conn, productsId []string) {
|
|
form.mustAddProductsFromQuery(ctx, conn, "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_id = any ($1) group by product_id, name, description, price, decimal_digits", productsId)
|
|
}
|
|
|
|
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) 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
|
|
, array_to_string(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, product_id, 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_tax using (invoice_product_id) where invoice_id = $1 group by invoice_product_id, product_id, name, description, discount_rate, price, quantity", invoiceId, 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)
|
|
}
|
|
|
|
type invoiceProductForm struct {
|
|
locale *Locale
|
|
company *Company
|
|
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 {
|
|
form := &invoiceProductForm{
|
|
locale: locale,
|
|
company: company,
|
|
InvoiceProductId: &InputField{
|
|
Label: pgettext("input", "Id", locale),
|
|
Type: "hidden",
|
|
Required: true,
|
|
},
|
|
ProductId: &InputField{
|
|
Label: pgettext("input", "Id", locale),
|
|
Type: "hidden",
|
|
Required: true,
|
|
},
|
|
Name: &InputField{
|
|
Label: pgettext("input", "Name", locale),
|
|
Type: "text",
|
|
Required: true,
|
|
},
|
|
Description: &InputField{
|
|
Label: pgettext("input", "Description", locale),
|
|
Type: "textarea",
|
|
},
|
|
Price: &InputField{
|
|
Label: pgettext("input", "Price", locale),
|
|
Type: "number",
|
|
Required: true,
|
|
Attributes: []template.HTMLAttr{
|
|
`min="0"`,
|
|
template.HTMLAttr(fmt.Sprintf(`step="%v"`, company.MinCents())),
|
|
},
|
|
},
|
|
Quantity: &InputField{
|
|
Label: pgettext("input", "Quantity", locale),
|
|
Type: "number",
|
|
Required: true,
|
|
Attributes: []template.HTMLAttr{
|
|
`min="0"`,
|
|
},
|
|
},
|
|
Discount: &InputField{
|
|
Label: pgettext("input", "Discount (%)", locale),
|
|
Type: "number",
|
|
Required: true,
|
|
Attributes: []template.HTMLAttr{
|
|
`min="0"`,
|
|
`max="100"`,
|
|
},
|
|
},
|
|
Tax: &SelectField{
|
|
Label: pgettext("input", "Taxes", locale),
|
|
Multiple: true,
|
|
Options: taxOptions,
|
|
},
|
|
}
|
|
form.Reindex(index)
|
|
return form
|
|
}
|
|
|
|
func (form *invoiceProductForm) Reindex(index int) {
|
|
suffix := "." + strconv.Itoa(index)
|
|
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
|
|
}
|
|
|
|
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()
|
|
validator.CheckRequiredInput(form.ProductId, gettext("Product ID can not be empty.", 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 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
|
|
}
|
|
if r.FormValue("quick") == "status" {
|
|
slug := conn.MustGetText(r.Context(), "", "update invoice set invoice_status = $1 where slug = $2 returning slug", form.InvoiceStatus, params[0].Value)
|
|
if slug == "" {
|
|
http.NotFound(w, r)
|
|
}
|
|
htmxRedirect(w, r, companyURI(mustGetCompany(r), "/invoices"))
|
|
} else {
|
|
slug := params[0].Value
|
|
if !form.Validate() {
|
|
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
|
|
}
|
|
closeModalAndRedirect(w, r, form.Location.Val, "/invoices/"+slug, "/invoices")
|
|
}
|
|
}
|
|
|
|
func closeModalAndRedirect(w http.ResponseWriter, r *http.Request, selector string, viewUri string, indexUri string) {
|
|
company := mustGetCompany(r)
|
|
nextUri := companyURI(company, indexUri)
|
|
if IsHTMxRequest(r) {
|
|
w.Header().Set(HxTrigger, "closeModal")
|
|
if selector == "view" {
|
|
nextUri = companyURI(company, viewUri)
|
|
}
|
|
}
|
|
htmxRedirect(w, r, nextUri)
|
|
}
|
|
|
|
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
|
|
locale := getLocale(r)
|
|
form := newInvoiceForm(r.Context(), conn, locale, company)
|
|
if !form.MustFillFromDatabase(r.Context(), conn, slug) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
form.Location.Val = r.URL.Query().Get("location")
|
|
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)
|
|
mustRenderModalTemplate(w, r, "invoices/edit.gohtml", page)
|
|
}
|
|
|
|
func HandleEditInvoiceAction(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
|
slug := params[0].Value
|
|
actionUri := fmt.Sprintf("/invoices/%s/edit", slug)
|
|
handleInvoiceAction(w, r, actionUri, func(w http.ResponseWriter, r *http.Request, form *invoiceForm) {
|
|
mustRenderEditInvoiceForm(w, r, slug, form)
|
|
})
|
|
}
|
|
|
|
type renderFormFunc func(w http.ResponseWriter, r *http.Request, form *invoiceForm)
|
|
|
|
func handleInvoiceAction(w http.ResponseWriter, r *http.Request, action string, renderForm renderFormFunc) {
|
|
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
|
|
}
|
|
switch r.Form.Get("action") {
|
|
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["id"])
|
|
w.WriteHeader(http.StatusOK)
|
|
renderForm(w, r, form)
|
|
default:
|
|
http.Error(w, gettext("Invalid action", locale), http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
type tagsForm struct {
|
|
Slug string
|
|
Tags *TagsField
|
|
}
|
|
|
|
func newTagsForm(slug string, locale *Locale) *tagsForm {
|
|
return &tagsForm{
|
|
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)
|
|
form := newTagsForm(params[0].Value, locale)
|
|
if notFoundErrorOrPanic(conn.QueryRow(r.Context(), `select array_to_string(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)
|
|
form := newTagsForm(params[0].Value, 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
|
|
}
|
|
slug := conn.MustGetText(r.Context(), "", "update invoice set tags = $1 where slug = $2 returning slug", form.Tags, form.Slug)
|
|
if slug == "" {
|
|
http.NotFound(w, r)
|
|
}
|
|
mustRenderStandaloneTemplate(w, r, "tags/view.gohtml", form)
|
|
}
|