Compare commits

..

No commits in common. "ae1949024b7215ff97448178d595b90506403a84" and "e9cc331ee06cb80ac1610cdb95ede11e445ab058" have entirely different histories.

20 changed files with 90 additions and 341 deletions

View File

@ -1,47 +0,0 @@
-- Deploy numerus:parse_price to pg
-- requires: schema_public
begin;
set search_path to numerus, public;
create or replace function parse_price(price text, decimal_digits integer) returns integer as
$$
declare
parts text[];
result int;
frac_part text;
begin
parts := string_to_array(price, '.');
if array_length(parts, 1) > 2 then
raise invalid_parameter_value using message = price || ' is not a valid price representation.';
end if;
result := parts[1]::integer;
for d in 1..decimal_digits loop
result := result * 10;
end loop;
if array_length(parts, 1) = 2 then
frac_part := rtrim(parts[2], '0');
if length(frac_part) > decimal_digits then
raise invalid_parameter_value using message = price || ' has too many digits in the fraction part.';
end if;
frac_part := rpad(frac_part, decimal_digits, '0');
result := result + frac_part::integer;
end if;
return result;
end;
$$
language plpgsql
immutable;
comment on function parse_price(text, integer) is
'Converts the string representation of a price in decimal form to cents, according to the number of decimal digits passed.';
revoke execute on function parse_price(text, integer) from public;
grant execute on function parse_price(text, integer) to invoicer;
grant execute on function parse_price(text, integer) to admin;
commit;

View File

@ -1,28 +0,0 @@
-- Deploy numerus:to_price to pg
-- requires: schema_numerus
begin;
set search_path to numerus, public;
create or replace function to_price(cents integer, decimal_digits integer) returns text as
$$
declare
result text;
scale integer := 10^decimal_digits;
begin
result = cents::text;
return (cents / scale)::text || '.' || to_char(mod(cents, scale), rpad('FM', decimal_digits + 2, '0'));
end;
$$
language plpgsql
immutable;
comment on function to_price(integer, integer) is
'Converts the cents to a price representation, without currency and any other separater than decimal.';
revoke execute on function to_price(integer, integer) from public;
grant execute on function to_price(integer, integer) to invoicer;
grant execute on function to_price(integer, integer) to admin;
commit;

View File

@ -15,10 +15,8 @@ const (
) )
type Company struct { type Company struct {
Id int Id int
CurrencySymbol string Slug string
DecimalDigits int
Slug string
} }
func CompanyHandler(next http.Handler) httprouter.Handle { func CompanyHandler(next http.Handler) httprouter.Handle {
@ -27,7 +25,7 @@ func CompanyHandler(next http.Handler) httprouter.Handle {
Slug: params[0].Value, Slug: params[0].Value,
} }
conn := getConn(r) conn := getConn(r)
err := conn.QueryRow(r.Context(), "select company_id, currency_symbol, decimal_digits from company join currency using (currency_code) where slug = $1", company.Slug).Scan(&company.Id, &company.CurrencySymbol, &company.DecimalDigits) err := conn.QueryRow(r.Context(), "select company_id from company where slug = $1", company.Slug).Scan(&company.Id)
if err != nil { if err != nil {
http.NotFound(w, r) http.NotFound(w, r)
return return
@ -43,15 +41,6 @@ func CompanyHandler(next http.Handler) httprouter.Handle {
} }
} }
func (c Company) MinCents() float64 {
var r float64
r = 1
for i := 0; i < c.DecimalDigits; i++ {
r /= 10.0
}
return r
}
func getCompany(r *http.Request) *Company { func getCompany(r *http.Request) *Company {
company := r.Context().Value(ContextCompanyKey) company := r.Context().Value(ContextCompanyKey)
if company == nil { if company == nil {
@ -88,7 +77,6 @@ func newTaxDetailsForm(ctx context.Context, conn *Conn, locale *Locale) *taxDeta
Name: "currency", Name: "currency",
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,
Selected: "EUR", Selected: "EUR",
}, },
} }

View File

