package invoice import ( "context" "dev.tandem.ws/tandem/camper/pkg/ods" "fmt" "net/http" "sort" "strconv" "strings" "time" "github.com/jackc/pgtype" "dev.tandem.ws/tandem/camper/pkg/auth" "dev.tandem.ws/tandem/camper/pkg/database" "dev.tandem.ws/tandem/camper/pkg/form" httplib "dev.tandem.ws/tandem/camper/pkg/http" "dev.tandem.ws/tandem/camper/pkg/locale" "dev.tandem.ws/tandem/camper/pkg/template" "dev.tandem.ws/tandem/camper/pkg/uuid" ) const ( removedProductSuffix = ".removed" defaultPaymentMethod = 1 ) type AdminHandler struct { } func NewAdminHandler() *AdminHandler { return &AdminHandler{} } func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var head string head, r.URL.Path = httplib.ShiftPath(r.URL.Path) switch head { case "": switch r.Method { case http.MethodGet: serveInvoiceIndex(w, r, user, company, conn) case http.MethodPost: addInvoice(w, r, user, company, conn) default: httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost) } case "batch": switch r.Method { case http.MethodPost: handleBatchAction(w, r, user, company, conn) default: httplib.MethodNotAllowed(w, r, http.MethodPost) } case "new": switch r.Method { case http.MethodGet: f := newInvoiceForm(r.Context(), conn, company, user.Locale) if invoiceToDuplicate := r.URL.Query().Get("duplicate"); uuid.Valid(invoiceToDuplicate) { f.MustFillFromDatabase(r.Context(), conn, user.Locale, invoiceToDuplicate) f.Slug = "" f.InvoiceStatus.Selected = []string{"created"} } else if quoteToInvoice := r.URL.Query().Get("quote"); uuid.Valid(quoteToInvoice) { f.MustFillFromQuote(r.Context(), conn, user.Locale, quoteToInvoice) } f.Date.Val = time.Now().Format("2006-01-02") f.MustRender(w, r, user, company, conn) case http.MethodPost: handleInvoiceAction(w, r, user, company, conn, "", "/invoices/new") default: httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost) } case "product-form": switch r.Method { case http.MethodGet: query := r.URL.Query() index, _ := strconv.Atoi(query.Get("index")) f := newInvoiceProductForm(index, company, user.Locale, mustGetTaxOptions(r.Context(), conn, company)) productSlug := query.Get("slug") if len(productSlug) > 0 { if !f.MustFillFromDatabase(r.Context(), conn, productSlug) { http.NotFound(w, r) return } quantity, _ := strconv.Atoi(query.Get("product.quantity." + strconv.Itoa(index))) if quantity > 0 { f.Quantity.Val = strconv.Itoa(quantity) } httplib.TriggerAfterSettle(w, "recompute") } template.MustRenderAdminNoLayout(w, r, user, company, "invoice/product-form.gohtml", f) default: httplib.MethodNotAllowed(w, r, http.MethodGet) } default: h.invoiceHandler(user, company, conn, head).ServeHTTP(w, r) } }) } func (h *AdminHandler) invoiceHandler(user *auth.User, company *auth.Company, conn *database.Conn, slug string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var head string head, r.URL.Path = httplib.ShiftPath(r.URL.Path) switch head { case "": switch r.Method { case http.MethodGet: serveInvoice(w, r, user, company, conn, slug) case http.MethodPut: handleUpdateInvoice(w, r, user, company, conn, slug) default: httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut) } case "edit": switch r.Method { case http.MethodGet: serveEditInvoice(w, r, user, company, conn, slug) case http.MethodPost: handleEditInvoiceAction(w, r, user, company, conn, slug) default: httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost) } default: http.NotFound(w, r) } }) } type IndexEntry struct { ID int Slug string Date time.Time Number string Total string CustomerName string Status string StatusLabel string } func serveInvoiceIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { filters := newInvoiceFilterForm(r.Context(), conn, company, user.Locale) if err := filters.Parse(r); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } page := &invoiceIndex{ Invoices: mustCollectInvoiceEntries(r.Context(), conn, user.Locale, filters), TotalAmount: mustComputeInvoicesTotalAmount(r.Context(), conn, filters), Filters: filters, InvoiceStatuses: mustCollectInvoiceStatuses(r.Context(), conn, user.Locale), } page.MustRender(w, r, user, company) } type invoiceIndex struct { Invoices []*IndexEntry TotalAmount string Filters *invoiceFilterForm InvoiceStatuses map[string]string } func (page *invoiceIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { template.MustRenderAdmin(w, r, user, company, "invoice/index.gohtml", page) } func mustCollectInvoiceEntries(ctx context.Context, conn *database.Conn, locale *locale.Locale, filters *invoiceFilterForm) []*IndexEntry { where, args := filters.BuildQuery([]interface{}{locale.Language.String()}) rows, err := conn.Query(ctx, fmt.Sprintf(` select invoice_id , invoice.slug , invoice_date , invoice_number , contact.name , 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...) if err != nil { panic(err) } defer rows.Close() var entries []*IndexEntry for rows.Next() { entry := &IndexEntry{} if err := rows.Scan(&entry.ID, &entry.Slug, &entry.Date, &entry.Number, &entry.CustomerName, &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 *database.Conn, filters *invoiceFilterForm) string { where, args := filters.BuildQuery(nil) text, err := conn.GetText(ctx, 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...) if err != nil { if database.ErrorIsNotFound(err) { return "0.0" } panic(err) } return text } func mustCollectInvoiceStatuses(ctx context.Context, conn *database.Conn, locale *locale.Locale) map[string]string { rows, err := conn.Query(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) if err != nil { panic(err) } 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.Locale company *auth.Company Customer *form.Select InvoiceStatus *form.Select InvoiceNumber *form.Input FromDate *form.Input ToDate *form.Input } func newInvoiceFilterForm(ctx context.Context, conn *database.Conn, company *auth.Company, locale *locale.Locale) *invoiceFilterForm { return &invoiceFilterForm{ locale: locale, company: company, Customer: &form.Select{ Name: "customer", Options: mustGetContactOptions(ctx, conn, company), }, InvoiceStatus: &form.Select{ Name: "invoice_status", Options: mustGetInvoiceStatusOptions(ctx, conn, locale), }, InvoiceNumber: &form.Input{ Name: "number", }, FromDate: &form.Input{ Name: "from_date", }, ToDate: &form.Input{ Name: "to_date", }, } } func (f *invoiceFilterForm) Parse(r *http.Request) error { if err := r.ParseForm(); err != nil { return err } f.Customer.FillValue(r) f.InvoiceStatus.FillValue(r) f.InvoiceNumber.FillValue(r) f.FromDate.FillValue(r) f.ToDate.FillValue(r) return nil } func (f *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", f.company.ID) maybeAppendWhere("contact_id = $%d", f.Customer.String(), func(v string) interface{} { customerId, _ := strconv.Atoi(f.Customer.Selected[0]) return customerId }) maybeAppendWhere("invoice.invoice_status = $%d", f.InvoiceStatus.String(), nil) maybeAppendWhere("invoice_number = $%d", f.InvoiceNumber.Val, nil) maybeAppendWhere("invoice_date >= $%d", f.FromDate.Val, nil) maybeAppendWhere("invoice_date <= $%d", f.ToDate.Val, nil) return strings.Join(where, ") AND ("), args } func (f *invoiceFilterForm) HasValue() bool { return (len(f.Customer.Selected) > 0 && f.Customer.Selected[0] != "") || (len(f.InvoiceStatus.Selected) > 0 && f.InvoiceStatus.Selected[0] != "") || f.InvoiceNumber.Val != "" || f.FromDate.Val != "" || f.ToDate.Val != "" } func serveInvoice(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, slug string) { pdf := false if strings.HasSuffix(slug, ".pdf") { pdf = true slug = slug[:len(slug)-len(".pdf")] } if !uuid.Valid(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, user, company, inv) } else { template.MustRenderAdmin(w, r, user, company, "invoice/view.gohtml", inv) } } 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 *database.Conn, company *auth.Company, slug string) *invoice { inv := &invoice{ Slug: slug, } var invoiceId int var decimalDigits int if err := conn.QueryRow(ctx, ` select invoice_id , decimal_digits , invoice_number , invoice_date , notes , instructions , contact.name , id_document_number , 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.Address, &inv.Invoicee.City, &inv.Invoicee.Province, &inv.Invoicee.PostalCode, &inv.Subtotal, &inv.Total, ); err != nil { if database.ErrorIsNotFound(err) { return nil } panic(err) } 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, err := conn.Query(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) if err != nil { panic(err) } 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 } func mustRenderNewInvoiceProductsForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, action string, form *invoiceForm) { page := newInvoiceProductsPage{ Action: "/admin/" + action, Form: form, Products: mustGetProductChoices(r.Context(), conn, company), } template.MustRenderAdmin(w, r, user, company, "invoice/products.gohtml", page) } func mustGetProductChoices(ctx context.Context, conn *database.Conn, company *auth.Company) []*productChoice { rows, err := conn.Query(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) if err != nil { panic(err) } 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 } func addInvoice(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { f := newInvoiceForm(r.Context(), conn, company, user.Locale) if err := f.Parse(r, conn, user.Locale); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if err := user.VerifyCSRFToken(r); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if !f.Validate(user.Locale) { if !httplib.IsHTMxRequest(r) { w.WriteHeader(http.StatusUnprocessableEntity) } f.MustRender(w, r, user, company, conn) return } slug, err := conn.AddInvoice(r.Context(), company.ID, f.Date.Val, f.Customer.Int(), f.Notes.Val, defaultPaymentMethod, newInvoiceProducts(f.Products)) if err != nil { panic(err) } httplib.Redirect(w, r, "/admin/invoices/"+slug, http.StatusSeeOther) } func newInvoiceProducts(src []*invoiceProductForm) database.NewInvoiceProductArray { dst := make(database.NewInvoiceProductArray, 0, len(src)) for _, p := range src { dst = append(dst, p.newInvoiceProduct()) } return dst } func editedInvoiceProducts(src []*invoiceProductForm) database.EditedInvoiceProductArray { dst := make(database.EditedInvoiceProductArray, 0, len(src)) for _, p := range src { dst = append(dst, p.editedInvoiceProduct()) } return dst } func handleBatchAction(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if err := user.VerifyCSRFToken(r); err != nil { http.Error(w, err.Error(), http.StatusForbidden) return } switch r.Form.Get("action") { case "download": slugs := r.Form["invoice"] if len(slugs) == 0 { http.Redirect(w, r, "/admin/invoices", http.StatusSeeOther) return } invoices := mustWriteInvoicesPdf(r, user, company, conn, slugs) w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", user.Locale.Pgettext("invoices.zip", "filename"))) w.WriteHeader(http.StatusOK) if _, err := w.Write(invoices); err != nil { panic(err) } case "export": filters := newInvoiceFilterForm(r.Context(), conn, company, user.Locale) if err := filters.Parse(r); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } entries := mustCollectInvoiceEntries(r.Context(), conn, user.Locale, filters) taxes := mustCollectInvoiceEntriesTaxes(r.Context(), conn, entries) taxColumns := mustCollectTaxColumns(r.Context(), conn, company) table := mustWriteInvoicesOds(entries, taxes, taxColumns, company, user.Locale) ods.MustWriteResponse(w, table, user.Locale.Pgettext("invoices.ods", "filename")) default: http.Error(w, user.Locale.Gettext("Invalid action"), http.StatusBadRequest) } } func mustCollectTaxColumns(ctx context.Context, conn *database.Conn, company *auth.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 *database.Conn, entries []*IndexEntry) map[int]taxMap { ids := mustMakeIDArray(entries, func(entry *IndexEntry) 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 *database.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 } type invoiceForm struct { Slug string Number string company *auth.Company InvoiceStatus *form.Select Customer *form.Select Date *form.Input Notes *form.Input Products []*invoiceProductForm RemovedProduct *invoiceProductForm Subtotal string Taxes [][]string Total string } func newInvoiceForm(ctx context.Context, conn *database.Conn, company *auth.Company, locale *locale.Locale) *invoiceForm { return &invoiceForm{ company: company, InvoiceStatus: &form.Select{ Name: "invoice_status", Selected: []string{"created"}, Options: mustGetInvoiceStatusOptions(ctx, conn, locale), }, Customer: &form.Select{ Name: "customer", Options: mustGetCustomerOptions(ctx, conn, company), }, Date: &form.Input{ Name: "date", }, Notes: &form.Input{ Name: "notes", }, } } func mustGetInvoiceStatusOptions(ctx context.Context, conn *database.Conn, locale *locale.Locale) []*form.Option { return form.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) } func (f *invoiceForm) Parse(r *http.Request, conn *database.Conn, l *locale.Locale) error { if err := r.ParseForm(); err != nil { return err } f.InvoiceStatus.FillValue(r) f.Customer.FillValue(r) f.Date.FillValue(r) f.Notes.FillValue(r) if _, ok := r.Form["product.id.0"]; ok { taxOptions := mustGetTaxOptions(r.Context(), conn, f.company) for index := 0; true; index++ { if _, ok := r.Form["product.id."+strconv.Itoa(index)]; !ok { break } productForm := newInvoiceProductForm(index, f.company, l, taxOptions) if err := productForm.Parse(r); err != nil { return err } f.Products = append(f.Products, productForm) } } return nil } func (f *invoiceForm) Validate(l *locale.Locale) bool { v := form.NewValidator(l) v.CheckSelectedOptions(f.InvoiceStatus, l.GettextNoop("Selected invoice status is not valid.")) v.CheckSelectedOptions(f.Customer, l.GettextNoop("Selected customer is not valid.")) if v.CheckRequired(f.Date, l.GettextNoop("Invoice date can not be empty.")) { v.CheckValidDate(f.Date, l.GettextNoop("Invoice date must be a valid date.")) } allOK := v.AllOK for _, product := range f.Products { allOK = product.Validate(l) && allOK } return allOK } func (f *invoiceForm) Update(l *locale.Locale) { products := f.Products f.Products = nil for n, product := range products { if product.Quantity.Val != "0" { product.Update(l) if n != len(f.Products) { product.Index = len(f.Products) product.Rename() } f.Products = append(f.Products, product) } } } func (f *invoiceForm) RemoveProduct(index int) { products := f.Products f.Products = nil for n, product := range products { if n == index { f.RemovedProduct = product } else { if n != len(f.Products) { product.Index = len(f.Products) product.Rename() } f.Products = append(f.Products, product) } } if f.RemovedProduct != nil { f.RemovedProduct.RenameWithSuffix(removedProductSuffix) } } func (f *invoiceForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { err := conn.QueryRow(r.Context(), "select subtotal, taxes, total from compute_new_invoice_amount($1, $2)", company.ID, newInvoiceProducts(f.Products)).Scan(&f.Subtotal, &f.Taxes, &f.Total) if err != nil { panic(err) } if len(f.Products) == 0 { f.Products = append(f.Products, newInvoiceProductForm(0, company, user.Locale, mustGetTaxOptions(r.Context(), conn, company))) } template.MustRenderAdminFiles(w, r, user, company, f, "invoice/form.gohtml", "invoice/product-form.gohtml") } const selectProductBySlug = ` select '' , product_id::text , 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 (f *invoiceForm) AddProducts(ctx context.Context, conn *database.Conn, l *locale.Locale, productsSlug []string) { f.mustAddProductsFromQuery(ctx, conn, l, selectProductBySlug, productsSlug) } func (f *invoiceForm) mustAddProductsFromQuery(ctx context.Context, conn *database.Conn, l *locale.Locale, sql string, args ...interface{}) { index := len(f.Products) taxOptions := mustGetTaxOptions(ctx, conn, f.company) rows, err := conn.Query(ctx, sql, args...) if err != nil { panic(err) } defer rows.Close() for rows.Next() { product := newInvoiceProductForm(index, f.company, l, taxOptions) if err := rows.Scan(&product.InvoiceProductId.Val, &product.ProductId.Val, &product.Name.Val, &product.Description.Val, &product.Price.Val, &product.Quantity.Val, &product.Discount.Val, &product.Tax.Selected); err != nil { panic(err) } f.Products = append(f.Products, product) index++ } if rows.Err() != nil { panic(rows.Err()) } } func (f *invoiceForm) InsertProduct(product *invoiceProductForm) { replaced := false for n, existing := range f.Products { if existing.Quantity.Val == "" || existing.Quantity.Val == "0" { product.Index = n f.Products[n] = product replaced = true break } } if !replaced { product.Index = len(f.Products) f.Products = append(f.Products, product) } product.Rename() } func (f *invoiceForm) MustFillFromDatabase(ctx context.Context, conn *database.Conn, l *locale.Locale, slug string) bool { var invoiceId int selectedInvoiceStatus := f.InvoiceStatus.Selected f.InvoiceStatus.Selected = nil err := conn.QueryRow(ctx, ` select invoice_id , array[invoice_status] , array[contact_id::text] , invoice_number , invoice_date::text , notes from invoice where slug = $1 `, slug).Scan(&invoiceId, &f.InvoiceStatus.Selected, &f.Customer.Selected, &f.Number, &f.Date.Val, &f.Notes.Val) if err != nil { if database.ErrorIsNotFound(err) { f.InvoiceStatus.Selected = selectedInvoiceStatus return false } panic(err) } f.Slug = slug f.Products = []*invoiceProductForm{} f.mustAddProductsFromQuery(ctx, conn, l, "select invoice_product_id::text, coalesce(product_id, 0)::text, name, description, to_price(price, $2), quantity::text, (discount_rate * 100)::integer::text, array_remove(array_agg(tax_id::text), 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, f.company.DecimalDigits) return true } func (f *invoiceForm) MustFillFromQuote(ctx context.Context, conn *database.Conn, l *locale.Locale, slug string) bool { var quoteId int note := l.Gettext("Re: quotation #%s of %s") dateFormat := l.Pgettext("MM/DD/YYYY", "to_char") err := 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).Scan("eId, f.Customer, f.Notes) if err != nil { if database.ErrorIsNotFound(err) { return false } panic(err) } f.Products = []*invoiceProductForm{} f.mustAddProductsFromQuery(ctx, conn, l, "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, f.company.DecimalDigits) return true } func mustGetTaxOptions(ctx context.Context, conn *database.Conn, company *auth.Company) []*form.Option { return form.MustGetOptions(ctx, conn, "select tax_id::text, tax.name from tax where tax.company_id = $1 order by tax.rate desc", company.ID) } func mustGetContactOptions(ctx context.Context, conn *database.Conn, company *auth.Company) []*form.Option { return form.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 *database.Conn, company *auth.Company) []*form.Option { return form.MustGetOptions(ctx, conn, "select contact_id::text, name from contact where company_id = $1 order by name", company.ID) } type invoiceProductForm struct { locale *locale.Locale company *auth.Company Index int InvoiceProductId *form.Input ProductId *form.Input Name *form.Input Description *form.Input Price *form.Input Quantity *form.Input Discount *form.Input Tax *form.Select } func newInvoiceProductForm(index int, company *auth.Company, locale *locale.Locale, taxOptions []*form.Option) *invoiceProductForm { f := &invoiceProductForm{ locale: locale, company: company, Index: index, InvoiceProductId: &form.Input{}, ProductId: &form.Input{}, Name: &form.Input{}, Description: &form.Input{}, Price: &form.Input{}, Quantity: &form.Input{}, Discount: &form.Input{}, Tax: &form.Select{ Options: taxOptions, }, } f.Rename() return f } func (f *invoiceProductForm) Rename() { f.RenameWithSuffix("." + strconv.Itoa(f.Index)) } func (f *invoiceProductForm) RenameWithSuffix(suffix string) { f.InvoiceProductId.Name = "product.invoice_product_id" + suffix f.ProductId.Name = "product.id" + suffix f.Name.Name = "product.name" + suffix f.Description.Name = "product.description" + suffix f.Price.Name = "product.price" + suffix f.Quantity.Name = "product.quantity" + suffix f.Discount.Name = "product.discount" + suffix f.Tax.Name = "product.tax" + suffix } func (f *invoiceProductForm) Parse(r *http.Request) error { if err := r.ParseForm(); err != nil { return err } f.InvoiceProductId.FillValue(r) f.ProductId.FillValue(r) f.Name.FillValue(r) f.Description.FillValue(r) f.Price.FillValue(r) f.Quantity.FillValue(r) f.Discount.FillValue(r) f.Tax.FillValue(r) return nil } func (f *invoiceProductForm) Validate(l *locale.Locale) bool { v := form.NewValidator(l) if f.InvoiceProductId.Val != "" { if v.CheckValidInteger(f.InvoiceProductId, l.GettextNoop("Invoice product ID must be an integer.")) { v.CheckMinInteger(f.InvoiceProductId, 1, l.GettextNoop("Invoice product ID one or greater.")) } } if f.ProductId.Val != "" { if v.CheckValidInteger(f.ProductId, l.GettextNoop("Product ID must be an integer.")) { v.CheckMinInteger(f.ProductId, 0, l.GettextNoop("Product ID must zero or greater.")) } } v.CheckRequired(f.Name, l.GettextNoop("Name can not be empty.")) if v.CheckRequired(f.Price, l.GettextNoop("Price can not be empty.")) { if v.CheckValidDecimal(f.Price, l.GettextNoop("Price must be a decimal number.")) { v.CheckMinDecimal(f.Price, 0, l.GettextNoop("Price must be zero or greater.")) } } if v.CheckRequired(f.Quantity, l.GettextNoop("Quantity can not be empty.")) { if v.CheckValidInteger(f.Quantity, l.GettextNoop("Quantity must be an integer.")) { v.CheckMinInteger(f.Quantity, 1, l.GettextNoop("Quantity must one or greater.")) } } if v.CheckRequired(f.Discount, l.GettextNoop("Discount can not be empty.")) { if v.CheckValidInteger(f.Discount, l.GettextNoop("Discount must be an integer.")) { if v.CheckMinInteger(f.Discount, 0, l.GettextNoop("Discount must be a percentage between 0 and 100.")) { v.CheckMaxInteger(f.Discount, 100, l.GettextNoop("Discount must be a percentage between 0 and 100.")) } } } v.CheckSelectedOptions(f.Tax, l.GettextNoop("Selected tax is not valid.")) // TODO v.CheckAtMostOneOfEachGroup(f.Tax, l.GettextNoop("You can only select a tax of each class.")) return v.AllOK } func (f *invoiceProductForm) Update(l *locale.Locale) { v := form.NewValidator(l) if !v.CheckValidDecimal(f.Price, "") || !v.CheckMinDecimal(f.Price, 0, "") { f.Price.Val = "0.0" f.Price.Error = nil } if !v.CheckValidInteger(f.Quantity, "") || !v.CheckMinInteger(f.Quantity, 0, "") { f.Quantity.Val = "1" f.Quantity.Error = nil } if !v.CheckValidInteger(f.Discount, "") || !v.CheckMinInteger(f.Discount, 0, "") || !v.CheckMaxInteger(f.Discount, 100, "") { f.Discount.Val = "0" f.Discount.Error = nil } } func (f *invoiceProductForm) MustFillFromDatabase(ctx context.Context, conn *database.Conn, slug string) bool { err := conn.QueryRow(ctx, selectProductBySlug, []string{slug}).Scan( f.InvoiceProductId, f.ProductId, f.Name, f.Description, f.Price, f.Quantity, f.Discount, f.Tax) if err != nil { if database.ErrorIsNotFound(err) { return false } panic(err) } return true } func (f *invoiceProductForm) newInvoiceProduct() *database.NewInvoiceProduct { productId := 0 if f.ProductId.Val != "" { productId = f.ProductId.Int() } var taxes []int if len(f.Tax.Selected) > 0 { taxes = make([]int, 0, len(f.Tax.Selected)) for _, t := range f.Tax.Selected { id, _ := strconv.Atoi(t) taxes = append(taxes, id) } } return &database.NewInvoiceProduct{ ProductId: productId, Name: f.Name.Val, Description: f.Description.Val, Price: f.Price.Val, Quantity: f.Quantity.Int(), Discount: float64(f.Discount.Int()) / 100.0, Taxes: taxes, } } func (f *invoiceProductForm) editedInvoiceProduct() *database.EditedInvoiceProduct { invoiceProductId := 0 if f.InvoiceProductId.Val != "" { invoiceProductId = f.InvoiceProductId.Int() } return &database.EditedInvoiceProduct{ NewInvoiceProduct: f.newInvoiceProduct(), InvoiceProductId: invoiceProductId, } } func handleUpdateInvoice(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, slug string) { if !uuid.Valid(slug) { http.NotFound(w, r) return } f := newInvoiceForm(r.Context(), conn, company, user.Locale) if err := f.Parse(r, conn, user.Locale); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if err := user.VerifyCSRFToken(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", f.InvoiceStatus, slug) if slug == "" { http.NotFound(w, r) return } httplib.Relocate(w, r, "/admin/invoices", http.StatusSeeOther) } else { if !f.Validate(user.Locale) { if !httplib.IsHTMxRequest(r) { w.WriteHeader(http.StatusUnprocessableEntity) } f.MustRender(w, r, user, company, conn) return } var err error slug, err = conn.EditInvoice(r.Context(), slug, f.InvoiceStatus.String(), f.Customer.Int(), f.Notes.Val, defaultPaymentMethod, editedInvoiceProducts(f.Products)) if err != nil { panic(err) } if slug == "" { http.NotFound(w, r) return } httplib.Redirect(w, r, "/admin/invoices/"+slug, http.StatusSeeOther) } } func serveEditInvoice(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, slug string) { if !uuid.Valid(slug) { http.NotFound(w, r) return } f := newInvoiceForm(r.Context(), conn, company, user.Locale) if !f.MustFillFromDatabase(r.Context(), conn, user.Locale, slug) { http.NotFound(w, r) return } f.MustRender(w, r, user, company, conn) } func handleEditInvoiceAction(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, slug string) { if !uuid.Valid(slug) { http.NotFound(w, r) return } actionUri := fmt.Sprintf("/invoices/%s/edit", slug) handleInvoiceAction(w, r, user, company, conn, slug, actionUri) } func handleInvoiceAction(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, slug string, action string) { f := newInvoiceForm(r.Context(), conn, company, user.Locale) if err := f.Parse(r, conn, user.Locale); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if err := user.VerifyCSRFToken(r); err != nil { http.Error(w, err.Error(), http.StatusForbidden) return } f.Slug = slug actionField := r.Form.Get("action") switch actionField { case "update": f.Update(user.Locale) w.WriteHeader(http.StatusOK) f.MustRender(w, r, user, company, conn) case "select-products": mustRenderNewInvoiceProductsForm(w, r, user, company, conn, action, f) case "add-products": f.AddProducts(r.Context(), conn, user.Locale, r.Form["slug"]) f.MustRender(w, r, user, company, conn) case "restore-product": restoredProduct := newInvoiceProductForm(0, company, user.Locale, mustGetTaxOptions(r.Context(), conn, company)) restoredProduct.RenameWithSuffix(removedProductSuffix) if err := restoredProduct.Parse(r); err != nil { panic(err) } f.InsertProduct(restoredProduct) f.Update(user.Locale) f.MustRender(w, r, user, company, conn) default: prefix := "remove-product." if strings.HasPrefix(actionField, prefix) { index, err := strconv.Atoi(actionField[len(prefix):]) if err != nil { http.Error(w, user.Locale.Gettext("Invalid action"), http.StatusBadRequest) } else { f.RemoveProduct(index) f.Update(user.Locale) f.MustRender(w, r, user, company, conn) } } else { http.Error(w, user.Locale.Gettext("Invalid action"), http.StatusBadRequest) } } }