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 }}