Compare commits

..

No commits in common. "d6034ad7327166cef5457c3a4b93627b2ec97c10" and "11d51df7fa67517dcf7a65d5b6049f2f6bd3e7b7" have entirely different histories.

16 changed files with 123 additions and 398 deletions

View File

@ -43,7 +43,7 @@ values (1, 'Melcior', 'IR1', 'Rei Blanc', parse_packed_phone_number('0732621', '
alter sequence product_product_id_seq restart; alter sequence product_product_id_seq restart;
insert into product(company_id, name, description, price) insert into product(company_id, name, description, price)
values (1, 'Or', 'Metall de transició tou, brillant, groc, pesant, mal·leable, dúctil i que no reacciona amb la majoria de productes químics, però és sensible al clor i a laigua règia.', 5592) values (1, 'Or', 'Metall de transició tou, brillant, groc, pesant, mal·leable, dúctil i que no reacciona amb la majoria de productes químics, però és sensible al clor i a laigua règia.', 5592)
, (1, 'Encens', 'Goma resina fragrant que desprèn una olor característica quan es crema.', 215) , (1, 'Encens', 'Goma resina fragrant que desprèn una olor característica quan es crema.', 215)
, (1, 'Mirra', 'Goma resinosa aromàtica de color gris groguenc i gust amargant.', 690) , (1, 'Mirra', 'Goma resinosa aromàtica de color gris groguenc i gust amargant.', 690)
, (1, 'Paper higiènic (pack de 32 U)', 'Paper que susa per mantenir la higiene personal després de defecar o orinar.', 799) , (1, 'Paper higiènic (pack de 32 U)', 'Paper que susa per mantenir la higiene personal després de defecar o orinar.', 799)

View File

@ -1,19 +1,29 @@
-- Deploy numerus:invoice_amount to pg -- Deploy numerus:invoice_amount to pg
-- requires: schema_numerus -- requires: schema_numerus
-- requires: invoice_product -- requires: invoice_product
-- requires: invoice_product_amount -- requires: invoice_tax_amount
begin; begin;
set search_path to numerus, public; set search_path to numerus, public;
create or replace view invoice_amount as create or replace view invoice_amount as
with taxable as (
select invoice_id
, sum(round(price * quantity * (1 - discount_rate))::integer)::integer as subtotal
from invoice_product
group by invoice_id
), taxes as (
select invoice_id
, sum(amount)::integer as tax_amount
from invoice_tax_amount
group by invoice_id
)
select invoice_id select invoice_id
, sum(subtotal)::integer as subtotal , subtotal
, sum(total)::integer as total , subtotal + coalesce(tax_amount, 0) as total
from invoice_product from taxable
join invoice_product_amount using (invoice_product_id) left join taxes using (invoice_id)
group by invoice_id
; ;
grant select on table invoice_amount to invoicer; grant select on table invoice_amount to invoicer;

View File

@ -1,22 +0,0 @@
-- Deploy numerus:invoice_product_amount to pg
-- requires: schema_numerus
-- requires: invoice_product
-- requires: invoice_product_tax
begin;
set search_path to numerus, public;
create or replace view invoice_product_amount as
select invoice_product_id
, round(price * quantity * (1 - discount_rate))::integer as subtotal
, max(round(price * quantity * (1 - discount_rate))::integer) + coalesce(sum(round(round(price * quantity * (1 - discount_rate))::integer * tax_rate)::integer)::integer, 0) as total
from invoice_product
left join invoice_product_tax using (invoice_product_id)
group by invoice_product_id, price, quantity, discount_rate
;
grant select on table invoice_product_amount to invoicer;
grant select on table invoice_product_amount to admin;
commit;

View File

@ -66,7 +66,6 @@ func (field *InputField) Float64() float64 {
type SelectOption struct { type SelectOption struct {
Value string Value string
Label string Label string
Group string
} }
type SelectField struct { type SelectField struct {
@ -127,17 +126,13 @@ func (field *SelectField) IsSelected(v string) bool {
return false return false
} }
func (field *SelectField) FindOption(value string) *SelectOption { func (field *SelectField) isValidOption(selected string) bool {
for _, option := range field.Options { for _, option := range field.Options {
if option.Value == value { if option.Value == selected {
return option return true
} }
} }
return nil return false
}
func (field *SelectField) isValidOption(selected string) bool {
return field.FindOption(selected) != nil
} }
func MustGetOptions(ctx context.Context, conn *Conn, sql string, args ...interface{}) []*SelectOption { func MustGetOptions(ctx context.Context, conn *Conn, sql string, args ...interface{}) []*SelectOption {
@ -163,29 +158,6 @@ func MustGetOptions(ctx context.Context, conn *Conn, sql string, args ...interfa
return options return options
} }
func MustGetGroupedOptions(ctx context.Context, conn *Conn, sql string, args ...interface{}) []*SelectOption {
rows, err := conn.Query(ctx, sql, args...)
if err != nil {
panic(err)
}
defer rows.Close()
var options []*SelectOption
for rows.Next() {
option := &SelectOption{}
err = rows.Scan(&option.Value, &option.Label, &option.Group)
if err != nil {
panic(err)
}
options = append(options, option)
}
if rows.Err() != nil {
panic(rows.Err())
}
return options
}
func mustGetCountryOptions(ctx context.Context, conn *Conn, locale *Locale) []*SelectOption { func mustGetCountryOptions(ctx context.Context, conn *Conn, locale *Locale) []*SelectOption {
return MustGetOptions(ctx, conn, "select country.country_code, coalesce(i18n.name, country.name) as l10n_name from country left join country_i18n as i18n on country.country_code = i18n.country_code and i18n.lang_tag = $1 order by l10n_name", locale.Language) return MustGetOptions(ctx, conn, "select country.country_code, coalesce(i18n.name, country.name) as l10n_name from country left join country_i18n as i18n on country.country_code = i18n.country_code and i18n.lang_tag = $1 order by l10n_name", locale.Language)
} }
@ -229,20 +201,6 @@ func (v *FormValidator) CheckValidSelectOption(field *SelectField, message strin
return v.checkSelect(field, field.HasValidOptions(), message) return v.checkSelect(field, field.HasValidOptions(), message)
} }
func (v *FormValidator) CheckAtMostOneOfEachGroup(field *SelectField, message string) bool {
repeated := false
groups := map[string]bool{}
for _, selected := range field.Selected {
option := field.FindOption(selected)
if exists := groups[option.Group]; exists {
repeated = true
break
}
groups[option.Group] = true
}
return v.checkSelect(field, !repeated, message)
}
func (v *FormValidator) CheckValidURL(field *InputField, message string) bool { func (v *FormValidator) CheckValidURL(field *InputField, message string) bool {
_, err := url.ParseRequestURI(field.Val) _, err := url.ParseRequestURI(field.Val)
return v.checkInput(field, err == nil, message) return v.checkInput(field, err == nil, message)

View File

@ -11,7 +11,6 @@ import (
"math" "math"
"net/http" "net/http"
"os/exec" "os/exec"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -121,18 +120,16 @@ func mustClose(closer io.Closer) {
} }
type invoice struct { type invoice struct {
Number string Number string
Slug string Slug string
Date time.Time Date time.Time
Invoicer taxDetails Invoicer taxDetails
Invoicee taxDetails Invoicee taxDetails
Notes string Notes string
Products []*invoiceProduct Products []*invoiceProduct
Subtotal string Subtotal string
Taxes [][]string Taxes [][]string
TaxClasses []string Total string
HasDiscounts bool
Total string
} }
type taxDetails struct { type taxDetails struct {
@ -150,10 +147,7 @@ type invoiceProduct struct {
Name string Name string
Description string Description string
Price string Price string
Discount int
Quantity int Quantity int
Taxes map[string]int
Subtotal string
Total string Total string
} }
@ -172,31 +166,15 @@ func mustGetInvoice(ctx context.Context, conn *Conn, company *Company, slug stri
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 { 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) panic(err)
} }
rows := conn.MustQuery(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]) 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.name, description, discount_rate, price, quantity, subtotal, total", invoiceId, decimalDigits) rows := conn.MustQuery(ctx, "select name, description, to_price(price, $2), quantity, to_price(round(price * quantity * (1 - discount_rate))::integer, 2) from invoice_product where invoice_id = $1", invoiceId, decimalDigits)
defer rows.Close() defer rows.Close()
taxClasses := map[string]bool{}
for rows.Next() { for rows.Next() {
product := &invoiceProduct{ product := &invoiceProduct{}
Taxes: make(map[string]int), if err := rows.Scan(&product.Name, &product.Description, &product.Price, &product.Quantity, &product.Total); err != nil {
}
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) 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) inv.Products = append(inv.Products, product)
} }
for taxClass := range taxClasses {
inv.TaxClasses = append(inv.TaxClasses, taxClass)
}
sort.Strings(inv.TaxClasses)
if rows.Err() != nil { if rows.Err() != nil {
panic(rows.Err()) panic(rows.Err())
} }
@ -440,7 +418,7 @@ func (form *invoiceForm) AddProducts(ctx context.Context, conn *Conn, productsId
} }
func mustGetTaxOptions(ctx context.Context, conn *Conn, company *Company) []*SelectOption { func mustGetTaxOptions(ctx context.Context, conn *Conn, company *Company) []*SelectOption {
return MustGetGroupedOptions(ctx, conn, "select tax_id::text, tax.name, tax_class.name from tax join tax_class using (tax_class_id) where tax.company_id = $1 order by tax_class.name, tax.name", company.Id) return MustGetOptions(ctx, conn, "select tax_id::text, name from tax where company_id = $1 order by name", company.Id)
} }
type invoiceProductForm struct { type invoiceProductForm struct {
@ -547,6 +525,5 @@ func (form *invoiceProductForm) Validate() bool {
validator.CheckValidInteger(form.Discount, 0, 100, gettext("Discount must be a percentage between 0 and 100.", 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)) 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))
return validator.AllOK() return validator.AllOK()
} }

View File

@ -208,6 +208,5 @@ func (form *productForm) Validate() bool {
validator.CheckValidDecimal(form.Price, form.company.MinCents(), math.MaxFloat64, gettext("Price must be a number greater than zero.", form.locale)) validator.CheckValidDecimal(form.Price, form.company.MinCents(), math.MaxFloat64, gettext("Price must be a number greater than zero.", form.locale))
} }
validator.CheckValidSelectOption(form.Tax, gettext("Selected tax is not valid.", 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))
return validator.AllOK() return validator.AllOK()
} }

