diff --git a/demo/demo.sql b/demo/demo.sql index 1a06d44..45c0a31 100644 --- a/demo/demo.sql +++ b/demo/demo.sql @@ -60,14 +60,15 @@ select add_product(1, 'Cavall Fort', 'Revista quinzenal en llengua catalana i de select add_product(1, 'Palla', 'Tija seca dels cereals després que el gra o llavor ha estat separat mitjançant la trilla.', '25.00', array[3]); select add_product(1, 'Teia', 'Fusta resinosa de pi i d’altres arbres, provinent sobretot del cor de l’arbre, que crema amb molta facilitat.', '7.00', array[2]); +alter sequence tag_tag_id_seq restart; alter sequence invoice_invoice_id_seq restart; alter sequence invoice_product_invoice_product_id_seq restart; -select add_invoice(1, '', (current_date - '28 days'::interval)::date, 6, 'Vol esmorzar!', 1, '{"(1,Teia,\"Fusta resinosa de pi i d’altres arbres, provinent sobretot del cor de l’arbre, que crema amb molta facilitat.\",7.00,1,0.0,{2})","(5,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{2})"}'); -select add_invoice(1, '', (current_date - '24 days'::interval)::date, 5, '', 1, '{"(1,Palla,Tija seca dels cereals després que el gra o llavor ha estat separat mitjançant la trilla.,25.00,25,0.0,{3})","(5,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{2})"}'); -select add_invoice(1, '', (current_date - '17 days'::interval)::date, 4, '', 1, '{"(1,\"Paper higiènic (pack de 32 U)\",Paper que s’usa per mantenir la higiene personal després de defecar o orinar.,7.99,10,0.0,{4})","(5,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{2})"}'); -select add_invoice(1, '', (current_date - '7 days'::interval)::date, 3, '', 1, '{"(1,Mirra,Goma resinosa aromàtica de color gris groguenc i gust amargant.,7.22,144,0.05,{2})","(5,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.45,1,0.0,{2})"}'); -select add_invoice(1, '', (current_date - '4 days'::interval)::date, 2, '', 1, '{"(1,Encens,Goma resina fragrant que desprèn una olor característica quan es crema.,2.26,460,0.05,{2})","(5,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.53,1,0.0,{2})"}'); -select add_invoice(1, '', (current_date - '1 days'::interval)::date, 1, '', 1, '{"(1,Or,\"Metall de transició tou, brillant, groc, pesant, mal·leable, dúctil i que no reacciona amb la majoria de productes químics, però és sensible al clor i a l’aigua règia.\",57.82,18,0.05,{2})","(5,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.43,1,0.0,{2})"}'); +select add_invoice(1, '', (current_date - '28 days'::interval)::date, 6, 'Vol esmorzar!', 1, '{producte}','{"(1,Teia,\"Fusta resinosa de pi i d’altres arbres, provinent sobretot del cor de l’arbre, que crema amb molta facilitat.\",7.00,1,0.0,{2})","(5,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{2})"}'); +select add_invoice(1, '', (current_date - '24 days'::interval)::date, 5, '', 1, '{producte,bestia}','{"(1,Palla,Tija seca dels cereals després que el gra o llavor ha estat separat mitjançant la trilla.,25.00,25,0.0,{3})","(5,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{2})"}'); +select add_invoice(1, '', (current_date - '17 days'::interval)::date, 4, '', 1, '{producte,higiene}','{"(1,\"Paper higiènic (pack de 32 U)\",Paper que s’usa per mantenir la higiene personal després de defecar o orinar.,7.99,10,0.0,{4})","(5,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{2})"}'); +select add_invoice(1, '', (current_date - '7 days'::interval)::date, 3, '', 1, '{producte,mag}','{"(1,Mirra,Goma resinosa aromàtica de color gris groguenc i gust amargant.,7.22,144,0.05,{2})","(5,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.45,1,0.0,{2})"}'); +select add_invoice(1, '', (current_date - '4 days'::interval)::date, 2, '', 1, '{producte,mag}','{"(1,Encens,Goma resina fragrant que desprèn una olor característica quan es crema.,2.26,460,0.05,{2})","(5,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.53,1,0.0,{2})"}'); +select add_invoice(1, '', (current_date - '1 days'::interval)::date, 1, '', 1, '{producte,mag}','{"(1,Or,\"Metall de transició tou, brillant, groc, pesant, mal·leable, dúctil i que no reacciona amb la majoria de productes químics, però és sensible al clor i a l’aigua règia.\",57.82,18,0.05,{2})","(5,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.43,1,0.0,{2})"}'); update invoice set invoice_status = 'paid' where invoice_id in (1, 5); update invoice set invoice_status = 'unpaid' where invoice_id = 3; diff --git a/deploy/add_invoice.sql b/deploy/add_invoice.sql index e709ed5..0fff44c 100644 --- a/deploy/add_invoice.sql +++ b/deploy/add_invoice.sql @@ -9,12 +9,15 @@ -- requires: invoice_product -- requires: invoice_product_tax -- requires: next_invoice_number +-- requires: tag_name +-- requires: tag +-- requires: invoice_tag begin; set search_path to numerus, public; -create or replace function add_invoice(company_id integer, invoice_number text, invoice_date date, contact_id integer, notes text, payment_method_id integer, products new_invoice_product[]) returns uuid as +create or replace function add_invoice(company integer, invoice_number text, invoice_date date, contact_id integer, notes text, payment_method_id integer, tags tag_name[], products new_invoice_product[]) returns uuid as $$ declare iid integer; @@ -24,11 +27,11 @@ declare ipid integer; begin if invoice_number is null or length(trim(invoice_number)) = 0 then - invoice_number = next_invoice_number(company_id, invoice_date); + invoice_number = next_invoice_number(company, invoice_date); end if; insert into invoice (company_id, invoice_number, invoice_date, contact_id, notes, currency_code, payment_method_id) - select company.company_id + select company_id , invoice_number , invoice_date , contact_id @@ -36,7 +39,7 @@ begin , currency_code , add_invoice.payment_method_id from company - where company.company_id = add_invoice.company_id + where company.company_id = add_invoice.company returning invoice_id, slug, currency_code into iid, pslug, ccode; @@ -61,13 +64,27 @@ begin join unnest(product.tax) as ptax(tax_id) using (tax_id); end loop; + if array_length(tags, 1) > 0 then + insert into tag (company_id, name) + select add_invoice.company, new_tag.name + from unnest (tags) as new_tag(name) + on conflict (company_id, name) do nothing + ; + + insert into invoice_tag (invoice_id, tag_id) + select iid, tag_id + from tag + join unnest (tags) as new_tag(name) on company_id = add_invoice.company and tag.name = new_tag.name + ; + end if; + return pslug; end; $$ language plpgsql; -revoke execute on function add_invoice(integer, text, date, integer, text, integer, new_invoice_product[]) from public; -grant execute on function add_invoice(integer, text, date, integer, text, integer, new_invoice_product[]) to invoicer; -grant execute on function add_invoice(integer, text, date, integer, text, integer, new_invoice_product[]) to admin; +revoke execute on function add_invoice(integer, text, date, integer, text, integer, tag_name[], new_invoice_product[]) from public; +grant execute on function add_invoice(integer, text, date, integer, text, integer, tag_name[], new_invoice_product[]) to invoicer; +grant execute on function add_invoice(integer, text, date, integer, text, integer, tag_name[], new_invoice_product[]) to admin; commit; diff --git a/deploy/invoice_tag.sql b/deploy/invoice_tag.sql new file mode 100644 index 0000000..54f3ee2 --- /dev/null +++ b/deploy/invoice_tag.sql @@ -0,0 +1,31 @@ +-- Deploy numerus:invoice_tag to pg +-- requires: schema_numerus +-- requires: tag +-- requires: invoice + +begin; + +set search_path to numerus, public; + +create table invoice_tag ( + invoice_id integer not null references invoice, + tag_id integer not null references tag, + primary key (invoice_id, tag_id) +); + +grant select, insert, update, delete on table invoice_tag to invoicer; +grant select, insert, update, delete on table invoice_tag to admin; + +alter table invoice_tag enable row level security; + +create policy company_policy +on invoice_tag +using ( + exists( + select 1 + from invoice + where invoice.invoice_id = invoice_tag.invoice_id + ) +); + +commit; diff --git a/deploy/tag.sql b/deploy/tag.sql new file mode 100644 index 0000000..3071316 --- /dev/null +++ b/deploy/tag.sql @@ -0,0 +1,35 @@ +-- Deploy numerus:tag to pg +-- requires: schema_numerus +-- requires: tag_name + +begin; + +set search_path to numerus, public; + +create table tag ( + tag_id serial primary key, + company_id integer not null references company, + name tag_name not null, + unique (company_id, name) +); + +grant select, insert, update, delete on table tag to invoicer; +grant select, insert, update, delete on table tag to admin; + +grant usage on sequence tag_tag_id_seq to invoicer; +grant usage on sequence tag_tag_id_seq to admin; + +alter table tag enable row level security; + +create policy company_policy +on tag +using ( + exists( + select 1 + from company_user + join user_profile using (user_id) + where company_user.company_id = tag.company_id + ) +); + +commit; diff --git a/deploy/tag_name.sql b/deploy/tag_name.sql new file mode 100644 index 0000000..70f6d5a --- /dev/null +++ b/deploy/tag_name.sql @@ -0,0 +1,12 @@ +-- Deploy numerus:tag_name to pg +-- requires: schema_numerus + +begin; + +set search_path to numerus, public; + +-- The same as a topic on gitea +create domain tag_name as text +check ( value ~ '^[a-z0-9][a-z0-9-]{0,34}$' ); + +commit; diff --git a/pkg/invoices.go b/pkg/invoices.go index fe39bf5..a1e9c6f 100644 --- a/pkg/invoices.go +++ b/pkg/invoices.go @@ -14,6 +14,7 @@ import ( "net/http" "os" "os/exec" + "regexp" "sort" "strconv" "strings" @@ -27,6 +28,7 @@ type InvoiceEntry struct { Total string CustomerName string CustomerSlug string + Tags []string Status string StatusLabel string } @@ -39,21 +41,51 @@ type InvoicesIndexPage struct { func IndexInvoices(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { conn := getConn(r) locale := getLocale(r) + tag := r.URL.Query().Get("tag") page := &InvoicesIndexPage{ - Invoices: mustCollectInvoiceEntries(r.Context(), conn, mustGetCompany(r), locale), + Invoices: mustCollectInvoiceEntries(r.Context(), conn, mustGetCompany(r), locale, tag), InvoiceStatuses: mustCollectInvoiceStatuses(r.Context(), conn, locale), } mustRenderAppTemplate(w, r, "invoices/index.gohtml", page) } -func mustCollectInvoiceEntries(ctx context.Context, conn *Conn, company *Company, locale *Locale) []*InvoiceEntry { - rows := conn.MustQuery(ctx, "select invoice.slug, invoice_date, invoice_number, contact.business_name, contact.slug, invoice.invoice_status, isi18n.name, to_price(total, decimal_digits) from invoice join contact using (contact_id) join invoice_status_i18n isi18n on invoice.invoice_status = isi18n.invoice_status and isi18n.lang_tag = $2 join invoice_amount using (invoice_id) join currency using (currency_code) where invoice.company_id = $1 order by invoice_date desc, invoice_number desc", company.Id, locale.Language.String()) +func mustCollectInvoiceEntries(ctx context.Context, conn *Conn, company *Company, locale *Locale, tag string) []*InvoiceEntry { + rows := conn.MustQuery(ctx, ` + select invoice.slug + , invoice_date + , invoice_number + , contact.business_name + , contact.slug + , array_agg(tag.name::text) + , invoice.invoice_status + , isi18n.name + , to_price(total, decimal_digits) + from invoice + left join invoice_tag using (invoice_id) + left join tag using(tag_id) + join contact using (contact_id) + join invoice_status_i18n isi18n on invoice.invoice_status = isi18n.invoice_status and isi18n.lang_tag = $2 + join invoice_amount using (invoice_id) + join currency using (currency_code) + where invoice.company_id = $1 and (($3 = '') or (tag.name = $3)) + group by invoice.slug + , invoice_date + , invoice_number + , contact.business_name + , contact.slug + , invoice.invoice_status + , isi18n.name + , total + , decimal_digits + order by invoice_date desc + , invoice_number desc + `, company.Id, locale.Language.String(), tag) defer rows.Close() var entries []*InvoiceEntry for rows.Next() { entry := &InvoiceEntry{} - if err := rows.Scan(&entry.Slug, &entry.Date, &entry.Number, &entry.CustomerName, &entry.CustomerSlug, &entry.Status, &entry.StatusLabel, &entry.Total); err != nil { + if err := rows.Scan(&entry.Slug, &entry.Date, &entry.Number, &entry.CustomerName, &entry.CustomerSlug, &entry.Tags, &entry.Status, &entry.StatusLabel, &entry.Total); err != nil { panic(err) } entries = append(entries, entry) @@ -330,7 +362,9 @@ func HandleAddInvoice(w http.ResponseWriter, r *http.Request, _ httprouter.Param mustRenderNewInvoiceForm(w, r, form) return } - slug := conn.MustGetText(r.Context(), "", "select add_invoice($1, $2, $3, $4, $5, $6, $7)", company.Id, form.Number, form.Date, form.Customer, form.Notes, form.PaymentMethod, NewInvoiceProductArray(form.Products)) + reg := regexp.MustCompile("[^a-z0-9-]+") + tags := strings.Split(reg.ReplaceAllString(form.Tags.Val, " "), " ") + slug := conn.MustGetText(r.Context(), "", "select add_invoice($1, $2, $3, $4, $5, $6, $7, $8)", company.Id, form.Number, form.Date, form.Customer, form.Notes, form.PaymentMethod, tags, NewInvoiceProductArray(form.Products)) http.Redirect(w, r, companyURI(company, "/invoices/"+slug), http.StatusSeeOther) } @@ -421,6 +455,7 @@ type invoiceForm struct { Date *InputField Notes *InputField PaymentMethod *SelectField + Tags *InputField Products []*invoiceProductForm } @@ -435,10 +470,9 @@ func newInvoiceForm(ctx context.Context, conn *Conn, locale *Locale, company *Co Options: MustGetOptions(ctx, conn, "select contact_id::text, business_name from contact where company_id = $1 order by business_name", company.Id), }, Number: &InputField{ - Name: "number", - Label: pgettext("input", "Number", locale), - Type: "text", - Required: false, + Name: "number", + Label: pgettext("input", "Number", locale), + Type: "text", }, Date: &InputField{ Name: "date", @@ -451,6 +485,11 @@ func newInvoiceForm(ctx context.Context, conn *Conn, locale *Locale, company *Co Label: pgettext("input", "Notes", locale), Type: "textarea", }, + Tags: &InputField{ + Name: "tags", + Label: pgettext("input", "Tags", locale), + Type: "text", + }, PaymentMethod: &SelectField{ Name: "payment_method", Required: true, @@ -469,6 +508,7 @@ func (form *invoiceForm) Parse(r *http.Request) error { form.Number.FillValue(r) form.Date.FillValue(r) form.Notes.FillValue(r) + form.Tags.FillValue(r) form.PaymentMethod.FillValue(r) if _, ok := r.Form["product.id.0"]; ok { taxOptions := mustGetTaxOptions(r.Context(), getConn(r), form.company) @@ -541,7 +581,24 @@ func (form *invoiceForm) MustFillFromDatabase(ctx context.Context, conn *Conn, s var invoiceId int selectedPaymentMethod := form.PaymentMethod.Selected form.PaymentMethod.Clear() - if notFoundErrorOrPanic(conn.QueryRow(ctx, "select invoice_id, contact_id, invoice_number, invoice_date, notes, payment_method_id from invoice where slug = $1", slug).Scan(&invoiceId, form.Customer, form.Number, form.Date, form.Notes, form.PaymentMethod)) { + if notFoundErrorOrPanic(conn.QueryRow(ctx, ` + select invoice_id + , contact_id + , invoice_number + , invoice_date + , notes + , payment_method_id + , string_agg(tag.name, ', ') + from invoice + left join invoice_tag using (invoice_id) + left join tag using(tag_id) where slug = $1 + group by invoice_id + , contact_id + , invoice_number + , invoice_date + , notes + , payment_method_id + `, slug).Scan(&invoiceId, form.Customer, form.Number, form.Date, form.Notes, form.PaymentMethod, form.Tags)) { form.PaymentMethod.Selected = selectedPaymentMethod return } diff --git a/po/ca.po b/po/ca.po index c7cc2ae..70057f6 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-03-09 12:08+0100\n" +"POT-Creation-Date: 2023-03-10 13:59+0100\n" "PO-Revision-Date: 2023-01-18 17:08+0100\n" "Last-Translator: jordi fita mas \n" "Language-Team: Catalan \n" @@ -46,51 +46,51 @@ msgctxt "title" msgid "New Invoice" msgstr "Nova factura" -#: web/template/invoices/products.gohtml:41 +#: web/template/invoices/products.gohtml:42 #: web/template/products/index.gohtml:21 msgctxt "product" msgid "All" msgstr "Tots" -#: web/template/invoices/products.gohtml:42 +#: web/template/invoices/products.gohtml:43 #: web/template/products/index.gohtml:22 msgctxt "title" msgid "Name" msgstr "Nom" -#: web/template/invoices/products.gohtml:43 +#: web/template/invoices/products.gohtml:44 #: web/template/invoices/view.gohtml:54 web/template/products/index.gohtml:23 msgctxt "title" msgid "Price" msgstr "Preu" -#: web/template/invoices/products.gohtml:57 +#: web/template/invoices/products.gohtml:58 #: web/template/products/index.gohtml:37 msgid "No products added yet." msgstr "No hi ha cap producte." -#: web/template/invoices/products.gohtml:65 web/template/invoices/new.gohtml:61 +#: web/template/invoices/products.gohtml:66 web/template/invoices/new.gohtml:62 msgctxt "action" msgid "Add products" msgstr "Afegeix productes" -#: web/template/invoices/new.gohtml:42 web/template/invoices/view.gohtml:59 +#: web/template/invoices/new.gohtml:43 web/template/invoices/view.gohtml:59 msgctxt "title" msgid "Subtotal" msgstr "Subtotal" -#: web/template/invoices/new.gohtml:52 web/template/invoices/view.gohtml:63 +#: web/template/invoices/new.gohtml:53 web/template/invoices/view.gohtml:63 #: web/template/invoices/view.gohtml:103 msgctxt "title" msgid "Total" msgstr "Total" -#: web/template/invoices/new.gohtml:64 +#: web/template/invoices/new.gohtml:65 msgctxt "action" msgid "Update" msgstr "Actualitza" -#: web/template/invoices/new.gohtml:66 web/template/invoices/index.gohtml:19 +#: web/template/invoices/new.gohtml:67 web/template/invoices/index.gohtml:19 msgctxt "action" msgid "New invoice" msgstr "Nova factura" @@ -127,8 +127,8 @@ msgstr "Estat" #: web/template/invoices/index.gohtml:33 msgctxt "title" -msgid "Label" -msgstr "Etiqueta" +msgid "Tags" +msgstr "Etiquetes" #: web/template/invoices/index.gohtml:34 msgctxt "title" @@ -150,12 +150,12 @@ msgctxt "action" msgid "Select invoice %v" msgstr "Selecciona factura %v" -#: web/template/invoices/index.gohtml:86 web/template/invoices/view.gohtml:14 +#: web/template/invoices/index.gohtml:91 web/template/invoices/view.gohtml:14 msgctxt "action" msgid "Duplicate" msgstr "Duplica" -#: web/template/invoices/index.gohtml:96 +#: web/template/invoices/index.gohtml:101 msgid "No invoices added yet." msgstr "No hi ha cap factura." @@ -428,44 +428,44 @@ msgstr "No podeu deixar la contrasenya en blanc." msgid "Invalid user or password." msgstr "Nom d’usuari o contrasenya incorrectes." -#: pkg/products.go:165 pkg/invoices.go:578 +#: pkg/products.go:165 pkg/invoices.go:635 msgctxt "input" msgid "Name" msgstr "Nom" -#: pkg/products.go:171 pkg/invoices.go:583 +#: pkg/products.go:171 pkg/invoices.go:640 msgctxt "input" msgid "Description" msgstr "Descripció" -#: pkg/products.go:176 pkg/invoices.go:587 +#: pkg/products.go:176 pkg/invoices.go:644 msgctxt "input" msgid "Price" msgstr "Preu" -#: pkg/products.go:186 pkg/invoices.go:613 +#: pkg/products.go:186 pkg/invoices.go:670 msgctxt "input" msgid "Taxes" msgstr "Imposts" -#: pkg/products.go:206 pkg/profile.go:92 pkg/invoices.go:492 -#: pkg/invoices.go:649 +#: pkg/products.go:206 pkg/profile.go:92 pkg/invoices.go:532 +#: pkg/invoices.go:706 msgid "Name can not be empty." msgstr "No podeu deixar el nom en blanc." -#: pkg/products.go:207 pkg/invoices.go:650 +#: pkg/products.go:207 pkg/invoices.go:707 msgid "Price can not be empty." msgstr "No podeu deixar el preu en blanc." -#: pkg/products.go:208 pkg/invoices.go:651 +#: pkg/products.go:208 pkg/invoices.go:708 msgid "Price must be a number greater than zero." msgstr "El preu ha de ser un número major a zero." -#: pkg/products.go:210 pkg/invoices.go:659 +#: pkg/products.go:210 pkg/invoices.go:716 msgid "Selected tax is not valid." msgstr "Heu seleccionat un impost que no és vàlid." -#: pkg/products.go:211 pkg/invoices.go:660 +#: pkg/products.go:211 pkg/invoices.go:717 msgid "You can only select a tax of each class." msgstr "Només podeu seleccionar un impost de cada classe." @@ -573,83 +573,88 @@ msgstr "La confirmació no és igual a la contrasenya." msgid "Selected language is not valid." msgstr "Heu seleccionat un idioma que no és vàlid." -#: pkg/invoices.go:270 +#: pkg/invoices.go:302 msgid "Select a customer to bill." msgstr "Escolliu un client a facturar." -#: pkg/invoices.go:363 pkg/invoices.go:392 +#: pkg/invoices.go:397 pkg/invoices.go:426 msgid "Invalid action" msgstr "Acció invàlida." -#: pkg/invoices.go:386 +#: pkg/invoices.go:420 msgid "invoices.zip" msgstr "factures.zip" -#: pkg/invoices.go:433 +#: pkg/invoices.go:468 msgctxt "input" msgid "Customer" msgstr "Client" -#: pkg/invoices.go:439 +#: pkg/invoices.go:474 msgctxt "input" msgid "Number" msgstr "Número" -#: pkg/invoices.go:445 +#: pkg/invoices.go:479 msgctxt "input" msgid "Invoice Date" msgstr "Data de factura" -#: pkg/invoices.go:451 +#: pkg/invoices.go:485 msgctxt "input" msgid "Notes" msgstr "Notes" -#: pkg/invoices.go:457 +#: pkg/invoices.go:490 +msgctxt "input" +msgid "Tags" +msgstr "Etiquetes" + +#: pkg/invoices.go:496 msgctxt "input" msgid "Payment Method" msgstr "Mètode de pagament" -#: pkg/invoices.go:493 +#: pkg/invoices.go:533 msgid "Invoice date can not be empty." msgstr "No podeu deixar la data de la factura en blanc." -#: pkg/invoices.go:494 +#: pkg/invoices.go:534 msgid "Invoice date must be a valid date." msgstr "La data de facturació ha de ser vàlida." -#: pkg/invoices.go:496 +#: pkg/invoices.go:536 msgid "Selected payment method is not valid." msgstr "Heu seleccionat un mètode de pagament que no és vàlid." -#: pkg/invoices.go:573 +#: pkg/invoices.go:630 msgctxt "input" msgid "Id" msgstr "Identificador" -#: pkg/invoices.go:596 +#: pkg/invoices.go:653 msgctxt "input" msgid "Quantity" msgstr "Quantitat" -#: pkg/invoices.go:604 +#: pkg/invoices.go:661 msgctxt "input" msgid "Discount (%)" msgstr "Descompte (%)" -#: pkg/invoices.go:653 +#: pkg/invoices.go:710 msgid "Quantity can not be empty." msgstr "No podeu deixar la quantitat en blanc." -#: pkg/invoices.go:654 +#: pkg/invoices.go:711 msgid "Quantity must be a number greater than zero." msgstr "La quantitat ha de ser un número major a zero." -#: pkg/invoices.go:656 +#: pkg/invoices.go:713 msgid "Discount can not be empty." msgstr "No podeu deixar el descompte en blanc." -#: pkg/invoices.go:657 +#: pkg/invoices.go:714 msgid "Discount must be a percentage between 0 and 100." msgstr "El descompte ha de ser un percentatge entre 0 i 100." @@ -751,6 +756,10 @@ msgstr "No podeu deixar el codi postal en blanc." msgid "This value is not a valid postal code." msgstr "Aquest valor no és un codi postal vàlid." +#~ msgctxt "title" +#~ msgid "Label" +#~ msgstr "Etiqueta" + #~ msgid "Select a tax for this product." #~ msgstr "Escolliu un impost per aquest producte." diff --git a/po/es.po b/po/es.po index 54e38f4..4a8374d 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-03-09 12:08+0100\n" +"POT-Creation-Date: 2023-03-10 13:59+0100\n" "PO-Revision-Date: 2023-01-18 17:45+0100\n" "Last-Translator: jordi fita mas \n" "Language-Team: Spanish \n" @@ -46,51 +46,51 @@ msgctxt "title" msgid "New Invoice" msgstr "Nueva factura" -#: web/template/invoices/products.gohtml:41 +#: web/template/invoices/products.gohtml:42 #: web/template/products/index.gohtml:21 msgctxt "product" msgid "All" msgstr "Todos" -#: web/template/invoices/products.gohtml:42 +#: web/template/invoices/products.gohtml:43 #: web/template/products/index.gohtml:22 msgctxt "title" msgid "Name" msgstr "Nombre" -#: web/template/invoices/products.gohtml:43 +#: web/template/invoices/products.gohtml:44 #: web/template/invoices/view.gohtml:54 web/template/products/index.gohtml:23 msgctxt "title" msgid "Price" msgstr "Precio" -#: web/template/invoices/products.gohtml:57 +#: web/template/invoices/products.gohtml:58 #: web/template/products/index.gohtml:37 msgid "No products added yet." msgstr "No hay productos." -#: web/template/invoices/products.gohtml:65 web/template/invoices/new.gohtml:61 +#: web/template/invoices/products.gohtml:66 web/template/invoices/new.gohtml:62 msgctxt "action" msgid "Add products" msgstr "Añadir productos" -#: web/template/invoices/new.gohtml:42 web/template/invoices/view.gohtml:59 +#: web/template/invoices/new.gohtml:43 web/template/invoices/view.gohtml:59 msgctxt "title" msgid "Subtotal" msgstr "Subtotal" -#: web/template/invoices/new.gohtml:52 web/template/invoices/view.gohtml:63 +#: web/template/invoices/new.gohtml:53 web/template/invoices/view.gohtml:63 #: web/template/invoices/view.gohtml:103 msgctxt "title" msgid "Total" msgstr "Total" -#: web/template/invoices/new.gohtml:64 +#: web/template/invoices/new.gohtml:65 msgctxt "action" msgid "Update" msgstr "Actualizar" -#: web/template/invoices/new.gohtml:66 web/template/invoices/index.gohtml:19 +#: web/template/invoices/new.gohtml:67 web/template/invoices/index.gohtml:19 msgctxt "action" msgid "New invoice" msgstr "Nueva factura" @@ -127,8 +127,8 @@ msgstr "Estado" #: web/template/invoices/index.gohtml:33 msgctxt "title" -msgid "Label" -msgstr "Etiqueta" +msgid "Tags" +msgstr "Etiquetes" #: web/template/invoices/index.gohtml:34 msgctxt "title" @@ -150,12 +150,12 @@ msgctxt "action" msgid "Select invoice %v" msgstr "Seleccionar factura %v" -#: web/template/invoices/index.gohtml:86 web/template/invoices/view.gohtml:14 +#: web/template/invoices/index.gohtml:91 web/template/invoices/view.gohtml:14 msgctxt "action" msgid "Duplicate" msgstr "Duplicar" -#: web/template/invoices/index.gohtml:96 +#: web/template/invoices/index.gohtml:101 msgid "No invoices added yet." msgstr "No hay facturas." @@ -428,44 +428,44 @@ msgstr "No podéis dejar la contraseña en blanco." msgid "Invalid user or password." msgstr "Nombre de usuario o contraseña inválido." -#: pkg/products.go:165 pkg/invoices.go:578 +#: pkg/products.go:165 pkg/invoices.go:635 msgctxt "input" msgid "Name" msgstr "Nombre" -#: pkg/products.go:171 pkg/invoices.go:583 +#: pkg/products.go:171 pkg/invoices.go:640 msgctxt "input" msgid "Description" msgstr "Descripción" -#: pkg/products.go:176 pkg/invoices.go:587 +#: pkg/products.go:176 pkg/invoices.go:644 msgctxt "input" msgid "Price" msgstr "Precio" -#: pkg/products.go:186 pkg/invoices.go:613 +#: pkg/products.go:186 pkg/invoices.go:670 msgctxt "input" msgid "Taxes" msgstr "Impuestos" -#: pkg/products.go:206 pkg/profile.go:92 pkg/invoices.go:492 -#: pkg/invoices.go:649 +#: pkg/products.go:206 pkg/profile.go:92 pkg/invoices.go:532 +#: pkg/invoices.go:706 msgid "Name can not be empty." msgstr "No podéis dejar el nombre en blanco." -#: pkg/products.go:207 pkg/invoices.go:650 +#: pkg/products.go:207 pkg/invoices.go:707 msgid "Price can not be empty." msgstr "No podéis dejar el precio en blanco." -#: pkg/products.go:208 pkg/invoices.go:651 +#: pkg/products.go:208 pkg/invoices.go:708 msgid "Price must be a number greater than zero." msgstr "El precio tiene que ser un número mayor a cero." -#: pkg/products.go:210 pkg/invoices.go:659 +#: pkg/products.go:210 pkg/invoices.go:716 msgid "Selected tax is not valid." msgstr "Habéis escogido un impuesto que no es válido." -#: pkg/products.go:211 pkg/invoices.go:660 +#: pkg/products.go:211 pkg/invoices.go:717 msgid "You can only select a tax of each class." msgstr "Solo podéis escoger un impuesto de cada clase." @@ -573,83 +573,88 @@ msgstr "La confirmación no corresponde con la contraseña." msgid "Selected language is not valid." msgstr "Habéis escogido un idioma que no es válido." -#: pkg/invoices.go:270 +#: pkg/invoices.go:302 msgid "Select a customer to bill." msgstr "Escoged un cliente a facturar." -#: pkg/invoices.go:363 pkg/invoices.go:392 +#: pkg/invoices.go:397 pkg/invoices.go:426 msgid "Invalid action" msgstr "Acción inválida." -#: pkg/invoices.go:386 +#: pkg/invoices.go:420 msgid "invoices.zip" msgstr "facturas.zip" -#: pkg/invoices.go:433 +#: pkg/invoices.go:468 msgctxt "input" msgid "Customer" msgstr "Cliente" -#: pkg/invoices.go:439 +#: pkg/invoices.go:474 msgctxt "input" msgid "Number" msgstr "Número" -#: pkg/invoices.go:445 +#: pkg/invoices.go:479 msgctxt "input" msgid "Invoice Date" msgstr "Fecha de factura" -#: pkg/invoices.go:451 +#: pkg/invoices.go:485 msgctxt "input" msgid "Notes" msgstr "Notas" -#: pkg/invoices.go:457 +#: pkg/invoices.go:490 +msgctxt "input" +msgid "Tags" +msgstr "Etiquetes" + +#: pkg/invoices.go:496 msgctxt "input" msgid "Payment Method" msgstr "Método de pago" -#: pkg/invoices.go:493 +#: pkg/invoices.go:533 msgid "Invoice date can not be empty." msgstr "No podéis dejar la fecha de la factura en blanco." -#: pkg/invoices.go:494 +#: pkg/invoices.go:534 msgid "Invoice date must be a valid date." msgstr "La fecha de factura debe ser válida." -#: pkg/invoices.go:496 +#: pkg/invoices.go:536 msgid "Selected payment method is not valid." msgstr "Habéis escogido un método de pago que no es válido." -#: pkg/invoices.go:573 +#: pkg/invoices.go:630 msgctxt "input" msgid "Id" msgstr "Identificador" -#: pkg/invoices.go:596 +#: pkg/invoices.go:653 msgctxt "input" msgid "Quantity" msgstr "Cantidad" -#: pkg/invoices.go:604 +#: pkg/invoices.go:661 msgctxt "input" msgid "Discount (%)" msgstr "Descuento (%)" -#: pkg/invoices.go:653 +#: pkg/invoices.go:710 msgid "Quantity can not be empty." msgstr "No podéis dejar la cantidad en blanco." -#: pkg/invoices.go:654 +#: pkg/invoices.go:711 msgid "Quantity must be a number greater than zero." msgstr "La cantidad tiene que ser un número mayor a cero." -#: pkg/invoices.go:656 +#: pkg/invoices.go:713 msgid "Discount can not be empty." msgstr "No podéis dejar el descuento en blanco." -#: pkg/invoices.go:657 +#: pkg/invoices.go:714 msgid "Discount must be a percentage between 0 and 100." msgstr "El descuento tiene que ser un porcentaje entre 0 y 100." @@ -751,6 +756,10 @@ msgstr "No podéis dejar el código postal en blanco." msgid "This value is not a valid postal code." msgstr "Este valor no es un código postal válido válido." +#~ msgctxt "title" +#~ msgid "Label" +#~ msgstr "Etiqueta" + #~ msgid "Select a tax for this product." #~ msgstr "Escoged un impuesto para este producto." diff --git a/revert/add_invoice.sql b/revert/add_invoice.sql index 612cc0d..7a200d9 100644 --- a/revert/add_invoice.sql +++ b/revert/add_invoice.sql @@ -2,6 +2,6 @@ begin; -drop function if exists numerus.add_invoice(integer, text, date, integer, text, integer, numerus.new_invoice_product[]); +drop function if exists numerus.add_invoice(integer, text, date, integer, text, integer, numerus.tag_name[], numerus.new_invoice_product[]); commit; diff --git a/revert/invoice_tag.sql b/revert/invoice_tag.sql new file mode 100644 index 0000000..49192e7 --- /dev/null +++ b/revert/invoice_tag.sql @@ -0,0 +1,7 @@ +-- Revert numerus:invoice_tag from pg + +begin; + +drop table if exists numerus.invoice_tag; + +commit; diff --git a/revert/tag.sql b/revert/tag.sql new file mode 100644 index 0000000..f936d9a --- /dev/null +++ b/revert/tag.sql @@ -0,0 +1,7 @@ +-- Revert numerus:tag from pg + +begin; + +drop table if exists numerus.tag; + +commit; diff --git a/revert/tag_name.sql b/revert/tag_name.sql new file mode 100644 index 0000000..69a026d --- /dev/null +++ b/revert/tag_name.sql @@ -0,0 +1,7 @@ +-- Revert numerus:tag_name from pg + +begin; + +drop domain if exists numerus.tag_name; + +commit; diff --git a/sqitch.plan b/sqitch.plan index 629b635..bfa237f 100644 --- a/sqitch.plan +++ b/sqitch.plan @@ -58,7 +58,10 @@ invoice_product_tax [schema_numerus invoice_product tax tax_rate] 2023-02-15T13: new_invoice_product [schema_numerus] 2023-02-16T21:06:01Z jordi fita mas # Add type for passing products to new invoices invoice_number_counter [schema_numerus company] 2023-02-17T13:04:48Z jordi fita mas # Add relation to count invoice numbers next_invoice_number [schema_numerus invoice_number_counter] 2023-02-17T13:21:48Z jordi fita mas # Add function to retrieve the next invoice number -add_invoice [schema_numerus invoice company currency parse_price new_invoice_product tax invoice_product invoice_product_tax next_invoice_number] 2023-02-16T21:12:46Z jordi fita mas # Add function to create new invoices +tag_name [schema_numerus] 2023-03-10T11:06:11Z jordi fita mas # Add domain for tag names +tag [schema_numerus tag_name] 2023-03-10T11:04:24Z jordi fita mas # Add relation for tags +invoice_tag [schema_numerus tag invoice] 2023-03-10T11:37:43Z jordi fita mas # Add relation for invoice tag +add_invoice [schema_numerus invoice company currency parse_price new_invoice_product tax invoice_product invoice_product_tax next_invoice_number tag_name tag invoice_tag] 2023-02-16T21:12:46Z jordi fita mas # Add function to create new invoices invoice_tax_amount [schema_numerus invoice_product invoice_product_tax] 2023-02-22T12:08:35Z jordi fita mas # Add view for invoice tax amount invoice_product_amount [schema_numerus invoice_product invoice_product_tax] 2023-03-01T11:18:05Z jordi fita mas # Add view for invoice product subtotal and total invoice_amount [schema_numerus invoice_product invoice_product_amount] 2023-02-22T12:58:46Z jordi fita mas # Add view to compute subtotal and total for invoices diff --git a/test/add_invoice.sql b/test/add_invoice.sql index e95ccd6..df31966 100644 --- a/test/add_invoice.sql +++ b/test/add_invoice.sql @@ -5,22 +5,24 @@ reset client_min_messages; begin; -select plan(17); +select plan(19); set search_path to auth, numerus, public; -select has_function('numerus', 'add_invoice', array ['integer', 'text', 'date', 'integer', 'text', 'integer', 'new_invoice_product[]']); -select function_lang_is('numerus', 'add_invoice', array ['integer', 'text', 'date', 'integer', 'text', 'integer', 'new_invoice_product[]'], 'plpgsql'); -select function_returns('numerus', 'add_invoice', array ['integer', 'text', 'date', 'integer', 'text', 'integer', 'new_invoice_product[]'], 'uuid'); -select isnt_definer('numerus', 'add_invoice', array ['integer', 'text', 'date', 'integer', 'text', 'integer', 'new_invoice_product[]']); -select volatility_is('numerus', 'add_invoice', array ['integer', 'text', 'date', 'integer', 'text', 'integer', 'new_invoice_product[]'], 'volatile'); -select function_privs_are('numerus', 'add_invoice', array ['integer', 'text', 'date', 'integer', 'text', 'integer', 'new_invoice_product[]'], 'guest', array []::text[]); -select function_privs_are('numerus', 'add_invoice', array ['integer', 'text', 'date', 'integer', 'text', 'integer', 'new_invoice_product[]'], 'invoicer', array ['EXECUTE']); -select function_privs_are('numerus', 'add_invoice', array ['integer', 'text', 'date', 'integer', 'text', 'integer', 'new_invoice_product[]'], 'admin', array ['EXECUTE']); -select function_privs_are('numerus', 'add_invoice', array ['integer', 'text', 'date', 'integer', 'text', 'integer', 'new_invoice_product[]'], 'authenticator', array []::text[]); +select has_function('numerus', 'add_invoice', array ['integer', 'text', 'date', 'integer', 'text', 'integer', 'tag_name[]', 'new_invoice_product[]']); +select function_lang_is('numerus', 'add_invoice', array ['integer', 'text', 'date', 'integer', 'text', 'integer', 'tag_name[]', 'new_invoice_product[]'], 'plpgsql'); +select function_returns('numerus', 'add_invoice', array ['integer', 'text', 'date', 'integer', 'text', 'integer', 'tag_name[]', 'new_invoice_product[]'], 'uuid'); +select isnt_definer('numerus', 'add_invoice', array ['integer', 'text', 'date', 'integer', 'text', 'integer', 'tag_name[]', 'new_invoice_product[]']); +select volatility_is('numerus', 'add_invoice', array ['integer', 'text', 'date', 'integer', 'text', 'integer', 'tag_name[]', 'new_invoice_product[]'], 'volatile'); +select function_privs_are('numerus', 'add_invoice', array ['integer', 'text', 'date', 'integer', 'text', 'integer', 'tag_name[]', 'new_invoice_product[]'], 'guest', array []::text[]); +select function_privs_are('numerus', 'add_invoice', array ['integer', 'text', 'date', 'integer', 'text', 'integer', 'tag_name[]', 'new_invoice_product[]'], 'invoicer', array ['EXECUTE']); +select function_privs_are('numerus', 'add_invoice', array ['integer', 'text', 'date', 'integer', 'text', 'integer', 'tag_name[]', 'new_invoice_product[]'], 'admin', array ['EXECUTE']); +select function_privs_are('numerus', 'add_invoice', array ['integer', 'text', 'date', 'integer', 'text', 'integer', 'tag_name[]', 'new_invoice_product[]'], 'authenticator', array []::text[]); set client_min_messages to warning; +truncate invoice_tag cascade; +truncate tag cascade; truncate invoice_number_counter cascade; truncate invoice_product_tax cascade; truncate invoice_product cascade; @@ -81,27 +83,27 @@ values (12, 1, 'Contact 2.1', 'XX555', '', '777-777-777', 'c@c', '', '', '', '', select lives_ok( - $$ select add_invoice(1, 'INV001', '2023-02-15', 12, 'Notes 1', 111, '{"(7,Product 1,Description 1,12.24,2,0.0,{4})"}') $$, + $$ select add_invoice(1, 'INV001', '2023-02-15', 12, 'Notes 1', 111, '{tag1,tag2}','{"(7,Product 1,Description 1,12.24,2,0.0,{4})"}') $$, 'Should be able to insert an invoice for the first company with a product' ); select lives_ok( - $$ select add_invoice(1, 'INV002', '2023-02-16', 13, 'Notes 2', 111, '{"(7,Product 1 bis,Description 1 bis,33.33,1,0.50,\"{4,3}\")","(8,Product 2,Description 2,24.00,3,0.75,{})"}') $$, + $$ select add_invoice(1, 'INV002', '2023-02-16', 13, 'Notes 2', 111, '{}', '{"(7,Product 1 bis,Description 1 bis,33.33,1,0.50,\"{4,3}\")","(8,Product 2,Description 2,24.00,3,0.75,{})"}') $$, 'Should be able to insert a second invoice for the first company with two product' ); select lives_ok( - $$ select add_invoice(2, 'INV101', '2023-02-14', 15, 'Notes 3', 222, '{"(11,Product 4.3,,11.11,1,0.0,{6})"}') $$, + $$ select add_invoice(2, 'INV101', '2023-02-14', 15, 'Notes 3', 222, '{tag3}','{"(11,Product 4.3,,11.11,1,0.0,{6})"}') $$, 'Should be able to insert an invoice for the second company with a product' ); select lives_ok( - $$ select add_invoice(1, NULL, '2023-03-15', 13, '', 111, '{"(7,PA1,DA1,44.33,1,0.50,{})"}') $$, + $$ select add_invoice(1, NULL, '2023-03-15', 13, '', 111, '{tag2}', '{"(7,PA1,DA1,44.33,1,0.50,{})"}') $$, 'Should be able to insert an invoice with an autogenerated number' ); select lives_ok( - $$ select add_invoice(2, ' ', '2023-04-16', 14, '', 222, '{"(11,PA2,DA2,55.33,10,0.75,{})"}') $$, + $$ select add_invoice(2, ' ', '2023-04-16', 14, '', 222, '{tag2,tag3,tag4}','{"(11,PA2,DA2,55.33,10,0.75,{})"}') $$, 'Should consider non-null, but otherwise empty numbers the same as null and autogenerate it' ); @@ -138,6 +140,30 @@ select bag_eq( 'Should have created all invoice product taxes' ); +select bag_eq( + $$ select company_id, name from tag $$, + $$ values (1, 'tag1') + , (1, 'tag2') + , (2, 'tag2') + , (2, 'tag3') + , (2, 'tag4') + $$, + 'Should have added all new tags once' +); + +select bag_eq( + $$ select invoice_number, tag.name from invoice_tag join invoice using (invoice_id) join tag using (tag_id) $$, + $$ values ('INV001', 'tag1') + , ('INV001', 'tag2') + , ('INV101', 'tag3') + , ('F20230006', 'tag2') + , ('INV056-23', 'tag2') + , ('INV056-23', 'tag3') + , ('INV056-23', 'tag4') + $$, + 'Should have assigned the tags to invoices' +); + select * from finish(); diff --git a/test/invoice_tag.sql b/test/invoice_tag.sql new file mode 100644 index 0000000..f97ab73 --- /dev/null +++ b/test/invoice_tag.sql @@ -0,0 +1,136 @@ +-- Test invoice_tag +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('invoice_tag'); +select has_pk('invoice_tag' ); +select col_is_pk('invoice_tag', array['invoice_id', 'tag_id']); +select table_privs_are('invoice_tag', 'guest', array []::text[]); +select table_privs_are('invoice_tag', 'invoicer', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('invoice_tag', 'admin', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('invoice_tag', 'authenticator', array []::text[]); + +select has_column('invoice_tag', 'invoice_id'); +select col_is_fk('invoice_tag', 'invoice_id'); +select fk_ok('invoice_tag', 'invoice_id', 'invoice', 'invoice_id'); +select col_type_is('invoice_tag', 'invoice_id', 'integer'); +select col_not_null('invoice_tag', 'invoice_id'); +select col_hasnt_default('invoice_tag', 'invoice_id'); + +select has_column('invoice_tag', 'tag_id'); +select col_is_fk('invoice_tag', 'tag_id'); +select fk_ok('invoice_tag', 'tag_id', 'tag', 'tag_id'); +select col_type_is('invoice_tag', 'tag_id', 'integer'); +select col_not_null('invoice_tag', 'tag_id'); +select col_hasnt_default('invoice_tag', 'tag_id'); + + +set client_min_messages to warning; +truncate invoice_tag cascade; +truncate invoice cascade; +truncate tag cascade; +truncate contact cascade; +truncate company_user cascade; +truncate company cascade; +truncate payment_method 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') +; + +set constraints "company_default_payment_method_id_fkey" deferred; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_payment_method_id) +values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 222) + , (4, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', 'FR', 'USD', 444) +; + +insert into payment_method (payment_method_id, company_id, name, instructions) +values (444, 4, 'cash', 'cash') + , (222, 2, 'cash', 'cash') +; + +set constraints "company_default_payment_method_id_fkey" immediate; + +insert into company_user (company_id, user_id) +values (2, 1) + , (4, 5) +; + +insert into contact (contact_id, company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code) +values (6, 2, 'Contact 1', 'XX555', '', '777-777-777', 'c@c', '', '', '', '', '', 'ES') + , (8, 4, 'Contact 2', 'XX666', '', '888-888-888', 'd@d', '', '', '', '', '', 'ES') +; + +insert into invoice (invoice_id, company_id, invoice_number, contact_id, currency_code, payment_method_id) +values (10, 2, 'INV020001', 6, 'EUR', 222) + , (12, 4, 'INV040001', 8, 'EUR', 444) +; + +insert into tag (tag_id, company_id, name) +values (14, 2, 'web') + , (15, 2, 'design') + , (16, 4, 'product') + , (17, 4, 'development') + , (18, 4, 'something-else') + , (19, 4, 'design') +; + +insert into invoice_tag (invoice_id, tag_id) +values (10, 14) + , (10, 15) + , (12, 18) +; + +prepare invoice_tag_data as +select invoice_id, tag_id +from invoice_tag +; + +set role invoicer; +select is_empty('invoice_tag_data', 'Should show no data when cookie is not set yet'); +reset role; + +select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog'); +select bag_eq( + 'invoice_tag_data', + $$ values ( 10, 14 ) + , ( 10, 15 ) + $$, + 'Should only list invoice tags of the companies where demo@tandem.blog is user of' +); +reset role; + +select set_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog'); +select bag_eq( + 'invoice_tag_data', + $$ values ( 12, 18 ) + $$, + 'Should only list invoice tags of the companies where admin@tandem.blog is user of' +); +reset role; + +select set_cookie('not-a-cookie'); +select throws_ok( + 'invoice_tag_data', + '42501', 'permission denied for table invoice_tag', + 'Should not allow select to guest users' +); + + +reset role; +select * +from finish(); + +rollback; + diff --git a/test/tag.sql b/test/tag.sql new file mode 100644 index 0000000..4163d56 --- /dev/null +++ b/test/tag.sql @@ -0,0 +1,139 @@ +-- Test tag +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(33); + +set search_path to numerus, auth, public; + +select has_table('tag'); +select has_pk('tag' ); +select table_privs_are('tag', 'guest', array []::text[]); +select table_privs_are('tag', 'invoicer', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('tag', 'admin', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('tag', 'authenticator', array []::text[]); + +select has_sequence('tag_tag_id_seq'); +select sequence_privs_are('tag_tag_id_seq', 'guest', array[]::text[]); +select sequence_privs_are('tag_tag_id_seq', 'invoicer', array['USAGE']); +select sequence_privs_are('tag_tag_id_seq', 'admin', array['USAGE']); +select sequence_privs_are('tag_tag_id_seq', 'authenticator', array[]::text[]); + +select has_column('tag', 'tag_id'); +select col_is_pk('tag', 'tag_id'); +select col_type_is('tag', 'tag_id', 'integer'); +select col_not_null('tag', 'tag_id'); +select col_has_default('tag', 'tag_id'); +select col_default_is('tag', 'tag_id', 'nextval(''tag_tag_id_seq''::regclass)'); + +select has_column('tag', 'company_id'); +select col_is_fk('tag', 'company_id'); +select fk_ok('tag', 'company_id', 'company', 'company_id'); +select col_type_is('tag', 'company_id', 'integer'); +select col_not_null('tag', 'company_id'); +select col_hasnt_default('tag', 'company_id'); + +select has_column('tag', 'name'); +select col_type_is('tag', 'name', 'tag_name'); +select col_not_null('tag', 'name'); +select col_hasnt_default('tag', 'name'); +select col_is_unique('tag', array['company_id', 'name']); + + +set client_min_messages to warning; +truncate tag cascade; +truncate company_user cascade; +truncate company cascade; +truncate payment_method 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') +; + +set constraints "company_default_payment_method_id_fkey" deferred; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_payment_method_id) +values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 222) + , (4, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', 'FR', 'USD', 444) +; + +insert into payment_method (payment_method_id, company_id, name, instructions) +values (444, 4, 'cash', 'cash') + , (222, 2, 'cash', 'cash') +; + +set constraints "company_default_payment_method_id_fkey" immediate; + +insert into company_user (company_id, user_id) +values (2, 1) + , (4, 5) +; + +insert into tag (company_id, name) +values (2, 'web') + , (2, 'design') + , (4, 'product') + , (4, 'development') + , (4, 'something-else') + , (4, 'design') +; + +prepare tag_data as +select company_id, name +from tag +; + +set role invoicer; +select is_empty('tag_data', 'Should show no data when cookie is not set yet'); +reset role; + +select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog'); +select bag_eq( + 'tag_data', + $$ values ( 2, 'web' ) + , ( 2, 'design' ) + $$, + 'Should only list tags of the companies where demo@tandem.blog is user of' +); +reset role; + +select set_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog'); +select bag_eq( + 'tag_data', + $$ values (4, 'product' ) + , (4, 'development' ) + , (4, 'something-else' ) + , (4, 'design' ) + $$, + 'Should only list tags of the companies where admin@tandem.blog is user of' +); +reset role; + +select set_cookie('not-a-cookie'); +select throws_ok( + 'tag_data', + '42501', 'permission denied for table tag', + 'Should not allow select to guest users' +); +reset role; + +select throws_ok( $$ + insert into tag (company_id, name) + values (2, 'web') + $$, + '23505', 'duplicate key value violates unique constraint "tag_company_id_name_key"', + 'Should not allow repeated tag names within the same company' +); + + +select * +from finish(); + +rollback; + diff --git a/test/tag_name.sql b/test/tag_name.sql new file mode 100644 index 0000000..0397220 --- /dev/null +++ b/test/tag_name.sql @@ -0,0 +1,59 @@ +-- Test tag_name +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(12); + +set search_path to numerus, public; + +select has_domain('tag_name'); +select domain_type_is('tag_name', 'text'); + +select lives_ok($$ select 'abcdefghijklmnopqrstuvwxyz012345678'::tag_name $$, 'Should be able to cast strings with up to 35 lowercase alphanumeric characters to tag_name'); +select lives_ok($$ select 'a'::tag_name $$, 'Should be able to cast strings with a single letter to tag_name'); +select lives_ok($$ select '1'::tag_name $$, 'Should be able to cast strings with a single number to tag_name'); +select lives_ok($$ select 'a-long-tag'::tag_name $$, 'Should be able to cast strings with dashes to tag_name'); + +select throws_ok( + $$ SELECT 'abcdefghijklmnopqrstuvwxyz0123456789'::tag_name $$, + 23514, null, + 'Should reject tag names with more than 35 characters' +); + +select throws_ok( + $$ SELECT 'aB'::tag_name $$, + 23514, null, + 'Should reject tag names with uppercase characters' +); + +select throws_ok( + $$ SELECT 'a$'::tag_name $$, + 23514, null, + 'Should reject tag names with symbols' +); + +select throws_ok( + $$ SELECT 'a a'::tag_name $$, + 23514, null, + 'Should reject tag names with spaces' +); + +select throws_ok( + $$ SELECT '-aa'::tag_name $$, + 23514, null, + 'Should reject tag names starting with a dash' +); + +select throws_ok( + $$ SELECT ''::tag_name $$, + 23514, null, + 'Should reject empty tag names' +); + +select * +from finish(); + +rollback; diff --git a/verify/add_invoice.sql b/verify/add_invoice.sql index 4662ff7..c8e6807 100644 --- a/verify/add_invoice.sql +++ b/verify/add_invoice.sql @@ -2,6 +2,6 @@ begin; -select has_function_privilege('numerus.add_invoice(integer, text, date, integer, text, integer, numerus.new_invoice_product[])', 'execute'); +select has_function_privilege('numerus.add_invoice(integer, text, date, integer, text, integer, numerus.tag_name[], numerus.new_invoice_product[])', 'execute'); rollback; diff --git a/verify/invoice_tag.sql b/verify/invoice_tag.sql new file mode 100644 index 0000000..5a59afb --- /dev/null +++ b/verify/invoice_tag.sql @@ -0,0 +1,13 @@ +-- Verify numerus:invoice_tag on pg + +begin; + +select invoice_id + , tag_id +from numerus.invoice_tag +where false; + +select 1 / count(*) from pg_class where oid = 'numerus.invoice_tag'::regclass and relrowsecurity; +select 1 / count(*) from pg_policy where polname = 'company_policy' and polrelid = 'numerus.invoice_tag'::regclass; + +rollback; diff --git a/verify/tag.sql b/verify/tag.sql new file mode 100644 index 0000000..e22603e --- /dev/null +++ b/verify/tag.sql @@ -0,0 +1,14 @@ +-- Verify numerus:tag on pg + +begin; + +select tag_id + , company_id + , name +from numerus.tag +where false; + +select 1 / count(*) from pg_class where oid = 'numerus.tag'::regclass and relrowsecurity; +select 1 / count(*) from pg_policy where polname = 'company_policy' and polrelid = 'numerus.tag'::regclass; + +rollback; diff --git a/verify/tag_name.sql b/verify/tag_name.sql new file mode 100644 index 0000000..8777cae --- /dev/null +++ b/verify/tag_name.sql @@ -0,0 +1,7 @@ +-- Verify numerus:tag_name on pg + +begin; + +select pg_catalog.has_type_privilege('numerus.tag_name', 'usage'); + +rollback; diff --git a/web/template/invoices/index.gohtml b/web/template/invoices/index.gohtml index 1312cd8..5dd6f49 100644 --- a/web/template/invoices/index.gohtml +++ b/web/template/invoices/index.gohtml @@ -30,7 +30,7 @@ {{( pgettext "Invoice Num." "title" )}} {{( pgettext "Customer" "title" )}} {{( pgettext "Status" "title" )}} - {{( pgettext "Label" "title" )}} + {{( pgettext "Tags" "title" )}} {{( pgettext "Amount" "title" )}} {{( pgettext "Download" "title" )}} {{( pgettext "Actions" "title" )}} @@ -69,7 +69,12 @@ - + + {{- range $index, $tag := .Tags }} + {{- if gt $index 0 }}, {{ end -}} + {{ . }} + {{- end }} + {{ .Total|formatPrice }} diff --git a/web/template/invoices/products.gohtml b/web/template/invoices/products.gohtml index a7e700d..2423f67 100644 --- a/web/template/invoices/products.gohtml +++ b/web/template/invoices/products.gohtml @@ -21,6 +21,7 @@ {{ template "hidden-field" .Number }} {{ template "hidden-field" .Date }} {{ template "hidden-field" .Notes }} + {{ template "hidden-field" .Tags }} {{- range $product := .Products }}