Allow multiple taxes, and even not tax, for products

It seems that we do not agree en whether the IRPF tax should be
something of the product or the contact, so we decided to make the
product have multiple taxes, just in case, and if only one is needed,
then users can just select one; no need to limit to one.
This commit is contained in:
jordi fita mas 2023-02-08 13:47:36 +01:00
parent 2a98b9c0af
commit 4be2597a86
17 changed files with 453 additions and 170 deletions

View File

@ -14,7 +14,6 @@ create table product (
name text not null, name text not null,
description text not null, description text not null,
price integer not null, price integer not null,
tax_id integer not null references tax,
created_at timestamptz not null default current_timestamp created_at timestamptz not null default current_timestamp
); );

31
deploy/product_tax.sql Normal file
View File

@ -0,0 +1,31 @@
-- Deploy numerus:product_tax to pg
-- requires: schema_numerus
-- requires: product
-- requires: tax
begin;
set search_path to numerus, public;
create table product_tax (
product_id integer not null references product,
tax_id integer not null references tax,
primary key (product_id, tax_id)
);
grant select, insert, update, delete on table product_tax to invoicer;
grant select, insert, update, delete on table product_tax to admin;
alter table product_tax enable row level security;
create policy company_policy
on product_tax
using (
exists(
select 1
from product
where product.product_id = product_tax.product_id
)
);
commit;

View File

@ -89,7 +89,7 @@ func newTaxDetailsForm(ctx context.Context, conn *Conn, locale *Locale) *taxDeta
Label: pgettext("input", "Currency", locale), Label: pgettext("input", "Currency", locale),
Options: MustGetOptions(ctx, conn, "select currency_code, currency_symbol from currency order by currency_code"), Options: MustGetOptions(ctx, conn, "select currency_code, currency_symbol from currency order by currency_code"),
Required: true, Required: true,
Selected: "EUR", Selected: []string{"EUR"},
}, },
} }
} }

View File