View File

@ -43,9 +43,6 @@ func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename s
"formatDate": func(time time.Time) template.HTML { "formatDate": func(time time.Time) template.HTML {
return template.HTML(`<time datetime="` + time.Format("2006-01-02") + `">` + time.Format("02/01/2006") + "</time>") return template.HTML(`<time datetime="` + time.Format("2006-01-02") + `">` + time.Format("02/01/2006") + "</time>")
}, },
"formatPercent": func(value int) string {
return fmt.Sprintf("%d %%", value)
},
"csrfToken": func() template.HTML { "csrfToken": func() template.HTML {
return template.HTML(fmt.Sprintf(`<input type="hidden" name="%s" value="%s">`, csrfTokenField, user.CsrfToken)) return template.HTML(fmt.Sprintf(`<input type="hidden" name="%s" value="%s">`, csrfTokenField, user.CsrfToken))
}, },
@ -57,16 +54,6 @@ func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename s
field.Attributes = append(field.Attributes, template.HTMLAttr(attr)) field.Attributes = append(field.Attributes, template.HTMLAttr(attr))
return field return field
}, },
"boolToInt": func(b bool) int {
if b {
return 1
} else {
return 0
}
},
"add": func(y, x int) int {
return x + y
},
"sub": func(y, x int) int { "sub": func(y, x int) int {
return x - y return x - y
}, },

