2023-02-11 21:16:48 +00:00
package pkg
import (
"context"
"fmt"
2023-02-12 20:06:48 +00:00
"github.com/jackc/pgx/v4"
2023-02-11 21:16:48 +00:00
"github.com/julienschmidt/httprouter"
"html/template"
2023-02-12 20:06:48 +00:00
"math"
2023-02-11 21:16:48 +00:00
"net/http"
2023-02-12 20:06:48 +00:00
"strconv"
2023-02-11 21:16:48 +00:00
"time"
)
2023-02-12 20:06:48 +00:00
type InvoiceEntry struct {
Slug string
Date time . Time
Number string
CustomerName string
CustomerSlug string
Status string
StatusLabel string
}
2023-02-11 21:16:48 +00:00
type InvoicesIndexPage struct {
2023-02-12 20:06:48 +00:00
Invoices [ ] * InvoiceEntry
2023-02-11 21:16:48 +00:00
}
func IndexInvoices ( w http . ResponseWriter , r * http . Request , _ httprouter . Params ) {
2023-02-12 20:06:48 +00:00
page := & InvoicesIndexPage {
2023-02-14 11:34:50 +00:00
Invoices : mustCollectInvoiceEntries ( r . Context ( ) , getConn ( r ) , mustGetCompany ( r ) , getLocale ( r ) ) ,
2023-02-12 20:06:48 +00:00
}
2023-02-11 21:16:48 +00:00
mustRenderAppTemplate ( w , r , "invoices/index.gohtml" , page )
}
2023-02-14 11:34:50 +00:00
func mustCollectInvoiceEntries ( ctx context . Context , conn * Conn , company * Company , locale * Locale ) [ ] * InvoiceEntry {
rows := conn . MustQuery ( ctx , "select invoice.slug, invoice_date, invoice_number, contact.business_name, contact.slug, invoice.invoice_status, isi18n.name from invoice join contact using (contact_id) join invoice_status_i18n isi18n on invoice.invoice_status = isi18n.invoice_status and isi18n.lang_tag = $2 where invoice.company_id = $1 order by invoice_date, invoice_number" , company . Id , locale . Language . String ( ) )
2023-02-12 20:06:48 +00:00
defer rows . Close ( )
var entries [ ] * InvoiceEntry
for rows . Next ( ) {
entry := & InvoiceEntry { }
2023-02-14 11:34:50 +00:00
if err := rows . Scan ( & entry . Slug , & entry . Date , & entry . Number , & entry . CustomerName , & entry . CustomerSlug , & entry . Status , & entry . StatusLabel ) ; err != nil {
2023-02-12 20:06:48 +00:00
panic ( err )
}
entries = append ( entries , entry )
}
if rows . Err ( ) != nil {
panic ( rows . Err ( ) )
}
return entries
}
2023-02-11 21:16:48 +00:00
func GetInvoiceForm ( w http . ResponseWriter , r * http . Request , params httprouter . Params ) {
locale := getLocale ( r )
conn := getConn ( r )
company := mustGetCompany ( r )
form := newInvoiceForm ( r . Context ( ) , conn , locale , company )
slug := params [ 0 ] . Value
if slug == "new" {
form . Customer . EmptyLabel = gettext ( "Select a customer to bill." , locale )
form . Date . Val = time . Now ( ) . Format ( "2006-01-02" )
w . WriteHeader ( http . StatusOK )
mustRenderNewInvoiceForm ( w , r , form )
return
}
}
func mustRenderNewInvoiceForm ( w http . ResponseWriter , r * http . Request , form * invoiceForm ) {
mustRenderAppTemplate ( w , r , "invoices/new.gohtml" , form )
}
2023-02-12 20:06:48 +00:00
func mustRenderNewInvoiceProductsForm ( w http . ResponseWriter , r * http . Request , form * invoiceForm ) {
conn := getConn ( r )
company := mustGetCompany ( r )
page := newInvoiceProductsPage {
Form : form ,
Products : mustGetProductChoices ( r . Context ( ) , conn , company ) ,
}
mustRenderAppTemplate ( w , r , "invoices/products.gohtml" , page )
}
func mustGetProductChoices ( ctx context . Context , conn * Conn , company * Company ) [ ] * productChoice {
rows := conn . MustQuery ( ctx , "select product.product_id, 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 )
defer rows . Close ( )
var choices [ ] * productChoice
for rows . Next ( ) {
entry := & productChoice { }
if err := rows . Scan ( & entry . Id , & entry . Name , & entry . Price ) ; err != nil {
panic ( err )
}
choices = append ( choices , entry )
}
if rows . Err ( ) != nil {
panic ( rows . Err ( ) )
}
return choices
}
type newInvoiceProductsPage struct {
Form * invoiceForm
Products [ ] * productChoice
}
type productChoice struct {
Id int
Name string
Price string
}
2023-02-11 21:16:48 +00:00
func HandleAddInvoice ( w http . ResponseWriter , r * http . Request , _ httprouter . Params ) {
2023-02-12 20:06:48 +00:00
locale := getLocale ( r )
conn := getConn ( r )
company := mustGetCompany ( r )
form := newInvoiceForm ( r . Context ( ) , conn , locale , company )
if err := form . Parse ( r ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusBadRequest )
return
}
if err := verifyCsrfTokenValid ( r ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusForbidden )
return
}
switch r . Form . Get ( "action" ) {
case "update" :
form . Update ( )
w . WriteHeader ( http . StatusOK )
mustRenderNewInvoiceForm ( w , r , form )
case "products" :
w . WriteHeader ( http . StatusOK )
mustRenderNewInvoiceProductsForm ( w , r , form )
case "add" :
if ! form . Validate ( ) {
w . WriteHeader ( http . StatusUnprocessableEntity )
mustRenderNewInvoiceForm ( w , r , form )
return
}
tx := conn . MustBegin ( r . Context ( ) )
invoiceId := tx . MustGetInteger ( r . Context ( ) , "insert into invoice (company_id, invoice_number, invoice_date, contact_id, notes, currency_code) select company_id, $2, $3, $4, $5, currency_code from company join currency using (currency_code) where company_id = $1 returning invoice_id" , company . Id , form . Number , form . Date , form . Customer , form . Notes )
batch := & pgx . Batch { }
for _ , product := range form . Products {
batch . Queue ( "insert into invoice_product(invoice_id, product_id, name, description, price, quantity, discount_rate) select $2, $3, $4, $5, parse_price($6, decimal_digits), $7, $8 / 100::decimal from company join currency using (currency_code) where company_id = $1" , company . Id , invoiceId , product . ProductId , product . Name , product . Description , product . Price , product . Quantity . Integer ( ) , product . Discount . Integer ( ) )
}
br := tx . SendBatch ( r . Context ( ) , batch )
for range form . Products {
if _ , err := br . Exec ( ) ; err != nil {
panic ( err )
}
}
if err := br . Close ( ) ; err != nil {
panic ( err )
}
tx . MustCommit ( r . Context ( ) )
http . Redirect ( w , r , companyURI ( company , "/invoices" ) , http . StatusSeeOther )
default :
http . Error ( w , gettext ( "Invalid action" , locale ) , http . StatusBadRequest )
}
2023-02-11 21:16:48 +00:00
}
2023-02-12 20:06:48 +00:00
func HandleAddProductsToInvoice ( w http . ResponseWriter , r * http . Request , _ httprouter . Params ) {
locale := getLocale ( r )
conn := getConn ( r )
company := mustGetCompany ( r )
form := newInvoiceForm ( r . Context ( ) , conn , locale , company )
if err := form . Parse ( r ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusBadRequest )
return
}
index := len ( form . Products )
productsId := r . Form [ "id" ]
rows := conn . MustQuery ( r . Context ( ) , "select product_id, name, description, to_price(price, decimal_digits), 1 as quantity, 0 as discount, array_agg(tax_id) from product join company using (company_id) join currency using (currency_code) left join product_tax using (product_id) where product_id = any ($1) group by product_id, name, description, price, decimal_digits" , productsId )
defer rows . Close ( )
for rows . Next ( ) {
product := newInvoiceProductForm ( index , company , locale , form . Tax . Options )
if err := rows . Scan ( product . ProductId , product . Name , product . Description , product . Price , product . Quantity , product . Discount , product . Tax ) ; err != nil {
panic ( err )
}
form . Products = append ( form . Products , product )
index ++
}
if rows . Err ( ) != nil {
panic ( rows . Err ( ) )
}
w . WriteHeader ( http . StatusOK )
mustRenderNewInvoiceForm ( w , r , form )
2023-02-11 21:16:48 +00:00
}
type invoiceForm struct {
locale * Locale
company * Company
Customer * SelectField
Number * InputField
Date * InputField
Notes * InputField
2023-02-12 20:06:48 +00:00
Tax * SelectField
2023-02-11 21:16:48 +00:00
Products [ ] * invoiceProductForm
}
func newInvoiceForm ( ctx context . Context , conn * Conn , locale * Locale , company * Company ) * invoiceForm {
return & invoiceForm {
locale : locale ,
company : company ,
Customer : & SelectField {
Name : "customer" ,
Label : pgettext ( "input" , "Customer" , locale ) ,
Required : true ,
Options : MustGetOptions ( ctx , conn , "select contact_id::text, business_name from contact where company_id = $1 order by business_name" , company . Id ) ,
} ,
Number : & InputField {
Name : "number" ,
Label : pgettext ( "input" , "Number" , locale ) ,
Type : "text" ,
Required : false ,
} ,
Date : & InputField {
Name : "date" ,
Label : pgettext ( "input" , "Invoice Date" , locale ) ,
Type : "date" ,
Required : true ,
} ,
Notes : & InputField {
Name : "description" ,
Label : pgettext ( "input" , "Notes" , locale ) ,
Type : "textarea" ,
} ,
2023-02-12 20:06:48 +00:00
Tax : & SelectField {
Name : "text" ,
Label : pgettext ( "input" , "Taxes" , locale ) ,
Multiple : true ,
Options : mustGetTaxOptions ( ctx , conn , company ) ,
2023-02-11 21:16:48 +00:00
} ,
}
}
2023-02-12 20:06:48 +00:00
func ( form * invoiceForm ) Parse ( r * http . Request ) error {
if err := r . ParseForm ( ) ; err != nil {
return err
}
form . Customer . FillValue ( r )
form . Number . FillValue ( r )
form . Date . FillValue ( r )
form . Notes . FillValue ( r )
2023-02-13 09:32:26 +00:00
if _ , ok := r . Form [ "product.id.0" ] ; ok {
for index := 0 ; true ; index ++ {
if _ , ok := r . Form [ "product.id." + strconv . Itoa ( index ) ] ; ! ok {
break
}
2023-02-12 20:06:48 +00:00
productForm := newInvoiceProductForm ( index , form . company , form . locale , form . Tax . Options )
if err := productForm . Parse ( r ) ; err != nil {
return err
}
form . Products = append ( form . Products , productForm )
}
}
return nil
}
func ( form * invoiceForm ) Validate ( ) bool {
validator := newFormValidator ( )
validator . CheckValidSelectOption ( form . Customer , gettext ( "Name can not be empty." , form . locale ) )
if validator . CheckRequiredInput ( form . Date , gettext ( "Invoice date can not be empty." , form . locale ) ) {
validator . CheckValidDate ( form . Date , gettext ( "Invoice date must be a valid date." , form . locale ) )
}
validator . CheckValidSelectOption ( form . Tax , gettext ( "Selected tax is not valid." , form . locale ) )
allOK := validator . AllOK ( )
for _ , product := range form . Products {
allOK = product . Validate ( ) && allOK
}
return allOK
}
func ( form * invoiceForm ) Update ( ) {
products := form . Products
form . Products = nil
index := 0
for _ , product := range products {
if product . Quantity . Val != "0" {
form . Products = append ( form . Products , product )
index ++
}
}
}
func mustGetTaxOptions ( ctx context . Context , conn * Conn , company * Company ) [ ] * SelectOption {
return MustGetOptions ( ctx , conn , "select tax_id::text, name from tax where company_id = $1 order by name" , company . Id )
}
type invoiceProductForm struct {
locale * Locale
company * Company
ProductId * InputField
Name * InputField
Description * InputField
Price * InputField
Quantity * InputField
Discount * InputField
Tax * SelectField
}
func newInvoiceProductForm ( index int , company * Company , locale * Locale , taxOptions [ ] * SelectOption ) * invoiceProductForm {
suffix := "." + strconv . Itoa ( index )
2023-02-11 21:16:48 +00:00
return & invoiceProductForm {
2023-02-12 20:06:48 +00:00
locale : locale ,
company : company ,
2023-02-11 21:16:48 +00:00
ProductId : & InputField {
2023-02-12 20:06:48 +00:00
Name : "product.id" + suffix ,
2023-02-11 21:16:48 +00:00
Label : pgettext ( "input" , "Id" , locale ) ,
Type : "hidden" ,
Required : true ,
} ,
Name : & InputField {
2023-02-12 20:06:48 +00:00
Name : "product.name" + suffix ,
2023-02-11 21:16:48 +00:00
Label : pgettext ( "input" , "Name" , locale ) ,
Type : "text" ,
Required : true ,
} ,
Description : & InputField {
2023-02-12 20:06:48 +00:00
Name : "product.description" + suffix ,
2023-02-11 21:16:48 +00:00
Label : pgettext ( "input" , "Description" , locale ) ,
Type : "textarea" ,
} ,
Price : & InputField {
2023-02-12 20:06:48 +00:00
Name : "product.price" + suffix ,
2023-02-11 21:16:48 +00:00
Label : pgettext ( "input" , "Price" , locale ) ,
Type : "number" ,
Required : true ,
Attributes : [ ] template . HTMLAttr {
` min="0" ` ,
template . HTMLAttr ( fmt . Sprintf ( ` step="%v" ` , company . MinCents ( ) ) ) ,
} ,
} ,
Quantity : & InputField {
2023-02-12 20:06:48 +00:00
Name : "product.quantity" + suffix ,
2023-02-11 21:16:48 +00:00
Label : pgettext ( "input" , "Quantity" , locale ) ,
Type : "number" ,
Required : true ,
Attributes : [ ] template . HTMLAttr {
` min="0" ` ,
} ,
} ,
Discount : & InputField {
2023-02-12 20:06:48 +00:00
Name : "product.discount" + suffix ,
2023-02-11 21:16:48 +00:00
Label : pgettext ( "input" , "Discount (%)" , locale ) ,
Type : "number" ,
Required : true ,
Attributes : [ ] template . HTMLAttr {
` min="0" ` ,
` max="100" ` ,
} ,
} ,
Tax : & SelectField {
2023-02-12 20:06:48 +00:00
Name : "product.tax" + suffix ,
2023-02-11 21:16:48 +00:00
Label : pgettext ( "input" , "Taxes" , locale ) ,
Multiple : true ,
2023-02-12 20:06:48 +00:00
Options : taxOptions ,
2023-02-11 21:16:48 +00:00
} ,
}
}
2023-02-12 20:06:48 +00:00
func ( form * invoiceProductForm ) Parse ( r * http . Request ) error {
if err := r . ParseForm ( ) ; err != nil {
return err
}
form . ProductId . FillValue ( r )
form . Name . FillValue ( r )
form . Description . FillValue ( r )
form . Price . FillValue ( r )
form . Quantity . FillValue ( r )
form . Discount . FillValue ( r )
form . Tax . FillValue ( r )
return nil
}
func ( form * invoiceProductForm ) Validate ( ) bool {
validator := newFormValidator ( )
validator . CheckRequiredInput ( form . Name , gettext ( "Name can not be empty." , form . locale ) )
if validator . CheckRequiredInput ( form . Price , gettext ( "Price can not be empty." , form . locale ) ) {
validator . CheckValidDecimal ( form . Price , form . company . MinCents ( ) , math . MaxFloat64 , gettext ( "Price must be a number greater than zero." , form . locale ) )
}
if validator . CheckRequiredInput ( form . Quantity , gettext ( "Quantity can not be empty." , form . locale ) ) {
2023-02-13 09:32:26 +00:00
validator . CheckValidInteger ( form . Quantity , 1 , math . MaxInt32 , gettext ( "Quantity must be a number greater than zero." , form . locale ) )
2023-02-12 20:06:48 +00:00
}
if validator . CheckRequiredInput ( form . Discount , gettext ( "Discount can not be empty." , form . locale ) ) {
validator . CheckValidInteger ( form . Discount , 0 , 100 , gettext ( "Discount must be a percentage between 0 and 100." , form . locale ) )
}
validator . CheckValidSelectOption ( form . Tax , gettext ( "Selected tax is not valid." , form . locale ) )
return validator . AllOK ( )
}