camper/pkg/invoice/admin.go

1371 lines
40 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package invoice
import (
"context"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/jackc/pgtype"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/customer"
"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/ods"
"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)
query := r.URL.Query()
if invoiceToDuplicate := 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(query.Get("booking")); err == nil {
f.MustFillFromBooking(r.Context(), conn, user.Locale, bookingToInvoice)
} else if customerSlug := query.Get("customer"); uuid.Valid(customerSlug) {
if err := f.Customer.FillFromDatabase(r.Context(), conn, customerSlug); err != nil {
panic(err)
}
}
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 ok, err := f.Validate(r.Context(), conn, user.Locale); err != nil {
panic(err)
} else if !ok {
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())
customerID, err := f.Customer.UpdateOrCreate(r.Context(), company, tx)
if err != nil {
panic(err)
}
slug, err := tx.AddInvoice(r.Context(), company.ID, f.Date.Val, customerID, 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 {
company *auth.Company
Slug string
Number string
BookingID *form.Input
InvoiceStatus *form.Select
Customer *customer.ContactForm
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, l *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, l),
},
Customer: customer.NewContactForm(ctx, conn, l),
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.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 f.Customer.Parse(r)
}
func (f *invoiceForm) Validate(ctx context.Context, conn *database.Conn, l *locale.Locale) (bool, error) {
v := form.NewValidator(l)
v.CheckSelectedOptions(f.InvoiceStatus, l.GettextNoop("Selected invoice status 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
}
if ok, err := f.Customer.Valid(ctx, conn, l); err != nil {
return false, err
} else if !ok {
allOK = false
}
return allOK, nil
}
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", "customer/contact.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
var contactSlug string
selectedInvoiceStatus := f.InvoiceStatus.Selected
f.InvoiceStatus.Selected = nil
err := conn.QueryRow(ctx, `
select invoice_id
, array[invoice_status]
, contact.slug
, invoice_number
, invoice_date::text
, notes
from invoice
join contact using (contact_id)
where invoice.slug = $1
`, slug).Scan(&invoiceId, &f.InvoiceStatus.Selected, &contactSlug, &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)
if err := f.Customer.FillFromDatabase(ctx, conn, contactSlug); err != nil {
panic(err)
}
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"),
)
if err := f.Customer.FillFromBooking(ctx, conn, bookingID); err != nil && !database.ErrorIsNotFound(err) {
panic(err)
}
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)
}
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 ok, err := f.Validate(r.Context(), conn, user.Locale); err != nil {
panic(err)
} else if !ok {
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())
customerID, err := f.Customer.UpdateOrCreate(r.Context(), company, tx)
if err != nil {
panic(err)
}
slug, err = tx.EditInvoice(r.Context(), slug, f.InvoiceStatus.String(), customerID, f.Notes.Val, defaultPaymentMethod, editedInvoiceProducts(f.Products))
if err != nil {
panic(err)
}
tx.MustCommit(r.Context())
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)
}
}
}