View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: numerus\n" "Project-Id-Version: numerus\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-03-01 14:00+0100\n" "POT-Creation-Date: 2023-02-28 11:56+0100\n"
"PO-Revision-Date: 2023-01-18 17:08+0100\n" "PO-Revision-Date: 2023-01-18 17:08+0100\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n" "Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Catalan <ca@dodds.net>\n" "Language-Team: Catalan <ca@dodds.net>\n"
@ -59,7 +59,7 @@ msgid "Name"
msgstr "Nom" msgstr "Nom"
#: web/template/invoices/products.gohtml:43 #: web/template/invoices/products.gohtml:43
#: web/template/invoices/view.gohtml:60 web/template/products/index.gohtml:23 #: web/template/invoices/view.gohtml:59 web/template/products/index.gohtml:23
msgctxt "title" msgctxt "title"
msgid "Price" msgid "Price"
msgstr "Preu" msgstr "Preu"
@ -74,13 +74,13 @@ msgctxt "action"
msgid "Add products" msgid "Add products"
msgstr "Afegeix productes" msgstr "Afegeix productes"
#: web/template/invoices/new.gohtml:41 web/template/invoices/view.gohtml:65 #: web/template/invoices/new.gohtml:41 web/template/invoices/view.gohtml:61
#: web/template/invoices/view.gohtml:87
msgctxt "title" msgctxt "title"
msgid "Subtotal" msgid "Subtotal"
msgstr "Subtotal" msgstr "Subtotal"
#: web/template/invoices/new.gohtml:51 web/template/invoices/view.gohtml:69 #: web/template/invoices/new.gohtml:51 web/template/invoices/view.gohtml:97
#: web/template/invoices/view.gohtml:109
msgctxt "title" msgctxt "title"
msgid "Total" msgid "Total"
msgstr "Total" msgstr "Total"
@ -149,26 +149,16 @@ msgctxt "action"
msgid "Download invoice" msgid "Download invoice"
msgstr "Descarrega factura" msgstr "Descarrega factura"
#: web/template/invoices/view.gohtml:59 #: web/template/invoices/view.gohtml:58
msgctxt "title" msgctxt "title"
msgid "Concept" msgid "Concept"
msgstr "Concepte" msgstr "Concepte"
#: web/template/invoices/view.gohtml:62 #: web/template/invoices/view.gohtml:60
msgctxt "title"
msgid "Discount"
msgstr "Descompte"
#: web/template/invoices/view.gohtml:64
msgctxt "title" msgctxt "title"
msgid "Units" msgid "Units"
msgstr "Unitats" msgstr "Unitats"
#: web/template/invoices/view.gohtml:99
msgctxt "title"
msgid "Tax Base"
msgstr "Base imposable"
#: web/template/dashboard.gohtml:2 #: web/template/dashboard.gohtml:2
msgctxt "title" msgctxt "title"
msgid "Dashboard" msgid "Dashboard"
@ -384,47 +374,43 @@ msgstr "No podeu deixar la contrasenya en blanc."
msgid "Invalid user or password." msgid "Invalid user or password."
msgstr "Nom dusuari o contrasenya incorrectes." msgstr "Nom dusuari o contrasenya incorrectes."
#: pkg/products.go:165 pkg/invoices.go:468 #: pkg/products.go:165 pkg/invoices.go:446
msgctxt "input" msgctxt "input"
msgid "Name" msgid "Name"
msgstr "Nom" msgstr "Nom"
#: pkg/products.go:171 pkg/invoices.go:473 #: pkg/products.go:171 pkg/invoices.go:451
msgctxt "input" msgctxt "input"
msgid "Description" msgid "Description"
msgstr "Descripció" msgstr "Descripció"
#: pkg/products.go:176 pkg/invoices.go:477 #: pkg/products.go:176 pkg/invoices.go:455
msgctxt "input" msgctxt "input"
msgid "Price" msgid "Price"
msgstr "Preu" msgstr "Preu"
#: pkg/products.go:186 pkg/invoices.go:366 pkg/invoices.go:503 #: pkg/products.go:186 pkg/invoices.go:344 pkg/invoices.go:481
msgctxt "input" msgctxt "input"
msgid "Taxes" msgid "Taxes"
msgstr "Imposts" msgstr "Imposts"
#: pkg/products.go:206 pkg/profile.go:92 pkg/invoices.go:399 #: pkg/products.go:206 pkg/profile.go:92 pkg/invoices.go:377
#: pkg/invoices.go:539 #: pkg/invoices.go:517
msgid "Name can not be empty." msgid "Name can not be empty."
msgstr "No podeu deixar el nom en blanc." msgstr "No podeu deixar el nom en blanc."
#: pkg/products.go:207 pkg/invoices.go:540 #: pkg/products.go:207 pkg/invoices.go:518
msgid "Price can not be empty." msgid "Price can not be empty."
msgstr "No podeu deixar el preu en blanc." msgstr "No podeu deixar el preu en blanc."
#: pkg/products.go:208 pkg/invoices.go:541 #: pkg/products.go:208 pkg/invoices.go:519
msgid "Price must be a number greater than zero." msgid "Price must be a number greater than zero."
msgstr "El preu ha de ser un número major a zero." msgstr "El preu ha de ser un número major a zero."
#: pkg/products.go:210 pkg/invoices.go:403 pkg/invoices.go:549 #: pkg/products.go:210 pkg/invoices.go:381 pkg/invoices.go:527
msgid "Selected tax is not valid." msgid "Selected tax is not valid."
msgstr "Heu seleccionat un impost que no és vàlid." msgstr "Heu seleccionat un impost que no és vàlid."
#: pkg/products.go:211 pkg/invoices.go:550
msgid "You can only select a tax of each class."
msgstr "Només podeu seleccionar un impost de cada classe."
#: pkg/company.go:90 #: pkg/company.go:90
msgctxt "input" msgctxt "input"
msgid "Currency" msgid "Currency"
@ -497,70 +483,70 @@ msgstr "La confirmació no és igual a la contrasenya."
msgid "Selected language is not valid." msgid "Selected language is not valid."
msgstr "Heu seleccionat un idioma que no és vàlid." msgstr "Heu seleccionat un idioma que no és vàlid."
#: pkg/invoices.go:229 #: pkg/invoices.go:207
msgid "Select a customer to bill." msgid "Select a customer to bill."
msgstr "Escolliu un client a facturar." msgstr "Escolliu un client a facturar."
#: pkg/invoices.go:322 #: pkg/invoices.go:300
msgid "Invalid action" msgid "Invalid action"
msgstr "Acció invàlida." msgstr "Acció invàlida."
#: pkg/invoices.go:343 #: pkg/invoices.go:321
msgctxt "input" msgctxt "input"
msgid "Customer" msgid "Customer"
msgstr "Client" msgstr "Client"
#: pkg/invoices.go:349 #: pkg/invoices.go:327
msgctxt "input" msgctxt "input"
msgid "Number" msgid "Number"
msgstr "Número" msgstr "Número"
#: pkg/invoices.go:355 #: pkg/invoices.go:333
msgctxt "input" msgctxt "input"
msgid "Invoice Date" msgid "Invoice Date"
msgstr "Data de factura" msgstr "Data de factura"
#: pkg/invoices.go:361 #: pkg/invoices.go:339
msgctxt "input" msgctxt "input"
msgid "Notes" msgid "Notes"
msgstr "Notes" msgstr "Notes"
#: pkg/invoices.go:400 #: pkg/invoices.go:378
msgid "Invoice date can not be empty." msgid "Invoice date can not be empty."
msgstr "No podeu deixar la data de la factura en blanc." msgstr "No podeu deixar la data de la factura en blanc."
#: pkg/invoices.go:401 #: pkg/invoices.go:379
msgid "Invoice date must be a valid date." msgid "Invoice date must be a valid date."
msgstr "La data de facturació ha de ser vàlida." msgstr "La data de facturació ha de ser vàlida."
#: pkg/invoices.go:463 #: pkg/invoices.go:441
msgctxt "input" msgctxt "input"
msgid "Id" msgid "Id"
msgstr "Identificador" msgstr "Identificador"
#: pkg/invoices.go:486 #: pkg/invoices.go:464
msgctxt "input" msgctxt "input"
msgid "Quantity" msgid "Quantity"
msgstr "Quantitat" msgstr "Quantitat"
#: pkg/invoices.go:494 #: pkg/invoices.go:472
msgctxt "input" msgctxt "input"
msgid "Discount (%)" msgid "Discount (%)"
msgstr "Descompte (%)" msgstr "Descompte (%)"
#: pkg/invoices.go:543 #: pkg/invoices.go:521
msgid "Quantity can not be empty." msgid "Quantity can not be empty."
msgstr "No podeu deixar la quantitat en blanc." msgstr "No podeu deixar la quantitat en blanc."
#: pkg/invoices.go:544 #: pkg/invoices.go:522
msgid "Quantity must be a number greater than zero." msgid "Quantity must be a number greater than zero."
msgstr "La quantitat ha de ser un número major a zero." msgstr "La quantitat ha de ser un número major a zero."
#: pkg/invoices.go:546 #: pkg/invoices.go:524
msgid "Discount can not be empty." msgid "Discount can not be empty."
msgstr "No podeu deixar el descompte en blanc." msgstr "No podeu deixar el descompte en blanc."
#: pkg/invoices.go:547 #: pkg/invoices.go:525
msgid "Discount must be a percentage between 0 and 100." msgid "Discount must be a percentage between 0 and 100."
msgstr "El descompte ha de ser un percentatge entre 0 i 100." msgstr "El descompte ha de ser un percentatge entre 0 i 100."

