camper/pkg/invoice/admin.go

1345 lines
40 KiB
Go
Raw Normal View History

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 bookingToInvoice, err := strconv.Atoi(r.URL.Query().Get("booking")); err == nil {
f.MustFillFromBooking(r.Context(), conn, user.Locale, bookingToInvoice)
}
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
}
tx := conn.MustBegin(r.Context())
defer tx.Rollback(r.Context())
slug, err := tx.AddInvoice(r.Context(), company.ID, f.Date.Val, f.Customer.Int(), f.Notes.Val, defaultPaymentMethod, newInvoiceProducts(f.Products))
if err != nil {
panic(err)
}
if bookingID, err := strconv.Atoi(f.BookingID.Val); err == nil {
if _, err := tx.Exec(r.Context(),
"insert into booking_invoice (booking_id, invoice_id) select $1, invoice_id from invoice where slug = $2",
bookingID,
slug,
); err != nil {
panic(err)
}
if _, err := tx.Exec(r.Context(),
"update booking set booking_status = 'invoiced' where booking_id = $1",
bookingID,
); err != nil {
panic(err)
}
}
tx.MustCommit(r.Context())
httplib.Redirect(w, r, "/admin/bookings", http.StatusSeeOther)
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
BookingID *form.Input
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,
BookingID: &form.Input{
Name: "booking_id",
},
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.BookingID.FillValue(r)
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) MustFillFromBooking(ctx context.Context, conn *database.Conn, l *locale.Locale, bookingID int) bool {
note := l.Gettext("Re: booking #%s of %s%s")
dateFormat := l.Pgettext("MM/DD/YYYY", "to_char")
err := conn.QueryRow(ctx, `
select format($2, left(slug::text, 10), to_char(lower(stay), $3), to_char(upper(stay), $3))
from booking
where booking_id = $1
`, bookingID, note, dateFormat).Scan(&f.Notes.Val)
if err != nil {
if database.ErrorIsNotFound(err) {
return false
}
panic(err)
}
f.BookingID.Val = strconv.Itoa(bookingID)
f.Products = []*invoiceProductForm{}
f.mustAddProductsFromQuery(ctx, conn, l, `
select '', '', quantity || ' × ' || product.name, '', to_price(round(price / (1 + rate))::integer, $2), '1', '0', array[tax_id::text] from (
select $4 as name, subtotal_nights as price, upper(stay) - lower(stay) as quantity, 2 as tax_id from booking where booking_id = $1
union all
select $5, subtotal_adults, number_adults, 2 from booking where booking_id = $1
union all
select $6, subtotal_teenagers, number_teenagers, 2 from booking where booking_id = $1
union all
select $7, subtotal_children, number_children, 2 from booking where booking_id = $1
union all
select $8, subtotal_dogs, number_dogs, 2 from booking where booking_id = $1
union all
select coalesce(i18n.name, type_option.name), subtotal, units, 2
from booking_option
join campsite_type_option as type_option using (campsite_type_option_id)
left join campsite_type_option_i18n as i18n on i18n.campsite_type_option_id = type_option.campsite_type_option_id and lang_tag = $3
union all
select $9, subtotal_tourist_tax, number_adults, 4 from booking where booking_id = $1
) as product
join tax using (tax_id)
where quantity > 0
`,
bookingID,
f.company.DecimalDigits,
l.Language,
l.Pgettext("Night", "cart"),
l.Pgettext("Adults aged 17 or older", "input"),
l.Pgettext("Teenagers from 11 to 16 years old", "input"),
l.Pgettext("Children from 2 to 10 years old", "input"),
l.Pgettext("Dogs", "input"),
l.Pgettext("Tourist tax", "cart"),
)
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."))
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)
}
}
}