@ -222,10 +222,10 @@ func newContactForm(ctx context.Context, conn *Conn, locale *Locale) *contactFor
}, },
Country: &SelectField{ Country: &SelectField{
Name: "country", Name: "country",
Label: pgettext("input", "Tax", locale), Label: pgettext("input", "Country", locale),
Options: mustGetCountryOptions(ctx, conn, locale), Options: mustGetCountryOptions(ctx, conn, locale),
Required: true, Required: true,
Selected: "ES", Selected: []string{"ES"},
Attributes: []template.HTMLAttr{ Attributes: []template.HTMLAttr{
`autocomplete="country"`, `autocomplete="country"`,
}, },
@ -253,12 +253,18 @@ func (form *contactForm) Parse(r *http.Request) error {
func (form *contactForm) Validate(ctx context.Context, conn *Conn) bool { func (form *contactForm) Validate(ctx context.Context, conn *Conn) bool {
validator := newFormValidator() validator := newFormValidator()
country := ""
if validator.CheckValidSelectOption(form.Country, gettext("Selected country is not valid.", form.locale)) {
country = form.Country.Selected[0]
}
validator.CheckRequiredInput(form.BusinessName, gettext("Business name can not be empty.", form.locale)) validator.CheckRequiredInput(form.BusinessName, gettext("Business name can not be empty.", form.locale))
if validator.CheckRequiredInput(form.VATIN, gettext("VAT number can not be empty.", form.locale)) { if validator.CheckRequiredInput(form.VATIN, gettext("VAT number can not be empty.", form.locale)) {
validator.CheckValidVATINInput(form.VATIN, form.Country.Selected, gettext("This value is not a valid VAT number.", form.locale)) validator.CheckValidVATINInput(form.VATIN, country, gettext("This value is not a valid VAT number.", form.locale))
} }
if validator.CheckRequiredInput(form.Phone, gettext("Phone can not be empty.", form.locale)) { if validator.CheckRequiredInput(form.Phone, gettext("Phone can not be empty.", form.locale)) {
validator.CheckValidPhoneInput(form.Phone, form.Country.Selected, gettext("This value is not a valid phone number.", form.locale)) validator.CheckValidPhoneInput(form.Phone, country, gettext("This value is not a valid phone number.", form.locale))
} }
if validator.CheckRequiredInput(form.Email, gettext("Email can not be empty.", form.locale)) { if validator.CheckRequiredInput(form.Email, gettext("Email can not be empty.", form.locale)) {
validator.CheckValidEmailInput(form.Email, gettext("This value is not a valid email. It should be like name@domain.com.", form.locale)) validator.CheckValidEmailInput(form.Email, gettext("This value is not a valid email. It should be like name@domain.com.", form.locale))
@ -270,8 +276,7 @@ func (form *contactForm) Validate(ctx context.Context, conn *Conn) bool {
validator.CheckRequiredInput(form.City, gettext("City can not be empty.", form.locale)) validator.CheckRequiredInput(form.City, gettext("City can not be empty.", form.locale))
validator.CheckRequiredInput(form.Province, gettext("Province can not be empty.", form.locale)) validator.CheckRequiredInput(form.Province, gettext("Province can not be empty.", form.locale))
if validator.CheckRequiredInput(form.PostalCode, gettext("Postal code can not be empty.", form.locale)) { if validator.CheckRequiredInput(form.PostalCode, gettext("Postal code can not be empty.", form.locale)) {
validator.CheckValidPostalCode(ctx, conn, form.PostalCode, form.Country.Selected, gettext("This value is not a valid postal code.", form.locale)) validator.CheckValidPostalCode(ctx, conn, form.PostalCode, country, gettext("This value is not a valid postal code.", form.locale))
} }
validator.CheckValidSelectOption(form.Country, gettext("Selected country is not valid.", form.locale))
return validator.AllOK() return validator.AllOK()
} }

View File

@ -70,6 +70,14 @@ type Conn struct {
*pgxpool.Conn *pgxpool.Conn
} }
func (c *Conn) MustBegin(ctx context.Context) *Tx {
tx, err := c.Begin(ctx)
if err != nil {
panic(err)
}
return &Tx{tx}
}
func (c *Conn) MustGetText(ctx context.Context, def string, sql string, args ...interface{}) string { func (c *Conn) MustGetText(ctx context.Context, def string, sql string, args ...interface{}) string {
var result string var result string
if err := c.Conn.QueryRow(ctx, sql, args...).Scan(&result); err != nil { if err := c.Conn.QueryRow(ctx, sql, args...).Scan(&result); err != nil {
@ -87,3 +95,46 @@ func (c *Conn) MustExec(ctx context.Context, sql string, args ...interface{}) {
panic(err) panic(err)
} }
} }
type Tx struct {
pgx.Tx
}
func (tx *Tx) MustCommit(ctx context.Context) {
if err := tx.Commit(ctx); err != nil {
panic(err)
}
}
func (tx *Tx) MustRollback(ctx context.Context) {
if err := tx.Rollback(ctx); err != nil {
panic(err)
}
}
func (tx *Tx) MustGetInteger(ctx context.Context, sql string, args ...interface{}) int {
var result int
if err := tx.QueryRow(ctx, sql, args...).Scan(&result); err != nil {
panic(err)
}
return result
}
func (tx *Tx) MustGetIntegerOrDefault(ctx context.Context, def int, sql string, args ...interface{}) int {
var result int
if err := tx.QueryRow(ctx, sql, args...).Scan(&result); err != nil {
if err == pgx.ErrNoRows {
return def
}
panic(err)
}
return result
}
func (tx *Tx) MustCopyFrom(ctx context.Context, tableName string, columns []string, rows [][]interface{}) int64 {
copied, err := tx.CopyFrom(ctx, pgx.Identifier{tableName}, columns, pgx.CopyFromRows(rows))
if err != nil {
panic(err)
}
return copied
}

View File

@ -58,34 +58,56 @@ type SelectOption struct {
type SelectField struct { type SelectField struct {
Name string Name string
Label string Label string
Selected string Selected []string
Options []*SelectOption Options []*SelectOption
Attributes []template.HTMLAttr Attributes []template.HTMLAttr
Required bool Required bool
Multiple bool
EmptyLabel string EmptyLabel string
Errors []error Errors []error
} }
func (field *SelectField) Scan(value interface{}) error { func (field *SelectField) Scan(value interface{}) error {
if value == nil { if value == nil {
field.Selected = "" field.Selected = append(field.Selected, "")
return nil return nil
} }
field.Selected = fmt.Sprintf("%v", value) field.Selected = append(field.Selected, fmt.Sprintf("%v", value))
return nil return nil
} }
func (field *SelectField) Value() (driver.Value, error) { func (field *SelectField) Value() (driver.Value, error) {
return field.Selected, nil if field.Selected == nil {
return "", nil
}
return field.Selected[0], nil
} }
func (field *SelectField) FillValue(r *http.Request) { func (field *SelectField) FillValue(r *http.Request) {
field.Selected = r.FormValue(field.Name) field.Selected = r.Form[field.Name]
} }
func (field *SelectField) HasValidOption() bool { func (field *SelectField) HasValidOptions() bool {
for _, selected := range field.Selected {
if !field.isValidOption(selected) {
return false
}
}
return true
}
func (field *SelectField) IsSelected(v string) bool {
for _, selected := range field.Selected {
if selected == v {
return true
}
}
return false
}
func (field *SelectField) isValidOption(selected string) bool {
for _, option := range field.Options { for _, option := range field.Options {
if option.Value == field.Selected { if option.Value == selected {
return true return true
} }
} }
@ -155,7 +177,7 @@ func (v *FormValidator) CheckPasswordConfirmation(password *InputField, confirm
} }
func (v *FormValidator) CheckValidSelectOption(field *SelectField, message string) bool { func (v *FormValidator) CheckValidSelectOption(field *SelectField, message string) bool {
return v.checkSelect(field, field.HasValidOption(), message) return v.checkSelect(field, field.HasValidOptions(), message)
} }
func (v *FormValidator) checkValidURL(field *InputField, message string) bool { func (v *FormValidator) checkValidURL(field *InputField, message string) bool {

View File

@ -36,12 +36,12 @@ func GetProductForm(w http.ResponseWriter, r *http.Request, params httprouter.Pa
form := newProductForm(r.Context(), conn, locale, company) form := newProductForm(r.Context(), conn, locale, company)
slug := params[0].Value slug := params[0].Value
if slug == "new" { if slug == "new" {
form.Tax.EmptyLabel = gettext("Select a tax for this product.", locale)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
mustRenderNewProductForm(w, r, form) mustRenderNewProductForm(w, r, form)
return return
} }
err := conn.QueryRow(r.Context(), "select product.name, product.description, to_price(price, decimal_digits), tax_id from product join company using (company_id) join currency using (currency_code) where product.slug = $1", slug).Scan(form.Name, form.Description, form.Price, form.Tax) var productId int
err := conn.QueryRow(r.Context(), "select product_id, product.name, product.description, to_price(price, decimal_digits) from product join company using (company_id) join currency using (currency_code) where product.slug = $1", slug).Scan(&productId, form.Name, form.Description, form.Price)
if err != nil { if err != nil {
if err == pgx.ErrNoRows { if err == pgx.ErrNoRows {
http.NotFound(w, r) http.NotFound(w, r)
@ -50,6 +50,16 @@ func GetProductForm(w http.ResponseWriter, r *http.Request, params httprouter.Pa
panic(err) panic(err)
} }
} }
rows, err := conn.Query(r.Context(), "select tax_id from product_tax where product_id = $1", productId)
if err != nil {
panic(err)
}
defer rows.Close()
for rows.Next() {
if err := rows.Scan(form.Tax); err != nil {
panic(err)
}
}
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
mustRenderEditProductForm(w, r, form) mustRenderEditProductForm(w, r, form)
} }
@ -79,7 +89,24 @@ func HandleAddProduct(w http.ResponseWriter, r *http.Request, _ httprouter.Param
mustRenderNewProductForm(w, r, form) mustRenderNewProductForm(w, r, form)
return return
} }
conn.MustExec(r.Context(), "insert into product (company_id, name, description, price, tax_id) select company_id, $2, $3, parse_price($4, decimal_digits), $5 from company join currency using (currency_code) where company_id = $1", company.Id, form.Name, form.Description, form.Price, form.Tax) tx := conn.MustBegin(r.Context())
productId := tx.MustGetInteger(r.Context(), "insert into product (company_id, name, description, price) select company_id, $2, $3, parse_price($4, decimal_digits) from company join currency using (currency_code) where company_id = $1 returning product_id", company.Id, form.Name, form.Description, form.Price)
if len(form.Tax.Selected) > 0 {
batch := &pgx.Batch{}
for _, tax := range form.Tax.Selected {
batch.Queue("insert into product_tax(product_id, tax_id) values ($1, $2)", productId, tax)
}
br := tx.SendBatch(r.Context(), batch)
for range form.Tax.Selected {
if _, err := br.Exec(); err != nil {
panic(err)
}
}
if err := br.Close(); err != nil {
panic(err)
}
}
tx.MustCommit(r.Context())
http.Redirect(w, r, companyURI(company, "/products"), http.StatusSeeOther) http.Redirect(w, r, companyURI(company, "/products"), http.StatusSeeOther)
} }
@ -100,10 +127,28 @@ func HandleUpdateProduct(w http.ResponseWriter, r *http.Request, params httprout
mustRenderEditProductForm(w, r, form) mustRenderEditProductForm(w, r, form)
return return
} }
slug := conn.MustGetText(r.Context(), "", "update product set name = $1, description = $2, price = parse_price($3, decimal_digits), tax_id = $4 from company join currency using (currency_code) where product.company_id = company.company_id and product.slug = $5 returning product.slug", form.Name, form.Description, form.Price, form.Tax, params[0].Value) tx := conn.MustBegin(r.Context())
if slug == "" { slug := params[0].Value
productId := tx.MustGetIntegerOrDefault(r.Context(), 0, "update product set name = $1, description = $2, price = parse_price($3, decimal_digits) from company join currency using (currency_code) where product.company_id = company.company_id and product.slug = $4 returning product_id", form.Name, form.Description, form.Price, slug)
if productId == 0 {
tx.MustRollback(r.Context())
http.NotFound(w, r) http.NotFound(w, r)
} }
batch := &pgx.Batch{}
batch.Queue("delete from product_tax where product_id = $1", productId)
for _, tax := range form.Tax.Selected {
batch.Queue("insert into product_tax(product_id, tax_id) values ($1, $2)", productId, tax)
}
br := tx.SendBatch(r.Context(), batch)
for i := 0; i < batch.Len(); i++ {
if _, err := br.Exec(); err != nil {
panic(err)
}
}
if err := br.Close(); err != nil {
panic(err)
}
tx.MustCommit(r.Context())
http.Redirect(w, r, companyURI(company, "/products/"+slug), http.StatusSeeOther) http.Redirect(w, r, companyURI(company, "/products/"+slug), http.StatusSeeOther)
} }
@ -166,8 +211,8 @@ func newProductForm(ctx context.Context, conn *Conn, locale *Locale, company *Co
}, },
Tax: &SelectField{ Tax: &SelectField{
Name: "tax", Name: "tax",
Label: pgettext("input", "Tax", locale), Label: pgettext("input", "Taxes", locale),
Required: true, Multiple: true,
Options: MustGetOptions(ctx, conn, "select tax_id::text, name from tax where company_id = $1 order by name", company.Id), Options: MustGetOptions(ctx, conn, "select tax_id::text, name from tax where company_id = $1 order by name", company.Id),
}, },
} }

View File

@ -102,7 +102,7 @@ func GetProfileForm(w http.ResponseWriter, r *http.Request, _ httprouter.Params)
form := newProfileForm(r.Context(), conn, locale) form := newProfileForm(r.Context(), conn, locale)
form.Name.Val = conn.MustGetText(r.Context(), "", "select name from user_profile") form.Name.Val = conn.MustGetText(r.Context(), "", "select name from user_profile")
form.Email.Val = user.Email form.Email.Val = user.Email
form.Language.Selected = user.Language.String() form.Language.Selected = []string{user.Language.String()}
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
mustRenderProfileForm(w, r, form) mustRenderProfileForm(w, r, form)

128
po/ca.po
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-02-05 14:04+0100\n" "POT-Creation-Date: 2023-02-08 13:43+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"
@ -238,11 +238,11 @@ msgctxt "input"
msgid "Password" msgid "Password"
msgstr "Contrasenya" msgstr "Contrasenya"
#: pkg/login.go:69 pkg/profile.go:89 pkg/contacts.go:263 #: pkg/login.go:69 pkg/profile.go:89 pkg/contacts.go:269
msgid "Email can not be empty." msgid "Email can not be empty."
msgstr "No podeu deixar el correu-e en blanc." msgstr "No podeu deixar el correu-e en blanc."
#: pkg/login.go:70 pkg/profile.go:90 pkg/contacts.go:264 #: pkg/login.go:70 pkg/profile.go:90 pkg/contacts.go:270
msgid "This value is not a valid email. It should be like name@domain.com." msgid "This value is not a valid email. It should be like name@domain.com."
msgstr "Aquest valor no és un correu-e vàlid. Hauria de ser similar a nom@domini.cat." msgstr "Aquest valor no és un correu-e vàlid. Hauria de ser similar a nom@domini.cat."
@ -254,43 +254,39 @@ 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:39 #: pkg/products.go:193
msgid "Select a tax for this product."
msgstr "Escolliu un impost per aquest producte."
#: pkg/products.go:148
msgctxt "input" msgctxt "input"
msgid "Name" msgid "Name"
msgstr "Nom" msgstr "Nom"
#: pkg/products.go:154 #: pkg/products.go:199
msgctxt "input" msgctxt "input"
msgid "Description" msgid "Description"
msgstr "Descripció" msgstr "Descripció"
#: pkg/products.go:159 #: pkg/products.go:204
msgctxt "input" msgctxt "input"
msgid "Price" msgid "Price"
msgstr "Preu" msgstr "Preu"
#: pkg/products.go:169 pkg/contacts.go:225 #: pkg/products.go:214
msgctxt "input" msgctxt "input"
msgid "Tax" msgid "Taxes"
msgstr "Impost" msgstr "Imposts"
#: pkg/products.go:189 pkg/profile.go:92 #: pkg/products.go:234 pkg/profile.go:92
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:190 #: pkg/products.go:235
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:191 #: pkg/products.go:236
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:193 #: pkg/products.go:238
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."
@ -398,57 +394,65 @@ msgctxt "input"
msgid "Postal code" msgid "Postal code"
msgstr "Codi postal" msgstr "Codi postal"
#: pkg/contacts.go:256 #: pkg/contacts.go:225
msgid "Business name can not be empty." msgctxt "input"
msgstr "No podeu deixar el nom i els cognoms en blanc." msgid "Country"
msgstr "País"
#: pkg/contacts.go:257
msgid "VAT number can not be empty."
msgstr "No podeu deixar el DNI o NIF en blanc."
#: pkg/contacts.go:258 #: pkg/contacts.go:258
msgid "This value is not a valid VAT number."
msgstr "Aquest valor no és un DNI o NIF vàlid."
#: pkg/contacts.go:260
msgid "Phone can not be empty."
msgstr "No podeu deixar el telèfon en blanc."
#: pkg/contacts.go:261
msgid "This value is not a valid phone number."
msgstr "Aquest valor no és un telèfon vàlid."
#: pkg/contacts.go:267
msgid "This value is not a valid web address. It should be like https://domain.com/."
msgstr "Aquest valor no és una adreça web vàlida. Hauria de ser similar a https://domini.cat/."
#: pkg/contacts.go:269
msgid "Address can not be empty."
msgstr "No podeu deixar ladreça en blanc."
#: pkg/contacts.go:270
msgid "City can not be empty."
msgstr "No podeu deixar la població en blanc."
#: pkg/contacts.go:271
msgid "Province can not be empty."
msgstr "No podeu deixar la província en blanc."
#: pkg/contacts.go:272
msgid "Postal code can not be empty."
msgstr "No podeu deixar el codi postal en blanc."
#: pkg/contacts.go:273
msgid "This value is not a valid postal code."
msgstr "Aquest valor no és un codi postal vàlid."
#: pkg/contacts.go:275
msgid "Selected country is not valid." msgid "Selected country is not valid."
msgstr "Heu seleccionat un país que no és vàlid." msgstr "Heu seleccionat un país que no és vàlid."
#: pkg/contacts.go:262
msgid "Business name can not be empty."
msgstr "No podeu deixar el nom i els cognoms en blanc."
#: pkg/contacts.go:263
msgid "VAT number can not be empty."
msgstr "No podeu deixar el DNI o NIF en blanc."
#: pkg/contacts.go:264
msgid "This value is not a valid VAT number."
msgstr "Aquest valor no és un DNI o NIF vàlid."
#: pkg/contacts.go:266
msgid "Phone can not be empty."
msgstr "No podeu deixar el telèfon en blanc."
#: pkg/contacts.go:267
msgid "This value is not a valid phone number."
msgstr "Aquest valor no és un telèfon vàlid."
#: pkg/contacts.go:273
msgid "This value is not a valid web address. It should be like https://domain.com/."
msgstr "Aquest valor no és una adreça web vàlida. Hauria de ser similar a https://domini.cat/."
#: pkg/contacts.go:275
msgid "Address can not be empty."
msgstr "No podeu deixar ladreça en blanc."
#: pkg/contacts.go:276
msgid "City can not be empty."
msgstr "No podeu deixar la població en blanc."
#: pkg/contacts.go:277
msgid "Province can not be empty."
msgstr "No podeu deixar la província en blanc."
#: pkg/contacts.go:278
msgid "Postal code can not be empty."
msgstr "No podeu deixar el codi postal en blanc."
#: pkg/contacts.go:279
msgid "This value is not a valid postal code."
msgstr "Aquest valor no és un codi postal vàlid."
#~ msgid "Select a tax for this product."
#~ msgstr "Escolliu un impost per aquest producte."
#~ msgctxt "input" #~ msgctxt "input"
#~ msgid "Country" #~ msgid "Tax"
#~ msgstr "País" #~ msgstr "Impost"
#~ msgctxt "nav" #~ msgctxt "nav"
#~ msgid "Customers" #~ msgid "Customers"

128
po/es.po
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-02-05 14:04+0100\n" "POT-Creation-Date: 2023-02-08 13:43+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"
@ -238,11 +238,11 @@ msgctxt "input"
msgid "Password" msgid "Password"
msgstr "Contraseña" msgstr "Contraseña"
#: pkg/login.go:69 pkg/profile.go:89 pkg/contacts.go:263 #: pkg/login.go:69 pkg/profile.go:89 pkg/contacts.go:269
msgid "Email can not be empty." msgid "Email can not be empty."
msgstr "No podéis dejar el correo-e en blanco." msgstr "No podéis dejar el correo-e en blanco."
#: pkg/login.go:70 pkg/profile.go:90 pkg/contacts.go:264 #: pkg/login.go:70 pkg/profile.go:90 pkg/contacts.go:270
msgid "This value is not a valid email. It should be like name@domain.com." msgid "This value is not a valid email. It should be like name@domain.com."
msgstr "Este valor no es un correo-e válido. Tiene que ser parecido a nombre@dominio.es." msgstr "Este valor no es un correo-e válido. Tiene que ser parecido a nombre@dominio.es."
@ -254,43 +254,39 @@ 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:39 #: pkg/products.go:193
msgid "Select a tax for this product."
msgstr "Escoged un impuesto para este producto."
#: pkg/products.go:148
msgctxt "input" msgctxt "input"
msgid "Name" msgid "Name"
msgstr "Nombre" msgstr "Nombre"
#: pkg/products.go:154 #: pkg/products.go:199
msgctxt "input" msgctxt "input"
msgid "Description" msgid "Description"
msgstr "Descripción" msgstr "Descripción"
#: pkg/products.go:159 #: pkg/products.go:204
msgctxt "input" msgctxt "input"
msgid "Price" msgid "Price"
msgstr "Precio" msgstr "Precio"
#: pkg/products.go:169 pkg/contacts.go:225 #: pkg/products.go:214
msgctxt "input" msgctxt "input"
msgid "Tax" msgid "Taxes"
msgstr "Impuesto" msgstr "Impuestos"
#: pkg/products.go:189 pkg/profile.go:92 #: pkg/products.go:234 pkg/profile.go:92
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:190 #: pkg/products.go:235
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:191 #: pkg/products.go:236
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:193 #: pkg/products.go:238
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."
@ -398,57 +394,65 @@ msgctxt "input"
msgid "Postal code" msgid "Postal code"
msgstr "Código postal" msgstr "Código postal"
#: pkg/contacts.go:256 #: pkg/contacts.go:225
msgid "Business name can not be empty." msgctxt "input"
msgstr "No podéis dejar el nombre y los apellidos en blanco." msgid "Country"
msgstr "País"
#: pkg/contacts.go:257
msgid "VAT number can not be empty."
msgstr "No podéis dejar el DNI o NIF en blanco."
#: pkg/contacts.go:258 #: pkg/contacts.go:258
msgid "This value is not a valid VAT number."
msgstr "Este valor no es un DNI o NIF válido."
#: pkg/contacts.go:260
msgid "Phone can not be empty."
msgstr "No podéis dejar el teléfono en blanco."
#: pkg/contacts.go:261
msgid "This value is not a valid phone number."
msgstr "Este valor no es un teléfono válido."
#: pkg/contacts.go:267
msgid "This value is not a valid web address. It should be like https://domain.com/."
msgstr "Este valor no es una dirección web válida. Tiene que ser parecida a https://dominio.es/."
#: pkg/contacts.go:269
msgid "Address can not be empty."
msgstr "No podéis dejar la dirección en blanco."
#: pkg/contacts.go:270
msgid "City can not be empty."
msgstr "No podéis dejar la población en blanco."
#: pkg/contacts.go:271
msgid "Province can not be empty."
msgstr "No podéis dejar la provincia en blanco."
#: pkg/contacts.go:272
msgid "Postal code can not be empty."
msgstr "No podéis dejar el código postal en blanco."
#: pkg/contacts.go:273
msgid "This value is not a valid postal code."
msgstr "Este valor no es un código postal válido válido."
#: pkg/contacts.go:275
msgid "Selected country is not valid." msgid "Selected country is not valid."
msgstr "Habéis escogido un país que no es válido." msgstr "Habéis escogido un país que no es válido."
#: pkg/contacts.go:262
msgid "Business name can not be empty."
msgstr "No podéis dejar el nombre y los apellidos en blanco."
#: pkg/contacts.go:263
msgid "VAT number can not be empty."
msgstr "No podéis dejar el DNI o NIF en blanco."
#: pkg/contacts.go:264
msgid "This value is not a valid VAT number."
msgstr "Este valor no es un DNI o NIF válido."
#: pkg/contacts.go:266
msgid "Phone can not be empty."
msgstr "No podéis dejar el teléfono en blanco."
#: pkg/contacts.go:267
msgid "This value is not a valid phone number."
msgstr "Este valor no es un teléfono válido."
#: pkg/contacts.go:273
msgid "This value is not a valid web address. It should be like https://domain.com/."
msgstr "Este valor no es una dirección web válida. Tiene que ser parecida a https://dominio.es/."
#: pkg/contacts.go:275
msgid "Address can not be empty."
msgstr "No podéis dejar la dirección en blanco."
#: pkg/contacts.go:276
msgid "City can not be empty."
msgstr "No podéis dejar la población en blanco."
#: pkg/contacts.go:277
msgid "Province can not be empty."
msgstr "No podéis dejar la provincia en blanco."
#: pkg/contacts.go:278
msgid "Postal code can not be empty."
msgstr "No podéis dejar el código postal en blanco."
#: pkg/contacts.go:279
msgid "This value is not a valid postal code."
msgstr "Este valor no es un código postal válido válido."
#~ msgid "Select a tax for this product."
#~ msgstr "Escoged un impuesto para este producto."
#~ msgctxt "input" #~ msgctxt "input"
#~ msgid "Country" #~ msgid "Tax"
#~ msgstr "País" #~ msgstr "Impuesto"
#~ msgctxt "nav" #~ msgctxt "nav"
#~ msgid "Customers" #~ msgid "Customers"

7
revert/product_tax.sql Normal file
View File

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

View File

@ -45,3 +45,4 @@ to_price [schema_numerus] 2023-02-05T11:46:31Z jordi fita mas <jordi@tandem.blog
invoice_status [schema_numerus] 2023-02-07T14:50:26Z jordi fita mas <jordi@tandem.blog> # A relation of invoice status invoice_status [schema_numerus] 2023-02-07T14:50:26Z jordi fita mas <jordi@tandem.blog> # A relation of invoice status
invoice_status_i18n [schema_numerus invoice_status language] 2023-02-07T14:56:18Z jordi fita mas <jordi@tandem.blog> # Add relation for invoice status translatable texts invoice_status_i18n [schema_numerus invoice_status language] 2023-02-07T14:56:18Z jordi fita mas <jordi@tandem.blog> # Add relation for invoice status translatable texts
available_invoice_status [schema_numerus invoice_status invoice_status_i18n] 2023-02-07T15:07:06Z jordi fita mas <jordi@tandem.blog> # Add the list of available invoice status available_invoice_status [schema_numerus invoice_status invoice_status_i18n] 2023-02-07T15:07:06Z jordi fita mas <jordi@tandem.blog> # Add the list of available invoice status
product_tax [schema_numerus product tax] 2023-02-08T11:36:49Z jordi fita mas <jordi@tandem.blog> # Add relation of product taxes

View File

@ -5,7 +5,7 @@ reset client_min_messages;
begin; begin;
select plan(55); select plan(49);
set search_path to numerus, auth, public; set search_path to numerus, auth, public;
@ -57,13 +57,6 @@ select col_type_is('product', 'price', 'integer');
select col_not_null('product', 'price'); select col_not_null('product', 'price');
select col_hasnt_default('product', 'price'); select col_hasnt_default('product', 'price');
select has_column('product', 'tax_id');
select col_is_fk('product', 'tax_id');
select fk_ok('product', 'tax_id', 'tax', 'tax_id');
select col_type_is('product', 'tax_id', 'integer');
select col_not_null('product', 'tax_id');
select col_hasnt_default('product', 'tax_id');
select has_column('product', 'created_at'); select has_column('product', 'created_at');
select col_type_is('product', 'created_at', 'timestamp with time zone'); select col_type_is('product', 'created_at', 'timestamp with time zone');
select col_not_null('product', 'created_at'); select col_not_null('product', 'created_at');
@ -73,7 +66,6 @@ select col_default_is('product', 'created_at', current_timestamp);
set client_min_messages to warning; set client_min_messages to warning;
truncate product cascade; truncate product cascade;
truncate tax cascade;
truncate company_user cascade; truncate company_user cascade;
truncate company cascade; truncate company cascade;
truncate auth."user" cascade; truncate auth."user" cascade;
@ -94,14 +86,9 @@ values (2, 1)
, (4, 5) , (4, 5)
; ;
insert into tax (tax_id, company_id, name, rate) insert into product (company_id, name, description, price)
values (3, 2, 'IVA 21 %', 0.21) values (2, 'Product 1', 'Description 1', 1200)
, (6, 4, 'IVA 10 %', 0.10) , (4, 'Product 2', 'Description 2', 2400)
;
insert into product (company_id, name, description, price, tax_id)
values (2, 'Product 1', 'Description 1', 1200, 3)
, (4, 'Product 2', 'Description 2', 2400, 6)
; ;
prepare product_data as prepare product_data as

114
test/product_tax.sql Normal file
View File

@ -0,0 +1,114 @@
-- Test product_tax
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(23);
set search_path to numerus, auth, public;
select has_table('product_tax');
select has_pk('product_tax' );
select col_is_pk('product_tax', array['product_id', 'tax_id']);
select table_privs_are('product_tax', 'guest', array []::text[]);
select table_privs_are('product_tax', 'invoicer', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']);
select table_privs_are('product_tax', 'admin', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']);
select table_privs_are('product_tax', 'authenticator', array []::text[]);
select has_column('product_tax', 'product_id');
select col_is_fk('product_tax', 'product_id');
select fk_ok('product_tax', 'product_id', 'product', 'product_id');
select col_type_is('product_tax', 'product_id', 'integer');
select col_not_null('product_tax', 'product_id');
select col_hasnt_default('product_tax', 'product_id');
select has_column('product_tax', 'tax_id');
select col_is_fk('product_tax', 'tax_id');
select fk_ok('product_tax', 'tax_id', 'tax', 'tax_id');
select col_type_is('product_tax', 'tax_id', 'integer');
select col_not_null('product_tax', 'tax_id');
select col_hasnt_default('product_tax', 'tax_id');
set client_min_messages to warning;
truncate product cascade;
truncate product_tax cascade;
truncate tax cascade;
truncate company_user cascade;
truncate company cascade;
truncate auth."user" cascade;
reset client_min_messages;
insert into auth."user" (user_id, email, name, password, role, cookie, cookie_expires_at)
values (1, 'demo@tandem.blog', 'Demo', 'test', 'invoicer', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month')
, (5, 'admin@tandem.blog', 'Demo', 'test', 'admin', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month')
;
insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code)
values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR')
, (4, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', 'FR', 'USD')
;
insert into company_user (company_id, user_id)
values (2, 1)
, (4, 5)
;
insert into tax (tax_id, company_id, name, rate)
values (3, 2, 'IVA 21 %', 0.21)
, (6, 4, 'IVA 10 %', 0.10)
;
insert into product (product_id, company_id, name, description, price)
values (7, 2, 'Product 1', 'Description 1', 1200)
, (8, 4, 'Product 2', 'Description 2', 2400)
;
insert into product_tax (product_id, tax_id)
values (7, 3)
, (8, 6)
;
prepare product_tax_data as
select product_id, tax_id
from product_tax
order by product_id, tax_id;
set role invoicer;
select is_empty('product_tax_data', 'Should show no data when cookie is not set yet');
reset role;
select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog');
select bag_eq(
'product_tax_data',
$$ values (7, 3)
$$,
'Should only list tax of products of the companies where demo@tandem.blog is user of'
);
reset role;
select set_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog');
select bag_eq(
'product_tax_data',
$$ values (8, 6)
$$,
'Should only list tax of products of the companies where admin@tandem.blog is user of'
);
reset role;
select set_cookie('not-a-cookie');
select throws_ok(
'product_tax_data',
'42501', 'permission denied for table product_tax',
'Should not allow select to guest users'
);
reset role;
select *
from finish();
rollback;

View File

@ -8,7 +8,6 @@ select product_id
, name , name
, description , description
, price , price
, tax_id
, created_at , created_at
from numerus.product from numerus.product
where false; where false;

13
verify/product_tax.sql Normal file
View File

@ -0,0 +1,13 @@
-- Verify numerus:product_tax on pg
begin;
select product_id
, tax_id
from numerus.product_tax
where false;
select 1 / count(*) from pg_class where oid = 'numerus.product_tax'::regclass and relrowsecurity;
select 1 / count(*) from pg_policy where polname = 'company_policy' and polrelid = 'numerus.product_tax'::regclass;
rollback;

View File

@ -1,10 +1,10 @@
{{ define "input-field" -}} {{ define "input-field" -}}
<div class="input {{ if .Errors }}has-errors{{ end }}"> <div class="input {{ if .Errors }}has-errors{{ end }}">
{{ if eq .Type "textarea" }} {{ if eq .Type "textarea" }}
<textarea name="{{ .Name }}" id="{{ .Name }}-field" <textarea name="{{ .Name }}" id="{{ .Name }}-field"
{{- range $attribute := .Attributes }} {{$attribute}} {{ end }} {{- range $attribute := .Attributes }} {{$attribute}} {{ end }}
{{ if .Required }}required="required"{{ end }} placeholder="{{ .Label }}" {{ if .Required }}required="required"{{ end }} placeholder="{{ .Label }}"
>{{ .Val }}</textarea> >{{ .Val }}</textarea>
{{ else }} {{ else }}
<input type="{{ .Type }}" name="{{ .Name }}" id="{{ .Name }}-field" <input type="{{ .Type }}" name="{{ .Name }}" id="{{ .Name }}-field"
{{- range $attribute := .Attributes }} {{$attribute}} {{ end }} {{- range $attribute := .Attributes }} {{$attribute}} {{ end }}
@ -25,6 +25,7 @@
<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 .Required }}required="required"{{ end }} {{ if .Required }}required="required"{{ end }}
> >
{{- with .EmptyLabel }} {{- with .EmptyLabel }}
@ -32,7 +33,7 @@
{{- end}} {{- end}}
{{- range $option := .Options }} {{- range $option := .Options }}
<option value="{{ .Value }}" <option value="{{ .Value }}"
{{- if eq .Value $.Selected }} selected="selected"{{ end }}>{{ .Label }}</option> {{- if $.IsSelected .Value }} selected="selected"{{ end }}>{{ .Label }}</option>
{{- end }} {{- end }}
</select> </select>
<label for="{{ .Name }}-field">{{ .Label }}</label> <label for="{{ .Name }}-field">{{ .Label }}</label>