View File

@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: numerus\n" "Project-Id-Version: numerus\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-03-01 14:00+0100\n" "POT-Creation-Date: 2023-02-28 11:56+0100\n"
"PO-Revision-Date: 2023-01-18 17:45+0100\n" "PO-Revision-Date: 2023-01-18 17:45+0100\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n" "Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Spanish <es@tp.org.es>\n" "Language-Team: Spanish <es@tp.org.es>\n"
@ -59,7 +59,7 @@ msgid "Name"
msgstr "Nombre" msgstr "Nombre"
#: web/template/invoices/products.gohtml:43 #: web/template/invoices/products.gohtml:43
#: web/template/invoices/view.gohtml:60 web/template/products/index.gohtml:23 #: web/template/invoices/view.gohtml:59 web/template/products/index.gohtml:23
msgctxt "title" msgctxt "title"
msgid "Price" msgid "Price"
msgstr "Precio" msgstr "Precio"
@ -74,13 +74,13 @@ msgctxt "action"
msgid "Add products" msgid "Add products"
msgstr "Añadir productos" msgstr "Añadir productos"
#: web/template/invoices/new.gohtml:41 web/template/invoices/view.gohtml:65 #: web/template/invoices/new.gohtml:41 web/template/invoices/view.gohtml:61
#: web/template/invoices/view.gohtml:87
msgctxt "title" msgctxt "title"
msgid "Subtotal" msgid "Subtotal"
msgstr "Subtotal" msgstr "Subtotal"
#: web/template/invoices/new.gohtml:51 web/template/invoices/view.gohtml:69 #: web/template/invoices/new.gohtml:51 web/template/invoices/view.gohtml:97
#: web/template/invoices/view.gohtml:109
msgctxt "title" msgctxt "title"
msgid "Total" msgid "Total"
msgstr "Total" msgstr "Total"
@ -149,26 +149,16 @@ msgctxt "action"
msgid "Download invoice" msgid "Download invoice"
msgstr "Descargar factura" msgstr "Descargar factura"
#: web/template/invoices/view.gohtml:59 #: web/template/invoices/view.gohtml:58
msgctxt "title" msgctxt "title"
msgid "Concept" msgid "Concept"
msgstr "Concepto" msgstr "Concepto"
#: web/template/invoices/view.gohtml:62 #: web/template/invoices/view.gohtml:60
msgctxt "title"
msgid "Discount"
msgstr "Descuento"
#: web/template/invoices/view.gohtml:64
msgctxt "title" msgctxt "title"
msgid "Units" msgid "Units"
msgstr "Unidades" msgstr "Unidades"
#: web/template/invoices/view.gohtml:99
msgctxt "title"
msgid "Tax Base"
msgstr "Base imponible"
#: web/template/dashboard.gohtml:2 #: web/template/dashboard.gohtml:2
msgctxt "title" msgctxt "title"
msgid "Dashboard" msgid "Dashboard"
@ -384,47 +374,43 @@ msgstr "No podéis dejar la contraseña en blanco."
msgid "Invalid user or password." msgid "Invalid user or password."
msgstr "Nombre de usuario o contraseña inválido." msgstr "Nombre de usuario o contraseña inválido."
#: pkg/products.go:165 pkg/invoices.go:468 #: pkg/products.go:165 pkg/invoices.go:446
msgctxt "input" msgctxt "input"
msgid "Name" msgid "Name"
msgstr "Nombre" msgstr "Nombre"
#: pkg/products.go:171 pkg/invoices.go:473 #: pkg/products.go:171 pkg/invoices.go:451
msgctxt "input" msgctxt "input"
msgid "Description" msgid "Description"
msgstr "Descripción" msgstr "Descripción"
#: pkg/products.go:176 pkg/invoices.go:477 #: pkg/products.go:176 pkg/invoices.go:455
msgctxt "input" msgctxt "input"
msgid "Price" msgid "Price"
msgstr "Precio" msgstr "Precio"
#: pkg/products.go:186 pkg/invoices.go:366 pkg/invoices.go:503 #: pkg/products.go:186 pkg/invoices.go:344 pkg/invoices.go:481
msgctxt "input" msgctxt "input"
msgid "Taxes" msgid "Taxes"
msgstr "Impuestos" msgstr "Impuestos"
#: pkg/products.go:206 pkg/profile.go:92 pkg/invoices.go:399 #: pkg/products.go:206 pkg/profile.go:92 pkg/invoices.go:377
#: pkg/invoices.go:539 #: pkg/invoices.go:517
msgid "Name can not be empty." msgid "Name can not be empty."
msgstr "No podéis dejar el nombre en blanco." msgstr "No podéis dejar el nombre en blanco."
#: pkg/products.go:207 pkg/invoices.go:540 #: pkg/products.go:207 pkg/invoices.go:518
msgid "Price can not be empty." msgid "Price can not be empty."
msgstr "No podéis dejar el precio en blanco." msgstr "No podéis dejar el precio en blanco."
#: pkg/products.go:208 pkg/invoices.go:541 #: pkg/products.go:208 pkg/invoices.go:519
msgid "Price must be a number greater than zero." msgid "Price must be a number greater than zero."
msgstr "El precio tiene que ser un número mayor a cero." msgstr "El precio tiene que ser un número mayor a cero."
#: pkg/products.go:210 pkg/invoices.go:403 pkg/invoices.go:549 #: pkg/products.go:210 pkg/invoices.go:381 pkg/invoices.go:527
msgid "Selected tax is not valid." msgid "Selected tax is not valid."
msgstr "Habéis escogido un impuesto que no es válido." msgstr "Habéis escogido un impuesto que no es válido."
#: pkg/products.go:211 pkg/invoices.go:550
msgid "You can only select a tax of each class."
msgstr "Solo podéis escojer un impuesto de cada clase."
#: pkg/company.go:90 #: pkg/company.go:90
msgctxt "input" msgctxt "input"
msgid "Currency" msgid "Currency"
@ -497,70 +483,70 @@ msgstr "La confirmación no corresponde con la contraseña."
msgid "Selected language is not valid." msgid "Selected language is not valid."
msgstr "Habéis escogido un idioma que no es válido." msgstr "Habéis escogido un idioma que no es válido."
#: pkg/invoices.go:229 #: pkg/invoices.go:207
msgid "Select a customer to bill." msgid "Select a customer to bill."
msgstr "Escoged un cliente a facturar." msgstr "Escoged un cliente a facturar."
#: pkg/invoices.go:322 #: pkg/invoices.go:300
msgid "Invalid action" msgid "Invalid action"
msgstr "Acción inválida." msgstr "Acción inválida."
#: pkg/invoices.go:343 #: pkg/invoices.go:321
msgctxt "input" msgctxt "input"
msgid "Customer" msgid "Customer"
msgstr "Cliente" msgstr "Cliente"
#: pkg/invoices.go:349 #: pkg/invoices.go:327
msgctxt "input" msgctxt "input"
msgid "Number" msgid "Number"
msgstr "Número" msgstr "Número"
#: pkg/invoices.go:355 #: pkg/invoices.go:333
msgctxt "input" msgctxt "input"
msgid "Invoice Date" msgid "Invoice Date"
msgstr "Fecha de factura" msgstr "Fecha de factura"
#: pkg/invoices.go:361 #: pkg/invoices.go:339
msgctxt "input" msgctxt "input"
msgid "Notes" msgid "Notes"
msgstr "Notas" msgstr "Notas"
#: pkg/invoices.go:400 #: pkg/invoices.go:378
msgid "Invoice date can not be empty." msgid "Invoice date can not be empty."
msgstr "No podéis dejar la fecha de la factura en blanco." msgstr "No podéis dejar la fecha de la factura en blanco."
#: pkg/invoices.go:401 #: pkg/invoices.go:379
msgid "Invoice date must be a valid date." msgid "Invoice date must be a valid date."
msgstr "La fecha de factura debe ser válida." msgstr "La fecha de factura debe ser válida."
#: pkg/invoices.go:463 #: pkg/invoices.go:441
msgctxt "input" msgctxt "input"
msgid "Id" msgid "Id"
msgstr "Identificador" msgstr "Identificador"
#: pkg/invoices.go:486 #: pkg/invoices.go:464
msgctxt "input" msgctxt "input"
msgid "Quantity" msgid "Quantity"
msgstr "Cantidad" msgstr "Cantidad"
#: pkg/invoices.go:494 #: pkg/invoices.go:472
msgctxt "input" msgctxt "input"
msgid "Discount (%)" msgid "Discount (%)"
msgstr "Descuento (%)" msgstr "Descuento (%)"
#: pkg/invoices.go:543 #: pkg/invoices.go:521
msgid "Quantity can not be empty." msgid "Quantity can not be empty."
msgstr "No podéis dejar la cantidad en blanco." msgstr "No podéis dejar la cantidad en blanco."
#: pkg/invoices.go:544 #: pkg/invoices.go:522
msgid "Quantity must be a number greater than zero." msgid "Quantity must be a number greater than zero."
msgstr "La cantidad tiene que ser un número mayor a cero." msgstr "La cantidad tiene que ser un número mayor a cero."
#: pkg/invoices.go:546 #: pkg/invoices.go:524
msgid "Discount can not be empty." msgid "Discount can not be empty."
msgstr "No podéis dejar el descuento en blanco." msgstr "No podéis dejar el descuento en blanco."
#: pkg/invoices.go:547 #: pkg/invoices.go:525
msgid "Discount must be a percentage between 0 and 100." msgid "Discount must be a percentage between 0 and 100."
msgstr "El descuento tiene que ser un percentage entre 0 y 100." msgstr "El descuento tiene que ser un percentage entre 0 y 100."

