1371 lines
40 KiB
Go
1371 lines
40 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 := 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)
|
||
}
|
||
}
|
||
}
|