From 4be2597a86dd75b63d84cb7524876fc7ff0372a5 Mon Sep 17 00:00:00 2001 From: jordi fita mas Date: Wed, 8 Feb 2023 13:47:36 +0100 Subject: [PATCH] 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. --- deploy/product.sql | 1 - deploy/product_tax.sql | 31 ++++++++++ pkg/company.go | 2 +- pkg/contacts.go | 17 ++++-- pkg/db.go | 51 ++++++++++++++++ pkg/form.go | 38 +++++++++--- pkg/products.go | 59 +++++++++++++++--- pkg/profile.go | 2 +- po/ca.po | 128 ++++++++++++++++++++------------------- po/es.po | 128 ++++++++++++++++++++------------------- revert/product_tax.sql | 7 +++ sqitch.plan | 1 + test/product.sql | 21 ++----- test/product_tax.sql | 114 ++++++++++++++++++++++++++++++++++ verify/product.sql | 1 - verify/product_tax.sql | 13 ++++ web/template/form.gohtml | 9 +-- 17 files changed, 453 insertions(+), 170 deletions(-) create mode 100644 deploy/product_tax.sql create mode 100644 revert/product_tax.sql create mode 100644 test/product_tax.sql create mode 100644 verify/product_tax.sql diff --git a/deploy/product.sql b/deploy/product.sql index 7ddef6a..5d6232c 100644 --- a/deploy/product.sql +++ b/deploy/product.sql @@ -14,7 +14,6 @@ create table product ( name text not null, description text not null, price integer not null, - tax_id integer not null references tax, created_at timestamptz not null default current_timestamp ); diff --git a/deploy/product_tax.sql b/deploy/product_tax.sql new file mode 100644 index 0000000..7188f8a --- /dev/null +++ b/deploy/product_tax.sql @@ -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; diff --git a/pkg/company.go b/pkg/company.go index 146c8ac..ded4f0c 100644 --- a/pkg/company.go +++ b/pkg/company.go @@ -89,7 +89,7 @@ func newTaxDetailsForm(ctx context.Context, conn *Conn, locale *Locale) *taxDeta Label: pgettext("input", "Currency", locale), Options: MustGetOptions(ctx, conn, "select currency_code, currency_symbol from currency order by currency_code"), Required: true, - Selected: "EUR", + Selected: []string{"EUR"}, }, } } diff --git a/pkg/contacts.go b/pkg/contacts.go index ed44982..bd0f1bc 100644 --- a/pkg/contacts.go +++ b/pkg/contacts.go @@ -222,10 +222,10 @@ func newContactForm(ctx context.Context, conn *Conn, locale *Locale) *contactFor }, Country: &SelectField{ Name: "country", - Label: pgettext("input", "Tax", locale), + Label: pgettext("input", "Country", locale), Options: mustGetCountryOptions(ctx, conn, locale), Required: true, - Selected: "ES", + Selected: []string{"ES"}, Attributes: []template.HTMLAttr{ `autocomplete="country"`, }, @@ -253,12 +253,18 @@ func (form *contactForm) Parse(r *http.Request) error { func (form *contactForm) Validate(ctx context.Context, conn *Conn) bool { 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)) 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)) { - 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)) { 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.Province, gettext("Province 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() } diff --git a/pkg/db.go b/pkg/db.go index 232320a..6c59468 100644 --- a/pkg/db.go +++ b/pkg/db.go @@ -70,6 +70,14 @@ type Conn struct { *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 { var result string 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) } } + +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 +} diff --git a/pkg/form.go b/pkg/form.go index ca0dfbc..adcc407 100644 --- a/pkg/form.go +++ b/pkg/form.go @@ -58,34 +58,56 @@ type SelectOption struct { type SelectField struct { Name string Label string - Selected string + Selected []string Options []*SelectOption Attributes []template.HTMLAttr Required bool + Multiple bool EmptyLabel string Errors []error } func (field *SelectField) Scan(value interface{}) error { if value == nil { - field.Selected = "" + field.Selected = append(field.Selected, "") return nil } - field.Selected = fmt.Sprintf("%v", value) + field.Selected = append(field.Selected, fmt.Sprintf("%v", value)) return nil } 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) { - 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 { - if option.Value == field.Selected { + if option.Value == selected { return true } } @@ -155,7 +177,7 @@ func (v *FormValidator) CheckPasswordConfirmation(password *InputField, confirm } 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 { diff --git a/pkg/products.go b/pkg/products.go index 7877ed4..263b6c3 100644 --- a/pkg/products.go +++ b/pkg/products.go @@ -36,12 +36,12 @@ func GetProductForm(w http.ResponseWriter, r *http.Request, params httprouter.Pa form := newProductForm(r.Context(), conn, locale, company) slug := params[0].Value if slug == "new" { - form.Tax.EmptyLabel = gettext("Select a tax for this product.", locale) w.WriteHeader(http.StatusOK) mustRenderNewProductForm(w, r, form) 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 == pgx.ErrNoRows { http.NotFound(w, r) @@ -50,6 +50,16 @@ func GetProductForm(w http.ResponseWriter, r *http.Request, params httprouter.Pa 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) mustRenderEditProductForm(w, r, form) } @@ -79,7 +89,24 @@ func HandleAddProduct(w http.ResponseWriter, r *http.Request, _ httprouter.Param mustRenderNewProductForm(w, r, form) 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) } @@ -100,10 +127,28 @@ func HandleUpdateProduct(w http.ResponseWriter, r *http.Request, params httprout mustRenderEditProductForm(w, r, form) 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) - if slug == "" { + tx := conn.MustBegin(r.Context()) + 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) } + 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) } @@ -166,8 +211,8 @@ func newProductForm(ctx context.Context, conn *Conn, locale *Locale, company *Co }, Tax: &SelectField{ Name: "tax", - Label: pgettext("input", "Tax", locale), - Required: true, + Label: pgettext("input", "Taxes", locale), + Multiple: true, Options: MustGetOptions(ctx, conn, "select tax_id::text, name from tax where company_id = $1 order by name", company.Id), }, } diff --git a/pkg/profile.go b/pkg/profile.go index f91e2b1..eb33bca 100644 --- a/pkg/profile.go +++ b/pkg/profile.go @@ -102,7 +102,7 @@ func GetProfileForm(w http.ResponseWriter, r *http.Request, _ httprouter.Params) form := newProfileForm(r.Context(), conn, locale) form.Name.Val = conn.MustGetText(r.Context(), "", "select name from user_profile") form.Email.Val = user.Email - form.Language.Selected = user.Language.String() + form.Language.Selected = []string{user.Language.String()} w.WriteHeader(http.StatusOK) mustRenderProfileForm(w, r, form) diff --git a/po/ca.po b/po/ca.po index d5b4aca..fb5b368 100644 --- a/po/ca.po +++ b/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-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" "Last-Translator: jordi fita mas \n" "Language-Team: Catalan \n" @@ -238,11 +238,11 @@ msgctxt "input" msgid "Password" 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." 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." 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." msgstr "Nom d’usuari o contrasenya incorrectes." -#: pkg/products.go:39 -msgid "Select a tax for this product." -msgstr "Escolliu un impost per aquest producte." - -#: pkg/products.go:148 +#: pkg/products.go:193 msgctxt "input" msgid "Name" msgstr "Nom" -#: pkg/products.go:154 +#: pkg/products.go:199 msgctxt "input" msgid "Description" msgstr "Descripció" -#: pkg/products.go:159 +#: pkg/products.go:204 msgctxt "input" msgid "Price" msgstr "Preu" -#: pkg/products.go:169 pkg/contacts.go:225 +#: pkg/products.go:214 msgctxt "input" -msgid "Tax" -msgstr "Impost" +msgid "Taxes" +msgstr "Imposts" -#: pkg/products.go:189 pkg/profile.go:92 +#: pkg/products.go:234 pkg/profile.go:92 msgid "Name can not be empty." msgstr "No podeu deixar el nom en blanc." -#: pkg/products.go:190 +#: pkg/products.go:235 msgid "Price can not be empty." 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." 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." msgstr "Heu seleccionat un impost que no és vàlid." @@ -398,57 +394,65 @@ msgctxt "input" msgid "Postal code" msgstr "Codi postal" -#: pkg/contacts.go:256 -msgid "Business name can not be empty." -msgstr "No podeu deixar el nom i els cognoms en blanc." - -#: pkg/contacts.go:257 -msgid "VAT number can not be empty." -msgstr "No podeu deixar el DNI o NIF en blanc." +#: pkg/contacts.go:225 +msgctxt "input" +msgid "Country" +msgstr "País" #: 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 l’adreç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." 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 l’adreç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" -#~ msgid "Country" -#~ msgstr "País" +#~ msgid "Tax" +#~ msgstr "Impost" #~ msgctxt "nav" #~ msgid "Customers" diff --git a/po/es.po b/po/es.po index 01e5c9e..cbd9f11 100644 --- a/po/es.po +++ b/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-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" "Last-Translator: jordi fita mas \n" "Language-Team: Spanish \n" @@ -238,11 +238,11 @@ msgctxt "input" msgid "Password" 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." 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." 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." msgstr "Nombre de usuario o contraseña inválido." -#: pkg/products.go:39 -msgid "Select a tax for this product." -msgstr "Escoged un impuesto para este producto." - -#: pkg/products.go:148 +#: pkg/products.go:193 msgctxt "input" msgid "Name" msgstr "Nombre" -#: pkg/products.go:154 +#: pkg/products.go:199 msgctxt "input" msgid "Description" msgstr "Descripción" -#: pkg/products.go:159 +#: pkg/products.go:204 msgctxt "input" msgid "Price" msgstr "Precio" -#: pkg/products.go:169 pkg/contacts.go:225 +#: pkg/products.go:214 msgctxt "input" -msgid "Tax" -msgstr "Impuesto" +msgid "Taxes" +msgstr "Impuestos" -#: pkg/products.go:189 pkg/profile.go:92 +#: pkg/products.go:234 pkg/profile.go:92 msgid "Name can not be empty." msgstr "No podéis dejar el nombre en blanco." -#: pkg/products.go:190 +#: pkg/products.go:235 msgid "Price can not be empty." 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." 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." msgstr "Habéis escogido un impuesto que no es válido." @@ -398,57 +394,65 @@ msgctxt "input" msgid "Postal code" msgstr "Código postal" -#: pkg/contacts.go:256 -msgid "Business name can not be empty." -msgstr "No podéis dejar el nombre y los apellidos en blanco." - -#: 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:225 +msgctxt "input" +msgid "Country" +msgstr "País" #: 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." 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" -#~ msgid "Country" -#~ msgstr "País" +#~ msgid "Tax" +#~ msgstr "Impuesto" #~ msgctxt "nav" #~ msgid "Customers" diff --git a/revert/product_tax.sql b/revert/product_tax.sql new file mode 100644 index 0000000..a78980e --- /dev/null +++ b/revert/product_tax.sql @@ -0,0 +1,7 @@ +-- Revert numerus:product_tax from pg + +begin; + +drop table if exists numerus.product_tax; + +commit; diff --git a/sqitch.plan b/sqitch.plan index 4e21bb3..8746f04 100644 --- a/sqitch.plan +++ b/sqitch.plan @@ -45,3 +45,4 @@ to_price [schema_numerus] 2023-02-05T11:46:31Z jordi fita mas # A relation of invoice status invoice_status_i18n [schema_numerus invoice_status language] 2023-02-07T14:56:18Z jordi fita mas # 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 # Add the list of available invoice status +product_tax [schema_numerus product tax] 2023-02-08T11:36:49Z jordi fita mas # Add relation of product taxes diff --git a/test/product.sql b/test/product.sql index af8dec8..b92abd6 100644 --- a/test/product.sql +++ b/test/product.sql @@ -5,7 +5,7 @@ reset client_min_messages; begin; -select plan(55); +select plan(49); 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_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 col_type_is('product', 'created_at', 'timestamp with time zone'); 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; truncate product cascade; -truncate tax cascade; truncate company_user cascade; truncate company cascade; truncate auth."user" cascade; @@ -94,14 +86,9 @@ 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 (company_id, name, description, price, tax_id) -values (2, 'Product 1', 'Description 1', 1200, 3) - , (4, 'Product 2', 'Description 2', 2400, 6) +insert into product (company_id, name, description, price) +values (2, 'Product 1', 'Description 1', 1200) + , (4, 'Product 2', 'Description 2', 2400) ; prepare product_data as diff --git a/test/product_tax.sql b/test/product_tax.sql new file mode 100644 index 0000000..cd87c41 --- /dev/null +++ b/test/product_tax.sql @@ -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; + diff --git a/verify/product.sql b/verify/product.sql index ec643bf..a791537 100644 --- a/verify/product.sql +++ b/verify/product.sql @@ -8,7 +8,6 @@ select product_id , name , description , price - , tax_id , created_at from numerus.product where false; diff --git a/verify/product_tax.sql b/verify/product_tax.sql new file mode 100644 index 0000000..9c4519b --- /dev/null +++ b/verify/product_tax.sql @@ -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; diff --git a/web/template/form.gohtml b/web/template/form.gohtml index 476afe6..f12f30c 100644 --- a/web/template/form.gohtml +++ b/web/template/form.gohtml @@ -1,10 +1,10 @@ {{ define "input-field" -}}
{{ if eq .Type "textarea" }} - + {{ if .Required }}required="required"{{ end }} placeholder="{{ .Label }}" + >{{ .Val }} {{ else }}