View File

@ -1,7 +0,0 @@
-- Revert numerus:invoice_product_amount from pg
begin;
drop view if exists numerus.invoice_product_amount;
commit;

View File

@ -58,7 +58,6 @@ invoice_number_counter [schema_numerus company] 2023-02-17T13:04:48Z jordi fita
next_invoice_number [schema_numerus invoice_number_counter] 2023-02-17T13:21:48Z jordi fita mas <jordi@tandem.blog> # Add function to retrieve the next invoice number next_invoice_number [schema_numerus invoice_number_counter] 2023-02-17T13:21:48Z jordi fita mas <jordi@tandem.blog> # Add function to retrieve the next invoice number
add_invoice [schema_numerus invoice company currency parse_price new_invoice_product tax invoice_product invoice_product_tax next_invoice_number] 2023-02-16T21:12:46Z jordi fita mas <jordi@tandem.blog> # Add function to create new invoices add_invoice [schema_numerus invoice company currency parse_price new_invoice_product tax invoice_product invoice_product_tax next_invoice_number] 2023-02-16T21:12:46Z jordi fita mas <jordi@tandem.blog> # Add function to create new invoices
invoice_tax_amount [schema_numerus invoice_product invoice_product_tax] 2023-02-22T12:08:35Z jordi fita mas <jordi@tandem.blog> # Add view for invoice tax amount invoice_tax_amount [schema_numerus invoice_product invoice_product_tax] 2023-02-22T12:08:35Z jordi fita mas <jordi@tandem.blog> # Add view for invoice tax amount
invoice_product_amount [schema_numerus invoice_product invoice_product_tax] 2023-03-01T11:18:05Z jordi fita mas <jordi@tandem.blog> # Add view for invoice product subtotal and total invoice_amount [schema_numerus invoice_product invoice_tax_amount] 2023-02-22T12:58:46Z jordi fita mas <jordi@tandem.blog> # Add view to compute subtotal and total for invoices
invoice_amount [schema_numerus invoice_product invoice_product_amount] 2023-02-22T12:58:46Z jordi fita mas <jordi@tandem.blog> # Add view to compute subtotal and total for invoices
new_invoice_amount [schema_numerus] 2023-02-23T12:08:25Z jordi fita mas <jordi@tandem.blog> # Add type to return when computing new invoice amounts new_invoice_amount [schema_numerus] 2023-02-23T12:08:25Z jordi fita mas <jordi@tandem.blog> # Add type to return when computing new invoice amounts
compute_new_invoice_amount [schema_numerus company currency tax new_invoice_product new_invoice_amount] 2023-02-23T12:20:13Z jordi fita mas <jordi@tandem.blog> # Add function to compute the subtotal, taxes, and total amounts for a new invoice compute_new_invoice_amount [schema_numerus company currency tax new_invoice_product new_invoice_amount] 2023-02-23T12:20:13Z jordi fita mas <jordi@tandem.blog> # Add function to compute the subtotal, taxes, and total amounts for a new invoice

