2023-05-03 10:46:25 +00:00
package pkg
import (
"context"
"fmt"
"github.com/julienschmidt/httprouter"
"html/template"
"math"
"net/http"
2023-05-07 20:49:52 +00:00
"strconv"
"strings"
2023-05-03 10:46:25 +00:00
"time"
)
type ExpenseEntry struct {
2024-01-26 01:29:51 +00:00
ID int
2023-05-14 16:46:16 +00:00
Slug string
InvoiceDate time . Time
InvoiceNumber string
Amount string
2023-10-02 10:16:50 +00:00
Taxes map [ string ] string
Total string
2023-05-14 16:46:16 +00:00
InvoicerName string
OriginalFileName string
Tags [ ] string
2023-07-11 13:33:26 +00:00
Status string
StatusLabel string
2023-05-03 10:46:25 +00:00
}
type expensesIndexPage struct {
2023-07-11 13:33:26 +00:00
Expenses [ ] * ExpenseEntry
2023-10-02 14:36:42 +00:00
SumAmount string
SumTaxes map [ string ] string
SumTotal string
2023-07-11 13:33:26 +00:00
Filters * expenseFilterForm
2023-10-02 10:16:50 +00:00
TaxClasses [ ] string
2023-07-11 13:33:26 +00:00
ExpenseStatuses map [ string ] string
2023-05-03 10:46:25 +00:00
}
func IndexExpenses ( w http . ResponseWriter , r * http . Request , _ httprouter . Params ) {
conn := getConn ( r )
2023-05-07 20:49:52 +00:00
locale := getLocale ( r )
2023-05-03 10:46:25 +00:00
company := mustGetCompany ( r )
2023-05-07 20:49:52 +00:00
filters := newExpenseFilterForm ( r . Context ( ) , conn , locale , company )
if err := filters . Parse ( r ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusBadRequest )
return
}
2023-05-03 10:46:25 +00:00
page := & expensesIndexPage {
2023-07-11 13:33:26 +00:00
Expenses : mustCollectExpenseEntries ( r . Context ( ) , conn , locale , filters ) ,
ExpenseStatuses : mustCollectExpenseStatuses ( r . Context ( ) , conn , locale ) ,
2023-10-02 10:16:50 +00:00
TaxClasses : mustCollectTaxClasses ( r . Context ( ) , conn , company ) ,
2023-07-11 13:33:26 +00:00
Filters : filters ,
2023-05-03 10:46:25 +00:00
}
2023-10-02 14:36:42 +00:00
page . mustComputeExpensesTotalAmount ( r . Context ( ) , conn , filters )
2023-05-03 10:46:25 +00:00
mustRenderMainTemplate ( w , r , "expenses/index.gohtml" , page )
}
2023-07-11 13:33:26 +00:00
func mustCollectExpenseEntries ( ctx context . Context , conn * Conn , locale * Locale , filters * expenseFilterForm ) [ ] * ExpenseEntry {
where , args := filters . BuildQuery ( [ ] interface { } { locale . Language . String ( ) } )
2023-05-07 20:49:52 +00:00
rows := conn . MustQuery ( ctx , fmt . Sprintf ( `
2024-01-26 01:29:51 +00:00
select expense_id
, expense . slug
2023-05-03 10:46:25 +00:00
, invoice_date
, invoice_number
2023-10-02 10:16:50 +00:00
, to_price ( expense . amount , decimal_digits ) as amount
, array_agg ( array [ tax_class . name , to_price ( coalesce ( expense_tax . amount , 0 ) , decimal_digits ) ] ) filter ( where tax_class . name is not null )
, to_price ( expense . amount + coalesce ( sum ( expense_tax . amount ) : : integer , 0 ) , decimal_digits ) as total
Split contact relation into tax_details, phone, web, and email
We need to have contacts with just a name: we need to assign
freelancer’s quote as expense linked the government, but of course we
do not have a phone or email for that “contact”, much less a VATIN or
other tax details.
It is also interesting for other expenses-only contacts to not have to
input all tax details, as we may not need to invoice then, thus are
useless for us, but sometimes it might be interesting to have them,
“just in case”.
Of course, i did not want to make nullable any of the tax details
required to generate an invoice, otherwise we could allow illegal
invoices. Therefore, that data had to go in a different relation,
and invoice’s foreign key update to point to that relation, not just
customer, or we would again be able to create invalid invoices.
We replaced the contact’s trade name with just name, because we do not
need _three_ names for a contact, but we _do_ need two: the one we use
to refer to them and the business name for tax purposes.
The new contact_phone, contact_web, and contact_email relations could be
simply a nullable field, but i did not see the point, since there are
not that many instances where i need any of this data.
Now company.taxDetailsForm is no longer “the same as contactForm with
some extra fields”, because i have to add a check whether the user needs
to invoice the contact, to check that the required values are there.
I have an additional problem with the contact form when not using
JavaScript: i must set the required field to all tax details fields to
avoid the “(optional)” suffix, and because they _are_ required when
that checkbox is enabled, but i can not set them optional when the check
is unchecked. My solution for now is to ignore the form validation,
and later i will add some JavaScript that adds the validation again,
so it will work in all cases.
2023-06-30 19:32:48 +00:00
, contact . name
2023-05-14 16:46:16 +00:00
, coalesce ( attachment . original_filename , ' ' )
2023-05-03 10:46:25 +00:00
, expense . tags
2023-07-11 13:33:26 +00:00
, expense . expense_status
, esi18n . name
2023-05-03 10:46:25 +00:00
from expense
2023-05-14 16:46:16 +00:00
left join expense_attachment as attachment using ( expense_id )
2023-10-02 10:16:50 +00:00
left join expense_tax_amount as expense_tax using ( expense_id )
left join tax using ( tax_id )
left join tax_class using ( tax_class_id )
2023-05-03 10:46:25 +00:00
join contact using ( contact_id )
2023-07-11 13:33:26 +00:00
join expense_status_i18n esi18n on expense . expense_status = esi18n . expense_status and esi18n . lang_tag = $ 1
2023-05-03 10:46:25 +00:00
join currency using ( currency_code )
2023-05-07 20:49:52 +00:00
where ( % s )
2024-01-26 01:29:51 +00:00
group by expense_id
, expense . slug
Compute the total amount, base plus taxes, of all expenses
This works mostly like invoices: i have to “update” the expense form
to compute its total based on the subtotal and the selected taxes,
although in this case i do no need to compute the subtotal because that
is given by the user.
Nevertheless, i added a new function to compute that total because it
was already hairy enough for the dashboard, that also needs to compute
the tota, not just the base, and i wanted to test that function.
There is no need for a custom input type for that function as it only
needs a couple of simple domains. I have created the output type,
though, because otherwise i would need to have records or “reuse” any
other “amount” output type, which would be confusing.\
Part of #68.
2023-07-13 18:50:26 +00:00
, invoice_date
, invoice_number
, expense . amount
, decimal_digits
, contact . name
, attachment . original_filename
, expense . tags
, expense . expense_status
, esi18n . name
2023-10-02 09:04:35 +00:00
order by invoice_date desc , contact . name , total desc
2023-06-20 09:33:28 +00:00
` , where ) , args ... )
2023-05-03 10:46:25 +00:00
defer rows . Close ( )
var entries [ ] * ExpenseEntry
for rows . Next ( ) {
2023-10-02 10:16:50 +00:00
entry := & ExpenseEntry {
Taxes : make ( map [ string ] string ) ,
}
var taxes [ ] [ ] string
2024-01-26 01:29:51 +00:00
if err := rows . Scan ( & entry . ID , & entry . Slug , & entry . InvoiceDate , & entry . InvoiceNumber , & entry . Amount , & taxes , & entry . Total , & entry . InvoicerName , & entry . OriginalFileName , & entry . Tags , & entry . Status , & entry . StatusLabel ) ; err != nil {
2023-05-03 10:46:25 +00:00
panic ( err )
}
2023-10-02 10:16:50 +00:00
for _ , tax := range taxes {
entry . Taxes [ tax [ 0 ] ] = tax [ 1 ]
}
2023-05-03 10:46:25 +00:00
entries = append ( entries , entry )
}
if rows . Err ( ) != nil {
panic ( rows . Err ( ) )
}
return entries
}
2023-06-20 09:33:28 +00:00
2023-07-11 13:33:26 +00:00
func mustCollectExpenseStatuses ( ctx context . Context , conn * Conn , locale * Locale ) map [ string ] string {
rows := conn . MustQuery ( ctx , `
select expense_status . expense_status
, esi18n . name
from expense_status
join expense_status_i18n esi18n using ( expense_status )
where esi18n . lang_tag = $ 1
order by expense_status ` , locale . Language . String ( ) )
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
}
2023-10-02 14:36:42 +00:00
func ( page * expensesIndexPage ) mustComputeExpensesTotalAmount ( ctx context . Context , conn * Conn , filters * expenseFilterForm ) {
2023-06-20 09:33:28 +00:00
where , args := filters . BuildQuery ( nil )
2023-10-02 14:36:42 +00:00
row := conn . QueryRow ( ctx , fmt . Sprintf ( `
select to_price ( sum ( subtotal ) : : integer , decimal_digits )
, to_price ( sum ( subtotal + taxes ) : : integer , decimal_digits )
Compute the total amount, base plus taxes, of all expenses
This works mostly like invoices: i have to “update” the expense form
to compute its total based on the subtotal and the selected taxes,
although in this case i do no need to compute the subtotal because that
is given by the user.
Nevertheless, i added a new function to compute that total because it
was already hairy enough for the dashboard, that also needs to compute
the tota, not just the base, and i wanted to test that function.
There is no need for a custom input type for that function as it only
needs a couple of simple domains. I have created the output type,
though, because otherwise i would need to have records or “reuse” any
other “amount” output type, which would be confusing.\
Part of #68.
2023-07-13 18:50:26 +00:00
from (
select expense_id
, expense . amount as subtotal
, coalesce ( sum ( tax . amount ) : : integer , 0 ) as taxes
, currency_code
from expense
left join expense_tax_amount as tax using ( expense_id )
where ( % s )
group by expense_id
, expense . amount
, currency_code
) as expense
2023-06-20 09:33:28 +00:00
join currency using ( currency_code )
group by decimal_digits
` , where ) , args ... )
2023-11-06 12:18:02 +00:00
if notFoundErrorOrPanic ( row . Scan ( & page . SumAmount , & page . SumTotal ) ) {
page . SumAmount = "0.0"
page . SumTotal = "0.0"
2023-10-02 14:36:42 +00:00
}
row = conn . QueryRow ( ctx , fmt . Sprintf ( `
select array_agg ( array [ tax_class_name , to_price ( coalesce ( tax_amount , 0 ) , decimal_digits ) ] ) filter ( where tax_class_name is not null )
from (
select tax_class . name as tax_class_name
, coalesce ( sum ( expense_tax . amount ) : : integer , 0 ) as tax_amount
, currency_code
from expense
left join expense_tax_amount as expense_tax using ( expense_id )
left join tax using ( tax_id )
left join tax_class using ( tax_class_id )
where ( % s )
group by tax_class . name
, currency_code
) as tax
join currency using ( currency_code )
group by decimal_digits
` , where ) , args ... )
var taxes [ ] [ ] string
2023-11-06 12:18:02 +00:00
if notFoundErrorOrPanic ( row . Scan ( & taxes ) ) {
// well, nothing to do
2023-10-02 14:36:42 +00:00
}
page . SumTaxes = make ( map [ string ] string )
for _ , tax := range taxes {
page . SumTaxes [ tax [ 0 ] ] = tax [ 1 ]
}
2023-06-20 09:33:28 +00:00
}
2023-10-02 10:16:50 +00:00
func mustCollectTaxClasses ( ctx context . Context , conn * Conn , company * Company ) [ ] string {
rows := conn . MustQuery ( ctx , "select name from tax_class where company_id = $1" , company . Id )
defer rows . Close ( )
var taxClasses [ ] string
for rows . Next ( ) {
var taxClass string
if err := rows . Scan ( & taxClass ) ; err != nil {
panic ( err )
}
taxClasses = append ( taxClasses , taxClass )
}
return taxClasses
}
2023-05-03 10:46:25 +00:00
func ServeExpenseForm ( w http . ResponseWriter , r * http . Request , params httprouter . Params ) {
locale := getLocale ( r )
conn := getConn ( r )
company := mustGetCompany ( r )
form := newExpenseForm ( r . Context ( ) , conn , locale , company )
slug := params [ 0 ] . Value
if slug == "new" {
w . WriteHeader ( http . StatusOK )
form . InvoiceDate . Val = time . Now ( ) . Format ( "2006-01-02" )
mustRenderNewExpenseForm ( w , r , form )
return
}
Return HTTP 404 instead of 500 for invalid UUID values in URL
Since most of PL/pgSQL functions accept a `uuid` domain, we get an error
if the value is not valid, forcing us to return an HTTP 500, as we
can not detect that the error was due to that.
Instead, i now validate that the slug is indeed a valid UUID before
attempting to send it to the database, returning the correct HTTP error
code and avoiding useless calls to the database.
I based the validation function of Parse() from Google’s uuid package[0]
because this function is an order or magnitude faster in benchmarks:
goos: linux
goarch: amd64
pkg: dev.tandem.ws/tandem/numerus/pkg
cpu: Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz
BenchmarkValidUuid-4 36946050 29.37 ns/op
BenchmarkValidUuid_Re-4 3633169 306.70 ns/op
The regular expression used for the benchmark was:
var re = regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
And the input parameter for both functions was the following valid UUID,
because most of the time the passed UUID will be valid:
"f47ac10b-58cc-0372-8567-0e02b2c3d479"
I did not use the uuid package, even though it is in Debian’s
repository, because i only need to check whether the value is valid,
not convert it to a byte array. As far as i know, that package can not
do that.
[0]: https://github.com/google/uuid
2023-07-17 09:46:11 +00:00
if ! ValidUuid ( slug ) {
http . NotFound ( w , r )
return
}
2023-05-03 10:46:25 +00:00
if ! form . MustFillFromDatabase ( r . Context ( ) , conn , slug ) {
http . NotFound ( w , r )
return
}
w . WriteHeader ( http . StatusOK )
mustRenderEditExpenseForm ( w , r , slug , form )
}
func mustRenderNewExpenseForm ( w http . ResponseWriter , r * http . Request , form * expenseForm ) {
locale := getLocale ( r )
form . Invoicer . EmptyLabel = gettext ( "Select a contact." , locale )
Compute the total amount, base plus taxes, of all expenses
This works mostly like invoices: i have to “update” the expense form
to compute its total based on the subtotal and the selected taxes,
although in this case i do no need to compute the subtotal because that
is given by the user.
Nevertheless, i added a new function to compute that total because it
was already hairy enough for the dashboard, that also needs to compute
the tota, not just the base, and i wanted to test that function.
There is no need for a custom input type for that function as it only
needs a couple of simple domains. I have created the output type,
though, because otherwise i would need to have records or “reuse” any
other “amount” output type, which would be confusing.\
Part of #68.
2023-07-13 18:50:26 +00:00
page := newNewExpensePage ( form , r )
mustRenderMainTemplate ( w , r , "expenses/new.gohtml" , page )
}
type newExpensePage struct {
Form * expenseForm
Taxes [ ] [ ] string
Total string
}
func newNewExpensePage ( form * expenseForm , r * http . Request ) * newExpensePage {
page := & newExpensePage {
Form : form ,
}
conn := getConn ( r )
company := mustGetCompany ( r )
err := conn . QueryRow ( r . Context ( ) , "select taxes, total from compute_new_expense_amount($1, $2, $3)" , company . Id , form . Amount , form . Tax . Selected ) . Scan ( & page . Taxes , & page . Total )
if err != nil {
panic ( err )
}
return page
2023-05-03 10:46:25 +00:00
}
func mustRenderEditExpenseForm ( w http . ResponseWriter , r * http . Request , slug string , form * expenseForm ) {
page := & editExpensePage {
Compute the total amount, base plus taxes, of all expenses
This works mostly like invoices: i have to “update” the expense form
to compute its total based on the subtotal and the selected taxes,
although in this case i do no need to compute the subtotal because that
is given by the user.
Nevertheless, i added a new function to compute that total because it
was already hairy enough for the dashboard, that also needs to compute
the tota, not just the base, and i wanted to test that function.
There is no need for a custom input type for that function as it only
needs a couple of simple domains. I have created the output type,
though, because otherwise i would need to have records or “reuse” any
other “amount” output type, which would be confusing.\
Part of #68.
2023-07-13 18:50:26 +00:00
newNewExpensePage ( form , r ) ,
slug ,
2023-05-03 10:46:25 +00:00
}
mustRenderMainTemplate ( w , r , "expenses/edit.gohtml" , page )
}
type editExpensePage struct {
Compute the total amount, base plus taxes, of all expenses
This works mostly like invoices: i have to “update” the expense form
to compute its total based on the subtotal and the selected taxes,
although in this case i do no need to compute the subtotal because that
is given by the user.
Nevertheless, i added a new function to compute that total because it
was already hairy enough for the dashboard, that also needs to compute
the tota, not just the base, and i wanted to test that function.
There is no need for a custom input type for that function as it only
needs a couple of simple domains. I have created the output type,
though, because otherwise i would need to have records or “reuse” any
other “amount” output type, which would be confusing.\
Part of #68.
2023-07-13 18:50:26 +00:00
* newExpensePage
2023-05-03 10:46:25 +00:00
Slug string
}
type expenseForm struct {
locale * Locale
company * Company
Invoicer * SelectField
InvoiceNumber * InputField
InvoiceDate * InputField
Tax * SelectField
Amount * InputField
2023-05-14 16:46:16 +00:00
File * FileField
2023-05-03 10:46:25 +00:00
Tags * TagsField
}
func newExpenseForm ( ctx context . Context , conn * Conn , locale * Locale , company * Company ) * expenseForm {
Compute the total amount, base plus taxes, of all expenses
This works mostly like invoices: i have to “update” the expense form
to compute its total based on the subtotal and the selected taxes,
although in this case i do no need to compute the subtotal because that
is given by the user.
Nevertheless, i added a new function to compute that total because it
was already hairy enough for the dashboard, that also needs to compute
the tota, not just the base, and i wanted to test that function.
There is no need for a custom input type for that function as it only
needs a couple of simple domains. I have created the output type,
though, because otherwise i would need to have records or “reuse” any
other “amount” output type, which would be confusing.\
Part of #68.
2023-07-13 18:50:26 +00:00
triggerRecompute := template . HTMLAttr ( ` data-hx-on="change: this.dispatchEvent(new CustomEvent('recompute', { bubbles: true}))" ` )
2023-05-03 10:46:25 +00:00
return & expenseForm {
locale : locale ,
company : company ,
Invoicer : & SelectField {
Name : "invoicer" ,
Label : pgettext ( "input" , "Contact" , locale ) ,
Required : true ,
Options : mustGetContactOptions ( ctx , conn , company ) ,
} ,
InvoiceNumber : & InputField {
Name : "invoice_number" ,
Label : pgettext ( "input" , "Invoice number" , locale ) ,
Type : "text" ,
} ,
InvoiceDate : & InputField {
Name : "invoice_date" ,
Label : pgettext ( "input" , "Invoice Date" , locale ) ,
Required : true ,
Type : "date" ,
} ,
Tax : & SelectField {
Name : "tax" ,
Label : pgettext ( "input" , "Taxes" , locale ) ,
Multiple : true ,
Options : mustGetTaxOptions ( ctx , conn , company ) ,
Compute the total amount, base plus taxes, of all expenses
This works mostly like invoices: i have to “update” the expense form
to compute its total based on the subtotal and the selected taxes,
although in this case i do no need to compute the subtotal because that
is given by the user.
Nevertheless, i added a new function to compute that total because it
was already hairy enough for the dashboard, that also needs to compute
the tota, not just the base, and i wanted to test that function.
There is no need for a custom input type for that function as it only
needs a couple of simple domains. I have created the output type,
though, because otherwise i would need to have records or “reuse” any
other “amount” output type, which would be confusing.\
Part of #68.
2023-07-13 18:50:26 +00:00
Attributes : [ ] template . HTMLAttr {
triggerRecompute ,
} ,
2023-05-03 10:46:25 +00:00
} ,
Amount : & InputField {
Name : "amount" ,
Label : pgettext ( "input" , "Amount" , locale ) ,
Type : "number" ,
Required : true ,
Attributes : [ ] template . HTMLAttr {
Compute the total amount, base plus taxes, of all expenses
This works mostly like invoices: i have to “update” the expense form
to compute its total based on the subtotal and the selected taxes,
although in this case i do no need to compute the subtotal because that
is given by the user.
Nevertheless, i added a new function to compute that total because it
was already hairy enough for the dashboard, that also needs to compute
the tota, not just the base, and i wanted to test that function.
There is no need for a custom input type for that function as it only
needs a couple of simple domains. I have created the output type,
though, because otherwise i would need to have records or “reuse” any
other “amount” output type, which would be confusing.\
Part of #68.
2023-07-13 18:50:26 +00:00
triggerRecompute ,
2023-05-03 10:46:25 +00:00
` min="0" ` ,
template . HTMLAttr ( fmt . Sprintf ( ` step="%v" ` , company . MinCents ( ) ) ) ,
} ,
} ,
2023-05-14 16:46:16 +00:00
File : & FileField {
Name : "file" ,
Label : pgettext ( "input" , "File" , locale ) ,
MaxSize : 1 << 20 ,
} ,
2023-05-03 10:46:25 +00:00
Tags : & TagsField {
Name : "tags" ,
Label : pgettext ( "input" , "Tags" , locale ) ,
} ,
}
}
2023-07-11 13:33:26 +00:00
func mustGetExpenseStatusOptions ( ctx context . Context , conn * Conn , locale * Locale ) [ ] * SelectOption {
return MustGetOptions ( ctx , conn , `
select expense_status . expense_status
, esi18n . name
from expense_status
join expense_status_i18n esi18n using ( expense_status )
where esi18n . lang_tag = $ 1
order by expense_status ` , locale . Language . String ( ) )
}
2023-05-03 10:46:25 +00:00
func ( form * expenseForm ) Parse ( r * http . Request ) error {
2023-05-14 16:46:16 +00:00
if err := r . ParseMultipartForm ( form . File . MaxSize ) ; err != nil {
2023-05-03 10:46:25 +00:00
return err
}
form . Invoicer . FillValue ( r )
form . InvoiceNumber . FillValue ( r )
form . InvoiceDate . FillValue ( r )
form . Tax . FillValue ( r )
form . Amount . FillValue ( r )
2023-05-14 16:46:16 +00:00
if err := form . File . FillValue ( r ) ; err != nil {
return err
}
2023-05-03 10:46:25 +00:00
form . Tags . FillValue ( r )
return nil
}
func ( form * expenseForm ) Validate ( ) bool {
validator := newFormValidator ( )
validator . CheckValidSelectOption ( form . Invoicer , gettext ( "Selected contact is not valid." , form . locale ) )
validator . CheckValidDate ( form . InvoiceDate , gettext ( "Invoice date must be a valid date." , form . locale ) )
validator . CheckValidSelectOption ( form . Tax , gettext ( "Selected tax is not valid." , form . locale ) )
validator . CheckAtMostOneOfEachGroup ( form . Tax , gettext ( "You can only select a tax of each class." , form . locale ) )
if validator . CheckRequiredInput ( form . Amount , gettext ( "Amount can not be empty." , form . locale ) ) {
validator . CheckValidDecimal ( form . Amount , form . company . MinCents ( ) , math . MaxFloat64 , gettext ( "Amount must be a number greater than zero." , form . locale ) )
}
return validator . AllOK ( )
}
func ( form * expenseForm ) MustFillFromDatabase ( ctx context . Context , conn * Conn , slug string ) bool {
2023-07-11 13:33:26 +00:00
if notFoundErrorOrPanic ( conn . QueryRow ( ctx , `
2023-05-03 10:46:25 +00:00
select contact_id
, invoice_number
, invoice_date
, to_price ( amount , decimal_digits )
2023-10-02 10:49:54 +00:00
, array_agg ( tax_id ) filter ( where tax_id is not null )
2023-05-27 19:36:10 +00:00
, tags
2023-05-03 10:46:25 +00:00
from expense
left join expense_tax using ( expense_id )
join currency using ( currency_code )
where expense . slug = $ 1
group by contact_id
, invoice_number
, invoice_date
, amount
, decimal_digits
, tags
` , slug ) . Scan (
form . Invoicer ,
form . InvoiceNumber ,
form . InvoiceDate ,
form . Amount ,
form . Tax ,
2023-07-11 13:33:26 +00:00
form . Tags ) ) {
return false
}
2023-10-02 10:49:54 +00:00
if len ( form . Tax . Selected ) == 1 && form . Tax . Selected [ 0 ] == "" {
form . Tax . Selected = nil
}
2023-07-11 13:33:26 +00:00
return true
2023-05-03 10:46:25 +00:00
}
2023-05-05 08:59:35 +00:00
func HandleUpdateExpense ( w http . ResponseWriter , r * http . Request , params httprouter . Params ) {
conn := getConn ( r )
locale := getLocale ( r )
company := mustGetCompany ( r )
form := newExpenseForm ( 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
}
Return HTTP 404 instead of 500 for invalid UUID values in URL
Since most of PL/pgSQL functions accept a `uuid` domain, we get an error
if the value is not valid, forcing us to return an HTTP 500, as we
can not detect that the error was due to that.
Instead, i now validate that the slug is indeed a valid UUID before
attempting to send it to the database, returning the correct HTTP error
code and avoiding useless calls to the database.
I based the validation function of Parse() from Google’s uuid package[0]
because this function is an order or magnitude faster in benchmarks:
goos: linux
goarch: amd64
pkg: dev.tandem.ws/tandem/numerus/pkg
cpu: Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz
BenchmarkValidUuid-4 36946050 29.37 ns/op
BenchmarkValidUuid_Re-4 3633169 306.70 ns/op
The regular expression used for the benchmark was:
var re = regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
And the input parameter for both functions was the following valid UUID,
because most of the time the passed UUID will be valid:
"f47ac10b-58cc-0372-8567-0e02b2c3d479"
I did not use the uuid package, even though it is in Debian’s
repository, because i only need to check whether the value is valid,
not convert it to a byte array. As far as i know, that package can not
do that.
[0]: https://github.com/google/uuid
2023-07-17 09:46:11 +00:00
slug := params [ 0 ] . Value
if ! ValidUuid ( slug ) {
http . NotFound ( w , r )
return
}
2024-08-13 00:34:21 +00:00
if ! form . Validate ( ) {
2024-08-27 09:07:39 +00:00
w . WriteHeader ( http . StatusUnprocessableEntity )
2024-08-13 00:34:21 +00:00
mustRenderEditExpenseForm ( w , r , slug , form )
return
}
taxes := mustSliceAtoi ( form . Tax . Selected )
if found := conn . MustGetText ( r . Context ( ) , "" , "select edit_expense($1, $2, $3, $4, $5, $6, $7)" , slug , form . InvoiceDate , form . Invoicer , form . InvoiceNumber , form . Amount , taxes , form . Tags ) ; found == "" {
http . NotFound ( w , r )
return
}
if len ( form . File . Content ) > 0 {
conn . MustQuery ( r . Context ( ) , "select attach_to_expense($1, $2, $3, $4)" , slug , form . File . OriginalFileName , form . File . ContentType , form . File . Content )
2023-05-14 16:46:16 +00:00
}
2024-08-13 00:34:21 +00:00
htmxRedirect ( w , r , companyURI ( company , "/expenses" ) )
2023-05-05 08:59:35 +00:00
}
2023-05-07 20:49:52 +00:00
type expenseFilterForm struct {
locale * Locale
company * Company
2023-06-20 09:17:07 +00:00
Contact * SelectField
2023-05-07 20:49:52 +00:00
InvoiceNumber * InputField
FromDate * InputField
ToDate * InputField
2023-07-11 13:33:26 +00:00
ExpenseStatus * SelectField
2023-05-07 20:49:52 +00:00
Tags * TagsField
TagsCondition * ToggleField
}
func newExpenseFilterForm ( ctx context . Context , conn * Conn , locale * Locale , company * Company ) * expenseFilterForm {
return & expenseFilterForm {
locale : locale ,
company : company ,
2023-06-20 09:17:07 +00:00
Contact : & SelectField {
Name : "contact" ,
Label : pgettext ( "input" , "Contact" , locale ) ,
EmptyLabel : gettext ( "All contacts" , locale ) ,
2023-05-07 20:49:52 +00:00
Options : mustGetContactOptions ( ctx , conn , company ) ,
} ,
InvoiceNumber : & InputField {
Name : "number" ,
Label : pgettext ( "input" , "Invoice Number" , locale ) ,
Type : "search" ,
} ,
FromDate : & InputField {
Name : "from_date" ,
Label : pgettext ( "input" , "From Date" , locale ) ,
Type : "date" ,
} ,
ToDate : & InputField {
Name : "to_date" ,
Label : pgettext ( "input" , "To Date" , locale ) ,
Type : "date" ,
} ,
Tags : & TagsField {
Name : "tags" ,
Label : pgettext ( "input" , "Tags" , locale ) ,
} ,
2023-07-11 13:33:26 +00:00
ExpenseStatus : & SelectField {
Name : "expense_status" ,
Label : pgettext ( "input" , "Expense Status" , locale ) ,
EmptyLabel : gettext ( "All status" , locale ) ,
Options : mustGetExpenseStatusOptions ( ctx , conn , locale ) ,
} ,
2023-05-07 20:49:52 +00:00
TagsCondition : & ToggleField {
Name : "tags_condition" ,
Label : pgettext ( "input" , "Tags Condition" , locale ) ,
Selected : "and" ,
FirstOption : & ToggleOption {
Value : "and" ,
Label : pgettext ( "tag condition" , "All" , locale ) ,
Description : gettext ( "Invoices must have all the specified labels." , locale ) ,
} ,
SecondOption : & ToggleOption {
Value : "or" ,
Label : pgettext ( "tag condition" , "Any" , locale ) ,
Description : gettext ( "Invoices must have at least one of the specified labels." , locale ) ,
} ,
} ,
}
}
func ( form * expenseFilterForm ) Parse ( r * http . Request ) error {
if err := r . ParseForm ( ) ; err != nil {
return err
}
2023-06-20 09:17:07 +00:00
form . Contact . FillValue ( r )
2023-05-07 20:49:52 +00:00
form . InvoiceNumber . FillValue ( r )
form . FromDate . FillValue ( r )
form . ToDate . FillValue ( r )
2023-07-11 13:33:26 +00:00
form . ExpenseStatus . FillValue ( r )
2023-05-07 20:49:52 +00:00
form . Tags . FillValue ( r )
form . TagsCondition . FillValue ( r )
return nil
}
2023-05-08 10:58:54 +00:00
2023-07-16 18:56:11 +00:00
func ( form * expenseFilterForm ) HasValue ( ) bool {
return form . Contact . HasValue ( ) ||
form . InvoiceNumber . HasValue ( ) ||
form . FromDate . HasValue ( ) ||
form . ToDate . HasValue ( ) ||
form . ExpenseStatus . HasValue ( ) ||
form . Tags . HasValue ( )
}
2023-06-20 09:33:28 +00:00
func ( form * expenseFilterForm ) 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 ( "expense.company_id = $%d" , form . company . Id )
maybeAppendWhere ( "contact_id = $%d" , form . Contact . String ( ) , func ( v string ) interface { } {
customerId , _ := strconv . Atoi ( form . Contact . Selected [ 0 ] )
return customerId
} )
2023-07-11 13:33:26 +00:00
maybeAppendWhere ( "expense.expense_status = $%d" , form . ExpenseStatus . String ( ) , nil )
2023-06-20 09:33:28 +00:00
maybeAppendWhere ( "invoice_number = $%d" , form . InvoiceNumber . String ( ) , nil )
maybeAppendWhere ( "invoice_date >= $%d" , form . FromDate . String ( ) , nil )
maybeAppendWhere ( "invoice_date <= $%d" , form . ToDate . String ( ) , nil )
if len ( form . Tags . Tags ) > 0 {
if form . TagsCondition . Selected == "and" {
appendWhere ( "expense.tags @> $%d" , form . Tags )
} else {
appendWhere ( "expense.tags && $%d" , form . Tags )
}
}
return strings . Join ( where , ") AND (" ) , args
}
2023-05-08 10:58:54 +00:00
func ServeEditExpenseTags ( w http . ResponseWriter , r * http . Request , params httprouter . Params ) {
2024-08-15 02:18:18 +00:00
serveTagsEditForm ( w , r , params , "/expenses/" , "select tags from expense where slug = $1" )
2023-05-08 10:58:54 +00:00
}
func HandleUpdateExpenseTags ( w http . ResponseWriter , r * http . Request , params httprouter . Params ) {
2024-08-15 02:18:18 +00:00
handleUpdateTags ( w , r , params , "/expenses/" , "update expense set tags = $1 where slug = $2 returning slug" )
2023-05-08 10:58:54 +00:00
}
2023-05-14 16:46:16 +00:00
func ServeExpenseAttachment ( w http . ResponseWriter , r * http . Request , params httprouter . Params ) {
2024-08-11 22:07:30 +00:00
serveAttachment ( w , r , params , `
2023-05-14 16:46:16 +00:00
select mime_type
, content
from expense
join expense_attachment using ( expense_id )
where slug = $ 1
2024-08-11 22:07:30 +00:00
` )
2023-05-14 16:46:16 +00:00
}
Compute the total amount, base plus taxes, of all expenses
This works mostly like invoices: i have to “update” the expense form
to compute its total based on the subtotal and the selected taxes,
although in this case i do no need to compute the subtotal because that
is given by the user.
Nevertheless, i added a new function to compute that total because it
was already hairy enough for the dashboard, that also needs to compute
the tota, not just the base, and i wanted to test that function.
There is no need for a custom input type for that function as it only
needs a couple of simple domains. I have created the output type,
though, because otherwise i would need to have records or “reuse” any
other “amount” output type, which would be confusing.\
Part of #68.
2023-07-13 18:50:26 +00:00
func HandleEditExpenseAction ( w http . ResponseWriter , r * http . Request , params httprouter . Params ) {
slug := params [ 0 ] . Value
Add option to export the list of quotes, invoices, and expenses to ODS
This was requested by a potential user, as they want to be able to do
whatever they want to do to these lists with a spreadsheet.
In fact, they requested to be able to export to CSV, but, as always,
using CSV is a minefield because of Microsoft: since their Excel product
is fucking unable to write and read CSV from different locales, even if
using the same exact Excel product, i can not also create a CSV file
that is guaranteed to work on all locales. If i used the non-standard
sep=; thing to tell Excel that it is a fucking stupid application, then
proper applications would show that line as a row, which is the correct
albeit undesirable behaviour.
The solution is to use a spreadsheet file format that does not have this
issue. As far as I know, by default Excel is able to read XLSX and ODS
files, but i refuse to use the artificially complex, not the actually
used in Excel, and lobbied standard that Microsoft somehow convinced ISO
to publish, as i am using a different format because of the mess they
made, and i do not want to bend over in front of them, so ODS it is.
ODS is neither an elegant or good format by any means, but at least i
can write them using simple strings, because there is no ODS library
in Debian and i am not going to write yet another DEB package for an
overengineered package to write a simple table—all i want is to say
“here are these n columns, and these m columns; have a good day!”.
Part of #51.
2023-07-18 11:29:36 +00:00
switch slug {
case "batch" :
HandleBatchExpenseAction ( w , r , params )
default :
if ! ValidUuid ( slug ) {
http . NotFound ( w , r )
return
}
actionUri := fmt . Sprintf ( "/invoices/%s/edit" , slug )
handleExpenseAction ( w , r , actionUri , func ( w http . ResponseWriter , r * http . Request , form * expenseForm ) {
mustRenderEditExpenseForm ( w , r , slug , form )
} )
Return HTTP 404 instead of 500 for invalid UUID values in URL
Since most of PL/pgSQL functions accept a `uuid` domain, we get an error
if the value is not valid, forcing us to return an HTTP 500, as we
can not detect that the error was due to that.
Instead, i now validate that the slug is indeed a valid UUID before
attempting to send it to the database, returning the correct HTTP error
code and avoiding useless calls to the database.
I based the validation function of Parse() from Google’s uuid package[0]
because this function is an order or magnitude faster in benchmarks:
goos: linux
goarch: amd64
pkg: dev.tandem.ws/tandem/numerus/pkg
cpu: Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz
BenchmarkValidUuid-4 36946050 29.37 ns/op
BenchmarkValidUuid_Re-4 3633169 306.70 ns/op
The regular expression used for the benchmark was:
var re = regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
And the input parameter for both functions was the following valid UUID,
because most of the time the passed UUID will be valid:
"f47ac10b-58cc-0372-8567-0e02b2c3d479"
I did not use the uuid package, even though it is in Debian’s
repository, because i only need to check whether the value is valid,
not convert it to a byte array. As far as i know, that package can not
do that.
[0]: https://github.com/google/uuid
2023-07-17 09:46:11 +00:00
}
Compute the total amount, base plus taxes, of all expenses
This works mostly like invoices: i have to “update” the expense form
to compute its total based on the subtotal and the selected taxes,
although in this case i do no need to compute the subtotal because that
is given by the user.
Nevertheless, i added a new function to compute that total because it
was already hairy enough for the dashboard, that also needs to compute
the tota, not just the base, and i wanted to test that function.
There is no need for a custom input type for that function as it only
needs a couple of simple domains. I have created the output type,
though, because otherwise i would need to have records or “reuse” any
other “amount” output type, which would be confusing.\
Part of #68.
2023-07-13 18:50:26 +00:00
}
func HandleNewExpenseAction ( w http . ResponseWriter , r * http . Request , params httprouter . Params ) {
handleExpenseAction ( w , r , "/expenses" , mustRenderNewExpenseForm )
}
type renderExpenseFormFunc func ( w http . ResponseWriter , r * http . Request , form * expenseForm )
func handleExpenseAction ( w http . ResponseWriter , r * http . Request , action string , renderForm renderExpenseFormFunc ) {
locale := getLocale ( r )
conn := getConn ( r )
company := mustGetCompany ( r )
form := newExpenseForm ( 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
}
actionField := r . Form . Get ( "action" )
switch actionField {
case "update" :
// Nothing else to do
w . WriteHeader ( http . StatusOK )
renderForm ( w , r , form )
case "add" :
if ! form . Validate ( ) {
2024-08-27 09:07:39 +00:00
w . WriteHeader ( http . StatusUnprocessableEntity )
Compute the total amount, base plus taxes, of all expenses
This works mostly like invoices: i have to “update” the expense form
to compute its total based on the subtotal and the selected taxes,
although in this case i do no need to compute the subtotal because that
is given by the user.
Nevertheless, i added a new function to compute that total because it
was already hairy enough for the dashboard, that also needs to compute
the tota, not just the base, and i wanted to test that function.
There is no need for a custom input type for that function as it only
needs a couple of simple domains. I have created the output type,
though, because otherwise i would need to have records or “reuse” any
other “amount” output type, which would be confusing.\
Part of #68.
2023-07-13 18:50:26 +00:00
renderForm ( w , r , form )
return
}
taxes := mustSliceAtoi ( form . Tax . Selected )
2024-08-13 00:34:21 +00:00
slug := conn . MustGetText ( r . Context ( ) , "" , "select add_expense($1, $2, $3, $4, $5, $6, $7)" , company . Id , form . InvoiceDate , form . Invoicer , form . InvoiceNumber , form . Amount , taxes , form . Tags )
Compute the total amount, base plus taxes, of all expenses
This works mostly like invoices: i have to “update” the expense form
to compute its total based on the subtotal and the selected taxes,
although in this case i do no need to compute the subtotal because that
is given by the user.
Nevertheless, i added a new function to compute that total because it
was already hairy enough for the dashboard, that also needs to compute
the tota, not just the base, and i wanted to test that function.
There is no need for a custom input type for that function as it only
needs a couple of simple domains. I have created the output type,
though, because otherwise i would need to have records or “reuse” any
other “amount” output type, which would be confusing.\
Part of #68.
2023-07-13 18:50:26 +00:00
if len ( form . File . Content ) > 0 {
conn . MustQuery ( r . Context ( ) , "select attach_to_expense($1, $2, $3, $4)" , slug , form . File . OriginalFileName , form . File . ContentType , form . File . Content )
}
htmxRedirect ( w , r , companyURI ( company , action ) )
default :
http . Error ( w , gettext ( "Invalid action" , locale ) , http . StatusBadRequest )
}
}
Add option to export the list of quotes, invoices, and expenses to ODS
This was requested by a potential user, as they want to be able to do
whatever they want to do to these lists with a spreadsheet.
In fact, they requested to be able to export to CSV, but, as always,
using CSV is a minefield because of Microsoft: since their Excel product
is fucking unable to write and read CSV from different locales, even if
using the same exact Excel product, i can not also create a CSV file
that is guaranteed to work on all locales. If i used the non-standard
sep=; thing to tell Excel that it is a fucking stupid application, then
proper applications would show that line as a row, which is the correct
albeit undesirable behaviour.
The solution is to use a spreadsheet file format that does not have this
issue. As far as I know, by default Excel is able to read XLSX and ODS
files, but i refuse to use the artificially complex, not the actually
used in Excel, and lobbied standard that Microsoft somehow convinced ISO
to publish, as i am using a different format because of the mess they
made, and i do not want to bend over in front of them, so ODS it is.
ODS is neither an elegant or good format by any means, but at least i
can write them using simple strings, because there is no ODS library
in Debian and i am not going to write yet another DEB package for an
overengineered package to write a simple table—all i want is to say
“here are these n columns, and these m columns; have a good day!”.
Part of #51.
2023-07-18 11:29:36 +00:00
func HandleBatchExpenseAction ( w http . ResponseWriter , r * http . Request , _ httprouter . Params ) {
if err := r . ParseForm ( ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusBadRequest )
return
}
if err := verifyCsrfTokenValid ( r ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusForbidden )
return
}
locale := getLocale ( r )
switch r . Form . Get ( "action" ) {
case "export" :
conn := getConn ( r )
company := getCompany ( r )
filters := newExpenseFilterForm ( r . Context ( ) , conn , locale , company )
if err := filters . Parse ( r ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusBadRequest )
return
}
2024-01-26 01:29:51 +00:00
entries := mustCollectExpenseEntries ( r . Context ( ) , conn , locale , filters )
2024-07-20 20:52:00 +00:00
vatin := mustCollectExpenseEntriesVATIN ( r . Context ( ) , conn , entries )
2024-01-26 01:29:51 +00:00
taxes := mustCollectExpenseEntriesTaxes ( r . Context ( ) , conn , entries )
taxColumns := mustCollectTaxColumns ( r . Context ( ) , conn , company )
2024-07-20 20:52:00 +00:00
ods := mustWriteExpensesOds ( entries , vatin , taxes , taxColumns , locale , company )
Add option to export the list of quotes, invoices, and expenses to ODS
This was requested by a potential user, as they want to be able to do
whatever they want to do to these lists with a spreadsheet.
In fact, they requested to be able to export to CSV, but, as always,
using CSV is a minefield because of Microsoft: since their Excel product
is fucking unable to write and read CSV from different locales, even if
using the same exact Excel product, i can not also create a CSV file
that is guaranteed to work on all locales. If i used the non-standard
sep=; thing to tell Excel that it is a fucking stupid application, then
proper applications would show that line as a row, which is the correct
albeit undesirable behaviour.
The solution is to use a spreadsheet file format that does not have this
issue. As far as I know, by default Excel is able to read XLSX and ODS
files, but i refuse to use the artificially complex, not the actually
used in Excel, and lobbied standard that Microsoft somehow convinced ISO
to publish, as i am using a different format because of the mess they
made, and i do not want to bend over in front of them, so ODS it is.
ODS is neither an elegant or good format by any means, but at least i
can write them using simple strings, because there is no ODS library
in Debian and i am not going to write yet another DEB package for an
overengineered package to write a simple table—all i want is to say
“here are these n columns, and these m columns; have a good day!”.
Part of #51.
2023-07-18 11:29:36 +00:00
writeOdsResponse ( w , ods , gettext ( "expenses.ods" , locale ) )
default :
http . Error ( w , gettext ( "Invalid action" , locale ) , http . StatusBadRequest )
}
}
2024-01-26 01:29:51 +00:00
func mustCollectExpenseEntriesTaxes ( ctx context . Context , conn * Conn , entries [ ] * ExpenseEntry ) map [ int ] taxMap {
ids := mustMakeIDArray ( entries , func ( entry * ExpenseEntry ) int {
return entry . ID
} )
return mustMakeTaxMap ( ctx , conn , ids , `
select expense_id
, tax_id
, to_price ( tax . amount , decimal_digits )
from expense_tax_amount as tax
join expense using ( expense_id )
join currency using ( currency_code )
where expense_id = any ( $ 1 )
` )
}
2024-07-20 20:52:00 +00:00
func mustCollectExpenseEntriesVATIN ( ctx context . Context , conn * Conn , entries [ ] * ExpenseEntry ) map [ int ] string {
ids := mustMakeIDArray ( entries , func ( entry * ExpenseEntry ) int {
return entry . ID
} )
return mustMakeVATINMap ( ctx , conn , ids , `
select expense_id
, vatin : : text
from contact_tax_details as tax
join expense using ( contact_id )
where expense_id = any ( $ 1 )
` )
}