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