View File

@ -1,110 +0,0 @@
-- Test invoice_product_amount
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(12);
set search_path to numerus, auth, public;
select has_view('invoice_product_amount');
select table_privs_are('invoice_product_amount', 'guest', array[]::text[]);
select table_privs_are('invoice_product_amount', 'invoicer', array['SELECT']);
select table_privs_are('invoice_product_amount', 'admin', array['SELECT']);
select table_privs_are('invoice_product_amount', 'authenticator', array[]::text[]);
select has_column('invoice_product_amount', 'invoice_product_id');
select col_type_is('invoice_product_amount', 'invoice_product_id', 'integer');
select has_column('invoice_product_amount', 'subtotal');
select col_type_is('invoice_product_amount', 'subtotal', 'integer');
select has_column('invoice_product_amount', 'total');
select col_type_is('invoice_product_amount', 'total', 'integer');
set client_min_messages to warning;
truncate invoice_product_tax cascade;
truncate invoice_product cascade;
truncate invoice cascade;
truncate contact cascade;
truncate product cascade;
truncate tax cascade;
truncate tax_class cascade;
truncate company cascade;
reset client_min_messages;
insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code)
values (1, 'Company 1', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR')
;
insert into tax_class (tax_class_id, company_id, name)
values (11, 1, 'tax')
;
insert into tax (tax_id, company_id, tax_class_id, name, rate)
values (2, 1, 11, 'IRPF -15 %', -0.15)
, (3, 1, 11, 'IVA 4 %', 0.04)
, (4, 1, 11, 'IVA 10 %', 0.10)
, (5, 1, 11, 'IVA 21 %', 0.21)
;
insert into product (product_id, company_id, name, price)
values (6, 1, 'Product', 1212)
;
insert into contact (contact_id, company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code)
values (7, 1, 'Contact', 'XX555', '', '777-777-777', 'c@c', '', '', '', '', '', 'ES')
;
insert into invoice (invoice_id, company_id, invoice_number, invoice_date, contact_id, currency_code)
values ( 8, 1, 'I1', current_date, 7, 'EUR')
, ( 9, 1, 'I2', current_date, 7, 'EUR')
, (10, 1, 'I3', current_date, 7, 'EUR')
, (11, 1, 'I4', current_date, 7, 'EUR')
;
insert into invoice_product (invoice_product_id, invoice_id, product_id, name, price, quantity, discount_rate)
values (12, 8, 6, 'P', 100, 1, 0.0)
, (13, 8, 6, 'P', 200, 2, 0.1)
, (14, 9, 6, 'P', 222, 3, 0.0)
, (15, 9, 6, 'P', 333, 4, 0.2)
, (16, 10, 6, 'P', 444, 5, 0.0)
, (17, 10, 6, 'P', 555, 6, 0.1)
, (18, 11, 6, 'P', 777, 8, 0.0)
;
insert into invoice_product_tax (invoice_product_id, tax_id, tax_rate)
values (12, 2, -0.15)
, (12, 5, 0.21)
, (13, 3, 0.04)
, (14, 4, 0.10)
, (14, 5, 0.21)
, (14, 2, -0.07)
, (15, 4, 0.10)
, (16, 4, 0.10)
, (16, 5, 0.21)
, (17, 5, 0.21)
, (17, 3, 0.04)
;
select bag_eq(
$$ select invoice_product_id, subtotal, total from invoice_product_amount $$,
$$ values (12, 100, 106)
, (13, 360, 374)
, (14, 666, 826)
, (15, 1066, 1173)
, (16, 2220, 2908)
, (17, 2997, 3746)
, (18, 6216, 6216)
$$,
'Should compute the subtotal and total for all products.'
);
select *
from finish();
rollback;

View File

@ -1,11 +0,0 @@
-- Verify numerus:invoice_product_amount on pg
begin;
select invoice_product_id
, subtotal
, total
from numerus.invoice_product_amount
where false;
rollback;

View File

@ -12,7 +12,7 @@
} }
.invoice h1 { .invoice h1 {
font-size: 1em; font-size: 1.6rem;
} }
.invoice > div { .invoice > div {
@ -76,20 +76,16 @@
padding-top: 1em; padding-top: 1em;
} }
.invoice .tfoot.separator th, .invoice .tfoot.separator td {
padding-top: 3em;
}
.invoice tbody .name td:first-child { .invoice tbody .name td:first-child {
font-weight: bold; font-weight: bold;
} }
.invoice tbody td:first-child { .invoice tbody td:first-child {
max-width: 15em; max-width: 20rem;
} }
.invoice .legal { .invoice .legal {
font-size: .75em; font-size: 1.2rem;
text-align: justify; text-align: justify;
} }
@ -125,7 +121,7 @@
body { body {
background-color: white; background-color: white;
color: black; color: black;
font-size: 1rem; font-size: 1.6rem;
line-height: 1.5; line-height: 1.5;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
@ -153,6 +149,6 @@
left: 0; left: 0;
top: 0; top: 0;
transform-origin: top left; transform-origin: top left;
transform: translateY(250mm) rotate(-90deg); transform: translateY(260mm) rotate(-90deg);
} }
} }