@ -224,7 +224,6 @@ func newContactForm(ctx context.Context, conn *Conn, locale *Locale) *contactFor
Name: "country", Name: "country",
Label: pgettext("input", "Tax", locale), Label: pgettext("input", "Tax", locale),
Options: mustGetCountryOptions(ctx, conn, locale), Options: mustGetCountryOptions(ctx, conn, locale),
Required: true,
Selected: "ES", Selected: "ES",
Attributes: []template.HTMLAttr{ Attributes: []template.HTMLAttr{
`autocomplete="country"`, `autocomplete="country"`,

View File

@ -61,8 +61,6 @@ type SelectField struct {
Selected string Selected string
Options []*SelectOption Options []*SelectOption
Attributes []template.HTMLAttr Attributes []template.HTMLAttr
Required bool
EmptyLabel string
Errors []error Errors []error
} }
@ -177,11 +175,6 @@ func (v *FormValidator) CheckValidInteger(field *InputField, min int, max int, m
return v.checkInput(field, err == nil && value >= min && value <= max, message) return v.checkInput(field, err == nil && value >= min && value <= max, message)
} }
func (v *FormValidator) CheckValidDecimal(field *InputField, min float64, max float64, message string) bool {
value, err := strconv.ParseFloat(field.Val, 64)
return v.checkInput(field, err == nil && value >= min && value <= max, message)
}
func (v *FormValidator) checkInput(field *InputField, ok bool, message string) bool { func (v *FormValidator) checkInput(field *InputField, ok bool, message string) bool {
if !ok { if !ok {
field.Errors = append(field.Errors, errors.New(message)) field.Errors = append(field.Errors, errors.New(message))

View File

@ -2,7 +2,6 @@ package pkg
import ( import (
"context" "context"
"fmt"
"github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"html/template" "html/template"
@ -13,7 +12,7 @@ import (
type ProductEntry struct { type ProductEntry struct {
Slug string Slug string
Name string Name string
Price string Price int
} }
type productsIndexPage struct { type productsIndexPage struct {
@ -36,12 +35,11 @@ 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) err := conn.QueryRow(r.Context(), "select name, description, price, tax_id from product where slug = $1", slug).Scan(form.Name, form.Description, form.Price, form.Tax)
if err != nil { if err != nil {
if err == pgx.ErrNoRows { if err == pgx.ErrNoRows {
http.NotFound(w, r) http.NotFound(w, r)
@ -79,7 +77,7 @@ 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) conn.MustExec(r.Context(), "insert into product (company_id, name, description, price, tax_id) values ($1, $2, $3, $4, $5)", company.Id, form.Name, form.Description, form.Price, form.Tax)
http.Redirect(w, r, companyURI(company, "/products"), http.StatusSeeOther) http.Redirect(w, r, companyURI(company, "/products"), http.StatusSeeOther)
} }
@ -100,7 +98,7 @@ 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) slug := conn.MustGetText(r.Context(), "", "update product set name = $1, description = $2, price = $3, tax_id = $4 where slug = $5 returning slug", form.Name, form.Description, form.Price, form.Tax, params[0].Value)
if slug == "" { if slug == "" {
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -108,7 +106,7 @@ func HandleUpdateProduct(w http.ResponseWriter, r *http.Request, params httprout
} }
func mustGetProductEntries(ctx context.Context, conn *Conn, company *Company) []*ProductEntry { func mustGetProductEntries(ctx context.Context, conn *Conn, company *Company) []*ProductEntry {
rows, err := conn.Query(ctx, "select product.slug, product.name, to_price(price, decimal_digits) from product join company using (company_id) join currency using (currency_code) where company_id = $1 order by name", company.Id) rows, err := conn.Query(ctx, "select slug, name, price from product where company_id = $1 order by name", company.Id)
if err != nil { if err != nil {
panic(err) panic(err)
} }
@ -132,7 +130,6 @@ func mustGetProductEntries(ctx context.Context, conn *Conn, company *Company) []
type productForm struct { type productForm struct {
locale *Locale locale *Locale
company *Company
Name *InputField Name *InputField
Description *InputField Description *InputField
Price *InputField Price *InputField
@ -141,8 +138,7 @@ type productForm struct {
func newProductForm(ctx context.Context, conn *Conn, locale *Locale, company *Company) *productForm { func newProductForm(ctx context.Context, conn *Conn, locale *Locale, company *Company) *productForm {
return &productForm{ return &productForm{
locale: locale, locale: locale,
company: company,
Name: &InputField{ Name: &InputField{
Name: "name", Name: "name",
Label: pgettext("input", "Name", locale), Label: pgettext("input", "Name", locale),
@ -161,14 +157,12 @@ func newProductForm(ctx context.Context, conn *Conn, locale *Locale, company *Co
Required: true, Required: true,
Attributes: []template.HTMLAttr{ Attributes: []template.HTMLAttr{
`min="0"`, `min="0"`,
template.HTMLAttr(fmt.Sprintf(`step="%v"`, company.MinCents())),
}, },
}, },
Tax: &SelectField{ Tax: &SelectField{
Name: "tax", Name: "tax",
Label: pgettext("input", "Tax", locale), Label: pgettext("input", "Tax", locale),
Required: 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),
}, },
} }
} }
@ -188,7 +182,7 @@ func (form *productForm) Validate() bool {
validator := newFormValidator() validator := newFormValidator()
validator.CheckRequiredInput(form.Name, gettext("Name can not be empty.", form.locale)) validator.CheckRequiredInput(form.Name, gettext("Name can not be empty.", form.locale))
if validator.CheckRequiredInput(form.Price, gettext("Price can not be empty.", form.locale)) { if validator.CheckRequiredInput(form.Price, gettext("Price can not be empty.", form.locale)) {
validator.CheckValidDecimal(form.Price, form.company.MinCents(), math.MaxFloat64, gettext("Price must be a number greater than zero.", form.locale)) validator.CheckValidInteger(form.Price, 0, math.MaxInt, gettext("Price must be a number greater than zero.", form.locale))
} }
validator.CheckValidSelectOption(form.Tax, gettext("Selected tax is not valid.", form.locale)) validator.CheckValidSelectOption(form.Tax, gettext("Selected tax is not valid.", form.locale))
return validator.AllOK() return validator.AllOK()

View File

@ -61,10 +61,9 @@ func newProfileForm(ctx context.Context, conn *Conn, locale *Locale) *profileFor
}, },
}, },
Language: &SelectField{ Language: &SelectField{
Name: "language", Name: "language",
Label: pgettext("input", "Language", locale), Label: pgettext("input", "Language", locale),
Options: languages, Options: languages,
Required: true,
Attributes: []template.HTMLAttr{ Attributes: []template.HTMLAttr{
`autocomplete="language"`, `autocomplete="language"`,
}, },

View File

@ -2,13 +2,9 @@ package pkg
import ( import (
"fmt" "fmt"
"golang.org/x/text/message"
"golang.org/x/text/number"
"html/template" "html/template"
"io" "io"
"math"
"net/http" "net/http"
"strconv"
) )
const overrideMethodName = "_method" const overrideMethodName = "_method"
@ -31,14 +27,6 @@ func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename s
"companyURI": func(uri string) string { "companyURI": func(uri string) string {
return companyURI(company, uri) return companyURI(company, uri)
}, },
"formatPrice": func(price string) string {
p := message.NewPrinter(locale.Language)
f, err := strconv.ParseFloat(price, 64)
if err != nil {
f = math.NaN()
}
return p.Sprintf("%.*f", company.DecimalDigits, number.Decimal(f))
},
"csrfToken": func() template.HTML { "csrfToken": func() template.HTML {
return template.HTML(fmt.Sprintf(`<input type="hidden" name="%s" value="%s">`, csrfTokenField, user.CsrfToken)) return template.HTML(fmt.Sprintf(`<input type="hidden" name="%s" value="%s">`, csrfTokenField, user.CsrfToken))
}, },

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-04 11:24+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:88 pkg/contacts.go:262
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:89 pkg/contacts.go:263
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,74 +254,70 @@ 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:144
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:150
msgctxt "input" msgctxt "input"
msgid "Description" msgid "Description"
msgstr "Descripció" msgstr "Descripció"
#: pkg/products.go:159 #: pkg/products.go:155
msgctxt "input" msgctxt "input"
msgid "Price" msgid "Price"
msgstr "Preu" msgstr "Preu"
#: pkg/products.go:169 pkg/contacts.go:225 #: pkg/products.go:164 pkg/contacts.go:225
msgctxt "input" msgctxt "input"
msgid "Tax" msgid "Tax"
msgstr "Impost" msgstr "Impost"
#: pkg/products.go:189 pkg/profile.go:92 #: pkg/products.go:183 pkg/profile.go:91
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:184
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:185
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:187
msgid "Selected tax is not valid." msgid "Selected tax is not valid."
msgstr "Heu seleccionat un impost que no és vàlid." msgstr "Heu seleccionat un impost que no és vàlid."
#: pkg/company.go:89 #: pkg/company.go:78
msgctxt "input" msgctxt "input"
msgid "Currency" msgid "Currency"
msgstr "Moneda" msgstr "Moneda"
#: pkg/company.go:107 #: pkg/company.go:95
msgid "Selected currency is not valid." msgid "Selected currency is not valid."
msgstr "Heu seleccionat una moneda que no és vàlida." msgstr "Heu seleccionat una moneda que no és vàlida."
#: pkg/company.go:229 #: pkg/company.go:217
msgctxt "input" msgctxt "input"
msgid "Tax name" msgid "Tax name"
msgstr "Nom impost" msgstr "Nom impost"
#: pkg/company.go:235 #: pkg/company.go:223
msgctxt "input" msgctxt "input"
msgid "Rate (%)" msgid "Rate (%)"
msgstr "Percentatge" msgstr "Percentatge"
#: pkg/company.go:257 #: pkg/company.go:245
msgid "Tax name can not be empty." msgid "Tax name can not be empty."
msgstr "No podeu deixar el nom de limpost en blanc." msgstr "No podeu deixar el nom de limpost en blanc."
#: pkg/company.go:258 #: pkg/company.go:246
msgid "Tax rate can not be empty." msgid "Tax rate can not be empty."
msgstr "No podeu deixar percentatge en blanc." msgstr "No podeu deixar percentatge en blanc."
#: pkg/company.go:259 #: pkg/company.go:247
msgid "Tax rate must be an integer between -99 and 99." msgid "Tax rate must be an integer between -99 and 99."
msgstr "El percentatge ha de ser entre -99 i 99." msgstr "El percentatge ha de ser entre -99 i 99."
@ -345,11 +341,11 @@ msgctxt "input"
msgid "Language" msgid "Language"
msgstr "Idioma" msgstr "Idioma"
#: pkg/profile.go:93 #: pkg/profile.go:92
msgid "Confirmation does not match password." msgid "Confirmation does not match password."
msgstr "La confirmació no és igual a la contrasenya." msgstr "La confirmació no és igual a la contrasenya."
#: pkg/profile.go:94 #: pkg/profile.go:93
msgid "Selected language is not valid." msgid "Selected language is not valid."
msgstr "Heu seleccionat un idioma que no és vàlid." msgstr "Heu seleccionat un idioma que no és vàlid."
@ -398,51 +394,51 @@ msgctxt "input"
msgid "Postal code" msgid "Postal code"
msgstr "Codi postal" msgstr "Codi postal"
#: pkg/contacts.go:256 #: pkg/contacts.go:255
msgid "Business name can not be empty." msgid "Business name can not be empty."
msgstr "No podeu deixar el nom i els cognoms en blanc." msgstr "No podeu deixar el nom i els cognoms en blanc."
#: pkg/contacts.go:257 #: pkg/contacts.go:256
msgid "VAT number can not be empty." msgid "VAT number can not be empty."
msgstr "No podeu deixar el DNI o NIF en blanc." msgstr "No podeu deixar el DNI o NIF en blanc."
#: pkg/contacts.go:258 #: pkg/contacts.go:257
msgid "This value is not a valid VAT number." msgid "This value is not a valid VAT number."
msgstr "Aquest valor no és un DNI o NIF vàlid." msgstr "Aquest valor no és un DNI o NIF vàlid."
#: pkg/contacts.go:260 #: pkg/contacts.go:259
msgid "Phone can not be empty." msgid "Phone can not be empty."
msgstr "No podeu deixar el telèfon en blanc." msgstr "No podeu deixar el telèfon en blanc."
#: pkg/contacts.go:261 #: pkg/contacts.go:260
msgid "This value is not a valid phone number." msgid "This value is not a valid phone number."
msgstr "Aquest valor no és un telèfon vàlid." msgstr "Aquest valor no és un telèfon vàlid."
#: pkg/contacts.go:267 #: pkg/contacts.go:266
msgid "This value is not a valid web address. It should be like https://domain.com/." 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/." msgstr "Aquest valor no és una adreça web vàlida. Hauria de ser similar a https://domini.cat/."
#: pkg/contacts.go:269 #: pkg/contacts.go:268
msgid "Address can not be empty." msgid "Address can not be empty."
msgstr "No podeu deixar ladreça en blanc." msgstr "No podeu deixar ladreça en blanc."
#: pkg/contacts.go:270 #: pkg/contacts.go:269
msgid "City can not be empty." msgid "City can not be empty."
msgstr "No podeu deixar la població en blanc." msgstr "No podeu deixar la població en blanc."
#: pkg/contacts.go:271 #: pkg/contacts.go:270
msgid "Province can not be empty." msgid "Province can not be empty."
msgstr "No podeu deixar la província en blanc." msgstr "No podeu deixar la província en blanc."
#: pkg/contacts.go:272 #: pkg/contacts.go:271
msgid "Postal code can not be empty." msgid "Postal code can not be empty."
msgstr "No podeu deixar el codi postal en blanc." msgstr "No podeu deixar el codi postal en blanc."
#: pkg/contacts.go:273 #: pkg/contacts.go:272
msgid "This value is not a valid postal code." msgid "This value is not a valid postal code."
msgstr "Aquest valor no és un codi postal vàlid." msgstr "Aquest valor no és un codi postal vàlid."
#: pkg/contacts.go:275 #: pkg/contacts.go:274
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."

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-04 11:24+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:88 pkg/contacts.go:262
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:89 pkg/contacts.go:263
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,74 +254,70 @@ 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:144
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:150
msgctxt "input" msgctxt "input"
msgid "Description" msgid "Description"
msgstr "Descripción" msgstr "Descripción"
#: pkg/products.go:159 #: pkg/products.go:155
msgctxt "input" msgctxt "input"
msgid "Price" msgid "Price"
msgstr "Precio" msgstr "Precio"
#: pkg/products.go:169 pkg/contacts.go:225 #: pkg/products.go:164 pkg/contacts.go:225
msgctxt "input" msgctxt "input"
msgid "Tax" msgid "Tax"
msgstr "Impuesto" msgstr "Impuesto"
#: pkg/products.go:189 pkg/profile.go:92 #: pkg/products.go:183 pkg/profile.go:91
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:184
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:185
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:187
msgid "Selected tax is not valid." msgid "Selected tax is not valid."
msgstr "Habéis escogido un impuesto que no es válido." msgstr "Habéis escogido un impuesto que no es válido."
#: pkg/company.go:89 #: pkg/company.go:78
msgctxt "input" msgctxt "input"
msgid "Currency" msgid "Currency"
msgstr "Moneda" msgstr "Moneda"
#: pkg/company.go:107 #: pkg/company.go:95
msgid "Selected currency is not valid." msgid "Selected currency is not valid."
msgstr "Habéis escogido una moneda que no es válida." msgstr "Habéis escogido una moneda que no es válida."
#: pkg/company.go:229 #: pkg/company.go:217
msgctxt "input" msgctxt "input"
msgid "Tax name" msgid "Tax name"
msgstr "Nombre impuesto" msgstr "Nombre impuesto"
#: pkg/company.go:235 #: pkg/company.go:223
msgctxt "input" msgctxt "input"
msgid "Rate (%)" msgid "Rate (%)"
msgstr "Porcentaje" msgstr "Porcentaje"
#: pkg/company.go:257 #: pkg/company.go:245
msgid "Tax name can not be empty." msgid "Tax name can not be empty."
msgstr "No podéis dejar el nombre del impuesto en blanco." msgstr "No podéis dejar el nombre del impuesto en blanco."
#: pkg/company.go:258 #: pkg/company.go:246
msgid "Tax rate can not be empty." msgid "Tax rate can not be empty."
msgstr "No podéis dejar el porcentaje en blanco." msgstr "No podéis dejar el porcentaje en blanco."
#: pkg/company.go:259 #: pkg/company.go:247
msgid "Tax rate must be an integer between -99 and 99." msgid "Tax rate must be an integer between -99 and 99."
msgstr "El porcentaje tiene que estar entre -99 y 99." msgstr "El porcentaje tiene que estar entre -99 y 99."
@ -345,11 +341,11 @@ msgctxt "input"
msgid "Language" msgid "Language"
msgstr "Idioma" msgstr "Idioma"
#: pkg/profile.go:93 #: pkg/profile.go:92
msgid "Confirmation does not match password." msgid "Confirmation does not match password."
msgstr "La confirmación no corresponde con la contraseña." msgstr "La confirmación no corresponde con la contraseña."
#: pkg/profile.go:94 #: pkg/profile.go:93
msgid "Selected language is not valid." msgid "Selected language is not valid."
msgstr "Habéis escogido un idioma que no es válido." msgstr "Habéis escogido un idioma que no es válido."
@ -398,51 +394,51 @@ msgctxt "input"
msgid "Postal code" msgid "Postal code"
msgstr "Código postal" msgstr "Código postal"
#: pkg/contacts.go:256 #: pkg/contacts.go:255
msgid "Business name can not be empty." msgid "Business name can not be empty."
msgstr "No podéis dejar el nombre y los apellidos en blanco." msgstr "No podéis dejar el nombre y los apellidos en blanco."
#: pkg/contacts.go:257 #: pkg/contacts.go:256
msgid "VAT number can not be empty." msgid "VAT number can not be empty."
msgstr "No podéis dejar el DNI o NIF en blanco." msgstr "No podéis dejar el DNI o NIF en blanco."
#: pkg/contacts.go:258 #: pkg/contacts.go:257
msgid "This value is not a valid VAT number." msgid "This value is not a valid VAT number."
msgstr "Este valor no es un DNI o NIF válido." msgstr "Este valor no es un DNI o NIF válido."
#: pkg/contacts.go:260 #: pkg/contacts.go:259
msgid "Phone can not be empty." msgid "Phone can not be empty."
msgstr "No podéis dejar el teléfono en blanco." msgstr "No podéis dejar el teléfono en blanco."
#: pkg/contacts.go:261 #: pkg/contacts.go:260
msgid "This value is not a valid phone number." msgid "This value is not a valid phone number."
msgstr "Este valor no es un teléfono válido." msgstr "Este valor no es un teléfono válido."
#: pkg/contacts.go:267 #: pkg/contacts.go:266
msgid "This value is not a valid web address. It should be like https://domain.com/." 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/." msgstr "Este valor no es una dirección web válida. Tiene que ser parecida a https://dominio.es/."
#: pkg/contacts.go:269 #: pkg/contacts.go:268
msgid "Address can not be empty." msgid "Address can not be empty."
msgstr "No podéis dejar la dirección en blanco." msgstr "No podéis dejar la dirección en blanco."
#: pkg/contacts.go:270 #: pkg/contacts.go:269
msgid "City can not be empty." msgid "City can not be empty."
msgstr "No podéis dejar la población en blanco." msgstr "No podéis dejar la población en blanco."
#: pkg/contacts.go:271 #: pkg/contacts.go:270
msgid "Province can not be empty." msgid "Province can not be empty."
msgstr "No podéis dejar la provincia en blanco." msgstr "No podéis dejar la provincia en blanco."
#: pkg/contacts.go:272 #: pkg/contacts.go:271
msgid "Postal code can not be empty." msgid "Postal code can not be empty."
msgstr "No podéis dejar el código postal en blanco." msgstr "No podéis dejar el código postal en blanco."
#: pkg/contacts.go:273 #: pkg/contacts.go:272
msgid "This value is not a valid postal code." msgid "This value is not a valid postal code."
msgstr "Este valor no es un código postal válido válido." msgstr "Este valor no es un código postal válido válido."
#: pkg/contacts.go:275 #: pkg/contacts.go:274
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."

View File

@ -1,7 +0,0 @@
-- Revert numerus:parse_price from pg
begin;
drop function if exists numerus.parse_price(text, integer);
commit;

View File

@ -1,7 +0,0 @@
-- Revert numerus:to_price from pg
begin;
drop function if exists numerus.to_price(integer, integer);
commit;

View File

@ -40,5 +40,3 @@ tax_rate [schema_numerus] 2023-01-28T11:33:39Z jordi fita mas <jordi@tandem.blog
tax [schema_numerus company tax_rate] 2023-01-28T11:45:47Z jordi fita mas <jordi@tandem.blog> # Add relation for taxes tax [schema_numerus company tax_rate] 2023-01-28T11:45:47Z jordi fita mas <jordi@tandem.blog> # Add relation for taxes
contact [schema_numerus company extension_vat email extension_pg_libphonenumber extension_uri currency_code currency country_code country] 2023-01-29T12:59:18Z jordi fita mas <jordi@tandem.blog> # Add the relation for contacts contact [schema_numerus company extension_vat email extension_pg_libphonenumber extension_uri currency_code currency country_code country] 2023-01-29T12:59:18Z jordi fita mas <jordi@tandem.blog> # Add the relation for contacts
product [schema_numerus company] 2023-02-04T09:17:24Z jordi fita mas <jordi@tandem.blog> # Add relation for products product [schema_numerus company] 2023-02-04T09:17:24Z jordi fita mas <jordi@tandem.blog> # Add relation for products
parse_price [schema_public] 2023-02-05T11:04:54Z jordi fita mas <jordi@tandem.blog> # Add function to convert from price to cents
to_price [schema_numerus] 2023-02-05T11:46:31Z jordi fita mas <jordi@tandem.blog> # Add function to format cents to prices

View File

@ -1,53 +0,0 @@
-- Test parse_price
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(36);
set search_path to auth, numerus, public;
select has_function('numerus', 'parse_price', array ['text', 'integer']);
select function_lang_is('numerus', 'parse_price', array ['text', 'integer'], 'plpgsql');
select function_returns('numerus', 'parse_price', array ['text', 'integer'], 'integer');
select isnt_definer('numerus', 'parse_price', array ['text', 'integer']);
select volatility_is('numerus', 'parse_price', array ['text', 'integer'], 'immutable');
select function_privs_are('numerus', 'parse_price', array ['text', 'integer'], 'guest', array []::text[]);
select function_privs_are('numerus', 'parse_price', array ['text', 'integer'], 'invoicer', array ['EXECUTE']);
select function_privs_are('numerus', 'parse_price', array ['text', 'integer'], 'admin', array ['EXECUTE']);
select function_privs_are('numerus', 'parse_price', array ['text', 'integer'], 'authenticator', array []::text[]);
select is( parse_price('1.1', 2), 110 );
select is( parse_price('1.1', 3), 1100 );
select is( parse_price('0', 2), 0 );
select is( parse_price('0', 3), 0 );
select is( parse_price('0.01', 2), 1 );
select is( parse_price('0.001', 3), 1 );
select is( parse_price('0.1', 2), 10 );
select is( parse_price('0.01', 3), 10 );
select is( parse_price('1', 2), 100 );
select is( parse_price('0.1', 3), 100 );
select is( parse_price('10', 2), 1000 );
select is( parse_price('1', 3), 1000 );
select is( parse_price('23.23', 2), 2323 );
select is( parse_price('23.23', 3), 23230 );
select throws_ok( $$ select parse_price('234.234', 2) $$ );
select is( parse_price('234.234', 3), 234234 );
select throws_ok( $$ select parse_price('2345.2345', 2) $$ );
select throws_ok( $$ select parse_price('2345.2345', 3) $$ );
select is( parse_price('00000000000000001.100000000000000000000', 2), 110 );
select is( parse_price('00000000000000001.100000000000000000000', 3), 1100 );
select is( parse_price('00000000000000000.100000000000000000000', 2), 10 );
select is( parse_price('00000000000000000.100000000000000000000', 3), 100 );
select is( parse_price('00000000000123456.780000000000000000000', 2), 12345678 );
select is( parse_price('00000000000123456.789000000000000000000', 3), 123456789 );
select throws_ok( $$ select parse_price('1,1', 2) $$ );
select throws_ok( $$ select parse_price('1.1.1', 2) $$ );
select throws_ok( $$ select parse_price('a.b', 2) $$ );
select *
from finish();
rollback;

View File

@ -1,40 +0,0 @@
-- Test to_price
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, public;
select has_function('numerus', 'to_price', array ['integer', 'integer']);
select function_lang_is('numerus', 'to_price', array ['integer', 'integer'], 'plpgsql');
select function_returns('numerus', 'to_price', array ['integer', 'integer'], 'text');
select isnt_definer('numerus', 'to_price', array ['integer', 'integer']);
select volatility_is('numerus', 'to_price', array ['integer', 'integer'], 'immutable');
select function_privs_are('numerus', 'to_price', array ['integer', 'integer'], 'guest', array []::text[]);
select function_privs_are('numerus', 'to_price', array ['integer', 'integer'], 'invoicer', array ['EXECUTE']);
select function_privs_are('numerus', 'to_price', array ['integer', 'integer'], 'admin', array ['EXECUTE']);
select function_privs_are('numerus', 'to_price', array ['integer', 'integer'], 'authenticator', array []::text[]);
select is( to_price(0, 2), '0.00' );
select is( to_price(0, 3), '0.000' );
select is( to_price(1, 2), '0.01' );
select is( to_price(1, 3), '0.001' );
select is( to_price(10, 2), '0.10' );
select is( to_price(10, 3), '0.010' );
select is( to_price(100, 2), '1.00' );
select is( to_price(100, 3), '0.100' );
select is( to_price(110, 2), '1.10' );
select is( to_price(1100, 3), '1.100' );
select is( to_price(12345678, 2), '123456.78' );
select is( to_price(12345678, 3), '12345.678' );
select is( to_price(12345678, 4), '1234.5678' );
select is( to_price(12345678, 5), '123.45678' );
select *
from finish();
rollback;

View File

@ -1,7 +0,0 @@
-- Verify numerus:parse_price on pg
begin;
select has_function_privilege('numerus.parse_price(text, integer)', 'execute');
rollback;

View File

@ -1,7 +0,0 @@
-- Verify numerus:to_price on pg
begin;
select has_function_privilege('numerus.to_price(integer, integer)', 'execute');
rollback;

View File

@ -342,13 +342,11 @@ input.width-2x {
color: var(--numerus--color--red); color: var(--numerus--color--red);
} }
[lang="en"] input:not([required]) + label::after, [lang="en"] input:not([required]) + label::after {
[lang="en"] select:not([required]) + label::after {
content: " (optional)" content: " (optional)"
} }
[lang="ca"] input:not([required]) + label::after, [lang="es"] input:not([required]) + label::after, [lang="ca"] input:not([required]) + label::after, [lang="es"] input:not([required]) + label::after {
[lang="ca"] select:not([required]) + label::after, [lang="es"] select:not([required]) + label::after {
content: " (opcional)" content: " (opcional)"
} }

View File

@ -1,8 +1,8 @@
{{ define "input-field" -}} {{ define "input-field" -}}
<div class="input {{ if .Errors }}has-errors{{ end }}"> <div class="input {{ if .Errors }}has-errors{{ end }}">
<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 }}
{{ if .Required }}required="required"{{ end }} value="{{ .Val }}" placeholder="{{ .Label }}"> {{ if .Required }}required="required"{{ end }} value="{{ .Val }}" placeholder="{{ .Label }}">
<label for="{{ .Name }}-field">{{ .Label }}</label> <label for="{{ .Name }}-field">{{ .Label }}</label>
{{- if .Errors }} {{- if .Errors }}
<ul> <ul>
@ -17,16 +17,12 @@
{{ define "select-field" -}} {{ define "select-field" -}}
<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 .Required }}required="required"{{ end }}
> >
{{- with .EmptyLabel }} {{- range $option := .Options }}
<option value="">{{ . }}</option> <option value="{{ .Value }}"
{{- end}} {{- if eq .Value $.Selected }} selected="selected"{{ end }}>{{ .Label }}</option>
{{- range $option := .Options }} {{- end }}
<option value="{{ .Value }}"
{{- if eq .Value $.Selected }} selected="selected"{{ end }}>{{ .Label }}</option>
{{- end }}
</select> </select>
<label for="{{ .Name }}-field">{{ .Label }}</label> <label for="{{ .Name }}-field">{{ .Label }}</label>
{{- if .Errors }} {{- if .Errors }}

View File

@ -29,7 +29,7 @@
<tr> <tr>
<td></td> <td></td>
<td><a href="{{ companyURI "/products/"}}{{ .Slug }}">{{ .Name }}</a></td> <td><a href="{{ companyURI "/products/"}}{{ .Slug }}">{{ .Name }}</a></td>
<td>{{ .Price | formatPrice }}</td> <td>{{ .Price }}</td>
</tr> </tr>
{{- end }} {{- end }}
{{ else }} {{ else }}