Compare commits
5 Commits
11d51df7fa
...
d6034ad732
Author | SHA1 | Date |
---|---|---|
jordi fita mas | d6034ad732 | |
jordi fita mas | e11a3c57f5 | |
jordi fita mas | 6a8ebab686 | |
jordi fita mas | 79ea2f366a | |
jordi fita mas | 2add9c74c1 |
|
@ -43,7 +43,7 @@ values (1, 'Melcior', 'IR1', 'Rei Blanc', parse_packed_phone_number('0732621', '
|
|||
|
||||
alter sequence product_product_id_seq restart;
|
||||
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 l‘aigua 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 l’aigua règia.', 5592)
|
||||
, (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, 'Paper higiènic (pack de 32 U)', 'Paper que s’usa per mantenir la higiene personal després de defecar o orinar.', 799)
|
||||
|
|
|
@ -1,29 +1,19 @@
|
|||
-- Deploy numerus:invoice_amount to pg
|
||||
-- requires: schema_numerus
|
||||
-- requires: invoice_product
|
||||
-- requires: invoice_tax_amount
|
||||
-- requires: invoice_product_amount
|
||||
|
||||
begin;
|
||||
|
||||
set search_path to numerus, public;
|
||||
|
||||
create or replace view invoice_amount as
|
||||
with taxable as (
|
||||
select invoice_id
|
||||
, sum(round(price * quantity * (1 - discount_rate))::integer)::integer as subtotal
|
||||
, sum(subtotal)::integer as subtotal
|
||||
, sum(total)::integer as total
|
||||
from invoice_product
|
||||
join invoice_product_amount using (invoice_product_id)
|
||||
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
|
||||
, subtotal
|
||||
, subtotal + coalesce(tax_amount, 0) as total
|
||||
from taxable
|
||||
left join taxes using (invoice_id)
|
||||
;
|
||||
|
||||
grant select on table invoice_amount to invoicer;
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
-- 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;
|
50
pkg/form.go
50
pkg/form.go
|
@ -66,6 +66,7 @@ func (field *InputField) Float64() float64 {
|
|||
type SelectOption struct {
|
||||
Value string
|
||||
Label string
|
||||
Group string
|
||||
}
|
||||
|
||||
type SelectField struct {
|
||||
|
@ -126,13 +127,17 @@ func (field *SelectField) IsSelected(v string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
func (field *SelectField) isValidOption(selected string) bool {
|
||||
func (field *SelectField) FindOption(value string) *SelectOption {
|
||||
for _, option := range field.Options {
|
||||
if option.Value == selected {
|
||||
return true
|
||||
if option.Value == value {
|
||||
return option
|
||||
}
|
||||
}
|
||||
return false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (field *SelectField) isValidOption(selected string) bool {
|
||||
return field.FindOption(selected) != nil
|
||||
}
|
||||
|
||||
func MustGetOptions(ctx context.Context, conn *Conn, sql string, args ...interface{}) []*SelectOption {
|
||||
|
@ -158,6 +163,29 @@ func MustGetOptions(ctx context.Context, conn *Conn, sql string, args ...interfa
|
|||
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 {
|
||||
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)
|
||||
}
|
||||
|
@ -201,6 +229,20 @@ func (v *FormValidator) CheckValidSelectOption(field *SelectField, message strin
|
|||
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 {
|
||||
_, err := url.ParseRequestURI(field.Val)
|
||||
return v.checkInput(field, err == nil, message)
|
||||
|
|
|
@ -11,6 +11,7 @@ import (
|
|||
"math"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
@ -129,6 +130,8 @@ type invoice struct {
|
|||
Products []*invoiceProduct
|
||||
Subtotal string
|
||||
Taxes [][]string
|
||||
TaxClasses []string
|
||||
HasDiscounts bool
|
||||
Total string
|
||||
}
|
||||
|
||||
|
@ -147,7 +150,10 @@ type invoiceProduct struct {
|
|||
Name string
|
||||
Description string
|
||||
Price string
|
||||
Discount int
|
||||
Quantity int
|
||||
Taxes map[string]int
|
||||
Subtotal string
|
||||
Total string
|
||||
}
|
||||
|
||||
|
@ -166,15 +172,31 @@ 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 {
|
||||
panic(err)
|
||||
}
|
||||
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)
|
||||
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)
|
||||
defer rows.Close()
|
||||
taxClasses := map[string]bool{}
|
||||
for rows.Next() {
|
||||
product := &invoiceProduct{}
|
||||
if err := rows.Scan(&product.Name, &product.Description, &product.Price, &product.Quantity, &product.Total); err != nil {
|
||||
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())
|
||||
}
|
||||
|
@ -418,7 +440,7 @@ func (form *invoiceForm) AddProducts(ctx context.Context, conn *Conn, productsId
|
|||
}
|
||||
|
||||
func mustGetTaxOptions(ctx context.Context, conn *Conn, company *Company) []*SelectOption {
|
||||
return MustGetOptions(ctx, conn, "select tax_id::text, name from tax where company_id = $1 order by name", company.Id)
|
||||
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)
|
||||
}
|
||||
|
||||
type invoiceProductForm struct {
|
||||
|
@ -525,5 +547,6 @@ func (form *invoiceProductForm) Validate() bool {
|
|||
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.CheckAtMostOneOfEachGroup(form.Tax, gettext("You can only select a tax of each class.", form.locale))
|
||||
return validator.AllOK()
|
||||
}
|
||||
|
|
|
@ -208,5 +208,6 @@ 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.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()
|
||||
}
|
||||
|
|
|
@ -43,6 +43,9 @@ func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename s
|
|||
"formatDate": func(time time.Time) template.HTML {
|
||||
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 {
|
||||
return template.HTML(fmt.Sprintf(`<input type="hidden" name="%s" value="%s">`, csrfTokenField, user.CsrfToken))
|
||||
},
|
||||
|
@ -54,6 +57,16 @@ func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename s
|
|||
field.Attributes = append(field.Attributes, template.HTMLAttr(attr))
|
||||
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 {
|
||||
return x - y
|
||||
},
|
||||
|
|
76
po/ca.po
76
po/ca.po
|
@ -8,7 +8,7 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: numerus\n"
|
||||
"Report-Msgid-Bugs-To: jordi@tandem.blog\n"
|
||||
"POT-Creation-Date: 2023-02-28 11:56+0100\n"
|
||||
"POT-Creation-Date: 2023-03-01 14:00+0100\n"
|
||||
"PO-Revision-Date: 2023-01-18 17:08+0100\n"
|
||||
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
|
||||
"Language-Team: Catalan <ca@dodds.net>\n"
|
||||
|
@ -59,7 +59,7 @@ msgid "Name"
|
|||
msgstr "Nom"
|
||||
|
||||
#: web/template/invoices/products.gohtml:43
|
||||
#: web/template/invoices/view.gohtml:59 web/template/products/index.gohtml:23
|
||||
#: web/template/invoices/view.gohtml:60 web/template/products/index.gohtml:23
|
||||
msgctxt "title"
|
||||
msgid "Price"
|
||||
msgstr "Preu"
|
||||
|
@ -74,13 +74,13 @@ msgctxt "action"
|
|||
msgid "Add products"
|
||||
msgstr "Afegeix productes"
|
||||
|
||||
#: web/template/invoices/new.gohtml:41 web/template/invoices/view.gohtml:61
|
||||
#: web/template/invoices/view.gohtml:87
|
||||
#: web/template/invoices/new.gohtml:41 web/template/invoices/view.gohtml:65
|
||||
msgctxt "title"
|
||||
msgid "Subtotal"
|
||||
msgstr "Subtotal"
|
||||
|
||||
#: web/template/invoices/new.gohtml:51 web/template/invoices/view.gohtml:97
|
||||
#: web/template/invoices/new.gohtml:51 web/template/invoices/view.gohtml:69
|
||||
#: web/template/invoices/view.gohtml:109
|
||||
msgctxt "title"
|
||||
msgid "Total"
|
||||
msgstr "Total"
|
||||
|
@ -149,16 +149,26 @@ msgctxt "action"
|
|||
msgid "Download invoice"
|
||||
msgstr "Descarrega factura"
|
||||
|
||||
#: web/template/invoices/view.gohtml:58
|
||||
#: web/template/invoices/view.gohtml:59
|
||||
msgctxt "title"
|
||||
msgid "Concept"
|
||||
msgstr "Concepte"
|
||||
|
||||
#: web/template/invoices/view.gohtml:60
|
||||
#: web/template/invoices/view.gohtml:62
|
||||
msgctxt "title"
|
||||
msgid "Discount"
|
||||
msgstr "Descompte"
|
||||
|
||||
#: web/template/invoices/view.gohtml:64
|
||||
msgctxt "title"
|
||||
msgid "Units"
|
||||
msgstr "Unitats"
|
||||
|
||||
#: web/template/invoices/view.gohtml:99
|
||||
msgctxt "title"
|
||||
msgid "Tax Base"
|
||||
msgstr "Base imposable"
|
||||
|
||||
#: web/template/dashboard.gohtml:2
|
||||
msgctxt "title"
|
||||
msgid "Dashboard"
|
||||
|
@ -374,43 +384,47 @@ msgstr "No podeu deixar la contrasenya en blanc."
|
|||
msgid "Invalid user or password."
|
||||
msgstr "Nom d’usuari o contrasenya incorrectes."
|
||||
|
||||
#: pkg/products.go:165 pkg/invoices.go:446
|
||||
#: pkg/products.go:165 pkg/invoices.go:468
|
||||
msgctxt "input"
|
||||
msgid "Name"
|
||||
msgstr "Nom"
|
||||
|
||||
#: pkg/products.go:171 pkg/invoices.go:451
|
||||
#: pkg/products.go:171 pkg/invoices.go:473
|
||||
msgctxt "input"
|
||||
msgid "Description"
|
||||
msgstr "Descripció"
|
||||
|
||||
#: pkg/products.go:176 pkg/invoices.go:455
|
||||
#: pkg/products.go:176 pkg/invoices.go:477
|
||||
msgctxt "input"
|
||||
msgid "Price"
|
||||
msgstr "Preu"
|
||||
|
||||
#: pkg/products.go:186 pkg/invoices.go:344 pkg/invoices.go:481
|
||||
#: pkg/products.go:186 pkg/invoices.go:366 pkg/invoices.go:503
|
||||
msgctxt "input"
|
||||
msgid "Taxes"
|
||||
msgstr "Imposts"
|
||||
|
||||
#: pkg/products.go:206 pkg/profile.go:92 pkg/invoices.go:377
|
||||
#: pkg/invoices.go:517
|
||||
#: pkg/products.go:206 pkg/profile.go:92 pkg/invoices.go:399
|
||||
#: pkg/invoices.go:539
|
||||
msgid "Name can not be empty."
|
||||
msgstr "No podeu deixar el nom en blanc."
|
||||
|
||||
#: pkg/products.go:207 pkg/invoices.go:518
|
||||
#: pkg/products.go:207 pkg/invoices.go:540
|
||||
msgid "Price can not be empty."
|
||||
msgstr "No podeu deixar el preu en blanc."
|
||||
|
||||
#: pkg/products.go:208 pkg/invoices.go:519
|
||||
#: pkg/products.go:208 pkg/invoices.go:541
|
||||
msgid "Price must be a number greater than zero."
|
||||
msgstr "El preu ha de ser un número major a zero."
|
||||
|
||||
#: pkg/products.go:210 pkg/invoices.go:381 pkg/invoices.go:527
|
||||
#: pkg/products.go:210 pkg/invoices.go:403 pkg/invoices.go:549
|
||||
msgid "Selected tax is not valid."
|
||||
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
|
||||
msgctxt "input"
|
||||
msgid "Currency"
|
||||
|
@ -483,70 +497,70 @@ msgstr "La confirmació no és igual a la contrasenya."
|
|||
msgid "Selected language is not valid."
|
||||
msgstr "Heu seleccionat un idioma que no és vàlid."
|
||||
|
||||
#: pkg/invoices.go:207
|
||||
#: pkg/invoices.go:229
|
||||
msgid "Select a customer to bill."
|
||||
msgstr "Escolliu un client a facturar."
|
||||
|
||||
#: pkg/invoices.go:300
|
||||
#: pkg/invoices.go:322
|
||||
msgid "Invalid action"
|
||||
msgstr "Acció invàlida."
|
||||
|
||||
#: pkg/invoices.go:321
|
||||
#: pkg/invoices.go:343
|
||||
msgctxt "input"
|
||||
msgid "Customer"
|
||||
msgstr "Client"
|
||||
|
||||
#: pkg/invoices.go:327
|
||||
#: pkg/invoices.go:349
|
||||
msgctxt "input"
|
||||
msgid "Number"
|
||||
msgstr "Número"
|
||||
|
||||
#: pkg/invoices.go:333
|
||||
#: pkg/invoices.go:355
|
||||
msgctxt "input"
|
||||
msgid "Invoice Date"
|
||||
msgstr "Data de factura"
|
||||
|
||||
#: pkg/invoices.go:339
|
||||
#: pkg/invoices.go:361
|
||||
msgctxt "input"
|
||||
msgid "Notes"
|
||||
msgstr "Notes"
|
||||
|
||||
#: pkg/invoices.go:378
|
||||
#: pkg/invoices.go:400
|
||||
msgid "Invoice date can not be empty."
|
||||
msgstr "No podeu deixar la data de la factura en blanc."
|
||||
|
||||
#: pkg/invoices.go:379
|
||||
#: pkg/invoices.go:401
|
||||
msgid "Invoice date must be a valid date."
|
||||
msgstr "La data de facturació ha de ser vàlida."
|
||||
|
||||
#: pkg/invoices.go:441
|
||||
#: pkg/invoices.go:463
|
||||
msgctxt "input"
|
||||
msgid "Id"
|
||||
msgstr "Identificador"
|
||||
|
||||
#: pkg/invoices.go:464
|
||||
#: pkg/invoices.go:486
|
||||
msgctxt "input"
|
||||
msgid "Quantity"
|
||||
msgstr "Quantitat"
|
||||
|
||||
#: pkg/invoices.go:472
|
||||
#: pkg/invoices.go:494
|
||||
msgctxt "input"
|
||||
msgid "Discount (%)"
|
||||
msgstr "Descompte (%)"
|
||||
|
||||
#: pkg/invoices.go:521
|
||||
#: pkg/invoices.go:543
|
||||
msgid "Quantity can not be empty."
|
||||
msgstr "No podeu deixar la quantitat en blanc."
|
||||
|
||||
#: pkg/invoices.go:522
|
||||
#: pkg/invoices.go:544
|
||||
msgid "Quantity must be a number greater than zero."
|
||||
msgstr "La quantitat ha de ser un número major a zero."
|
||||
|
||||
#: pkg/invoices.go:524
|
||||
#: pkg/invoices.go:546
|
||||
msgid "Discount can not be empty."
|
||||
msgstr "No podeu deixar el descompte en blanc."
|
||||
|
||||
#: pkg/invoices.go:525
|
||||
#: pkg/invoices.go:547
|
||||
msgid "Discount must be a percentage between 0 and 100."
|
||||
msgstr "El descompte ha de ser un percentatge entre 0 i 100."
|
||||
|
||||
|
|
76
po/es.po
76
po/es.po
|
@ -7,7 +7,7 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: numerus\n"
|
||||
"Report-Msgid-Bugs-To: jordi@tandem.blog\n"
|
||||
"POT-Creation-Date: 2023-02-28 11:56+0100\n"
|
||||
"POT-Creation-Date: 2023-03-01 14:00+0100\n"
|
||||
"PO-Revision-Date: 2023-01-18 17:45+0100\n"
|
||||
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
|
||||
"Language-Team: Spanish <es@tp.org.es>\n"
|
||||
|
@ -59,7 +59,7 @@ msgid "Name"
|
|||
msgstr "Nombre"
|
||||
|
||||
#: web/template/invoices/products.gohtml:43
|
||||
#: web/template/invoices/view.gohtml:59 web/template/products/index.gohtml:23
|
||||
#: web/template/invoices/view.gohtml:60 web/template/products/index.gohtml:23
|
||||
msgctxt "title"
|
||||
msgid "Price"
|
||||
msgstr "Precio"
|
||||
|
@ -74,13 +74,13 @@ msgctxt "action"
|
|||
msgid "Add products"
|
||||
msgstr "Añadir productos"
|
||||
|
||||
#: web/template/invoices/new.gohtml:41 web/template/invoices/view.gohtml:61
|
||||
#: web/template/invoices/view.gohtml:87
|
||||
#: web/template/invoices/new.gohtml:41 web/template/invoices/view.gohtml:65
|
||||
msgctxt "title"
|
||||
msgid "Subtotal"
|
||||
msgstr "Subtotal"
|
||||
|
||||
#: web/template/invoices/new.gohtml:51 web/template/invoices/view.gohtml:97
|
||||
#: web/template/invoices/new.gohtml:51 web/template/invoices/view.gohtml:69
|
||||
#: web/template/invoices/view.gohtml:109
|
||||
msgctxt "title"
|
||||
msgid "Total"
|
||||
msgstr "Total"
|
||||
|
@ -149,16 +149,26 @@ msgctxt "action"
|
|||
msgid "Download invoice"
|
||||
msgstr "Descargar factura"
|
||||
|
||||
#: web/template/invoices/view.gohtml:58
|
||||
#: web/template/invoices/view.gohtml:59
|
||||
msgctxt "title"
|
||||
msgid "Concept"
|
||||
msgstr "Concepto"
|
||||
|
||||
#: web/template/invoices/view.gohtml:60
|
||||
#: web/template/invoices/view.gohtml:62
|
||||
msgctxt "title"
|
||||
msgid "Discount"
|
||||
msgstr "Descuento"
|
||||
|
||||
#: web/template/invoices/view.gohtml:64
|
||||
msgctxt "title"
|
||||
msgid "Units"
|
||||
msgstr "Unidades"
|
||||
|
||||
#: web/template/invoices/view.gohtml:99
|
||||
msgctxt "title"
|
||||
msgid "Tax Base"
|
||||
msgstr "Base imponible"
|
||||
|
||||
#: web/template/dashboard.gohtml:2
|
||||
msgctxt "title"
|
||||
msgid "Dashboard"
|
||||
|
@ -374,43 +384,47 @@ msgstr "No podéis dejar la contraseña en blanco."
|
|||
msgid "Invalid user or password."
|
||||
msgstr "Nombre de usuario o contraseña inválido."
|
||||
|
||||
#: pkg/products.go:165 pkg/invoices.go:446
|
||||
#: pkg/products.go:165 pkg/invoices.go:468
|
||||
msgctxt "input"
|
||||
msgid "Name"
|
||||
msgstr "Nombre"
|
||||
|
||||
#: pkg/products.go:171 pkg/invoices.go:451
|
||||
#: pkg/products.go:171 pkg/invoices.go:473
|
||||
msgctxt "input"
|
||||
msgid "Description"
|
||||
msgstr "Descripción"
|
||||
|
||||
#: pkg/products.go:176 pkg/invoices.go:455
|
||||
#: pkg/products.go:176 pkg/invoices.go:477
|
||||
msgctxt "input"
|
||||
msgid "Price"
|
||||
msgstr "Precio"
|
||||
|
||||
#: pkg/products.go:186 pkg/invoices.go:344 pkg/invoices.go:481
|
||||
#: pkg/products.go:186 pkg/invoices.go:366 pkg/invoices.go:503
|
||||
msgctxt "input"
|
||||
msgid "Taxes"
|
||||
msgstr "Impuestos"
|
||||
|
||||
#: pkg/products.go:206 pkg/profile.go:92 pkg/invoices.go:377
|
||||
#: pkg/invoices.go:517
|
||||
#: pkg/products.go:206 pkg/profile.go:92 pkg/invoices.go:399
|
||||
#: pkg/invoices.go:539
|
||||
msgid "Name can not be empty."
|
||||
msgstr "No podéis dejar el nombre en blanco."
|
||||
|
||||
#: pkg/products.go:207 pkg/invoices.go:518
|
||||
#: pkg/products.go:207 pkg/invoices.go:540
|
||||
msgid "Price can not be empty."
|
||||
msgstr "No podéis dejar el precio en blanco."
|
||||
|
||||
#: pkg/products.go:208 pkg/invoices.go:519
|
||||
#: pkg/products.go:208 pkg/invoices.go:541
|
||||
msgid "Price must be a number greater than zero."
|
||||
msgstr "El precio tiene que ser un número mayor a cero."
|
||||
|
||||
#: pkg/products.go:210 pkg/invoices.go:381 pkg/invoices.go:527
|
||||
#: pkg/products.go:210 pkg/invoices.go:403 pkg/invoices.go:549
|
||||
msgid "Selected tax is not valid."
|
||||
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
|
||||
msgctxt "input"
|
||||
msgid "Currency"
|
||||
|
@ -483,70 +497,70 @@ msgstr "La confirmación no corresponde con la contraseña."
|
|||
msgid "Selected language is not valid."
|
||||
msgstr "Habéis escogido un idioma que no es válido."
|
||||
|
||||
#: pkg/invoices.go:207
|
||||
#: pkg/invoices.go:229
|
||||
msgid "Select a customer to bill."
|
||||
msgstr "Escoged un cliente a facturar."
|
||||
|
||||
#: pkg/invoices.go:300
|
||||
#: pkg/invoices.go:322
|
||||
msgid "Invalid action"
|
||||
msgstr "Acción inválida."
|
||||
|
||||
#: pkg/invoices.go:321
|
||||
#: pkg/invoices.go:343
|
||||
msgctxt "input"
|
||||
msgid "Customer"
|
||||
msgstr "Cliente"
|
||||
|
||||
#: pkg/invoices.go:327
|
||||
#: pkg/invoices.go:349
|
||||
msgctxt "input"
|
||||
msgid "Number"
|
||||
msgstr "Número"
|
||||
|
||||
#: pkg/invoices.go:333
|
||||
#: pkg/invoices.go:355
|
||||
msgctxt "input"
|
||||
msgid "Invoice Date"
|
||||
msgstr "Fecha de factura"
|
||||
|
||||
#: pkg/invoices.go:339
|
||||
#: pkg/invoices.go:361
|
||||
msgctxt "input"
|
||||
msgid "Notes"
|
||||
msgstr "Notas"
|
||||
|
||||
#: pkg/invoices.go:378
|
||||
#: pkg/invoices.go:400
|
||||
msgid "Invoice date can not be empty."
|
||||
msgstr "No podéis dejar la fecha de la factura en blanco."
|
||||
|
||||
#: pkg/invoices.go:379
|
||||
#: pkg/invoices.go:401
|
||||
msgid "Invoice date must be a valid date."
|
||||
msgstr "La fecha de factura debe ser válida."
|
||||
|
||||
#: pkg/invoices.go:441
|
||||
#: pkg/invoices.go:463
|
||||
msgctxt "input"
|
||||
msgid "Id"
|
||||
msgstr "Identificador"
|
||||
|
||||
#: pkg/invoices.go:464
|
||||
#: pkg/invoices.go:486
|
||||
msgctxt "input"
|
||||
msgid "Quantity"
|
||||
msgstr "Cantidad"
|
||||
|
||||
#: pkg/invoices.go:472
|
||||
#: pkg/invoices.go:494
|
||||
msgctxt "input"
|
||||
msgid "Discount (%)"
|
||||
msgstr "Descuento (%)"
|
||||
|
||||
#: pkg/invoices.go:521
|
||||
#: pkg/invoices.go:543
|
||||
msgid "Quantity can not be empty."
|
||||
msgstr "No podéis dejar la cantidad en blanco."
|
||||
|
||||
#: pkg/invoices.go:522
|
||||
#: pkg/invoices.go:544
|
||||
msgid "Quantity must be a number greater than zero."
|
||||
msgstr "La cantidad tiene que ser un número mayor a cero."
|
||||
|
||||
#: pkg/invoices.go:524
|
||||
#: pkg/invoices.go:546
|
||||
msgid "Discount can not be empty."
|
||||
msgstr "No podéis dejar el descuento en blanco."
|
||||
|
||||
#: pkg/invoices.go:525
|
||||
#: pkg/invoices.go:547
|
||||
msgid "Discount must be a percentage between 0 and 100."
|
||||
msgstr "El descuento tiene que ser un percentage entre 0 y 100."
|
||||
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
-- Revert numerus:invoice_product_amount from pg
|
||||
|
||||
begin;
|
||||
|
||||
drop view if exists numerus.invoice_product_amount;
|
||||
|
||||
commit;
|
|
@ -58,6 +58,7 @@ 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
|
||||
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_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_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_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
|
||||
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
|
||||
|
|
|
@ -0,0 +1,110 @@
|
|||
-- 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;
|
|
@ -0,0 +1,11 @@
|
|||
-- Verify numerus:invoice_product_amount on pg
|
||||
|
||||
begin;
|
||||
|
||||
select invoice_product_id
|
||||
, subtotal
|
||||
, total
|
||||
from numerus.invoice_product_amount
|
||||
where false;
|
||||
|
||||
rollback;
|
|
@ -12,7 +12,7 @@
|
|||
}
|
||||
|
||||
.invoice h1 {
|
||||
font-size: 1.6rem;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.invoice > div {
|
||||
|
@ -76,16 +76,20 @@
|
|||
padding-top: 1em;
|
||||
}
|
||||
|
||||
.invoice .tfoot.separator th, .invoice .tfoot.separator td {
|
||||
padding-top: 3em;
|
||||
}
|
||||
|
||||
.invoice tbody .name td:first-child {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.invoice tbody td:first-child {
|
||||
max-width: 20rem;
|
||||
max-width: 15em;
|
||||
}
|
||||
|
||||
.invoice .legal {
|
||||
font-size: 1.2rem;
|
||||
font-size: .75em;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
|
@ -121,7 +125,7 @@
|
|||
body {
|
||||
background-color: white;
|
||||
color: black;
|
||||
font-size: 1.6rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
@ -149,6 +153,6 @@
|
|||
left: 0;
|
||||
top: 0;
|
||||
transform-origin: top left;
|
||||
transform: translateY(260mm) rotate(-90deg);
|
||||
transform: translateY(250mm) rotate(-90deg);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,16 +43,27 @@
|
|||
<div class="input {{ if .Errors }}has-errors{{ end }}">
|
||||
<select id="{{ .Name }}-field" name="{{ .Name }}"
|
||||
{{- range $attribute := .Attributes }} {{$attribute}} {{ end -}}
|
||||
{{ if .Multiple }}multiple="multiple"{{ end }}
|
||||
{{ if .Required }}required="required"{{ end }}
|
||||
{{ if .Multiple }} multiple="multiple"{{ end -}}
|
||||
{{ if .Required }} required="required"{{ end -}}
|
||||
>
|
||||
{{- with .EmptyLabel }}
|
||||
<option value="">{{ . }}</option>
|
||||
{{- end}}
|
||||
{{- $withinGroup := "" -}}
|
||||
{{- range $option := .Options }}
|
||||
{{- if ne .Group $withinGroup }}
|
||||
{{- if ne $withinGroup "" }}
|
||||
</optgroup>
|
||||
{{ end }}
|
||||
<optgroup label="{{ .Group }}">
|
||||
{{- $withinGroup = .Group -}}
|
||||
{{ end }}
|
||||
<option value="{{ .Value }}"
|
||||
{{- if $.IsSelected .Value }} selected="selected"{{ end }}>{{ .Label }}</option>
|
||||
{{- end }}
|
||||
{{- if ne $withinGroup "" }}
|
||||
</optgroup>
|
||||
{{- end }}
|
||||
</select>
|
||||
<label for="{{ .Name }}-field">{{ .Label }}</label>
|
||||
{{- if .Errors }}
|
||||
|
|
|
@ -52,49 +52,61 @@
|
|||
{{ .Invoicee.City }} ({{ .Invoicee.PostalCode}}), {{ .Invoicee.Province }}<br>
|
||||
</address>
|
||||
|
||||
{{- $columns := 5 | add (len .TaxClasses) | add (boolToInt .HasDiscounts) -}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{( pgettext "Concept" "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 "Subtotal" "title" )}}</th>
|
||||
{{ range $class := .TaxClasses -}}
|
||||
<th class="numeric">{{ . }}</th>
|
||||
{{ end -}}
|
||||
<th class="numeric">{{( pgettext "Total" "title" )}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{{ $lastIndex := len .Products | sub 1 }}
|
||||
{{ range $index, $product := .Products -}}
|
||||
<tbody>
|
||||
{{ if .Description }}
|
||||
{{- if .Description }}
|
||||
<tr class="name">
|
||||
<td colspan="4">{{ .Name }}</td>
|
||||
<td colspan="{{ $columns }}">{{ .Name }}</td>
|
||||
</tr>
|
||||
{{ end -}}
|
||||
<tr>
|
||||
{{- if .Description }}
|
||||
<td>{{ .Description }}</td>
|
||||
<td class="numeric">{{ .Price | formatPrice }}</td>
|
||||
<td class="numeric">{{ .Quantity }}</td>
|
||||
<td class="numeric">{{ .Total | formatPrice }}</td>
|
||||
</tr>
|
||||
{{ else }}
|
||||
<tr class="name">
|
||||
{{- else }}
|
||||
<td>{{ .Name }}</td>
|
||||
{{- end -}}
|
||||
<td class="numeric">{{ .Price | formatPrice }}</td>
|
||||
{{ if $.HasDiscounts -}}
|
||||
<td class="numeric">{{ $product.Discount | formatPercent }}</td>
|
||||
{{ 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>
|
||||
{{- end }}
|
||||
{{ if (eq $index $lastIndex) }}
|
||||
<tr class="tfoot">
|
||||
<th scope="row" colspan="3">{{( pgettext "Subtotal" "title" )}}</th>
|
||||
<tr class="tfoot separator">
|
||||
<th scope="row" colspan="{{ $columns | sub 1 }}">{{( pgettext "Tax Base" "title" )}}</th>
|
||||
<td class="numeric">{{ $.Subtotal | formatPrice }}</td>
|
||||
</tr>
|
||||
{{ range $tax := $.Taxes -}}
|
||||
<tr class="tfoot">
|
||||
<th scope="row" colspan="3">{{ index . 0 }}</th>
|
||||
<th scope="row" colspan="{{ $columns | sub 1 }}">{{ index . 0 }}</th>
|
||||
<td class="numeric">{{ index . 1 | formatPrice }}</td>
|
||||
</tr>
|
||||
{{- end }}
|
||||
<tr class="tfoot">
|
||||
<th scope="row" colspan="3">{{( pgettext "Total" "title" )}}</th>
|
||||
<th scope="row" colspan="{{ $columns | sub 1 }}">{{( pgettext "Total" "title" )}}</th>
|
||||
<td class="numeric">{{ $.Total | formatPrice }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
|
|
Loading…
Reference in New Issue