View File

@ -43,26 +43,15 @@
<div class="input {{ if .Errors }}has-errors{{ end }}"> <div class="input {{ if .Errors }}has-errors{{ end }}">
<select id="{{ .Name }}-field" name="{{ .Name }}" <select id="{{ .Name }}-field" name="{{ .Name }}"
{{- range $attribute := .Attributes }} {{$attribute}} {{ end -}} {{- range $attribute := .Attributes }} {{$attribute}} {{ end -}}
{{ if .Multiple }} multiple="multiple"{{ end -}} {{ if .Multiple }}multiple="multiple"{{ end }}
{{ if .Required }} required="required"{{ end -}} {{ if .Required }}required="required"{{ end }}
> >
{{- with .EmptyLabel }} {{- with .EmptyLabel }}
<option value="">{{ . }}</option> <option value="">{{ . }}</option>
{{- end}} {{- end}}
{{- $withinGroup := "" -}}
{{- range $option := .Options }} {{- range $option := .Options }}
{{- if ne .Group $withinGroup }}
{{- if ne $withinGroup "" }}
</optgroup>
{{ end }}
<optgroup label="{{ .Group }}">
{{- $withinGroup = .Group -}}
{{ end }}
<option value="{{ .Value }}" <option value="{{ .Value }}"
{{- if $.IsSelected .Value }} selected="selected"{{ end }}>{{ .Label }}</option> {{- if $.IsSelected .Value }} selected="selected"{{ end }}>{{ .Label }}</option>
{{- end }}
{{- if ne $withinGroup "" }}
</optgroup>
{{- end }} {{- end }}
</select> </select>
<label for="{{ .Name }}-field">{{ .Label }}</label> <label for="{{ .Name }}-field">{{ .Label }}</label>

