I’ve removed the total amount because it is very difficult to get it with pagination, and customer never saw it (it was from Numerus), thus they won’t miss it—i hope.
1271 lines
38 KiB
Go
1271 lines
38 KiB
Go
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 := newFilterForm(r.Context(), conn, company, user.Locale)
|
||
if err := filters.Parse(r); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
page := &invoiceIndex{
|
||
Invoices: filters.buildCursor(mustCollectInvoiceEntries(r.Context(), conn, user.Locale, filters)),
|
||
Filters: filters,
|
||
InvoiceStatuses: mustCollectInvoiceStatuses(r.Context(), conn, user.Locale),
|
||
}
|
||
page.MustRender(w, r, user, company)
|
||
}
|
||
|
||
type invoiceIndex struct {
|
||
Invoices []*IndexEntry
|
||
Filters *filterForm
|
||
InvoiceStatuses map[string]string
|
||
}
|
||
|
||
func (page *invoiceIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
|
||
if httplib.IsHTMxRequest(r) && page.Filters.Paginated() {
|
||
template.MustRenderAdminNoLayout(w, r, user, company, "invoice/results.gohtml", page)
|
||
} else {
|
||
template.MustRenderAdminFiles(w, r, user, company, page, "invoice/index.gohtml", "invoice/results.gohtml")
|
||
}
|
||
}
|
||
|
||
func mustCollectInvoiceEntries(ctx context.Context, conn *database.Conn, locale *locale.Locale, filters *filterForm) []*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
|
||
limit %d
|
||
`, where, filters.PerPage()+1), 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 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
|
||
}
|
||
|
||
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/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 := newFilterForm(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)
|
||
}
|
||
}
|
||
}
|