2024-04-28 18:28:45 +00:00
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" }
2024-04-28 19:56:51 +00:00
} else if bookingToInvoice , err := strconv . Atoi ( r . URL . Query ( ) . Get ( "booking" ) ) ; err == nil {
f . MustFillFromBooking ( r . Context ( ) , conn , user . Locale , bookingToInvoice )
2024-04-28 18:28:45 +00:00
}
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
}
2024-04-28 19:56:51 +00:00
tx := conn . MustBegin ( r . Context ( ) )
defer tx . Rollback ( r . Context ( ) )
slug , err := tx . AddInvoice ( r . Context ( ) , company . ID , f . Date . Val , f . Customer . Int ( ) , f . Notes . Val , defaultPaymentMethod , newInvoiceProducts ( f . Products ) )
2024-04-28 18:28:45 +00:00
if err != nil {
panic ( err )
}
2024-04-28 19:56:51 +00:00
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 )
2024-04-28 18:28:45 +00:00
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
2024-04-28 19:56:51 +00:00
BookingID * form . Input
2024-04-28 18:28:45 +00:00
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 ,
2024-04-28 19:56:51 +00:00
BookingID : & form . Input {
Name : "booking_id" ,
} ,
2024-04-28 18:28:45 +00:00
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
}
2024-04-28 19:56:51 +00:00
f . BookingID . FillValue ( r )
2024-04-28 18:28:45 +00:00
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
}
2024-04-28 19:56:51 +00:00
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" )
2024-04-28 18:28:45 +00:00
dateFormat := l . Pgettext ( "MM/DD/YYYY" , "to_char" )
err := conn . QueryRow ( ctx , `
2024-04-28 19:56:51 +00:00
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 )
2024-04-28 18:28:45 +00:00
if err != nil {
if database . ErrorIsNotFound ( err ) {
return false
}
panic ( err )
}
2024-04-28 19:56:51 +00:00
f . BookingID . Val = strconv . Itoa ( bookingID )
2024-04-28 18:28:45 +00:00
f . Products = [ ] * invoiceProductForm { }
2024-04-28 19:56:51 +00:00
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" ) ,
)
2024-04-28 18:28:45 +00:00
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." ) )
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 )
}
}
}