View File

@ -52,61 +52,49 @@
{{ .Invoicee.City }} ({{ .Invoicee.PostalCode}}), {{ .Invoicee.Province }}<br> {{ .Invoicee.City }} ({{ .Invoicee.PostalCode}}), {{ .Invoicee.Province }}<br>
</address> </address>
{{- $columns := 5 | add (len .TaxClasses) | add (boolToInt .HasDiscounts) -}}
<table> <table>
<thead> <thead>
<tr> <tr>
<th>{{( pgettext "Concept" "title" )}}</th> <th>{{( pgettext "Concept" "title" )}}</th>
<th class="numeric">{{( pgettext "Price" "title" )}}</th> <th class="numeric">{{( pgettext "Price" "title" )}}</th>
{{ if .HasDiscounts -}}
<th class="numeric">{{( pgettext "Discount" "title" )}}</th>
{{ end -}}
<th class="numeric">{{( pgettext "Units" "title" )}}</th> <th class="numeric">{{( pgettext "Units" "title" )}}</th>
<th class="numeric">{{( pgettext "Subtotal" "title" )}}</th> <th class="numeric">{{( pgettext "Subtotal" "title" )}}</th>
{{ range $class := .TaxClasses -}}
<th class="numeric">{{ . }}</th>
{{ end -}}
<th class="numeric">{{( pgettext "Total" "title" )}}</th>
</tr> </tr>
</thead> </thead>
{{ $lastIndex := len .Products | sub 1 }} {{ $lastIndex := len .Products | sub 1 }}
{{ range $index, $product := .Products -}} {{ range $index, $product := .Products -}}
<tbody> <tbody>
{{- if .Description }} {{ if .Description }}
<tr class="name"> <tr class="name">
<td colspan="{{ $columns }}">{{ .Name }}</td> <td colspan="4">{{ .Name }}</td>
</tr> </tr>
{{ end -}} <tr>
<tr>
{{- if .Description }}
<td>{{ .Description }}</td> <td>{{ .Description }}</td>
{{- else }} <td class="numeric">{{ .Price | formatPrice }}</td>
<td class="numeric">{{ .Quantity }}</td>
<td class="numeric">{{ .Total | formatPrice }}</td>
</tr>
{{ else }}
<tr class="name">
<td>{{ .Name }}</td> <td>{{ .Name }}</td>
{{- end -}} <td class="numeric">{{ .Price | formatPrice }}</td>
<td class="numeric">{{ .Price | formatPrice }}</td> <td class="numeric">{{ .Quantity }}</td>
{{ if $.HasDiscounts -}} <td class="numeric">{{ .Total | formatPrice }}</td>
<td class="numeric">{{ $product.Discount | formatPercent }}</td> </tr>
{{ end -}} {{- end }}
<td class="numeric">{{ .Quantity }}</td>
<td class="numeric">{{ .Subtotal | formatPrice }}</td>
{{ range $class := $.TaxClasses -}}
<td class="numeric">{{ index $product.Taxes $class | formatPercent }}</td>
{{ end -}}
<td class="numeric">{{ .Total | formatPrice }}</td>
</tr>
{{ if (eq $index $lastIndex) }} {{ if (eq $index $lastIndex) }}
<tr class="tfoot separator"> <tr class="tfoot">
<th scope="row" colspan="{{ $columns | sub 1 }}">{{( pgettext "Tax Base" "title" )}}</th> <th scope="row" colspan="3">{{( pgettext "Subtotal" "title" )}}</th>
<td class="numeric">{{ $.Subtotal | formatPrice }}</td> <td class="numeric">{{ $.Subtotal | formatPrice }}</td>
</tr> </tr>
{{ range $tax := $.Taxes -}} {{ range $tax := $.Taxes -}}
<tr class="tfoot"> <tr class="tfoot">
<th scope="row" colspan="{{ $columns | sub 1 }}">{{ index . 0 }}</th> <th scope="row" colspan="3">{{ index . 0 }}</th>
<td class="numeric">{{ index . 1 | formatPrice }}</td> <td class="numeric">{{ index . 1 | formatPrice }}</td>
</tr> </tr>
{{- end }} {{- end }}
<tr class="tfoot"> <tr class="tfoot">
<th scope="row" colspan="{{ $columns | sub 1 }}">{{( pgettext "Total" "title" )}}</th> <th scope="row" colspan="3">{{( pgettext "Total" "title" )}}</th>
<td class="numeric">{{ $.Total | formatPrice }}</td> <td class="numeric">{{ $.Total | formatPrice }}</td>
</tr> </tr>
{{ end }} {{ end }}