From 17f7520876e4e2d4650e8e955dc0d0b1e6ac8e4d Mon Sep 17 00:00:00 2001 From: jordi fita mas Date: Sun, 28 Apr 2024 20:28:45 +0200 Subject: [PATCH] Add customer and invoices sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copied as much as i could from Numerus, and made as few modifications as i could to adapt to this code base; it is, quite frankly, a piece of shit. We need to be able to create invoices from scratch “just in case”, apparently, but it is not yet possible to create an invoice from a booking. --- debian/control | 1 + demo/demo.sql | 15 + deploy/add_contact.sql | 49 + deploy/add_invoice.sql | 75 + deploy/available_invoice_status.sql | 28 + deploy/compute_new_invoice_amount.sql | 64 + deploy/contact.sql | 45 + deploy/contact_email.sql | 31 + deploy/contact_phone.sql | 30 + deploy/discount_rate.sql | 14 + deploy/edit_contact.sql | 72 + deploy/edit_invoice.sql | 110 ++ deploy/edited_invoice_product.sql | 20 + deploy/invoice.sql | 44 + deploy/invoice_amount.sql | 22 + deploy/invoice_number_counter.sql | 32 + deploy/invoice_product.sql | 38 + deploy/invoice_product_amount.sql | 22 + deploy/invoice_product_product.sql | 18 + deploy/invoice_product_tax.sql | 33 + deploy/invoice_status.sql | 16 + deploy/invoice_status_i18n.sql | 20 + deploy/invoice_tax_amount.sql | 23 + deploy/new_invoice_amount.sql | 14 + deploy/new_invoice_product.sql | 19 + deploy/next_invoice_number.sql | 38 + deploy/payment_method.sql | 34 + deploy/product.sql | 40 + deploy/product_tax.sql | 31 + deploy/tax.sql | 37 + deploy/tax_class.sql | 33 + deploy/tax_rate.sql | 14 + go.mod | 3 +- go.sum | 2 + pkg/app/admin.go | 10 + pkg/booking/admin.go | 15 +- pkg/booking/checkin.go | 16 +- pkg/booking/public.go | 6 +- pkg/customer/admin.go | 324 +++++ pkg/database/EditedInvoiceProduct.go | 76 + pkg/database/NewInvoiceProduct.go | 76 + pkg/database/funcs.go | 16 + pkg/database/types.go | 46 + pkg/form/select.go | 9 + pkg/http/htmx.go | 11 +- pkg/invoice/admin.go | 1295 +++++++++++++++++ pkg/invoice/ods.go | 65 + pkg/invoice/pdf.go | 75 + pkg/{booking => ods}/ods.go | 18 +- pkg/template/render.go | 18 +- pkg/template/slug.go | 23 + po/ca.po | 614 ++++++-- po/es.po | 614 ++++++-- po/fr.po | 614 ++++++-- revert/add_contact.sql | 17 + revert/add_invoice.sql | 7 + revert/available_currencies.sql | 2 +- revert/available_invoice_status.sql | 10 + revert/compute_new_invoice_amount.sql | 7 + revert/contact.sql | 8 + revert/contact_email.sql | 23 + revert/contact_phone.sql | 24 + revert/discount_rate.sql | 7 + revert/edit_contact.sql | 17 + revert/edit_invoice.sql | 7 + revert/edited_invoice_product.sql | 7 + revert/invoice.sql | 7 + revert/invoice_amount.sql | 7 + revert/invoice_number_counter.sql | 7 + revert/invoice_product.sql | 7 + revert/invoice_product_amount.sql | 7 + revert/invoice_product_product.sql | 7 + revert/invoice_product_tax.sql | 7 + revert/invoice_status.sql | 7 + revert/invoice_status_i18n.sql | 7 + revert/invoice_tax_amount.sql | 7 + revert/new_invoice_amount.sql | 7 + revert/new_invoice_product.sql | 7 + revert/next_invoice_number.sql | 7 + revert/payment_method.sql | 7 + revert/product.sql | 7 + revert/product_tax.sql | 7 + revert/tax.sql | 8 + revert/tax_class.sql | 7 + revert/tax_rate.sql | 7 + sqitch.plan | 30 + test/add_contact.sql | 71 + test/add_invoice.sql | 138 ++ test/compute_new_invoice_amount.sql | 71 + test/contact.sql | 170 +++ test/contact_email.sql | 115 ++ test/contact_phone.sql | 114 ++ test/discount_rate.sql | 34 + test/edit_contact.sql | 95 ++ test/edit_invoice.sql | 149 ++ test/edited_invoice_product.sql | 27 + test/invoice.sql | 188 +++ test/invoice_amount.sql | 107 ++ test/invoice_number_counter.sql | 141 ++ test/invoice_product.sql | 162 +++ test/invoice_product_amount.sql | 111 ++ test/invoice_product_product.sql | 39 + test/invoice_product_tax.sql | 149 ++ test/invoice_status.sql | 35 + test/invoice_status_i18n.sql | 44 + test/invoice_tax_amount.sql | 112 ++ test/new_invoice_amount.sql | 22 + test/new_invoice_product.sql | 26 + test/next_invoice_number.sql | 51 + test/payment_method.sql | 125 ++ test/product.sql | 143 ++ test/product_tax.sql | 126 ++ test/tax.sql | 147 ++ test/tax_class.sql | 123 ++ test/tax_rate.sql | 34 + verify/add_contact.sql | 9 + verify/add_invoice.sql | 7 + verify/available_currencies.sql | 2 +- verify/available_invoice_status.sql | 21 + verify/compute_new_invoice_amount.sql | 7 + verify/contact.sql | 23 + verify/contact_email.sql | 13 + verify/contact_phone.sql | 13 + verify/discount_rate.sql | 7 + verify/edit_contact.sql | 9 + verify/edit_invoice.sql | 7 + verify/edited_invoice_product.sql | 7 + verify/invoice.sql | 22 + verify/invoice_amount.sql | 11 + verify/invoice_number_counter.sql | 14 + verify/invoice_product.sql | 18 + verify/invoice_product_amount.sql | 11 + verify/invoice_product_product.sql | 10 + verify/invoice_product_tax.sql | 14 + verify/invoice_status.sql | 10 + verify/invoice_status_i18n.sql | 11 + verify/invoice_tax_amount.sql | 11 + verify/new_invoice_amount.sql | 7 + verify/new_invoice_product.sql | 7 + verify/next_invoice_number.sql | 7 + verify/payment_method.sql | 15 + verify/product.sql | 18 + verify/product_tax.sql | 13 + verify/tax.sql | 16 + verify/tax_class.sql | 14 + verify/tax_rate.sql | 7 + web/static/camper.css | 162 ++- web/static/camper.js | 16 + web/static/invoice.css | 176 +++ web/templates/admin/customer/form.gohtml | 160 ++ web/templates/admin/customer/index.gohtml | 38 + web/templates/admin/form.gohtml | 6 + web/templates/admin/invoice/form.gohtml | 150 ++ web/templates/admin/invoice/index.gohtml | 168 +++ .../admin/invoice/product-form.gohtml | 85 ++ web/templates/admin/invoice/view.gohtml | 118 ++ web/templates/admin/layout.gohtml | 6 + 157 files changed, 9244 insertions(+), 399 deletions(-) create mode 100644 deploy/add_contact.sql create mode 100644 deploy/add_invoice.sql create mode 100644 deploy/available_invoice_status.sql create mode 100644 deploy/compute_new_invoice_amount.sql create mode 100644 deploy/contact.sql create mode 100644 deploy/contact_email.sql create mode 100644 deploy/contact_phone.sql create mode 100644 deploy/discount_rate.sql create mode 100644 deploy/edit_contact.sql create mode 100644 deploy/edit_invoice.sql create mode 100644 deploy/edited_invoice_product.sql create mode 100644 deploy/invoice.sql create mode 100644 deploy/invoice_amount.sql create mode 100644 deploy/invoice_number_counter.sql create mode 100644 deploy/invoice_product.sql create mode 100644 deploy/invoice_product_amount.sql create mode 100644 deploy/invoice_product_product.sql create mode 100644 deploy/invoice_product_tax.sql create mode 100644 deploy/invoice_status.sql create mode 100644 deploy/invoice_status_i18n.sql create mode 100644 deploy/invoice_tax_amount.sql create mode 100644 deploy/new_invoice_amount.sql create mode 100644 deploy/new_invoice_product.sql create mode 100644 deploy/next_invoice_number.sql create mode 100644 deploy/payment_method.sql create mode 100644 deploy/product.sql create mode 100644 deploy/product_tax.sql create mode 100644 deploy/tax.sql create mode 100644 deploy/tax_class.sql create mode 100644 deploy/tax_rate.sql create mode 100644 pkg/customer/admin.go create mode 100644 pkg/database/EditedInvoiceProduct.go create mode 100644 pkg/database/NewInvoiceProduct.go create mode 100644 pkg/invoice/admin.go create mode 100644 pkg/invoice/ods.go create mode 100644 pkg/invoice/pdf.go rename pkg/{booking => ods}/ods.go (87%) create mode 100644 pkg/template/slug.go create mode 100644 revert/add_contact.sql create mode 100644 revert/add_invoice.sql create mode 100644 revert/available_invoice_status.sql create mode 100644 revert/compute_new_invoice_amount.sql create mode 100644 revert/contact.sql create mode 100644 revert/contact_email.sql create mode 100644 revert/contact_phone.sql create mode 100644 revert/discount_rate.sql create mode 100644 revert/edit_contact.sql create mode 100644 revert/edit_invoice.sql create mode 100644 revert/edited_invoice_product.sql create mode 100644 revert/invoice.sql create mode 100644 revert/invoice_amount.sql create mode 100644 revert/invoice_number_counter.sql create mode 100644 revert/invoice_product.sql create mode 100644 revert/invoice_product_amount.sql create mode 100644 revert/invoice_product_product.sql create mode 100644 revert/invoice_product_tax.sql create mode 100644 revert/invoice_status.sql create mode 100644 revert/invoice_status_i18n.sql create mode 100644 revert/invoice_tax_amount.sql create mode 100644 revert/new_invoice_amount.sql create mode 100644 revert/new_invoice_product.sql create mode 100644 revert/next_invoice_number.sql create mode 100644 revert/payment_method.sql create mode 100644 revert/product.sql create mode 100644 revert/product_tax.sql create mode 100644 revert/tax.sql create mode 100644 revert/tax_class.sql create mode 100644 revert/tax_rate.sql create mode 100644 test/add_contact.sql create mode 100644 test/add_invoice.sql create mode 100644 test/compute_new_invoice_amount.sql create mode 100644 test/contact.sql create mode 100644 test/contact_email.sql create mode 100644 test/contact_phone.sql create mode 100644 test/discount_rate.sql create mode 100644 test/edit_contact.sql create mode 100644 test/edit_invoice.sql create mode 100644 test/edited_invoice_product.sql create mode 100644 test/invoice.sql create mode 100644 test/invoice_amount.sql create mode 100644 test/invoice_number_counter.sql create mode 100644 test/invoice_product.sql create mode 100644 test/invoice_product_amount.sql create mode 100644 test/invoice_product_product.sql create mode 100644 test/invoice_product_tax.sql create mode 100644 test/invoice_status.sql create mode 100644 test/invoice_status_i18n.sql create mode 100644 test/invoice_tax_amount.sql create mode 100644 test/new_invoice_amount.sql create mode 100644 test/new_invoice_product.sql create mode 100644 test/next_invoice_number.sql create mode 100644 test/payment_method.sql create mode 100644 test/product.sql create mode 100644 test/product_tax.sql create mode 100644 test/tax.sql create mode 100644 test/tax_class.sql create mode 100644 test/tax_rate.sql create mode 100644 verify/add_contact.sql create mode 100644 verify/add_invoice.sql create mode 100644 verify/available_invoice_status.sql create mode 100644 verify/compute_new_invoice_amount.sql create mode 100644 verify/contact.sql create mode 100644 verify/contact_email.sql create mode 100644 verify/contact_phone.sql create mode 100644 verify/discount_rate.sql create mode 100644 verify/edit_contact.sql create mode 100644 verify/edit_invoice.sql create mode 100644 verify/edited_invoice_product.sql create mode 100644 verify/invoice.sql create mode 100644 verify/invoice_amount.sql create mode 100644 verify/invoice_number_counter.sql create mode 100644 verify/invoice_product.sql create mode 100644 verify/invoice_product_amount.sql create mode 100644 verify/invoice_product_product.sql create mode 100644 verify/invoice_product_tax.sql create mode 100644 verify/invoice_status.sql create mode 100644 verify/invoice_status_i18n.sql create mode 100644 verify/invoice_tax_amount.sql create mode 100644 verify/new_invoice_amount.sql create mode 100644 verify/new_invoice_product.sql create mode 100644 verify/next_invoice_number.sql create mode 100644 verify/payment_method.sql create mode 100644 verify/product.sql create mode 100644 verify/product_tax.sql create mode 100644 verify/tax.sql create mode 100644 verify/tax_class.sql create mode 100644 verify/tax_rate.sql create mode 100644 web/static/invoice.css create mode 100644 web/templates/admin/customer/form.gohtml create mode 100644 web/templates/admin/customer/index.gohtml create mode 100644 web/templates/admin/invoice/form.gohtml create mode 100644 web/templates/admin/invoice/index.gohtml create mode 100644 web/templates/admin/invoice/product-form.gohtml create mode 100644 web/templates/admin/invoice/view.gohtml diff --git a/debian/control b/debian/control index 5670487..9b10aa7 100644 --- a/debian/control +++ b/debian/control @@ -10,6 +10,7 @@ Build-Depends: golang-any, golang-github-jackc-pgx-v4-dev, golang-github-leonelquinteros-gotext-dev, + golang-github-rainycape-unidecode-dev, golang-golang-x-text-dev, postgresql-all (>= 217~), sqitch, diff --git a/demo/demo.sql b/demo/demo.sql index 7b3223e..f13443f 100644 --- a/demo/demo.sql +++ b/demo/demo.sql @@ -24,6 +24,21 @@ values (52, 42, 'employee') , (52, 43, 'admin') ; +insert into payment_method (payment_method_id, company_id, name, instructions) +values (1, 52, 'Pagament', '') +; + +insert into tax_class (tax_class_id, company_id, name) +values (1, 52, 'VAT') +; + +insert into tax (tax_id, company_id, tax_class_id, name, rate) +values (1, 52, 1, 'General VAT (21 %)', 0.21) + , (2, 52, 1, 'Reduced VAT (10 %)', 0.10) + , (3, 52, 1, 'Super-reduced VAT (4 %)', 0.04) + , (4, 52, 1, 'VAT free (0 %)', 0.00) +; + select setup_redsys(52, '361716962', '1', 'test', 'redirect', 'sq7HjrUOBfKmC576ILgskD5srU870gJ7'); select setup_location(52, '

On som

Ctra. de Sadernes, km 2, 17855 MONTAGUT i OIX

', '', '

Càmping i Safari tents:
de 08/04 a 09/10

Cabanes i Bungalows:
de 08/04 a 11/12

ACSI:
de 08/04 a 11/12

'); diff --git a/deploy/add_contact.sql b/deploy/add_contact.sql new file mode 100644 index 0000000..47e24e1 --- /dev/null +++ b/deploy/add_contact.sql @@ -0,0 +1,49 @@ +-- Deploy camper:add_contact to pg +-- requires: roles +-- requires: schema_camper +-- requires: email +-- requires: extension_pg_libphonenumber +-- requires: country_code +-- requires: contact +-- requires: contact_phone +-- requires: contact_email + +begin; + +set search_path to camper, public; + +create or replace function add_contact(company_id integer, name text, id_document_type_id text, id_document_number text, phone text, email text, address text, city text, province text, postal_code text, country_code country_code) returns uuid as +$$ +declare + cid integer; + cslug uuid; +begin + insert into contact (company_id, name, id_document_type_id, id_document_number, address, city, province, postal_code, country_code) + values (company_id, name, id_document_type_id, id_document_number, address, city, province, postal_code, country_code) + returning contact_id, slug + into cid, cslug + ; + + if phone is not null and trim(phone) <> '' then + insert into contact_phone (contact_id, phone) + values (cid, parse_packed_phone_number(add_contact.phone, coalesce(country_code, 'ES'))) + ; + end if; + + if email is not null and trim(email) <> '' then + insert into contact_email (contact_id, email) + values (cid, add_contact.email) + ; + end if; + + return cslug; +end +$$ + language plpgsql +; + +revoke execute on function add_contact(integer, text, text, text, text, text, text, text, text, text, country_code) from public; +grant execute on function add_contact(integer, text, text, text, text, text, text, text, text, text, country_code) to employee; +grant execute on function add_contact(integer, text, text, text, text, text, text, text, text, text, country_code) to admin; + +commit; diff --git a/deploy/add_invoice.sql b/deploy/add_invoice.sql new file mode 100644 index 0000000..b3b9be9 --- /dev/null +++ b/deploy/add_invoice.sql @@ -0,0 +1,75 @@ +-- Deploy camper:add_invoice to pg +-- requires: roles +-- requires: schema_camper +-- requires: invoice +-- requires: company +-- requires: currency +-- requires: parse_price +-- requires: new_invoice_product +-- requires: tax +-- requires: invoice_product +-- requires: invoice_product_product +-- requires: invoice_product_tax +-- requires: next_invoice_number + +begin; + +set search_path to camper, public; + +create or replace function add_invoice(company integer, invoice_date date, contact_id integer, notes text, payment_method_id integer, products new_invoice_product[]) returns uuid as +$$ +declare + iid integer; + pslug uuid; + product new_invoice_product; + ccode text; + ipid integer; +begin + insert into invoice (company_id, invoice_number, invoice_date, contact_id, notes, currency_code, payment_method_id) + select company_id + , next_invoice_number(add_invoice.company, invoice_date) + , invoice_date + , contact_id + , notes + , currency_code + , add_invoice.payment_method_id + from company + where company.company_id = add_invoice.company + returning invoice_id, slug, currency_code + into iid, pslug, ccode; + + foreach product in array products + loop + insert into invoice_product (invoice_id, name, description, price, quantity, discount_rate) + select iid + , product.name + , coalesce(product.description, '') + , parse_price(product.price, currency.decimal_digits) + , product.quantity + , product.discount_rate + from currency + where currency_code = ccode + returning invoice_product_id + into ipid; + + if product.product_id is not null then + insert into invoice_product_product (invoice_product_id, product_id) + values (ipid, product.product_id); + end if; + + insert into invoice_product_tax (invoice_product_id, tax_id, tax_rate) + select ipid, tax_id, tax.rate + from tax + join unnest(product.tax) as ptax(tax_id) using (tax_id); + end loop; + + return pslug; +end; +$$ +language plpgsql; + +revoke execute on function add_invoice(integer, date, integer, text, integer, new_invoice_product[]) from public; +grant execute on function add_invoice(integer, date, integer, text, integer, new_invoice_product[]) to employee; +grant execute on function add_invoice(integer, date, integer, text, integer, new_invoice_product[]) to admin; + +commit; diff --git a/deploy/available_invoice_status.sql b/deploy/available_invoice_status.sql new file mode 100644 index 0000000..57b2931 --- /dev/null +++ b/deploy/available_invoice_status.sql @@ -0,0 +1,28 @@ +-- Deploy camper:available_invoice_status to pg +-- requires: schema_camper +-- requires: invoice_status +-- requires: invoice_status_i18n + +begin; + +set search_path to camper; + +insert into invoice_status (invoice_status, name) +values ('created', 'Created') + , ('sent', 'Sent') + , ('paid', 'Paid') + , ('unpaid', 'Unpaid') +; + +insert into invoice_status_i18n (invoice_status, lang_tag, name) +values ('created', 'ca', 'Creada') + , ('sent', 'ca', 'Enviada') + , ('paid', 'ca', 'Cobrada') + , ('unpaid', 'ca', 'No cobrada') + , ('created', 'es', 'Creada') + , ('sent', 'es', 'Enviada') + , ('paid', 'es', 'Cobrada') + , ('unpaid', 'es', 'No cobrada') +; + +commit; diff --git a/deploy/compute_new_invoice_amount.sql b/deploy/compute_new_invoice_amount.sql new file mode 100644 index 0000000..746e264 --- /dev/null +++ b/deploy/compute_new_invoice_amount.sql @@ -0,0 +1,64 @@ +-- Deploy camper:compute_new_invoice_amount to pg +-- requires: schema_camper +-- requires: company +-- requires: currency +-- requires: tax +-- requires: new_invoice_product +-- requires: new_invoice_amount + +begin; + +set search_path to camper, public; + +create or replace function compute_new_invoice_amount(company_id integer, products new_invoice_product[]) returns new_invoice_amount as +$$ +declare + result new_invoice_amount; +begin + if array_length(products, 1) is null then + select to_price(0, decimal_digits), array[]::text[][], to_price(0, decimal_digits) + from company + join currency using (currency_code) + where company.company_id = compute_new_invoice_amount.company_id + into result.subtotal, result.taxes, result.total; + else + with product as ( + select round(parse_price(price, currency.decimal_digits) * quantity * (1 - discount_rate))::integer as subtotal + , tax + , decimal_digits + from unnest(products) + join company on company.company_id = compute_new_invoice_amount.company_id + join currency using (currency_code) + ) + , tax_amount as ( + select tax_id + , sum(round(subtotal * tax.rate)::integer)::integer as amount + , decimal_digits + from product, unnest(product.tax) as product_tax(tax_id) + join tax using (tax_id) + group by tax_id, decimal_digits + ) + , tax_total as ( + select sum(amount)::integer as amount, array_agg(array[name, to_price(amount, decimal_digits)]) as taxes + from tax_amount + join tax using (tax_id) + ) + select to_price(sum(subtotal)::integer, decimal_digits) + , coalesce(taxes, array[]::text[][]) + , to_price(sum(subtotal)::integer + coalesce(tax_total.amount, 0), decimal_digits) as total + from product, tax_total + group by tax_total.amount, taxes, decimal_digits + into result.subtotal, result.taxes, result.total; + end if; + + return result; +end +$$ +language plpgsql +stable; + +revoke execute on function compute_new_invoice_amount(integer, new_invoice_product[]) from public; +grant execute on function compute_new_invoice_amount(integer, new_invoice_product[]) to employee; +grant execute on function compute_new_invoice_amount(integer, new_invoice_product[]) to admin; + +commit; diff --git a/deploy/contact.sql b/deploy/contact.sql new file mode 100644 index 0000000..b2f9738 --- /dev/null +++ b/deploy/contact.sql @@ -0,0 +1,45 @@ +-- Deploy camper:contact to pg +-- requires: roles +-- requires: schema_camper +-- requires: user_profile +-- requires: company +-- requires: id_document_type +-- requires: country_code +-- requires: country + +begin; + +set search_path to camper, public; + +create table contact ( + contact_id integer generated by default as identity primary key, + company_id integer not null references company, + slug uuid not null unique default gen_random_uuid(), + name text not null constraint name_not_empty check(length(trim(name)) > 1), + id_document_type_id varchar(1) not null references id_document_type, + id_document_number text not null, + address text not null, + city text not null, + province text not null, + postal_code text not null, + country_code country_code not null references country, + created_at timestamptz not null default current_timestamp +); + +grant select, insert, update, delete on table contact to employee; +grant select, insert, update, delete on table contact to admin; + +alter table contact enable row level security; + +create policy company_policy +on contact +using ( + exists( + select 1 + from company_user + join user_profile using (user_id) + where company_user.company_id = contact.company_id + ) +); + +commit; diff --git a/deploy/contact_email.sql b/deploy/contact_email.sql new file mode 100644 index 0000000..7274592 --- /dev/null +++ b/deploy/contact_email.sql @@ -0,0 +1,31 @@ +-- Deploy camper:contact_email to pg +-- requires: roles +-- requires: schema_camper +-- requires: email +-- requires: contact + +begin; + +set search_path to camper, public; + +create table contact_email ( + contact_id integer primary key references contact, + email email not null +); + +grant select, insert, update, delete on table contact_email to employee; +grant select, insert, update, delete on table contact_email to admin; + +alter table contact_email enable row level security; + +create policy company_policy +on contact_email +using ( + exists( + select 1 + from contact + where contact.contact_id = contact_email.contact_id + ) +); + +commit; diff --git a/deploy/contact_phone.sql b/deploy/contact_phone.sql new file mode 100644 index 0000000..4bd505e --- /dev/null +++ b/deploy/contact_phone.sql @@ -0,0 +1,30 @@ +-- Deploy camper:contact_phone to pg +-- requires: roles +-- requires: schema_camper +-- requires: extension_pg_libphonenumber + +begin; + +set search_path to camper, public; + +create table contact_phone ( + contact_id integer primary key references contact, + phone packed_phone_number not null +); + +grant select, insert, update, delete on table contact_phone to employee; +grant select, insert, update, delete on table contact_phone to admin; + +alter table contact_phone enable row level security; + +create policy company_policy +on contact_phone +using ( + exists( + select 1 + from contact + where contact.contact_id = contact_phone.contact_id + ) +); + +commit; diff --git a/deploy/discount_rate.sql b/deploy/discount_rate.sql new file mode 100644 index 0000000..5587223 --- /dev/null +++ b/deploy/discount_rate.sql @@ -0,0 +1,14 @@ +-- Deploy camper:discount_rate to pg +-- requires: schema_camper + +begin; + +set search_path to camper, public; + +create domain discount_rate as numeric +check (VALUE >= 0 and VALUE <= 1); + +comment on domain discount_rate is +'A rate for discount in the range [0, 1]'; + +commit; diff --git a/deploy/edit_contact.sql b/deploy/edit_contact.sql new file mode 100644 index 0000000..915ab46 --- /dev/null +++ b/deploy/edit_contact.sql @@ -0,0 +1,72 @@ +-- Deploy camper:edit_contact to pg +-- requires: roles +-- requires: schema_camper +-- requires: email +-- requires: country_code +-- requires: contact +-- requires: extension_pg_libphonenumber +-- requires: contact_phone +-- requires: contact_email + +begin; + +set search_path to camper, public; + +create or replace function edit_contact(contact_slug uuid, name text, id_document_type_id text, id_document_number text, phone text, email text, address text, city text, province text, postal_code text, country_code country_code) returns uuid as +$$ +declare + cid integer; +begin + update contact + set name = edit_contact.name + , id_document_type_id = edit_contact.id_document_type_id + , id_document_number = edit_contact.id_document_number + , address = edit_contact.address + , city = edit_contact.city + , province = edit_contact.province + , postal_code = edit_contact.postal_code + , country_code = edit_contact.country_code + where slug = contact_slug + returning contact_id + into cid + ; + + if cid is null then + return null; + end if; + + if phone is null or trim(phone) = '' then + delete from contact_phone + where contact_id = cid + ; + else + insert into contact_phone (contact_id, phone) + values (cid, parse_packed_phone_number(phone, coalesce(country_code, 'ES'))) + on conflict (contact_id) do update + set phone = excluded.phone + ; + end if; + + if email is null or trim(email) = '' then + delete from contact_email + where contact_id = cid + ; + else + insert into contact_email (contact_id, email) + values (cid, email) + on conflict (contact_id) do update + set email = excluded.email + ; + end if; + + return contact_slug; +end +$$ + language plpgsql +; + +revoke execute on function edit_contact(uuid, text, text, text, text, text, text, text, text, text, country_code) from public; +grant execute on function edit_contact(uuid, text, text, text, text, text, text, text, text, text, country_code) to employee; +grant execute on function edit_contact(uuid, text, text, text, text, text, text, text, text, text, country_code) to admin; + +commit; diff --git a/deploy/edit_invoice.sql b/deploy/edit_invoice.sql new file mode 100644 index 0000000..eeed3fc --- /dev/null +++ b/deploy/edit_invoice.sql @@ -0,0 +1,110 @@ +-- Deploy camper:edit_invoice to pg +-- requires: roles +-- requires: schema_camper +-- requires: invoice +-- requires: currency +-- requires: parse_price +-- requires: edited_invoice_product +-- requires: tax +-- requires: invoice_product +-- requires: invoice_product_product +-- requires: invoice_product_tax + +begin; + +set search_path to camper, public; + +create or replace function edit_invoice(invoice_slug uuid, invoice_status text, contact_id integer, notes text, payment_method_id integer, products edited_invoice_product[]) returns uuid as +$$ +declare + iid integer; + products_to_keep integer[]; + products_to_delete integer[]; + company integer; + ccode text; + product edited_invoice_product; + ipid integer; +begin + update invoice + set contact_id = edit_invoice.contact_id + , invoice_status = edit_invoice.invoice_status + , notes = edit_invoice.notes + , payment_method_id = edit_invoice.payment_method_id + where slug = invoice_slug + returning invoice_id, company_id, currency_code + into iid, company, ccode + ; + + if iid is null then + return null; + end if; + + foreach product in array products + loop + if product.invoice_product_id is null then + insert into invoice_product (invoice_id, name, description, price, quantity, discount_rate) + select iid + , product.name + , coalesce(product.description, '') + , parse_price(product.price, currency.decimal_digits) + , product.quantity + , product.discount_rate + from currency + where currency_code = ccode + returning invoice_product_id + into ipid; + else + ipid := product.invoice_product_id; + + update invoice_product + set name = product.name + , description = coalesce(product.description, '') + , price = parse_price(product.price, currency.decimal_digits) + , quantity = product.quantity + , discount_rate = product.discount_rate + from currency + where invoice_product_id = ipid + and currency_code = ccode; + end if; + products_to_keep := array_append(products_to_keep, ipid); + + if product.product_id is null then + delete from invoice_product_product where invoice_product_id = ipid; + else + insert into invoice_product_product (invoice_product_id, product_id) + values (ipid, product.product_id) + on conflict (invoice_product_id) do update + set product_id = product.product_id; + end if; + + delete from invoice_product_tax where invoice_product_id = ipid; + + insert into invoice_product_tax (invoice_product_id, tax_id, tax_rate) + select ipid, tax_id, tax.rate + from tax + join unnest(product.tax) as ptax(tax_id) using (tax_id); + end loop; + + select array_agg(invoice_product_id) + into products_to_delete + from invoice_product + where invoice_id = iid + and not (invoice_product_id = any(products_to_keep)); + + if array_length(products_to_delete, 1) > 0 then + delete from invoice_product_tax where invoice_product_id = any(products_to_delete); + delete from invoice_product_product where invoice_product_id = any(products_to_delete); + delete from invoice_product where invoice_product_id = any(products_to_delete); + end if; + + return invoice_slug; +end; +$$ +language plpgsql; + +revoke execute on function edit_invoice(uuid, text, integer, text, integer, edited_invoice_product[]) from public; +grant execute on function edit_invoice(uuid, text, integer, text, integer, edited_invoice_product[]) to employee; +grant execute on function edit_invoice(uuid, text, integer, text, integer, edited_invoice_product[]) to admin; + + +commit; diff --git a/deploy/edited_invoice_product.sql b/deploy/edited_invoice_product.sql new file mode 100644 index 0000000..7271e21 --- /dev/null +++ b/deploy/edited_invoice_product.sql @@ -0,0 +1,20 @@ +-- Deploy camper:edited_invoice_product to pg +-- requires: schema_camper +-- requires: discount_rate + +begin; + +set search_path to camper, public; + +create type edited_invoice_product as +( invoice_product_id integer +, product_id integer +, name text +, description text +, price text +, quantity integer +, discount_rate discount_rate +, tax integer[] +); + +commit; diff --git a/deploy/invoice.sql b/deploy/invoice.sql new file mode 100644 index 0000000..1c0e53a --- /dev/null +++ b/deploy/invoice.sql @@ -0,0 +1,44 @@ +-- Deploy camper:invoice to pg +-- requires: roles +-- requires: schema_camper +-- requires: user_profile +-- requires: company +-- requires: contact +-- requires: invoice_status +-- requires: currency + +begin; + +set search_path to camper, public; + +create table invoice ( + invoice_id integer generated by default as identity primary key, + company_id integer not null references company, + slug uuid not null unique default gen_random_uuid(), + invoice_number text not null constraint invoice_number_not_empty check(length(trim(invoice_number)) > 1), + invoice_date date not null default current_date, + contact_id integer not null references contact, + invoice_status text not null default 'created' references invoice_status, + notes text not null default '', + payment_method_id integer not null references payment_method, + currency_code text not null references currency, + created_at timestamptz not null default current_timestamp +); + +grant select, insert, update, delete on table invoice to employee; +grant select, insert, update, delete on table invoice to admin; + +alter table invoice enable row level security; + +create policy company_policy +on invoice +using ( + exists( + select 1 + from company_user + join user_profile using (user_id) + where company_user.company_id = invoice.company_id + ) +); + +commit; diff --git a/deploy/invoice_amount.sql b/deploy/invoice_amount.sql new file mode 100644 index 0000000..0d856eb --- /dev/null +++ b/deploy/invoice_amount.sql @@ -0,0 +1,22 @@ +-- Deploy camper:invoice_amount to pg +-- requires: schema_camper +-- requires: invoice_product +-- requires: invoice_product_amount + +begin; + +set search_path to camper, public; + +create or replace view invoice_amount as +select invoice_id + , sum(subtotal)::integer as subtotal + , sum(total)::integer as total +from invoice_product +join invoice_product_amount using (invoice_product_id) +group by invoice_id +; + +grant select on table invoice_amount to employee; +grant select on table invoice_amount to admin; + +commit; diff --git a/deploy/invoice_number_counter.sql b/deploy/invoice_number_counter.sql new file mode 100644 index 0000000..d9ca8e8 --- /dev/null +++ b/deploy/invoice_number_counter.sql @@ -0,0 +1,32 @@ +-- Deploy camper:invoice_number_counter to pg +-- requires: schema_camper +-- requires: company + +begin; + +set search_path to camper, public; + +create table invoice_number_counter ( + company_id integer not null references company, + year integer not null constraint year_always_positive check(year > 0), + currval integer not null constraint counter_zero_or_positive check(currval >= 0), + primary key (company_id, year) +); + +grant select, insert, update on table invoice_number_counter to employee; +grant select, insert, update on table invoice_number_counter to admin; + +alter table invoice_number_counter enable row level security; + +create policy company_policy +on invoice_number_counter +using ( + exists( + select 1 + from company_user + join user_profile using (user_id) + where company_user.company_id = invoice_number_counter.company_id + ) +); + +commit; diff --git a/deploy/invoice_product.sql b/deploy/invoice_product.sql new file mode 100644 index 0000000..23f05f8 --- /dev/null +++ b/deploy/invoice_product.sql @@ -0,0 +1,38 @@ +-- Deploy camper:invoice_product to pg +-- requires: schema_camper +-- requires: invoice +-- requires: discount_rate + +begin; + +set search_path to camper, public; + +create table invoice_product ( + invoice_product_id integer generated by default as identity primary key, + invoice_id integer not null references invoice, + name text not null constraint name_not_empty check(length(trim(name)) > 0), + description text not null default '', + price integer not null, + quantity integer not null default 1, + discount_rate discount_rate not null default 0.0 +); + +grant select, insert, update, delete on table invoice_product to employee; +grant select, insert, update, delete on table invoice_product to admin; + +grant usage on sequence invoice_product_invoice_product_id_seq to employee; +grant usage on sequence invoice_product_invoice_product_id_seq to admin; + +alter table invoice_product enable row level security; + +create policy company_policy +on invoice_product +using ( + exists( + select 1 + from invoice + where invoice.invoice_id = invoice_product.invoice_id + ) +); + +commit; diff --git a/deploy/invoice_product_amount.sql b/deploy/invoice_product_amount.sql new file mode 100644 index 0000000..5a37027 --- /dev/null +++ b/deploy/invoice_product_amount.sql @@ -0,0 +1,22 @@ +-- Deploy camper:invoice_product_amount to pg +-- requires: schema_camper +-- requires: invoice_product +-- requires: invoice_product_tax + +begin; + +set search_path to camper, public; + +create or replace view invoice_product_amount as +select invoice_product_id + , round(price * quantity * (1 - discount_rate))::integer as subtotal + , max(round(price * quantity * (1 - discount_rate))::integer) + coalesce(sum(round(round(price * quantity * (1 - discount_rate))::integer * tax_rate)::integer)::integer, 0) as total +from invoice_product +left join invoice_product_tax using (invoice_product_id) +group by invoice_product_id, price, quantity, discount_rate +; + +grant select on table invoice_product_amount to employee; +grant select on table invoice_product_amount to admin; + +commit; diff --git a/deploy/invoice_product_product.sql b/deploy/invoice_product_product.sql new file mode 100644 index 0000000..4c08bcc --- /dev/null +++ b/deploy/invoice_product_product.sql @@ -0,0 +1,18 @@ +-- Deploy camper:invoice_product_product to pg +-- requires: schema_camper +-- requires: invoice_product +-- requires: product + +begin; + +set search_path to camper; + +create table invoice_product_product ( + invoice_product_id integer primary key references invoice_product, + product_id integer not null references product +); + +grant select, insert, update, delete on table invoice_product_product to employee; +grant select, insert, update, delete on table invoice_product_product to admin; + +commit; diff --git a/deploy/invoice_product_tax.sql b/deploy/invoice_product_tax.sql new file mode 100644 index 0000000..205a5ab --- /dev/null +++ b/deploy/invoice_product_tax.sql @@ -0,0 +1,33 @@ +-- Deploy camper:invoice_product_tax to pg +-- requires: schema_camper +-- requires: invoice_product +-- requires: tax +-- requires: tax_rate + +begin; + +set search_path to camper, public; + +create table invoice_product_tax ( + invoice_product_id integer not null references invoice_product, + tax_id integer not null references tax, + tax_rate tax_rate not null, + primary key (invoice_product_id, tax_id) +); + +grant select, insert, update, delete on table invoice_product_tax to employee; +grant select, insert, update, delete on table invoice_product_tax to admin; + +alter table invoice_product_tax enable row level security; + +create policy company_policy +on invoice_product_tax +using ( + exists( + select 1 + from invoice_product + where invoice_product.invoice_product_id = invoice_product_tax.invoice_product_id + ) +); + +commit; diff --git a/deploy/invoice_status.sql b/deploy/invoice_status.sql new file mode 100644 index 0000000..31f4ca2 --- /dev/null +++ b/deploy/invoice_status.sql @@ -0,0 +1,16 @@ +-- Deploy camper:invoice_status to pg +-- requires: schema_camper + +begin; + +set search_path to camper, public; + +create table invoice_status ( + invoice_status text primary key, + name text not null +); + +grant select on table invoice_status to employee; +grant select on table invoice_status to admin; + +commit; diff --git a/deploy/invoice_status_i18n.sql b/deploy/invoice_status_i18n.sql new file mode 100644 index 0000000..b7b1beb --- /dev/null +++ b/deploy/invoice_status_i18n.sql @@ -0,0 +1,20 @@ +-- Deploy camper:invoice_status_i18n to pg +-- requires: schema_camper +-- requires: invoice_status +-- requires: language + +begin; + +set search_path to camper, public; + +create table invoice_status_i18n ( + invoice_status text not null references invoice_status, + lang_tag text not null references language, + name text not null, + primary key (invoice_status, lang_tag) +); + +grant select on table invoice_status_i18n to employee; +grant select on table invoice_status_i18n to admin; + +commit; diff --git a/deploy/invoice_tax_amount.sql b/deploy/invoice_tax_amount.sql new file mode 100644 index 0000000..66a8dc4 --- /dev/null +++ b/deploy/invoice_tax_amount.sql @@ -0,0 +1,23 @@ +-- Deploy camper:invoice_tax_amount to pg +-- requires: schema_camper +-- requires: invoice_product +-- requires: invoice_product_tax + +begin; + +set search_path to camper, public; + +create or replace view invoice_tax_amount as +select invoice_id + , tax_id + , sum(round(round(price * quantity * (1 - discount_rate))::integer * tax_rate)::integer)::integer as amount +from invoice_product +join invoice_product_tax using (invoice_product_id) +group by invoice_id + , tax_id +; + +grant select on table invoice_tax_amount to employee; +grant select on table invoice_tax_amount to admin; + +commit; diff --git a/deploy/new_invoice_amount.sql b/deploy/new_invoice_amount.sql new file mode 100644 index 0000000..3dfe25f --- /dev/null +++ b/deploy/new_invoice_amount.sql @@ -0,0 +1,14 @@ +-- Deploy camper:new_invoice_amount to pg +-- requires: schema_camper + +begin; + +set search_path to camper, public; + +create type new_invoice_amount as ( + subtotal text, + taxes text[][], + total text +); + +commit; diff --git a/deploy/new_invoice_product.sql b/deploy/new_invoice_product.sql new file mode 100644 index 0000000..a57cf59 --- /dev/null +++ b/deploy/new_invoice_product.sql @@ -0,0 +1,19 @@ +-- Deploy camper:new_invoice_product to pg +-- requires: schema_camper +-- requires: discount_rate + +begin; + +set search_path to camper, public; + +create type new_invoice_product as ( + product_id integer, + name text, + description text, + price text, + quantity integer, + discount_rate discount_rate, + tax integer[] +); + +commit; diff --git a/deploy/next_invoice_number.sql b/deploy/next_invoice_number.sql new file mode 100644 index 0000000..4fc8cea --- /dev/null +++ b/deploy/next_invoice_number.sql @@ -0,0 +1,38 @@ +-- Deploy camper:next_invoice_number to pg +-- requires: schema_camper +-- requires: invoice_number_counter + +begin; + +set search_path to camper, public; + +create or replace function next_invoice_number(company integer, invoice_date date) returns text +as +$$ +declare + num integer; + invoice_number text; +begin + insert into invoice_number_counter (company_id, year, currval) + values (next_invoice_number.company, date_part('year', invoice_date), 1) + on conflict (company_id, year) do + update + set currval = invoice_number_counter.currval + 1 + returning currval + into num; + + select to_char(invoice_date, to_char(num, 'FM' || replace(invoice_number_format, '"', '\""'))) + into invoice_number + from company + where company_id = next_invoice_number.company; + + return invoice_number; +end; +$$ +language plpgsql; + +revoke execute on function next_invoice_number(integer, date) from public; +grant execute on function next_invoice_number(integer, date) to employee; +grant execute on function next_invoice_number(integer, date) to admin; + +commit; diff --git a/deploy/payment_method.sql b/deploy/payment_method.sql new file mode 100644 index 0000000..d9b9bdb --- /dev/null +++ b/deploy/payment_method.sql @@ -0,0 +1,34 @@ +-- Deploy camper:payment_method to pg +-- requires: roles +-- requires: schema_camper +-- requires: user_profile +-- requires: company + +begin; + +set search_path to camper, public; + +create table payment_method ( + payment_method_id integer generated by default as identity primary key, + company_id integer not null references company, + name text not null constraint name_not_empty check(length(trim(name)) > 0), + instructions text not null +); + +grant select, insert, update, delete on table payment_method to employee; +grant select, insert, update, delete on table payment_method to admin; + +alter table payment_method enable row level security; + +create policy company_policy +on payment_method +using ( + exists( + select 1 + from company_user + join user_profile using (user_id) + where company_user.company_id = payment_method.company_id + ) +); + +commit; diff --git a/deploy/product.sql b/deploy/product.sql new file mode 100644 index 0000000..46ebb78 --- /dev/null +++ b/deploy/product.sql @@ -0,0 +1,40 @@ +-- Deploy camper:product to pg +-- requires: roles +-- requires: schema_camper +-- requires: user_profile +-- requires: company + +begin; + +set search_path to camper, public; + +create table product ( + product_id integer generated by default as identity primary key, + company_id integer not null references company, + slug uuid not null default gen_random_uuid(), + name text not null constraint name_not_empty check(length(trim(name)) > 0), + description text not null default '', + price integer not null, + created_at timestamptz not null default current_timestamp +); + +comment on column product.price is +'Price is stored in cents.'; + +grant select, insert, update, delete on table product to employee; +grant select, insert, update, delete on table product to admin; + +alter table product enable row level security; + +create policy company_policy +on product +using ( + exists( + select 1 + from company_user + join user_profile using (user_id) + where company_user.company_id = product.company_id + ) +); + +commit; diff --git a/deploy/product_tax.sql b/deploy/product_tax.sql new file mode 100644 index 0000000..f35ead9 --- /dev/null +++ b/deploy/product_tax.sql @@ -0,0 +1,31 @@ +-- Deploy camper:product_tax to pg +-- requires: schema_camper +-- requires: product +-- requires: tax + +begin; + +set search_path to camper, public; + +create table product_tax ( + product_id integer not null references product, + tax_id integer not null references tax, + primary key (product_id, tax_id) +); + +grant select, insert, update, delete on table product_tax to employee; +grant select, insert, update, delete on table product_tax to admin; + +alter table product_tax enable row level security; + +create policy company_policy +on product_tax +using ( + exists( + select 1 + from product + where product.product_id = product_tax.product_id + ) +); + +commit; diff --git a/deploy/tax.sql b/deploy/tax.sql new file mode 100644 index 0000000..4d88f1c --- /dev/null +++ b/deploy/tax.sql @@ -0,0 +1,37 @@ +-- Deploy camper:tax to pg +-- requires: roles +-- requires: schema_camper +-- requires: user_profile +-- requires: company +-- requires: tax_rate +-- requires: tax_class + +begin; + +set search_path to camper, public; + +create table tax ( + tax_id integer generated by default as identity primary key, + company_id integer not null references company, + tax_class_id integer not null references tax_class, + name text not null constraint name_not_empty check(length(trim(name)) > 0), + rate tax_rate not null +); + +grant select, insert, update, delete on table tax to employee; +grant select, insert, update, delete on table tax to admin; + +alter table tax enable row level security; + +create policy company_policy +on tax +using ( + exists( + select 1 + from company_user + join user_profile using (user_id) + where company_user.company_id = tax.company_id + ) +); + +commit; diff --git a/deploy/tax_class.sql b/deploy/tax_class.sql new file mode 100644 index 0000000..4a36480 --- /dev/null +++ b/deploy/tax_class.sql @@ -0,0 +1,33 @@ +-- Deploy camper:tax_class to pg +-- requires: roles +-- requires: schema_camper +-- requires: user_profile +-- requires: company + +begin; + +set search_path to camper, public; + +create table tax_class ( + tax_class_id integer generated by default as identity not null primary key, + company_id integer not null references company, + name text not null constraint name_not_empty check(length(trim(name)) > 0) +); + +grant select, insert, update, delete on table tax_class to employee; +grant select, insert, update, delete on table tax_class to admin; + +alter table tax_class enable row level security; + +create policy company_policy +on tax_class +using ( + exists( + select 1 + from company_user + join user_profile using (user_id) + where company_user.company_id = tax_class.company_id + ) +); + +commit; diff --git a/deploy/tax_rate.sql b/deploy/tax_rate.sql new file mode 100644 index 0000000..ecd5d25 --- /dev/null +++ b/deploy/tax_rate.sql @@ -0,0 +1,14 @@ +-- Deploy camper:tax_rate to pg +-- requires: schema_camper + +begin; + +set search_path to camper, public; + +create domain tax_rate as numeric +check (value > -1 and value < 1); + +comment on domain tax_rate is +'A rate for taxes in the range (-1, 1)'; + +commit; diff --git a/go.mod b/go.mod index 0cbc762..899b82c 100644 --- a/go.mod +++ b/go.mod @@ -4,15 +4,16 @@ go 1.19 require ( github.com/jackc/pgconn v1.11.0 + github.com/jackc/pgio v1.0.0 github.com/jackc/pgtype v1.10.0 github.com/jackc/pgx/v4 v4.15.0 github.com/leonelquinteros/gotext v1.5.0 + github.com/rainycape/unidecode v0.0.0-20150906181237-c9cf8cdbbfe8 golang.org/x/text v0.7.0 ) require ( github.com/jackc/chunkreader/v2 v2.0.1 // indirect - github.com/jackc/pgio v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgproto3/v2 v2.3.2 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect diff --git a/go.sum b/go.sum index b914d61..a093849 100644 --- a/go.sum +++ b/go.sum @@ -88,6 +88,8 @@ github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rainycape/unidecode v0.0.0-20150906181237-c9cf8cdbbfe8 h1:iZTHFqK/oFrjyFDkiw5U/RjQxkMlkpq6tHQIO407i+s= +github.com/rainycape/unidecode v0.0.0-20150906181237-c9cf8cdbbfe8/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= diff --git a/pkg/app/admin.go b/pkg/app/admin.go index 1ecf211..41e3735 100644 --- a/pkg/app/admin.go +++ b/pkg/app/admin.go @@ -13,9 +13,11 @@ import ( "dev.tandem.ws/tandem/camper/pkg/booking" "dev.tandem.ws/tandem/camper/pkg/campsite" "dev.tandem.ws/tandem/camper/pkg/company" + "dev.tandem.ws/tandem/camper/pkg/customer" "dev.tandem.ws/tandem/camper/pkg/database" "dev.tandem.ws/tandem/camper/pkg/home" httplib "dev.tandem.ws/tandem/camper/pkg/http" + "dev.tandem.ws/tandem/camper/pkg/invoice" "dev.tandem.ws/tandem/camper/pkg/legal" "dev.tandem.ws/tandem/camper/pkg/location" "dev.tandem.ws/tandem/camper/pkg/media" @@ -32,7 +34,9 @@ type adminHandler struct { booking *booking.AdminHandler campsite *campsite.AdminHandler company *company.AdminHandler + customer *customer.AdminHandler home *home.AdminHandler + invoice *invoice.AdminHandler legal *legal.AdminHandler location *location.AdminHandler media *media.AdminHandler @@ -49,7 +53,9 @@ func newAdminHandler(mediaDir string) *adminHandler { booking: booking.NewAdminHandler(), campsite: campsite.NewAdminHandler(), company: company.NewAdminHandler(), + customer: customer.NewAdminHandler(), home: home.NewAdminHandler(), + invoice: invoice.NewAdminHandler(), legal: legal.NewAdminHandler(), location: location.NewAdminHandler(), media: media.NewAdminHandler(mediaDir), @@ -85,10 +91,14 @@ func (h *adminHandler) Handle(user *auth.User, company *auth.Company, conn *data h.campsite.Handler(user, company, conn).ServeHTTP(w, r) case "company": h.company.Handler(user, company, conn).ServeHTTP(w, r) + case "customers": + h.customer.Handler(user, company, conn).ServeHTTP(w, r) case "home": h.home.Handler(user, company, conn).ServeHTTP(w, r) case "legal": h.legal.Handler(user, company, conn).ServeHTTP(w, r) + case "invoices": + h.invoice.Handler(user, company, conn).ServeHTTP(w, r) case "location": h.location.Handler(user, company, conn).ServeHTTP(w, r) case "media": diff --git a/pkg/booking/admin.go b/pkg/booking/admin.go index 7e5c8b9..ce490c9 100644 --- a/pkg/booking/admin.go +++ b/pkg/booking/admin.go @@ -7,6 +7,7 @@ package booking import ( "context" + "dev.tandem.ws/tandem/camper/pkg/ods" "errors" "net/http" "strconv" @@ -197,16 +198,16 @@ func (page bookingIndex) MustRender(w http.ResponseWriter, r *http.Request, user "Holder Name", "Status", } - ods, err := writeTableOds(page, columns, user.Locale, func(sb *strings.Builder, entry *bookingEntry) error { - if err := writeCellString(sb, entry.Reference); err != nil { + table, err := ods.WriteTable(page, columns, user.Locale, func(sb *strings.Builder, entry *bookingEntry) error { + if err := ods.WriteCellString(sb, entry.Reference); err != nil { return err } - writeCellDate(sb, entry.ArrivalDate) - writeCellDate(sb, entry.DepartureDate) - if err := writeCellString(sb, entry.HolderName); err != nil { + ods.WriteCellDate(sb, entry.ArrivalDate) + ods.WriteCellDate(sb, entry.DepartureDate) + if err := ods.WriteCellString(sb, entry.HolderName); err != nil { return err } - if err := writeCellString(sb, entry.StatusLabel); err != nil { + if err := ods.WriteCellString(sb, entry.StatusLabel); err != nil { return err } return nil @@ -214,7 +215,7 @@ func (page bookingIndex) MustRender(w http.ResponseWriter, r *http.Request, user if err != nil { panic(err) } - mustWriteOdsResponse(w, ods, user.Locale.Pgettext("bookings.ods", "filename")) + ods.MustWriteResponse(w, table, user.Locale.Pgettext("bookings.ods", "filename")) default: template.MustRenderAdmin(w, r, user, company, "booking/index.gohtml", page) } diff --git a/pkg/booking/checkin.go b/pkg/booking/checkin.go index 364b0b2..0f6ce93 100644 --- a/pkg/booking/checkin.go +++ b/pkg/booking/checkin.go @@ -71,9 +71,9 @@ func newCheckinForm(slug string) *checkInForm { } func (f *checkInForm) FillFromDatabase(ctx context.Context, conn *database.Conn, l *locale.Locale) error { - documentTypes := mustGetDocumentTypeOptions(ctx, conn, l) + documentTypes := form.MustGetDocumentTypeOptions(ctx, conn, l) sexes := mustGetSexOptions(ctx, conn, l) - countries := mustGetCountryOptions(ctx, conn, l) + countries := form.MustGetCountryOptions(ctx, conn, l) rows, err := conn.Query(ctx, ` select array[id_document_type_id] @@ -118,10 +118,6 @@ func (f *checkInForm) FillFromDatabase(ctx context.Context, conn *database.Conn, return nil } -func mustGetDocumentTypeOptions(ctx context.Context, conn *database.Conn, l *locale.Locale) []*form.Option { - return form.MustGetOptions(ctx, conn, "select idt.id_document_type_id::text, coalesce(i18n.name, idt.name) as l10n_name from id_document_type as idt left join id_document_type_i18n as i18n on idt.id_document_type_id = i18n.id_document_type_id and i18n.lang_tag = $1 order by l10n_name", l.Language) -} - func mustGetSexOptions(ctx context.Context, conn *database.Conn, l *locale.Locale) []*form.Option { return form.MustGetOptions(ctx, conn, "select sex.sex_id::text, coalesce(i18n.name, sex.name) as l10n_name from sex left join sex_i18n as i18n on sex.sex_id = i18n.sex_id and i18n.lang_tag = $1 order by l10n_name", l.Language) } @@ -131,9 +127,9 @@ func (f *checkInForm) Parse(r *http.Request, user *auth.User, conn *database.Con return err } - documentTypes := mustGetDocumentTypeOptions(r.Context(), conn, user.Locale) + documentTypes := form.MustGetDocumentTypeOptions(r.Context(), conn, user.Locale) sexes := mustGetSexOptions(r.Context(), conn, user.Locale) - countries := mustGetCountryOptions(r.Context(), conn, user.Locale) + countries := form.MustGetCountryOptions(r.Context(), conn, user.Locale) guest := newGuestFormWithOptions(documentTypes, sexes, countries, nil) count := guest.count(r) @@ -180,9 +176,9 @@ type guestForm struct { } func newGuestForm(ctx context.Context, conn *database.Conn, l *locale.Locale, slug string) (*guestForm, error) { - documentTypes := mustGetDocumentTypeOptions(ctx, conn, l) + documentTypes := form.MustGetDocumentTypeOptions(ctx, conn, l) sexes := mustGetSexOptions(ctx, conn, l) - countries := mustGetCountryOptions(ctx, conn, l) + countries := form.MustGetCountryOptions(ctx, conn, l) var country []string row := conn.QueryRow(ctx, "select array[coalesce(country_code, '')] from booking where slug = $1", slug) diff --git a/pkg/booking/public.go b/pkg/booking/public.go index fc0878a..6d73ff2 100644 --- a/pkg/booking/public.go +++ b/pkg/booking/public.go @@ -547,7 +547,7 @@ func newBookingCustomerFields(ctx context.Context, conn *database.Conn, l *local }, Country: &form.Select{ Name: "country", - Options: mustGetCountryOptions(ctx, conn, l), + Options: form.MustGetCountryOptions(ctx, conn, l), }, Email: &form.Input{ Name: "email", @@ -561,10 +561,6 @@ func newBookingCustomerFields(ctx context.Context, conn *database.Conn, l *local } } -func mustGetCountryOptions(ctx context.Context, conn *database.Conn, l *locale.Locale) []*form.Option { - return form.MustGetOptions(ctx, conn, "select country.country_code, coalesce(i18n.name, country.name) as l10n_name from country left join country_i18n as i18n on country.country_code = i18n.country_code and i18n.lang_tag = $1 order by l10n_name", l.Language) -} - func (f *bookingCustomerFields) FillValues(r *http.Request) { f.FullName.FillValue(r) f.Address.FillValue(r) diff --git a/pkg/customer/admin.go b/pkg/customer/admin.go new file mode 100644 index 0000000..fedf7ad --- /dev/null +++ b/pkg/customer/admin.go @@ -0,0 +1,324 @@ +package customer + +import ( + "context" + "net/http" + + "dev.tandem.ws/tandem/camper/pkg/auth" + "dev.tandem.ws/tandem/camper/pkg/database" + "dev.tandem.ws/tandem/camper/pkg/form" + httplib "dev.tandem.ws/tandem/camper/pkg/http" + "dev.tandem.ws/tandem/camper/pkg/locale" + "dev.tandem.ws/tandem/camper/pkg/template" +) + +type AdminHandler struct { +} + +func NewAdminHandler() *AdminHandler { + return &AdminHandler{} +} + +func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var head string + head, r.URL.Path = httplib.ShiftPath(r.URL.Path) + + switch head { + case "": + switch r.Method { + case http.MethodGet: + serveCustomerIndex(w, r, user, company, conn) + case http.MethodPost: + addCustomer(w, r, user, company, conn) + default: + httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost) + } + case "new": + switch r.Method { + case http.MethodGet: + f := newCustomerForm(r.Context(), conn, user.Locale) + f.MustRender(w, r, user, company) + default: + httplib.MethodNotAllowed(w, r, http.MethodGet) + } + default: + f := newCustomerForm(r.Context(), conn, user.Locale) + if err := f.FillFromDatabase(r.Context(), conn, head); err != nil { + if database.ErrorIsNotFound(err) { + http.NotFound(w, r) + return + } + panic(err) + } + h.customerHandler(user, company, conn, f).ServeHTTP(w, r) + } + }) +} + +func (h *AdminHandler) customerHandler(user *auth.User, company *auth.Company, conn *database.Conn, f *customerForm) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var head string + head, r.URL.Path = httplib.ShiftPath(r.URL.Path) + + switch head { + case "": + switch r.Method { + case http.MethodGet: + f.MustRender(w, r, user, company) + case http.MethodPut: + editCustomer(w, r, user, company, conn, f) + default: + httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut) + } + default: + http.NotFound(w, r) + } + }) +} + +func serveCustomerIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { + customers, err := collectCustomerEntries(r.Context(), conn, company) + if err != nil { + panic(err) + } + page := &customerIndex{ + Customers: customers, + } + page.MustRender(w, r, user, company) +} + +func collectCustomerEntries(ctx context.Context, conn *database.Conn, company *auth.Company) ([]*customerEntry, error) { + rows, err := conn.Query(ctx, ` + select '/admin/customers/' || slug + , name + , coalesce(email::text, '') + , coalesce(phone::text, '') + from contact + left join contact_email using (contact_id) + left join contact_phone using (contact_id) + where company_id = $1 + order by name + `, company.ID) + if err != nil { + return nil, err + } + defer rows.Close() + + var customers []*customerEntry + for rows.Next() { + customer := &customerEntry{} + if err = rows.Scan(&customer.URL, &customer.Name, &customer.Email, &customer.Phone); err != nil { + return nil, err + } + customers = append(customers, customer) + } + + return customers, nil +} + +type customerEntry struct { + URL string + Name string + Email string + Phone string +} + +type customerIndex struct { + Customers []*customerEntry +} + +func (page *customerIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { + template.MustRenderAdmin(w, r, user, company, "customer/index.gohtml", page) +} + +func addCustomer(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { + f := newCustomerForm(r.Context(), conn, user.Locale) + processCustomerForm(w, r, user, company, conn, f, func(ctx context.Context, tx *database.Tx) error { + var err error + f.Slug, err = tx.AddContact(ctx, company.ID, f.FullName.Val, f.IDDocumentType.String(), f.IDDocumentNumber.Val, f.Phone.Val, f.Email.Val, f.Address.Val, f.City.Val, f.Province.Val, f.PostalCode.Val, f.Country.String()) + return err + }) +} + +func editCustomer(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *customerForm) { + processCustomerForm(w, r, user, company, conn, f, func(ctx context.Context, tx *database.Tx) error { + _, err := tx.EditContact(ctx, f.Slug, f.FullName.Val, f.IDDocumentType.String(), f.IDDocumentNumber.Val, f.Phone.Val, f.Email.Val, f.Address.Val, f.City.Val, f.Province.Val, f.PostalCode.Val, f.Country.String()) + return err + }) +} + +func processCustomerForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *customerForm, act func(ctx context.Context, tx *database.Tx) error) { + if err := f.Parse(r); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := user.VerifyCSRFToken(r); err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + if ok, err := f.Valid(r.Context(), conn, user.Locale); err != nil { + panic(err) + } else if !ok { + if !httplib.IsHTMxRequest(r) { + w.WriteHeader(http.StatusUnprocessableEntity) + } + f.MustRender(w, r, user, company) + return + } + + tx := conn.MustBegin(r.Context()) + if err := act(r.Context(), tx); err == nil { + if err := tx.Commit(r.Context()); err != nil { + panic(err) + } + } else { + if err := tx.Rollback(r.Context()); err != nil { + panic(err) + } + panic(err) + } + httplib.Redirect(w, r, "/admin/customers", http.StatusSeeOther) +} + +type customerForm struct { + URL string + Slug string + FullName *form.Input + IDDocumentType *form.Select + IDDocumentNumber *form.Input + Address *form.Input + City *form.Input + Province *form.Input + PostalCode *form.Input + Country *form.Select + Email *form.Input + Phone *form.Input +} + +func newCustomerForm(ctx context.Context, conn *database.Conn, l *locale.Locale) *customerForm { + return &customerForm{ + FullName: &form.Input{ + Name: "full_name", + }, + IDDocumentType: &form.Select{ + Name: "id_document_type", + Options: form.MustGetDocumentTypeOptions(ctx, conn, l), + }, + IDDocumentNumber: &form.Input{ + Name: "id_document_number", + }, + Address: &form.Input{ + Name: "address", + }, + City: &form.Input{ + Name: "city", + }, + Province: &form.Input{ + Name: "province", + }, + PostalCode: &form.Input{ + Name: "postal_code", + }, + Country: &form.Select{ + Name: "country", + Options: form.MustGetCountryOptions(ctx, conn, l), + }, + Email: &form.Input{ + Name: "email", + }, + Phone: &form.Input{ + Name: "phone", + }, + } +} + +func (f *customerForm) FillFromDatabase(ctx context.Context, conn *database.Conn, slug string) error { + row := conn.QueryRow(ctx, ` + select '/admin/customers/' || slug + , slug + , name + , array[id_document_type_id::text] + , id_document_number + , address + , city + , province + , postal_code + , array[country_code::text] + , coalesce(email::text, '') + , coalesce(phone::text, '') + from contact as text + left join contact_email using (contact_id) + left join contact_phone using (contact_id) + where slug = $1 + `, slug) + return row.Scan( + &f.URL, + &f.Slug, + &f.FullName.Val, + &f.IDDocumentType.Selected, + &f.IDDocumentNumber.Val, + &f.Address.Val, + &f.City.Val, + &f.Province.Val, + &f.PostalCode.Val, + &f.Country.Selected, + &f.Email.Val, + &f.Phone.Val, + ) +} + +func (f *customerForm) Parse(r *http.Request) error { + if err := r.ParseForm(); err != nil { + return err + } + + f.FullName.FillValue(r) + f.IDDocumentType.FillValue(r) + f.IDDocumentNumber.FillValue(r) + f.Address.FillValue(r) + f.City.FillValue(r) + f.Province.FillValue(r) + f.PostalCode.FillValue(r) + f.Country.FillValue(r) + f.Email.FillValue(r) + f.Phone.FillValue(r) + return nil +} + +func (f *customerForm) Valid(ctx context.Context, conn *database.Conn, l *locale.Locale) (bool, error) { + v := form.NewValidator(l) + + var country string + if v.CheckSelectedOptions(f.Country, l.GettextNoop("Selected country is not valid.")) { + country = f.Country.Selected[0] + } + + v.CheckSelectedOptions(f.IDDocumentType, l.GettextNoop("Selected ID document type is not valid.")) + v.CheckRequired(f.IDDocumentNumber, l.GettextNoop("ID document number can not be empty.")) + + if v.CheckRequired(f.FullName, l.GettextNoop("Full name can not be empty.")) { + v.CheckMinLength(f.FullName, 1, l.GettextNoop("Full name must have at least one letter.")) + } + + v.CheckRequired(f.Address, l.GettextNoop("Address can not be empty.")) + v.CheckRequired(f.City, l.GettextNoop("Town or village can not be empty.")) + if v.CheckRequired(f.PostalCode, l.GettextNoop("Postcode can not be empty.")) && country != "" { + if _, err := v.CheckValidPostalCode(ctx, conn, f.PostalCode, country, l.GettextNoop("This postcode is not valid.")); err != nil { + return false, err + } + } + if f.Email.Val != "" { + v.CheckValidEmail(f.Email, l.GettextNoop("This email is not valid. It should be like name@domain.com.")) + } + if f.Phone.Val != "" && country != "" { + if _, err := v.CheckValidPhone(ctx, conn, f.Phone, country, l.GettextNoop("This phone number is not valid.")); err != nil { + return false, err + } + } + return v.AllOK, nil +} + +func (f *customerForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { + template.MustRenderAdmin(w, r, user, company, "customer/form.gohtml", f) +} diff --git a/pkg/database/EditedInvoiceProduct.go b/pkg/database/EditedInvoiceProduct.go new file mode 100644 index 0000000..745adb6 --- /dev/null +++ b/pkg/database/EditedInvoiceProduct.go @@ -0,0 +1,76 @@ +package database + +import ( + "fmt" + + "github.com/jackc/pgio" + "github.com/jackc/pgtype" +) + +const EditedInvoiceProductTypeName = "edited_invoice_product" + +type EditedInvoiceProduct struct { + *NewInvoiceProduct + InvoiceProductId int +} + +func (src EditedInvoiceProduct) EncodeBinary(ci *pgtype.ConnInfo, dst []byte) ([]byte, error) { + typeName := EditedInvoiceProductTypeName + dt, ok := ci.DataTypeForName(typeName) + if !ok { + return nil, fmt.Errorf("unable to find oid for type name %v", typeName) + } + var invoiceProductId interface{} + if src.InvoiceProductId > 0 { + invoiceProductId = src.InvoiceProductId + } + var productId interface{} + if src.ProductId > 0 { + productId = src.ProductId + } + values := []interface{}{ + invoiceProductId, + productId, + src.Name, + src.Description, + src.Price, + src.Quantity, + src.Discount, + src.Taxes, + } + ct := pgtype.NewValue(dt.Value).(*pgtype.CompositeType) + if err := ct.Set(values); err != nil { + return nil, err + } + return ct.EncodeBinary(ci, dst) +} + +type EditedInvoiceProductArray []*EditedInvoiceProduct + +func (src EditedInvoiceProductArray) EncodeBinary(ci *pgtype.ConnInfo, buf []byte) ([]byte, error) { + typeName := EditedInvoiceProductTypeName + dt, ok := ci.DataTypeForName(typeName) + if !ok { + return nil, fmt.Errorf("unable to find oid for type name %v", typeName) + } + + arrayHeader := pgtype.ArrayHeader{ + ElementOID: int32(dt.OID), + Dimensions: []pgtype.ArrayDimension{{Length: int32(len(src)), LowerBound: 1}}, + } + buf = arrayHeader.EncodeBinary(ci, buf) + for _, product := range src { + sp := len(buf) + buf = pgio.AppendInt32(buf, -1) + + elemBuf, err := product.EncodeBinary(ci, buf) + if err != nil { + return nil, err + } + if elemBuf != nil { + buf = elemBuf + pgio.SetInt32(buf[sp:], int32(len(buf[sp:])-4)) + } + } + return buf, nil +} diff --git a/pkg/database/NewInvoiceProduct.go b/pkg/database/NewInvoiceProduct.go new file mode 100644 index 0000000..81a4ca4 --- /dev/null +++ b/pkg/database/NewInvoiceProduct.go @@ -0,0 +1,76 @@ +package database + +import ( + "fmt" + + "github.com/jackc/pgio" + "github.com/jackc/pgtype" +) + +const NewInvoiceProductTypeName = "new_invoice_product" + +type NewInvoiceProduct struct { + ProductId int + Name string + Description string + Price string + Quantity int + Discount float64 + Taxes []int +} + +func (src NewInvoiceProduct) EncodeBinary(ci *pgtype.ConnInfo, dst []byte) ([]byte, error) { + typeName := NewInvoiceProductTypeName + dt, ok := ci.DataTypeForName(typeName) + if !ok { + return nil, fmt.Errorf("unable to find oid for type name %v", typeName) + } + var productId interface{} + if src.ProductId > 0 { + productId = src.ProductId + } + values := []interface{}{ + productId, + src.Name, + src.Description, + src.Price, + src.Quantity, + src.Discount, + src.Taxes, + } + ct := pgtype.NewValue(dt.Value).(*pgtype.CompositeType) + if err := ct.Set(values); err != nil { + return nil, err + } + return ct.EncodeBinary(ci, dst) +} + +type NewInvoiceProductArray []*NewInvoiceProduct + +func (src NewInvoiceProductArray) EncodeBinary(ci *pgtype.ConnInfo, buf []byte) ([]byte, error) { + typeName := NewInvoiceProductTypeName + dt, ok := ci.DataTypeForName(typeName) + if !ok { + return nil, fmt.Errorf("unable to find oid for type name %v", typeName) + } + + arrayHeader := pgtype.ArrayHeader{ + ElementOID: int32(dt.OID), + Dimensions: []pgtype.ArrayDimension{{Length: int32(len(src)), LowerBound: 1}}, + } + buf = arrayHeader.EncodeBinary(ci, buf) + for _, product := range src { + sp := len(buf) + buf = pgio.AppendInt32(buf, -1) + + elemBuf, err := product.EncodeBinary(ci, buf) + if err != nil { + return nil, err + } + if elemBuf != nil { + buf = elemBuf + pgio.SetInt32(buf[sp:], int32(len(buf[sp:])-4)) + } + } + return buf, nil +} diff --git a/pkg/database/funcs.go b/pkg/database/funcs.go index 112eadf..00f3691 100644 --- a/pkg/database/funcs.go +++ b/pkg/database/funcs.go @@ -370,3 +370,19 @@ func (c *Conn) CheckInGuests(ctx context.Context, bookingSlug string, guests []* _, err := c.Exec(ctx, "select check_in_guests(booking_id, $2) from booking where slug = $1", bookingSlug, CheckedInGuestArray(guests)) return err } + +func (c *Conn) AddInvoice(ctx context.Context, companyID int, date string, customerID int, notes string, paymentMethodID int, products NewInvoiceProductArray) (string, error) { + return c.GetText(ctx, "select add_invoice($1, $2, $3, $4, $5, $6)", companyID, date, customerID, notes, paymentMethodID, products) +} + +func (c *Conn) EditInvoice(ctx context.Context, invoiceSlug string, invoiceStatus string, contactID int, notes string, paymentMethodID int, products EditedInvoiceProductArray) (string, error) { + return c.GetText(ctx, "select edit_invoice($1, $2, $3, $4, $5, $6)", invoiceSlug, invoiceStatus, contactID, notes, paymentMethodID, products) +} + +func (tx *Tx) AddContact(ctx context.Context, companyID int, name string, idDocumentType string, idDocumentNumber string, phone string, email string, address string, city string, province string, postalCode string, countryCode string) (string, error) { + return tx.GetText(ctx, "select add_contact($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", companyID, name, idDocumentType, idDocumentNumber, phone, email, address, city, province, postalCode, countryCode) +} + +func (tx *Tx) EditContact(ctx context.Context, contactSlug, name string, idDocumentType string, idDocumentNumber string, phone string, email string, address string, city string, province string, postalCode string, countryCode string) (string, error) { + return tx.GetText(ctx, "select edit_contact($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", contactSlug, name, idDocumentType, idDocumentNumber, phone, email, address, city, province, postalCode, countryCode) +} diff --git a/pkg/database/types.go b/pkg/database/types.go index 3820411..4910eb9 100644 --- a/pkg/database/types.go +++ b/pkg/database/types.go @@ -49,6 +49,11 @@ func registerConnectionTypes(ctx context.Context, conn *pgx.Conn) error { return err } + discountRateOID, err := registerType(ctx, conn, &pgtype.Numeric{}, "discount_rate") + if err != nil { + return err + } + redsysRequestType, err := pgtype.NewCompositeType( RedsysRequestTypeName, []pgtype.CompositeTypeField{ @@ -150,6 +155,47 @@ func registerConnectionTypes(ctx context.Context, conn *pgx.Conn) error { return err } + newInvoiceProductType, err := pgtype.NewCompositeType( + NewInvoiceProductTypeName, + []pgtype.CompositeTypeField{ + {"product_id", pgtype.Int4OID}, + {"name", pgtype.TextOID}, + {"description", pgtype.TextOID}, + {"price", pgtype.TextOID}, + {"quantity", pgtype.Int4OID}, + {"discount_rate", discountRateOID}, + {"tax", pgtype.Int4ArrayOID}, + }, + conn.ConnInfo(), + ) + if err != nil { + return err + } + if _, err = registerType(ctx, conn, newInvoiceProductType, newInvoiceProductType.TypeName()); err != nil { + return err + } + + editedInvoiceProductType, err := pgtype.NewCompositeType( + EditedInvoiceProductTypeName, + []pgtype.CompositeTypeField{ + {"invoice_product_id", pgtype.Int4OID}, + {"product_id", pgtype.Int4OID}, + {"name", pgtype.TextOID}, + {"description", pgtype.TextOID}, + {"price", pgtype.TextOID}, + {"quantity", pgtype.Int4OID}, + {"discount_rate", discountRateOID}, + {"tax", pgtype.Int4ArrayOID}, + }, + conn.ConnInfo(), + ) + if err != nil { + return err + } + if _, err = registerType(ctx, conn, editedInvoiceProductType, editedInvoiceProductType.TypeName()); err != nil { + return err + } + return nil } diff --git a/pkg/form/select.go b/pkg/form/select.go index 8398bf5..a92c7cb 100644 --- a/pkg/form/select.go +++ b/pkg/form/select.go @@ -8,6 +8,7 @@ package form import ( "context" "database/sql/driver" + "dev.tandem.ws/tandem/camper/pkg/locale" "net/http" "strconv" @@ -113,3 +114,11 @@ func MustGetOptions(ctx context.Context, conn *database.Conn, sql string, args . return options } + +func MustGetCountryOptions(ctx context.Context, conn *database.Conn, l *locale.Locale) []*Option { + return MustGetOptions(ctx, conn, "select country.country_code, coalesce(i18n.name, country.name) as l10n_name from country left join country_i18n as i18n on country.country_code = i18n.country_code and i18n.lang_tag = $1 order by l10n_name", l.Language) +} + +func MustGetDocumentTypeOptions(ctx context.Context, conn *database.Conn, l *locale.Locale) []*Option { + return MustGetOptions(ctx, conn, "select idt.id_document_type_id::text, coalesce(i18n.name, idt.name) as l10n_name from id_document_type as idt left join id_document_type_i18n as i18n on idt.id_document_type_id = i18n.id_document_type_id and i18n.lang_tag = $1 order by l10n_name", l.Language) +} diff --git a/pkg/http/htmx.go b/pkg/http/htmx.go index 20ddeb7..0b09a96 100644 --- a/pkg/http/htmx.go +++ b/pkg/http/htmx.go @@ -11,9 +11,10 @@ import ( ) const ( - HxLocation = "HX-Location" - HxRedirect = "HX-Redirect" - HxRequest = "HX-Request" + HxLocation = "HX-Location" + HxRedirect = "HX-Redirect" + HxRequest = "HX-Request" + HxTriggerAfterSettle = "HX-Trigger-After-Settle" ) func Relocate(w http.ResponseWriter, r *http.Request, url string, code int) { @@ -37,6 +38,10 @@ func Redirect(w http.ResponseWriter, r *http.Request, url string, code int) { } } +func TriggerAfterSettle(w http.ResponseWriter, trigger string) { + w.Header().Set(HxTriggerAfterSettle, trigger) +} + func IsHTMxRequest(r *http.Request) bool { return r.Header.Get(HxRequest) == "true" } diff --git a/pkg/invoice/admin.go b/pkg/invoice/admin.go new file mode 100644 index 0000000..6698950 --- /dev/null +++ b/pkg/invoice/admin.go @@ -0,0 +1,1295 @@ +package invoice + +import ( + "context" + "dev.tandem.ws/tandem/camper/pkg/ods" + "fmt" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "github.com/jackc/pgtype" + + "dev.tandem.ws/tandem/camper/pkg/auth" + "dev.tandem.ws/tandem/camper/pkg/database" + "dev.tandem.ws/tandem/camper/pkg/form" + httplib "dev.tandem.ws/tandem/camper/pkg/http" + "dev.tandem.ws/tandem/camper/pkg/locale" + "dev.tandem.ws/tandem/camper/pkg/template" + "dev.tandem.ws/tandem/camper/pkg/uuid" +) + +const ( + removedProductSuffix = ".removed" + defaultPaymentMethod = 1 +) + +type AdminHandler struct { +} + +func NewAdminHandler() *AdminHandler { + return &AdminHandler{} +} + +func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var head string + head, r.URL.Path = httplib.ShiftPath(r.URL.Path) + + switch head { + case "": + switch r.Method { + case http.MethodGet: + serveInvoiceIndex(w, r, user, company, conn) + case http.MethodPost: + addInvoice(w, r, user, company, conn) + default: + httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost) + } + case "batch": + switch r.Method { + case http.MethodPost: + handleBatchAction(w, r, user, company, conn) + default: + httplib.MethodNotAllowed(w, r, http.MethodPost) + } + case "new": + switch r.Method { + case http.MethodGet: + f := newInvoiceForm(r.Context(), conn, company, user.Locale) + if invoiceToDuplicate := r.URL.Query().Get("duplicate"); uuid.Valid(invoiceToDuplicate) { + f.MustFillFromDatabase(r.Context(), conn, user.Locale, invoiceToDuplicate) + f.Slug = "" + f.InvoiceStatus.Selected = []string{"created"} + } else if quoteToInvoice := r.URL.Query().Get("quote"); uuid.Valid(quoteToInvoice) { + f.MustFillFromQuote(r.Context(), conn, user.Locale, quoteToInvoice) + } + f.Date.Val = time.Now().Format("2006-01-02") + f.MustRender(w, r, user, company, conn) + case http.MethodPost: + handleInvoiceAction(w, r, user, company, conn, "", "/invoices/new") + default: + httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost) + } + case "product-form": + switch r.Method { + case http.MethodGet: + query := r.URL.Query() + index, _ := strconv.Atoi(query.Get("index")) + f := newInvoiceProductForm(index, company, user.Locale, mustGetTaxOptions(r.Context(), conn, company)) + productSlug := query.Get("slug") + if len(productSlug) > 0 { + if !f.MustFillFromDatabase(r.Context(), conn, productSlug) { + http.NotFound(w, r) + return + } + quantity, _ := strconv.Atoi(query.Get("product.quantity." + strconv.Itoa(index))) + if quantity > 0 { + f.Quantity.Val = strconv.Itoa(quantity) + } + httplib.TriggerAfterSettle(w, "recompute") + } + template.MustRenderAdminNoLayout(w, r, user, company, "invoice/product-form.gohtml", f) + default: + httplib.MethodNotAllowed(w, r, http.MethodGet) + } + default: + h.invoiceHandler(user, company, conn, head).ServeHTTP(w, r) + } + }) +} +func (h *AdminHandler) invoiceHandler(user *auth.User, company *auth.Company, conn *database.Conn, slug string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var head string + head, r.URL.Path = httplib.ShiftPath(r.URL.Path) + + switch head { + case "": + switch r.Method { + case http.MethodGet: + serveInvoice(w, r, user, company, conn, slug) + case http.MethodPut: + handleUpdateInvoice(w, r, user, company, conn, slug) + default: + httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut) + } + case "edit": + switch r.Method { + case http.MethodGet: + serveEditInvoice(w, r, user, company, conn, slug) + case http.MethodPost: + handleEditInvoiceAction(w, r, user, company, conn, slug) + default: + httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost) + } + default: + http.NotFound(w, r) + } + }) +} + +type IndexEntry struct { + ID int + Slug string + Date time.Time + Number string + Total string + CustomerName string + Status string + StatusLabel string +} + +func serveInvoiceIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { + filters := newInvoiceFilterForm(r.Context(), conn, company, user.Locale) + if err := filters.Parse(r); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + page := &invoiceIndex{ + Invoices: mustCollectInvoiceEntries(r.Context(), conn, user.Locale, filters), + TotalAmount: mustComputeInvoicesTotalAmount(r.Context(), conn, filters), + Filters: filters, + InvoiceStatuses: mustCollectInvoiceStatuses(r.Context(), conn, user.Locale), + } + page.MustRender(w, r, user, company) +} + +type invoiceIndex struct { + Invoices []*IndexEntry + TotalAmount string + Filters *invoiceFilterForm + InvoiceStatuses map[string]string +} + +func (page *invoiceIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { + template.MustRenderAdmin(w, r, user, company, "invoice/index.gohtml", page) +} + +func mustCollectInvoiceEntries(ctx context.Context, conn *database.Conn, locale *locale.Locale, filters *invoiceFilterForm) []*IndexEntry { + where, args := filters.BuildQuery([]interface{}{locale.Language.String()}) + rows, err := conn.Query(ctx, fmt.Sprintf(` + select invoice_id + , invoice.slug + , invoice_date + , invoice_number + , contact.name + , 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 = $1 + join invoice_amount using (invoice_id) + join currency using (currency_code) + where (%s) + order by invoice_date desc + , invoice_number desc + `, where), args...) + if err != nil { + panic(err) + } + defer rows.Close() + + var entries []*IndexEntry + for rows.Next() { + entry := &IndexEntry{} + if err := rows.Scan(&entry.ID, &entry.Slug, &entry.Date, &entry.Number, &entry.CustomerName, &entry.Status, &entry.StatusLabel, &entry.Total); err != nil { + panic(err) + } + entries = append(entries, entry) + } + if rows.Err() != nil { + panic(rows.Err()) + } + + return entries +} + +func mustComputeInvoicesTotalAmount(ctx context.Context, conn *database.Conn, filters *invoiceFilterForm) string { + where, args := filters.BuildQuery(nil) + text, err := conn.GetText(ctx, fmt.Sprintf(` + select to_price(sum(total)::integer, decimal_digits) + from invoice + join invoice_amount using (invoice_id) + join currency using (currency_code) + where (%s) + group by decimal_digits + `, where), args...) + if err != nil { + if database.ErrorIsNotFound(err) { + return "0.0" + } + panic(err) + } + return text +} + +func mustCollectInvoiceStatuses(ctx context.Context, conn *database.Conn, locale *locale.Locale) map[string]string { + rows, err := conn.Query(ctx, ` + select invoice_status.invoice_status + , isi18n.name + from invoice_status + join invoice_status_i18n isi18n using(invoice_status) + where isi18n.lang_tag = $1 + order by invoice_status + `, locale.Language) + if err != nil { + panic(err) + } + defer rows.Close() + + statuses := map[string]string{} + for rows.Next() { + var key, name string + if err := rows.Scan(&key, &name); err != nil { + panic(err) + } + statuses[key] = name + } + if rows.Err() != nil { + panic(rows.Err()) + } + + return statuses +} + +type invoiceFilterForm struct { + locale *locale.Locale + company *auth.Company + Customer *form.Select + InvoiceStatus *form.Select + InvoiceNumber *form.Input + FromDate *form.Input + ToDate *form.Input +} + +func newInvoiceFilterForm(ctx context.Context, conn *database.Conn, company *auth.Company, locale *locale.Locale) *invoiceFilterForm { + return &invoiceFilterForm{ + locale: locale, + company: company, + Customer: &form.Select{ + Name: "customer", + Options: mustGetContactOptions(ctx, conn, company), + }, + InvoiceStatus: &form.Select{ + Name: "invoice_status", + Options: mustGetInvoiceStatusOptions(ctx, conn, locale), + }, + InvoiceNumber: &form.Input{ + Name: "number", + }, + FromDate: &form.Input{ + Name: "from_date", + }, + ToDate: &form.Input{ + Name: "to_date", + }, + } +} + +func (f *invoiceFilterForm) Parse(r *http.Request) error { + if err := r.ParseForm(); err != nil { + return err + } + f.Customer.FillValue(r) + f.InvoiceStatus.FillValue(r) + f.InvoiceNumber.FillValue(r) + f.FromDate.FillValue(r) + f.ToDate.FillValue(r) + return nil +} + +func (f *invoiceFilterForm) BuildQuery(args []interface{}) (string, []interface{}) { + var where []string + appendWhere := func(expression string, value interface{}) { + args = append(args, value) + where = append(where, fmt.Sprintf(expression, len(args))) + } + maybeAppendWhere := func(expression string, value string, conv func(string) interface{}) { + if value != "" { + if conv == nil { + appendWhere(expression, value) + } else { + appendWhere(expression, conv(value)) + } + } + } + + appendWhere("invoice.company_id = $%d", f.company.ID) + maybeAppendWhere("contact_id = $%d", f.Customer.String(), func(v string) interface{} { + customerId, _ := strconv.Atoi(f.Customer.Selected[0]) + return customerId + }) + maybeAppendWhere("invoice.invoice_status = $%d", f.InvoiceStatus.String(), nil) + maybeAppendWhere("invoice_number = $%d", f.InvoiceNumber.Val, nil) + maybeAppendWhere("invoice_date >= $%d", f.FromDate.Val, nil) + maybeAppendWhere("invoice_date <= $%d", f.ToDate.Val, nil) + return strings.Join(where, ") AND ("), args +} + +func (f *invoiceFilterForm) HasValue() bool { + return (len(f.Customer.Selected) > 0 && f.Customer.Selected[0] != "") || + (len(f.InvoiceStatus.Selected) > 0 && f.InvoiceStatus.Selected[0] != "") || + f.InvoiceNumber.Val != "" || + f.FromDate.Val != "" || + f.ToDate.Val != "" +} + +func serveInvoice(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, slug string) { + pdf := false + if strings.HasSuffix(slug, ".pdf") { + pdf = true + slug = slug[:len(slug)-len(".pdf")] + } + if !uuid.Valid(slug) { + http.NotFound(w, r) + return + } + inv := mustGetInvoice(r.Context(), conn, company, slug) + if inv == nil { + http.NotFound(w, r) + return + } + if pdf { + w.Header().Set("Content-Type", "application/pdf") + mustWriteInvoicePdf(w, r, user, company, inv) + } else { + template.MustRenderAdmin(w, r, user, company, "invoice/view.gohtml", inv) + } +} + +type invoice struct { + Number string + Slug string + Date time.Time + Invoicer taxDetails + Invoicee taxDetails + Notes string + PaymentInstructions string + Products []*invoiceProduct + Subtotal string + Taxes [][]string + TaxClasses []string + HasDiscounts bool + Total string + LegalDisclaimer string +} + +type taxDetails struct { + Name string + VATIN string + Address string + City string + PostalCode string + Province string + Email string + Phone string +} + +type invoiceProduct struct { + Name string + Description string + Price string + Discount int + Quantity int + Taxes map[string]int + Subtotal string + Total string +} + +func mustGetInvoice(ctx context.Context, conn *database.Conn, company *auth.Company, slug string) *invoice { + inv := &invoice{ + Slug: slug, + } + var invoiceId int + var decimalDigits int + if err := conn.QueryRow(ctx, ` + select invoice_id + , decimal_digits + , invoice_number + , invoice_date + , notes + , instructions + , contact.name + , id_document_number + , address + , city + , province + , postal_code + , to_price(subtotal, decimal_digits) + , to_price(total, decimal_digits) + from invoice + join payment_method using (payment_method_id) + join contact using (contact_id) + join invoice_amount using (invoice_id) + join currency using (currency_code) + where invoice.slug = $1`, slug).Scan( + &invoiceId, + &decimalDigits, + &inv.Number, + &inv.Date, + &inv.Notes, + &inv.PaymentInstructions, + &inv.Invoicee.Name, + &inv.Invoicee.VATIN, + &inv.Invoicee.Address, + &inv.Invoicee.City, + &inv.Invoicee.Province, + &inv.Invoicee.PostalCode, + &inv.Subtotal, + &inv.Total, + ); err != nil { + if database.ErrorIsNotFound(err) { + return nil + } + panic(err) + } + if err := conn.QueryRow(ctx, ` + select business_name + , vatin + , phone + , email + , address + , city + , province + , postal_code + , legal_disclaimer + from company + where company_id = $1 + `, company.ID).Scan( + &inv.Invoicer.Name, + &inv.Invoicer.VATIN, + &inv.Invoicer.Phone, + &inv.Invoicer.Email, + &inv.Invoicer.Address, + &inv.Invoicer.City, + &inv.Invoicer.Province, + &inv.Invoicer.PostalCode, + &inv.LegalDisclaimer); err != nil { + panic(err) + } + if err := conn.QueryRow(ctx, ` + select array_agg(array[name, to_price(amount, $2)]) + from invoice_tax_amount + join tax using (tax_id) + where invoice_id = $1 + `, invoiceId, decimalDigits).Scan(&inv.Taxes); err != nil { + panic(err) + } + rows, err := conn.Query(ctx, ` + select invoice_product.name + , description + , to_price(price, $2) + , (discount_rate * 100)::integer + , quantity + , to_price(subtotal, $2) + , to_price(total, $2) + , array_agg(array[tax_class.name, (tax_rate * 100)::integer::text]) filter (where tax_rate is not null) + from invoice_product + join invoice_product_amount using (invoice_product_id) + left join invoice_product_tax using (invoice_product_id) + left join tax using (tax_id) + left join tax_class using (tax_class_id) + where invoice_id = $1 + group by invoice_product_id + , invoice_product.name + , description + , discount_rate + , price + , quantity + , subtotal + , total + order by invoice_product_id + `, invoiceId, decimalDigits) + if err != nil { + panic(err) + } + defer rows.Close() + taxClasses := map[string]bool{} + for rows.Next() { + product := &invoiceProduct{ + Taxes: make(map[string]int), + } + var taxes [][]string + if err := rows.Scan( + &product.Name, + &product.Description, + &product.Price, + &product.Discount, + &product.Quantity, + &product.Subtotal, + &product.Total, + &taxes); err != nil { + panic(err) + } + for _, tax := range taxes { + taxClass := tax[0] + taxClasses[taxClass] = true + product.Taxes[taxClass], _ = strconv.Atoi(tax[1]) + } + if product.Discount > 0 { + inv.HasDiscounts = true + } + inv.Products = append(inv.Products, product) + } + for taxClass := range taxClasses { + inv.TaxClasses = append(inv.TaxClasses, taxClass) + } + sort.Strings(inv.TaxClasses) + if rows.Err() != nil { + panic(rows.Err()) + } + + return inv +} + +func mustRenderNewInvoiceProductsForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, action string, form *invoiceForm) { + page := newInvoiceProductsPage{ + Action: "/admin/" + action, + Form: form, + Products: mustGetProductChoices(r.Context(), conn, company), + } + template.MustRenderAdmin(w, r, user, company, "invoice/products.gohtml", page) +} + +func mustGetProductChoices(ctx context.Context, conn *database.Conn, company *auth.Company) []*productChoice { + 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) + if err != nil { + panic(err) + } + defer rows.Close() + + var choices []*productChoice + for rows.Next() { + entry := &productChoice{} + if err := rows.Scan(&entry.Slug, &entry.Name, &entry.Price); err != nil { + panic(err) + } + choices = append(choices, entry) + } + if rows.Err() != nil { + panic(rows.Err()) + } + + return choices +} + +type newInvoiceProductsPage struct { + Action string + Form *invoiceForm + Products []*productChoice +} + +type productChoice struct { + Slug string + Name string + Price string +} + +func addInvoice(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { + f := newInvoiceForm(r.Context(), conn, company, user.Locale) + if err := f.Parse(r, conn, user.Locale); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := user.VerifyCSRFToken(r); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if !f.Validate(user.Locale) { + if !httplib.IsHTMxRequest(r) { + w.WriteHeader(http.StatusUnprocessableEntity) + } + f.MustRender(w, r, user, company, conn) + return + } + slug, err := conn.AddInvoice(r.Context(), company.ID, f.Date.Val, f.Customer.Int(), f.Notes.Val, defaultPaymentMethod, newInvoiceProducts(f.Products)) + if err != nil { + panic(err) + } + httplib.Redirect(w, r, "/admin/invoices/"+slug, http.StatusSeeOther) +} + +func newInvoiceProducts(src []*invoiceProductForm) database.NewInvoiceProductArray { + dst := make(database.NewInvoiceProductArray, 0, len(src)) + for _, p := range src { + dst = append(dst, p.newInvoiceProduct()) + } + return dst +} + +func editedInvoiceProducts(src []*invoiceProductForm) database.EditedInvoiceProductArray { + dst := make(database.EditedInvoiceProductArray, 0, len(src)) + for _, p := range src { + dst = append(dst, p.editedInvoiceProduct()) + } + return dst +} + +func handleBatchAction(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := user.VerifyCSRFToken(r); err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + switch r.Form.Get("action") { + case "download": + slugs := r.Form["invoice"] + if len(slugs) == 0 { + http.Redirect(w, r, "/admin/invoices", http.StatusSeeOther) + return + } + invoices := mustWriteInvoicesPdf(r, user, company, conn, slugs) + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", user.Locale.Pgettext("invoices.zip", "filename"))) + w.WriteHeader(http.StatusOK) + if _, err := w.Write(invoices); err != nil { + panic(err) + } + case "export": + filters := newInvoiceFilterForm(r.Context(), conn, company, user.Locale) + if err := filters.Parse(r); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + entries := mustCollectInvoiceEntries(r.Context(), conn, user.Locale, filters) + taxes := mustCollectInvoiceEntriesTaxes(r.Context(), conn, entries) + taxColumns := mustCollectTaxColumns(r.Context(), conn, company) + table := mustWriteInvoicesOds(entries, taxes, taxColumns, company, user.Locale) + ods.MustWriteResponse(w, table, user.Locale.Pgettext("invoices.ods", "filename")) + default: + http.Error(w, user.Locale.Gettext("Invalid action"), http.StatusBadRequest) + } +} + +func mustCollectTaxColumns(ctx context.Context, conn *database.Conn, company *auth.Company) map[int]string { + rows, err := conn.Query(ctx, ` + select tax_id + , name + from tax + where company_id = $1 + `, company.ID) + if err != nil { + panic(err) + } + defer rows.Close() + + columns := make(map[int]string) + for rows.Next() { + var taxID int + var name string + err = rows.Scan(&taxID, &name) + if err != nil { + panic(err) + } + + columns[taxID] = name + } + return columns +} + +type taxMap map[int]string + +func mustCollectInvoiceEntriesTaxes(ctx context.Context, conn *database.Conn, entries []*IndexEntry) map[int]taxMap { + ids := mustMakeIDArray(entries, func(entry *IndexEntry) int { + return entry.ID + }) + return mustMakeTaxMap(ctx, conn, ids, ` + select invoice_id + , tax_id + , to_price(amount, decimal_digits) + from invoice_tax_amount + join invoice using (invoice_id) + join currency using (currency_code) + where invoice_id = any ($1) + `) +} + +func mustMakeIDArray[T any](entries []*T, id func(entry *T) int) *pgtype.Int4Array { + ids := make([]int, len(entries)) + i := 0 + for _, entry := range entries { + ids[i] = id(entry) + i++ + } + idArray := &pgtype.Int4Array{} + if err := idArray.Set(ids); err != nil { + panic(err) + } + return idArray +} + +func mustMakeTaxMap(ctx context.Context, conn *database.Conn, ids *pgtype.Int4Array, sql string) map[int]taxMap { + rows, err := conn.Query(ctx, sql, ids) + if err != nil { + panic(err) + } + defer rows.Close() + + taxes := make(map[int]taxMap) + for rows.Next() { + var entryID int + var taxID int + var amount string + err := rows.Scan(&entryID, &taxID, &amount) + if err != nil { + panic(err) + } + + entryTaxes := taxes[entryID] + if entryTaxes == nil { + entryTaxes = make(taxMap) + taxes[entryID] = entryTaxes + } + entryTaxes[taxID] = amount + } + if rows.Err() != nil { + panic(rows.Err()) + } + return taxes +} + +type invoiceForm struct { + Slug string + Number string + company *auth.Company + InvoiceStatus *form.Select + Customer *form.Select + Date *form.Input + Notes *form.Input + Products []*invoiceProductForm + RemovedProduct *invoiceProductForm + Subtotal string + Taxes [][]string + Total string +} + +func newInvoiceForm(ctx context.Context, conn *database.Conn, company *auth.Company, locale *locale.Locale) *invoiceForm { + return &invoiceForm{ + company: company, + InvoiceStatus: &form.Select{ + Name: "invoice_status", + Selected: []string{"created"}, + Options: mustGetInvoiceStatusOptions(ctx, conn, locale), + }, + Customer: &form.Select{ + Name: "customer", + Options: mustGetCustomerOptions(ctx, conn, company), + }, + Date: &form.Input{ + Name: "date", + }, + Notes: &form.Input{ + Name: "notes", + }, + } +} + +func mustGetInvoiceStatusOptions(ctx context.Context, conn *database.Conn, locale *locale.Locale) []*form.Option { + return form.MustGetOptions(ctx, conn, ` + select invoice_status.invoice_status + , isi18n.name + from invoice_status + join invoice_status_i18n isi18n using(invoice_status) + where isi18n.lang_tag = $1 + order by invoice_status`, locale.Language) +} + +func (f *invoiceForm) Parse(r *http.Request, conn *database.Conn, l *locale.Locale) error { + if err := r.ParseForm(); err != nil { + return err + } + f.InvoiceStatus.FillValue(r) + f.Customer.FillValue(r) + f.Date.FillValue(r) + f.Notes.FillValue(r) + if _, ok := r.Form["product.id.0"]; ok { + taxOptions := mustGetTaxOptions(r.Context(), conn, f.company) + for index := 0; true; index++ { + if _, ok := r.Form["product.id."+strconv.Itoa(index)]; !ok { + break + } + productForm := newInvoiceProductForm(index, f.company, l, taxOptions) + if err := productForm.Parse(r); err != nil { + return err + } + f.Products = append(f.Products, productForm) + } + } + return nil +} + +func (f *invoiceForm) Validate(l *locale.Locale) bool { + v := form.NewValidator(l) + + v.CheckSelectedOptions(f.InvoiceStatus, l.GettextNoop("Selected invoice status is not valid.")) + v.CheckSelectedOptions(f.Customer, l.GettextNoop("Selected customer is not valid.")) + if v.CheckRequired(f.Date, l.GettextNoop("Invoice date can not be empty.")) { + v.CheckValidDate(f.Date, l.GettextNoop("Invoice date must be a valid date.")) + } + + allOK := v.AllOK + for _, product := range f.Products { + allOK = product.Validate(l) && allOK + } + return allOK +} + +func (f *invoiceForm) Update(l *locale.Locale) { + products := f.Products + f.Products = nil + for n, product := range products { + if product.Quantity.Val != "0" { + product.Update(l) + if n != len(f.Products) { + product.Index = len(f.Products) + product.Rename() + } + f.Products = append(f.Products, product) + } + } +} + +func (f *invoiceForm) RemoveProduct(index int) { + products := f.Products + f.Products = nil + for n, product := range products { + if n == index { + f.RemovedProduct = product + } else { + if n != len(f.Products) { + product.Index = len(f.Products) + product.Rename() + } + f.Products = append(f.Products, product) + } + } + if f.RemovedProduct != nil { + f.RemovedProduct.RenameWithSuffix(removedProductSuffix) + } +} + +func (f *invoiceForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { + err := conn.QueryRow(r.Context(), "select subtotal, taxes, total from compute_new_invoice_amount($1, $2)", company.ID, newInvoiceProducts(f.Products)).Scan(&f.Subtotal, &f.Taxes, &f.Total) + if err != nil { + panic(err) + } + if len(f.Products) == 0 { + f.Products = append(f.Products, newInvoiceProductForm(0, company, user.Locale, mustGetTaxOptions(r.Context(), conn, company))) + } + template.MustRenderAdminFiles(w, r, user, company, f, "invoice/form.gohtml", "invoice/product-form.gohtml") +} + +const selectProductBySlug = ` + select '' + , product_id::text + , name + , description + , to_price(price, decimal_digits) + , '1' as quantity + , '0' as discount + , array_remove(array_agg(tax_id), null) + from product + join company using (company_id) + join currency using (currency_code) + left join product_tax using (product_id) + where product.slug = any ($1) + group by product_id + , name + , description + , price + , decimal_digits + ` + +func (f *invoiceForm) AddProducts(ctx context.Context, conn *database.Conn, l *locale.Locale, productsSlug []string) { + f.mustAddProductsFromQuery(ctx, conn, l, selectProductBySlug, productsSlug) +} + +func (f *invoiceForm) mustAddProductsFromQuery(ctx context.Context, conn *database.Conn, l *locale.Locale, sql string, args ...interface{}) { + index := len(f.Products) + taxOptions := mustGetTaxOptions(ctx, conn, f.company) + rows, err := conn.Query(ctx, sql, args...) + if err != nil { + panic(err) + } + defer rows.Close() + for rows.Next() { + product := newInvoiceProductForm(index, f.company, l, taxOptions) + if err := rows.Scan(&product.InvoiceProductId.Val, &product.ProductId.Val, &product.Name.Val, &product.Description.Val, &product.Price.Val, &product.Quantity.Val, &product.Discount.Val, &product.Tax.Selected); err != nil { + panic(err) + } + f.Products = append(f.Products, product) + index++ + } + if rows.Err() != nil { + panic(rows.Err()) + } +} + +func (f *invoiceForm) InsertProduct(product *invoiceProductForm) { + replaced := false + for n, existing := range f.Products { + if existing.Quantity.Val == "" || existing.Quantity.Val == "0" { + product.Index = n + f.Products[n] = product + replaced = true + break + } + } + if !replaced { + product.Index = len(f.Products) + f.Products = append(f.Products, product) + } + product.Rename() +} + +func (f *invoiceForm) MustFillFromDatabase(ctx context.Context, conn *database.Conn, l *locale.Locale, slug string) bool { + var invoiceId int + selectedInvoiceStatus := f.InvoiceStatus.Selected + f.InvoiceStatus.Selected = nil + err := conn.QueryRow(ctx, ` + select invoice_id + , array[invoice_status] + , array[contact_id::text] + , invoice_number + , invoice_date::text + , notes + from invoice + where slug = $1 + `, slug).Scan(&invoiceId, &f.InvoiceStatus.Selected, &f.Customer.Selected, &f.Number, &f.Date.Val, &f.Notes.Val) + if err != nil { + if database.ErrorIsNotFound(err) { + f.InvoiceStatus.Selected = selectedInvoiceStatus + return false + } + panic(err) + } + f.Slug = slug + f.Products = []*invoiceProductForm{} + f.mustAddProductsFromQuery(ctx, conn, l, "select invoice_product_id::text, coalesce(product_id, 0)::text, name, description, to_price(price, $2), quantity::text, (discount_rate * 100)::integer::text, array_remove(array_agg(tax_id::text), null) from invoice_product left join invoice_product_product using (invoice_product_id) left join invoice_product_tax using (invoice_product_id) where invoice_id = $1 group by invoice_product_id, coalesce(product_id, 0), name, description, discount_rate, price, quantity", invoiceId, f.company.DecimalDigits) + return true +} + +func (f *invoiceForm) MustFillFromQuote(ctx context.Context, conn *database.Conn, l *locale.Locale, slug string) bool { + var quoteId int + note := l.Gettext("Re: quotation #%s of %s") + dateFormat := l.Pgettext("MM/DD/YYYY", "to_char") + err := conn.QueryRow(ctx, ` + select quote_id + , coalesce(contact_id::text, '') + , (case when length(trim(notes)) = 0 then '' else notes || E'\n\n' end) || format($2, quote_number, to_char(quote_date, $3)) + , coalesce(payment_method_id::text, $4) + , tags + from quote + left join quote_contact using (quote_id) + left join quote_payment_method using (quote_id) + where slug = $1 + `, slug, note, dateFormat).Scan("eId, f.Customer, f.Notes) + if err != nil { + if database.ErrorIsNotFound(err) { + return false + } + panic(err) + } + f.Products = []*invoiceProductForm{} + f.mustAddProductsFromQuery(ctx, conn, l, "select '', coalesce(product_id::text, ''), name, description, to_price(price, $2), quantity, (discount_rate * 100)::integer, array_remove(array_agg(tax_id), null) from quote_product left join quote_product_product using (quote_product_id) left join quote_product_tax using (quote_product_id) where quote_id = $1 group by quote_product_id, coalesce(product_id::text, ''), name, description, discount_rate, price, quantity", quoteId, f.company.DecimalDigits) + return true +} + +func mustGetTaxOptions(ctx context.Context, conn *database.Conn, company *auth.Company) []*form.Option { + return form.MustGetOptions(ctx, conn, "select tax_id::text, tax.name from tax where tax.company_id = $1 order by tax.rate desc", company.ID) +} + +func mustGetContactOptions(ctx context.Context, conn *database.Conn, company *auth.Company) []*form.Option { + return form.MustGetOptions(ctx, conn, "select contact_id::text, name from contact where company_id = $1 order by name", company.ID) +} + +func mustGetCustomerOptions(ctx context.Context, conn *database.Conn, company *auth.Company) []*form.Option { + return form.MustGetOptions(ctx, conn, "select contact_id::text, name from contact where company_id = $1 order by name", company.ID) +} + +type invoiceProductForm struct { + locale *locale.Locale + company *auth.Company + Index int + InvoiceProductId *form.Input + ProductId *form.Input + Name *form.Input + Description *form.Input + Price *form.Input + Quantity *form.Input + Discount *form.Input + Tax *form.Select +} + +func newInvoiceProductForm(index int, company *auth.Company, locale *locale.Locale, taxOptions []*form.Option) *invoiceProductForm { + f := &invoiceProductForm{ + locale: locale, + company: company, + Index: index, + InvoiceProductId: &form.Input{}, + ProductId: &form.Input{}, + Name: &form.Input{}, + Description: &form.Input{}, + Price: &form.Input{}, + Quantity: &form.Input{}, + Discount: &form.Input{}, + Tax: &form.Select{ + Options: taxOptions, + }, + } + f.Rename() + return f +} + +func (f *invoiceProductForm) Rename() { + f.RenameWithSuffix("." + strconv.Itoa(f.Index)) +} + +func (f *invoiceProductForm) RenameWithSuffix(suffix string) { + f.InvoiceProductId.Name = "product.invoice_product_id" + suffix + f.ProductId.Name = "product.id" + suffix + f.Name.Name = "product.name" + suffix + f.Description.Name = "product.description" + suffix + f.Price.Name = "product.price" + suffix + f.Quantity.Name = "product.quantity" + suffix + f.Discount.Name = "product.discount" + suffix + f.Tax.Name = "product.tax" + suffix +} + +func (f *invoiceProductForm) Parse(r *http.Request) error { + if err := r.ParseForm(); err != nil { + return err + } + f.InvoiceProductId.FillValue(r) + f.ProductId.FillValue(r) + f.Name.FillValue(r) + f.Description.FillValue(r) + f.Price.FillValue(r) + f.Quantity.FillValue(r) + f.Discount.FillValue(r) + f.Tax.FillValue(r) + return nil +} + +func (f *invoiceProductForm) Validate(l *locale.Locale) bool { + v := form.NewValidator(l) + if f.InvoiceProductId.Val != "" { + if v.CheckValidInteger(f.InvoiceProductId, l.GettextNoop("Invoice product ID must be an integer.")) { + v.CheckMinInteger(f.InvoiceProductId, 1, l.GettextNoop("Invoice product ID one or greater.")) + } + } + if f.ProductId.Val != "" { + if v.CheckValidInteger(f.ProductId, l.GettextNoop("Product ID must be an integer.")) { + v.CheckMinInteger(f.ProductId, 0, l.GettextNoop("Product ID must zero or greater.")) + } + } + v.CheckRequired(f.Name, l.GettextNoop("Name can not be empty.")) + if v.CheckRequired(f.Price, l.GettextNoop("Price can not be empty.")) { + if v.CheckValidDecimal(f.Price, l.GettextNoop("Price must be a decimal number.")) { + v.CheckMinDecimal(f.Price, 0, l.GettextNoop("Price must be zero or greater.")) + } + } + if v.CheckRequired(f.Quantity, l.GettextNoop("Quantity can not be empty.")) { + if v.CheckValidInteger(f.Quantity, l.GettextNoop("Quantity must be an integer.")) { + v.CheckMinInteger(f.Quantity, 1, l.GettextNoop("Quantity must one or greater.")) + } + } + if v.CheckRequired(f.Discount, l.GettextNoop("Discount can not be empty.")) { + if v.CheckValidInteger(f.Discount, l.GettextNoop("Discount must be an integer.")) { + if v.CheckMinInteger(f.Discount, 0, l.GettextNoop("Discount must be a percentage between 0 and 100.")) { + v.CheckMaxInteger(f.Discount, 100, l.GettextNoop("Discount must be a percentage between 0 and 100.")) + } + } + } + v.CheckSelectedOptions(f.Tax, l.GettextNoop("Selected tax is not valid.")) + // TODO v.CheckAtMostOneOfEachGroup(f.Tax, l.GettextNoop("You can only select a tax of each class.")) + return v.AllOK +} + +func (f *invoiceProductForm) Update(l *locale.Locale) { + v := form.NewValidator(l) + if !v.CheckValidDecimal(f.Price, "") || !v.CheckMinDecimal(f.Price, 0, "") { + f.Price.Val = "0.0" + f.Price.Error = nil + } + if !v.CheckValidInteger(f.Quantity, "") || !v.CheckMinInteger(f.Quantity, 0, "") { + f.Quantity.Val = "1" + f.Quantity.Error = nil + } + if !v.CheckValidInteger(f.Discount, "") || !v.CheckMinInteger(f.Discount, 0, "") || !v.CheckMaxInteger(f.Discount, 100, "") { + f.Discount.Val = "0" + f.Discount.Error = nil + } +} + +func (f *invoiceProductForm) MustFillFromDatabase(ctx context.Context, conn *database.Conn, slug string) bool { + err := conn.QueryRow(ctx, selectProductBySlug, []string{slug}).Scan( + f.InvoiceProductId, + f.ProductId, + f.Name, + f.Description, + f.Price, + f.Quantity, + f.Discount, + f.Tax) + if err != nil { + if database.ErrorIsNotFound(err) { + return false + } + panic(err) + } + return true +} + +func (f *invoiceProductForm) newInvoiceProduct() *database.NewInvoiceProduct { + productId := 0 + if f.ProductId.Val != "" { + productId = f.ProductId.Int() + } + var taxes []int + if len(f.Tax.Selected) > 0 { + taxes = make([]int, 0, len(f.Tax.Selected)) + for _, t := range f.Tax.Selected { + id, _ := strconv.Atoi(t) + taxes = append(taxes, id) + } + } + return &database.NewInvoiceProduct{ + ProductId: productId, + Name: f.Name.Val, + Description: f.Description.Val, + Price: f.Price.Val, + Quantity: f.Quantity.Int(), + Discount: float64(f.Discount.Int()) / 100.0, + Taxes: taxes, + } +} + +func (f *invoiceProductForm) editedInvoiceProduct() *database.EditedInvoiceProduct { + invoiceProductId := 0 + if f.InvoiceProductId.Val != "" { + invoiceProductId = f.InvoiceProductId.Int() + } + return &database.EditedInvoiceProduct{ + NewInvoiceProduct: f.newInvoiceProduct(), + InvoiceProductId: invoiceProductId, + } +} + +func handleUpdateInvoice(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, slug string) { + if !uuid.Valid(slug) { + http.NotFound(w, r) + return + } + f := newInvoiceForm(r.Context(), conn, company, user.Locale) + if err := f.Parse(r, conn, user.Locale); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := user.VerifyCSRFToken(r); err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + if r.FormValue("quick") == "status" { + slug = conn.MustGetText(r.Context(), "update invoice set invoice_status = $1 where slug = $2 returning slug", f.InvoiceStatus, slug) + if slug == "" { + http.NotFound(w, r) + return + } + httplib.Relocate(w, r, "/admin/invoices", http.StatusSeeOther) + } else { + if !f.Validate(user.Locale) { + if !httplib.IsHTMxRequest(r) { + w.WriteHeader(http.StatusUnprocessableEntity) + } + f.MustRender(w, r, user, company, conn) + return + } + var err error + slug, err = conn.EditInvoice(r.Context(), slug, f.InvoiceStatus.String(), f.Customer.Int(), f.Notes.Val, defaultPaymentMethod, editedInvoiceProducts(f.Products)) + if err != nil { + panic(err) + } + if slug == "" { + http.NotFound(w, r) + return + } + httplib.Redirect(w, r, "/admin/invoices/"+slug, http.StatusSeeOther) + } +} + +func serveEditInvoice(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, slug string) { + if !uuid.Valid(slug) { + http.NotFound(w, r) + return + } + f := newInvoiceForm(r.Context(), conn, company, user.Locale) + if !f.MustFillFromDatabase(r.Context(), conn, user.Locale, slug) { + http.NotFound(w, r) + return + } + f.MustRender(w, r, user, company, conn) +} + +func handleEditInvoiceAction(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, slug string) { + if !uuid.Valid(slug) { + http.NotFound(w, r) + return + } + actionUri := fmt.Sprintf("/invoices/%s/edit", slug) + handleInvoiceAction(w, r, user, company, conn, slug, actionUri) +} + +func handleInvoiceAction(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, slug string, action string) { + f := newInvoiceForm(r.Context(), conn, company, user.Locale) + if err := f.Parse(r, conn, user.Locale); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := user.VerifyCSRFToken(r); err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + f.Slug = slug + actionField := r.Form.Get("action") + switch actionField { + case "update": + f.Update(user.Locale) + w.WriteHeader(http.StatusOK) + f.MustRender(w, r, user, company, conn) + case "select-products": + mustRenderNewInvoiceProductsForm(w, r, user, company, conn, action, f) + case "add-products": + f.AddProducts(r.Context(), conn, user.Locale, r.Form["slug"]) + f.MustRender(w, r, user, company, conn) + case "restore-product": + restoredProduct := newInvoiceProductForm(0, company, user.Locale, mustGetTaxOptions(r.Context(), conn, company)) + restoredProduct.RenameWithSuffix(removedProductSuffix) + if err := restoredProduct.Parse(r); err != nil { + panic(err) + } + f.InsertProduct(restoredProduct) + f.Update(user.Locale) + f.MustRender(w, r, user, company, conn) + default: + prefix := "remove-product." + if strings.HasPrefix(actionField, prefix) { + index, err := strconv.Atoi(actionField[len(prefix):]) + if err != nil { + http.Error(w, user.Locale.Gettext("Invalid action"), http.StatusBadRequest) + } else { + f.RemoveProduct(index) + f.Update(user.Locale) + f.MustRender(w, r, user, company, conn) + } + } else { + http.Error(w, user.Locale.Gettext("Invalid action"), http.StatusBadRequest) + } + } +} diff --git a/pkg/invoice/ods.go b/pkg/invoice/ods.go new file mode 100644 index 0000000..3520ef2 --- /dev/null +++ b/pkg/invoice/ods.go @@ -0,0 +1,65 @@ +package invoice + +import ( + "sort" + "strings" + + "dev.tandem.ws/tandem/camper/pkg/auth" + "dev.tandem.ws/tandem/camper/pkg/locale" + "dev.tandem.ws/tandem/camper/pkg/ods" +) + +func mustWriteInvoicesOds(invoices []*IndexEntry, taxes map[int]taxMap, taxColumns map[int]string, company *auth.Company, locale *locale.Locale) []byte { + taxIDs := extractTaxIDs(taxColumns) + columns := make([]string, 6+len(taxIDs)) + columns[0] = "Date" + columns[1] = "Invoice Num." + columns[2] = "Customer" + columns[3] = "Status" + i := 4 + for _, taxID := range taxIDs { + columns[i] = taxColumns[taxID] + i++ + } + columns[i] = "Amount" + table, err := ods.WriteTable(invoices, columns, locale, func(sb *strings.Builder, invoice *IndexEntry) error { + ods.WriteCellDate(sb, invoice.Date) + if err := ods.WriteCellString(sb, invoice.Number); err != nil { + return err + } + if err := ods.WriteCellString(sb, invoice.CustomerName); err != nil { + return err + } + if err := ods.WriteCellString(sb, invoice.StatusLabel); err != nil { + return err + } + writeTaxes(sb, taxes[invoice.ID], taxIDs, company, locale) + ods.WriteCellFloat(sb, invoice.Total, company, locale) + return nil + }) + if err != nil { + panic(err) + } + return table +} + +func extractTaxIDs(taxColumns map[int]string) []int { + taxIDs := make([]int, len(taxColumns)) + i := 0 + for k := range taxColumns { + taxIDs[i] = k + i++ + } + sort.Ints(taxIDs[:]) + return taxIDs +} + +func writeTaxes(sb *strings.Builder, taxes taxMap, taxIDs []int, company *auth.Company, locale *locale.Locale) { + for _, taxID := range taxIDs { + var amount string + if taxes != nil { + amount = taxes[taxID] + } + ods.WriteCellFloat(sb, amount, company, locale) + } +} diff --git a/pkg/invoice/pdf.go b/pkg/invoice/pdf.go new file mode 100644 index 0000000..3bcffb9 --- /dev/null +++ b/pkg/invoice/pdf.go @@ -0,0 +1,75 @@ +package invoice + +import ( + "archive/zip" + "bytes" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + + "dev.tandem.ws/tandem/camper/pkg/auth" + "dev.tandem.ws/tandem/camper/pkg/database" + "dev.tandem.ws/tandem/camper/pkg/template" +) + +func mustWriteInvoicesPdf(r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, slugs []string) []byte { + buf := new(bytes.Buffer) + w := zip.NewWriter(buf) + for _, slug := range slugs { + inv := mustGetInvoice(r.Context(), conn, company, slug) + if inv == nil { + continue + } + f, err := w.Create(fmt.Sprintf("%s-%s.pdf", inv.Number, template.Slugify(inv.Invoicee.Name))) + if err != nil { + panic(err) + } + mustWriteInvoicePdf(f, r, user, company, inv) + } + mustClose(w) + return buf.Bytes() +} + +func mustWriteInvoicePdf(w io.Writer, r *http.Request, user *auth.User, company *auth.Company, inv *invoice) { + cmd := exec.Command("weasyprint", "--stylesheet", "web/static/invoice.css", "-", "-") + var stderr bytes.Buffer + cmd.Stderr = &stderr + stdin, err := cmd.StdinPipe() + if err != nil { + panic(err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + panic(err) + } + defer func() { + err := stdout.Close() + if !errors.Is(err, os.ErrClosed) { + panic(err) + } + }() + if err = cmd.Start(); err != nil { + panic(err) + } + go func() { + defer mustClose(stdin) + template.MustRenderAdmin(stdin, r, user, company, "invoice/view.gohtml", inv) + }() + if _, err = io.Copy(w, stdout); err != nil { + panic(err) + } + if err := cmd.Wait(); err != nil { + log.Printf("ERR - %v\n", stderr.String()) + panic(err) + } +} + +func mustClose(closer io.Closer) { + if err := closer.Close(); err != nil { + panic(err) + } +} diff --git a/pkg/booking/ods.go b/pkg/ods/ods.go similarity index 87% rename from pkg/booking/ods.go rename to pkg/ods/ods.go index a319279..0f45370 100644 --- a/pkg/booking/ods.go +++ b/pkg/ods/ods.go @@ -1,8 +1,10 @@ -package booking +package ods import ( "archive/zip" "bytes" + "dev.tandem.ws/tandem/camper/pkg/auth" + "dev.tandem.ws/tandem/camper/pkg/template" "encoding/xml" "fmt" "net/http" @@ -44,7 +46,7 @@ const ( ` ) -func writeTableOds[K interface{}](rows []*K, columns []string, locale *locale.Locale, writeRow func(*strings.Builder, *K) error) ([]byte, error) { +func WriteTable[K interface{}](rows []*K, columns []string, locale *locale.Locale, writeRow func(*strings.Builder, *K) error) ([]byte, error) { var sb strings.Builder sb.WriteString(` @@ -90,7 +92,7 @@ func writeTableOds[K interface{}](rows []*K, columns []string, locale *locale.Lo sb.WriteString(` `) for _, t := range columns { - if err := writeCellString(&sb, locale.GetC(t, "header")); err != nil { + if err := WriteCellString(&sb, locale.GetC(t, "header")); err != nil { return nil, err } } @@ -148,7 +150,7 @@ func writeOdsFile(ods *zip.Writer, name string, content string, method uint16) e return err } -func writeCellString(sb *strings.Builder, s string) error { +func WriteCellString(sb *strings.Builder, s string) error { sb.WriteString(` `) if err := xml.EscapeText(sb, []byte(s)); err != nil { return err @@ -157,11 +159,15 @@ func writeCellString(sb *strings.Builder, s string) error { return nil } -func writeCellDate(sb *strings.Builder, t time.Time) { +func WriteCellDate(sb *strings.Builder, t time.Time) { sb.WriteString(fmt.Sprintf(" %s\n", t.Format(database.ISODateFormat), t.Format("02/01/06"))) } -func mustWriteOdsResponse(w http.ResponseWriter, ods []byte, filename string) { +func WriteCellFloat(sb *strings.Builder, s string, company *auth.Company, locale *locale.Locale) { + sb.WriteString(fmt.Sprintf(" %s\n", s, template.FormatPrice(s, locale.Language, locale.CurrencyPattern, company.DecimalDigits, company.CurrencySymbol))) +} + +func MustWriteResponse(w http.ResponseWriter, ods []byte, filename string) { w.Header().Set("Content-Type", "application/vnd.oasis.opendocument.spreadsheet") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) w.WriteHeader(http.StatusOK) diff --git a/pkg/template/render.go b/pkg/template/render.go index f931e73..b8a0cdb 100644 --- a/pkg/template/render.go +++ b/pkg/template/render.go @@ -117,6 +117,9 @@ func mustRenderLayout(w io.Writer, user *auth.User, company *auth.Company, templ "formatDateAttr": func(time time.Time) string { return time.Format(database.ISODateFormat) }, + "formatPercent": func(value int) string { + return fmt.Sprintf("%d %%", value) + }, "today": func() string { return time.Now().Format(database.ISODateFormat) }, @@ -129,22 +132,35 @@ func mustRenderLayout(w io.Writer, user *auth.User, company *auth.Company, templ "dec": func(i int) int { return i - 1 }, + "add": func(y, x int) int { + return x + y + }, + "sub": func(y, x int) int { + return x - y + }, "int": func(v interface{}) int { switch v := v.(type) { case int: return v + case bool: + if v { + return 1 + } else { + return 0 + } case time.Weekday: return int(v) case time.Month: return int(v) default: - panic(fmt.Errorf("Could not convert to integer")) + panic(fmt.Errorf("could not convert to integer")) } }, "hexToDec": func(s string) int { num, _ := strconv.ParseInt(s, 16, 0) return int(num) }, + "slugify": Slugify, }) templates = append(templates, "form.gohtml") files := make([]string, len(templates)) diff --git a/pkg/template/slug.go b/pkg/template/slug.go new file mode 100644 index 0000000..f817203 --- /dev/null +++ b/pkg/template/slug.go @@ -0,0 +1,23 @@ +package template + +import ( + "regexp" + "strings" + + "github.com/rainycape/unidecode" +) + +var ( + nonValidChars = regexp.MustCompile("[^a-z0-9-_]") + multipleDashes = regexp.MustCompile("-+") +) + +func Slugify(s string) (slug string) { + slug = strings.TrimSpace(s) + slug = unidecode.Unidecode(slug) + slug = strings.ToLower(slug) + slug = nonValidChars.ReplaceAllString(slug, "-") + slug = multipleDashes.ReplaceAllString(slug, "-") + slug = strings.Trim(slug, "-_") + return slug +} diff --git a/po/ca.po b/po/ca.po index efc5064..efb76bb 100644 --- a/po/ca.po +++ b/po/ca.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: camper\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n" -"POT-Creation-Date: 2024-04-26 16:53+0200\n" +"POT-Creation-Date: 2024-04-28 20:05+0200\n" "PO-Revision-Date: 2024-02-06 10:04+0100\n" "Last-Translator: jordi fita mas \n" "Language-Team: Catalan \n" @@ -239,6 +239,7 @@ msgstr "Opcions del tipus d’allotjament" #: web/templates/mail/payment/details.gotxt:39 #: web/templates/public/booking/fields.gohtml:146 #: web/templates/admin/payment/details.gohtml:140 +#: web/templates/admin/customer/form.gohtml:28 #: web/templates/admin/booking/fields.gohtml:188 msgctxt "title" msgid "Customer Details" @@ -247,6 +248,7 @@ msgstr "Detalls del client" #: web/templates/mail/payment/details.gotxt:41 #: web/templates/public/booking/fields.gohtml:149 #: web/templates/admin/payment/details.gohtml:143 +#: web/templates/admin/customer/form.gohtml:31 #: web/templates/admin/booking/fields.gohtml:191 msgctxt "input" msgid "Full name" @@ -255,6 +257,7 @@ msgstr "Nom i cognoms" #: web/templates/mail/payment/details.gotxt:42 #: web/templates/public/booking/fields.gohtml:158 #: web/templates/admin/payment/details.gohtml:147 +#: web/templates/admin/customer/form.gohtml:69 #: web/templates/admin/taxDetails.gohtml:69 msgctxt "input" msgid "Address" @@ -263,6 +266,7 @@ msgstr "Adreça" #: web/templates/mail/payment/details.gotxt:43 #: web/templates/public/booking/fields.gohtml:167 #: web/templates/admin/payment/details.gohtml:151 +#: web/templates/admin/customer/form.gohtml:105 #: web/templates/admin/taxDetails.gohtml:93 msgctxt "input" msgid "Postcode" @@ -278,6 +282,7 @@ msgstr "Població" #: web/templates/mail/payment/details.gotxt:45 #: web/templates/public/booking/fields.gohtml:187 #: web/templates/admin/payment/details.gohtml:159 +#: web/templates/admin/customer/form.gohtml:117 #: web/templates/admin/taxDetails.gohtml:101 msgctxt "input" msgid "Country" @@ -411,11 +416,16 @@ msgid "Order Number" msgstr "Número de comanda" #: web/templates/public/payment/details.gohtml:8 +#: web/templates/admin/invoice/index.gohtml:103 +#: web/templates/admin/invoice/view.gohtml:26 msgctxt "title" msgid "Date" msgstr "Data" #: web/templates/public/payment/details.gohtml:12 +#: web/templates/admin/invoice/form.gohtml:119 +#: web/templates/admin/invoice/view.gohtml:63 +#: web/templates/admin/invoice/view.gohtml:103 msgctxt "title" msgid "Total" msgstr "Total" @@ -579,6 +589,11 @@ msgctxt "input" msgid "Year" msgstr "Any" +#: web/templates/public/form.gohtml:83 web/templates/admin/form.gohtml:83 +msgctxt "action" +msgid "Filters" +msgstr "Filtres" + #: web/templates/public/campsite/type.gohtml:49 #: web/templates/public/booking/fields.gohtml:278 msgctxt "action" @@ -929,7 +944,7 @@ msgstr "Menú" #: web/templates/admin/campsite/type/option/form.gohtml:16 #: web/templates/admin/campsite/type/option/index.gohtml:10 #: web/templates/admin/campsite/type/index.gohtml:10 -#: web/templates/admin/layout.gohtml:46 web/templates/admin/layout.gohtml:95 +#: web/templates/admin/layout.gohtml:46 web/templates/admin/layout.gohtml:101 #: web/templates/admin/booking/fields.gohtml:266 msgctxt "title" msgid "Campsites" @@ -1004,13 +1019,15 @@ msgid "Campground map" msgstr "Mapa del càmping" #: web/templates/public/booking/fields.gohtml:176 +#: web/templates/admin/customer/form.gohtml:81 msgctxt "input" msgid "Town or village" msgstr "Població" #: web/templates/public/booking/fields.gohtml:193 +#: web/templates/admin/customer/form.gohtml:121 #: web/templates/admin/booking/fields.gohtml:204 -#: web/templates/admin/booking/guest.gohtml:109 +#: web/templates/admin/booking/guest.gohtml:111 msgid "Choose a country" msgstr "Esculli un país" @@ -1164,6 +1181,7 @@ msgstr "Àlies" #: web/templates/admin/campsite/type/form.gohtml:51 #: web/templates/admin/campsite/type/option/form.gohtml:41 #: web/templates/admin/season/form.gohtml:50 +#: web/templates/admin/invoice/product-form.gohtml:16 #: web/templates/admin/services/form.gohtml:53 #: web/templates/admin/profile.gohtml:29 #: web/templates/admin/surroundings/form.gohtml:41 @@ -1188,6 +1206,8 @@ msgstr "Contingut" #: web/templates/admin/campsite/type/form.gohtml:287 #: web/templates/admin/campsite/type/option/form.gohtml:98 #: web/templates/admin/season/form.gohtml:73 +#: web/templates/admin/customer/form.gohtml:153 +#: web/templates/admin/invoice/form.gohtml:137 #: web/templates/admin/services/form.gohtml:81 #: web/templates/admin/surroundings/form.gohtml:69 #: web/templates/admin/surroundings/index.gohtml:58 @@ -1211,6 +1231,7 @@ msgstr "Actualitza" #: web/templates/admin/campsite/type/form.gohtml:289 #: web/templates/admin/campsite/type/option/form.gohtml:100 #: web/templates/admin/season/form.gohtml:75 +#: web/templates/admin/customer/form.gohtml:155 #: web/templates/admin/services/form.gohtml:83 #: web/templates/admin/surroundings/form.gohtml:71 #: web/templates/admin/amenity/feature/form.gohtml:67 @@ -1232,6 +1253,7 @@ msgstr "Afegeix text legal" #: web/templates/admin/campsite/type/option/index.gohtml:30 #: web/templates/admin/campsite/type/index.gohtml:29 #: web/templates/admin/season/index.gohtml:29 +#: web/templates/admin/customer/index.gohtml:19 #: web/templates/admin/user/index.gohtml:20 #: web/templates/admin/surroundings/index.gohtml:83 #: web/templates/admin/amenity/feature/index.gohtml:30 @@ -1725,6 +1747,7 @@ msgid "Per night" msgstr "Per nit" #: web/templates/admin/campsite/type/option/form.gohtml:84 +#: web/templates/admin/invoice/product-form.gohtml:29 msgctxt "input" msgid "Price" msgstr "Preu" @@ -1824,12 +1847,316 @@ msgctxt "action" msgid "Cancel" msgstr "Canceŀla" +#: web/templates/admin/customer/form.gohtml:8 +msgctxt "title" +msgid "Edit Customer" +msgstr "Edició del client" + +#: web/templates/admin/customer/form.gohtml:10 +msgctxt "title" +msgid "New Customer" +msgstr "Nou client" + +#: web/templates/admin/customer/form.gohtml:15 +#: web/templates/admin/invoice/index.gohtml:105 +msgctxt "title" +msgid "Customer" +msgstr "Client" + +#: web/templates/admin/customer/form.gohtml:44 +#: web/templates/admin/booking/guest.gohtml:8 +msgctxt "input" +msgid "ID document number" +msgstr "Número de document d’identitat" + +#: web/templates/admin/customer/form.gohtml:56 +#: web/templates/admin/booking/guest.gohtml:20 +msgctxt "input" +msgid "ID document type" +msgstr "Tipus de document" + +#: web/templates/admin/customer/form.gohtml:61 +#: web/templates/admin/booking/guest.gohtml:25 +msgid "Choose an ID document type" +msgstr "Esculli un tipus de document" + +#: web/templates/admin/customer/form.gohtml:93 +#: web/templates/admin/taxDetails.gohtml:85 +msgctxt "input" +msgid "Province" +msgstr "Província" + +#: web/templates/admin/customer/form.gohtml:129 +#: web/templates/admin/booking/fields.gohtml:239 +msgctxt "input" +msgid "Email (optional)" +msgstr "Correu-e (opcional)" + +#: web/templates/admin/customer/form.gohtml:140 +#: web/templates/admin/booking/fields.gohtml:248 +#: web/templates/admin/booking/guest.gohtml:119 +msgctxt "input" +msgid "Phone (optional)" +msgstr "Telèfon (opcional)" + +#: web/templates/admin/customer/index.gohtml:6 +#: web/templates/admin/layout.gohtml:95 +msgctxt "title" +msgid "Customers" +msgstr "Clients" + +#: web/templates/admin/customer/index.gohtml:14 +msgctxt "action" +msgid "Add Customer" +msgstr "Afegeix client" + +#: web/templates/admin/customer/index.gohtml:20 +#: web/templates/admin/user/login-attempts.gohtml:20 +#: web/templates/admin/user/index.gohtml:21 +msgctxt "header" +msgid "Email" +msgstr "Correu-e" + +#: web/templates/admin/customer/index.gohtml:21 +msgctxt "header" +msgid "Phone" +msgstr "Telèfon" + +#: web/templates/admin/customer/index.gohtml:33 +msgid "No customer found." +msgstr "No s’ha trobat cap client." + #: web/templates/admin/dashboard.gohtml:6 #: web/templates/admin/dashboard.gohtml:13 web/templates/admin/layout.gohtml:89 msgctxt "title" msgid "Dashboard" msgstr "Tauler" +#: web/templates/admin/invoice/product-form.gohtml:11 +#: web/templates/admin/booking/guest.gohtml:5 +msgctxt "action" +msgid "Remove" +msgstr "Esborra" + +#: web/templates/admin/invoice/product-form.gohtml:44 +msgctxt "input" +msgid "Quantity" +msgstr "Quantitat" + +#: web/templates/admin/invoice/product-form.gohtml:58 +msgctxt "input" +msgid "Discount (%)" +msgstr "Descompte (%)" + +#: web/templates/admin/invoice/product-form.gohtml:73 +msgctxt "input" +msgid "Taxes" +msgstr "Imposts" + +#: web/templates/admin/invoice/product-form.gohtml:79 +msgid "Select a TAX" +msgstr "Escolliu un impost" + +#: web/templates/admin/invoice/form.gohtml:4 +msgctxt "title" +msgid "Edit Invoice “%s”" +msgstr "Edició de la factura «%s»" + +#: web/templates/admin/invoice/form.gohtml:6 +msgctxt "title" +msgid "New Invoice" +msgstr "Nova factura" + +#: web/templates/admin/invoice/form.gohtml:15 +#: web/templates/admin/invoice/index.gohtml:2 +#: web/templates/admin/invoice/view.gohtml:6 +#: web/templates/admin/layout.gohtml:98 +msgctxt "title" +msgid "Invoices" +msgstr "Factures" + +#: web/templates/admin/invoice/form.gohtml:32 +msgid "Product “%s” removed" +msgstr "S’ha esborrat el producte «%s»" + +#: web/templates/admin/invoice/form.gohtml:36 +msgctxt "action" +msgid "Undo" +msgstr "Desfes" + +#: web/templates/admin/invoice/form.gohtml:51 +#: web/templates/admin/invoice/index.gohtml:39 +msgctxt "input" +msgid "Customer" +msgstr "Client" + +#: web/templates/admin/invoice/form.gohtml:56 +msgid "Select a customer" +msgstr "Esculliu un client" + +#: web/templates/admin/invoice/form.gohtml:64 +msgctxt "input" +msgid "Invoice date" +msgstr "Data de la factura" + +#: web/templates/admin/invoice/form.gohtml:77 +#: web/templates/admin/invoice/index.gohtml:51 +msgctxt "input" +msgid "Invoice status" +msgstr "Estat de la factura" + +#: web/templates/admin/invoice/form.gohtml:92 +msgctxt "input" +msgid "Notes (optional)" +msgstr "Notes (opcional)" + +#: web/templates/admin/invoice/form.gohtml:109 +#: web/templates/admin/invoice/view.gohtml:59 +msgctxt "title" +msgid "Subtotal" +msgstr "Subtotal" + +#: web/templates/admin/invoice/form.gohtml:133 +msgctxt "action" +msgid "Add products" +msgstr "Afegeix productes" + +#: web/templates/admin/invoice/form.gohtml:140 +msgctxt "action" +msgid "Save" +msgstr "Desa" + +#: web/templates/admin/invoice/index.gohtml:25 +msgctxt "action" +msgid "Download invoices" +msgstr "Descarrega factures" + +#: web/templates/admin/invoice/index.gohtml:28 +msgctxt "action" +msgid "Export list" +msgstr "Exporta llista" + +#: web/templates/admin/invoice/index.gohtml:43 +msgid "All customers" +msgstr "Tots els clients" + +#: web/templates/admin/invoice/index.gohtml:55 +msgid "All statuses" +msgstr "Tots els estats" + +#: web/templates/admin/invoice/index.gohtml:63 +msgctxt "input" +msgid "From date" +msgstr "De la data" + +#: web/templates/admin/invoice/index.gohtml:72 +msgctxt "input" +msgid "To date" +msgstr "A la data" + +#: web/templates/admin/invoice/index.gohtml:81 +msgctxt "input" +msgid "Invoice number" +msgstr "Número de factura" + +#: web/templates/admin/invoice/index.gohtml:91 +msgctxt "action" +msgid "Filter" +msgstr "Filtra" + +#: web/templates/admin/invoice/index.gohtml:94 +msgctxt "action" +msgid "Reset" +msgstr "Restableix" + +#: web/templates/admin/invoice/index.gohtml:97 +msgctxt "action" +msgid "Add invoice" +msgstr "Afegeix factura" + +#: web/templates/admin/invoice/index.gohtml:102 +msgctxt "invoice" +msgid "All" +msgstr "Totes" + +#: web/templates/admin/invoice/index.gohtml:104 +msgctxt "title" +msgid "Invoice Num." +msgstr "Núm. de factura" + +#: web/templates/admin/invoice/index.gohtml:106 +msgctxt "title" +msgid "Status" +msgstr "Estat" + +#: web/templates/admin/invoice/index.gohtml:107 +msgctxt "title" +msgid "Download" +msgstr "Descàrrega" + +#: web/templates/admin/invoice/index.gohtml:108 +msgctxt "title" +msgid "Amount" +msgstr "Import" + +#: web/templates/admin/invoice/index.gohtml:115 +msgctxt "action" +msgid "Select invoice %v" +msgstr "Selecciona la factura %v" + +#: web/templates/admin/invoice/index.gohtml:144 +msgctxt "action" +msgid "Download invoice %s" +msgstr "Descarrega la factura %s" + +#: web/templates/admin/invoice/index.gohtml:154 +msgid "No invoices added yet." +msgstr "No s’ha afegit cap factura encara." + +#: web/templates/admin/invoice/index.gohtml:161 +msgid "Total" +msgstr "Total" + +#: web/templates/admin/invoice/view.gohtml:2 +msgctxt "title" +msgid "Invoice %s" +msgstr "Factura %s" + +#: web/templates/admin/invoice/view.gohtml:15 +msgctxt "action" +msgid "Edit" +msgstr "Edita" + +#: web/templates/admin/invoice/view.gohtml:18 +msgctxt "action" +msgid "Download invoice" +msgstr "Descarrega factura" + +#: web/templates/admin/invoice/view.gohtml:53 +msgctxt "title" +msgid "Concept" +msgstr "Concepte" + +#: web/templates/admin/invoice/view.gohtml:54 +msgctxt "title" +msgid "Price" +msgstr "Preu" + +#: web/templates/admin/invoice/view.gohtml:56 +msgctxt "title" +msgid "Discount" +msgstr "Descompte" + +#: web/templates/admin/invoice/view.gohtml:58 +msgctxt "title" +msgid "Units" +msgstr "Unitats" + +#: web/templates/admin/invoice/view.gohtml:93 +msgctxt "title" +msgid "Tax Base" +msgstr "Base imposable" + #: web/templates/admin/login.gohtml:6 web/templates/admin/login.gohtml:18 msgctxt "title" msgid "Login" @@ -1928,12 +2255,6 @@ msgctxt "title" msgid "Users" msgstr "Usuaris" -#: web/templates/admin/user/login-attempts.gohtml:20 -#: web/templates/admin/user/index.gohtml:21 -msgctxt "header" -msgid "Email" -msgstr "Correu-e" - #: web/templates/admin/user/login-attempts.gohtml:21 msgctxt "header" msgid "IP Address" @@ -1985,11 +2306,6 @@ msgctxt "input" msgid "Trade Name" msgstr "Nom comercial" -#: web/templates/admin/taxDetails.gohtml:85 -msgctxt "input" -msgid "Province" -msgstr "Província" - #: web/templates/admin/taxDetails.gohtml:111 msgctxt "input" msgid "Currency" @@ -2214,11 +2530,11 @@ msgctxt "title" msgid "Bookings" msgstr "Reserves" -#: web/templates/admin/layout.gohtml:101 +#: web/templates/admin/layout.gohtml:107 msgid "Breadcrumb" msgstr "Fil d’Ariadna" -#: web/templates/admin/layout.gohtml:113 +#: web/templates/admin/layout.gohtml:119 msgid "Camper Version: %s" msgstr "Camper versió: %s" @@ -2340,7 +2656,7 @@ msgid "Country (optional)" msgstr "País (opcional)" #: web/templates/admin/booking/fields.gohtml:212 -#: web/templates/admin/booking/guest.gohtml:128 +#: web/templates/admin/booking/guest.gohtml:130 msgctxt "input" msgid "Address (optional)" msgstr "Adreça (opcional)" @@ -2355,17 +2671,6 @@ msgctxt "input" msgid "Town or village (optional)" msgstr "Població (opcional)" -#: web/templates/admin/booking/fields.gohtml:239 -msgctxt "input" -msgid "Email (optional)" -msgstr "Correu-e (opcional)" - -#: web/templates/admin/booking/fields.gohtml:248 -#: web/templates/admin/booking/guest.gohtml:117 -msgctxt "input" -msgid "Phone (optional)" -msgstr "Telèfon (opcional)" - #: web/templates/admin/booking/form.gohtml:8 msgctxt "title" msgid "Edit Booking" @@ -2430,60 +2735,41 @@ msgstr "Nom del titular" msgid "No booking found." msgstr "No s’ha trobat cap reserva." -#: web/templates/admin/booking/guest.gohtml:5 -msgctxt "action" -msgid "Remove" -msgstr "Esborra" - -#: web/templates/admin/booking/guest.gohtml:8 -msgctxt "input" -msgid "ID document number" -msgstr "Número de document d’identitat" - -#: web/templates/admin/booking/guest.gohtml:20 -msgctxt "input" -msgid "ID document type" -msgstr "Tipus de document" - -#: web/templates/admin/booking/guest.gohtml:25 -msgid "Choose an ID document type" -msgstr "Esculli un tipus de document" - #: web/templates/admin/booking/guest.gohtml:33 msgctxt "input" msgid "ID document issue date (if any)" msgstr "Data d’expedició (si hi consta)" -#: web/templates/admin/booking/guest.gohtml:44 +#: web/templates/admin/booking/guest.gohtml:45 msgctxt "input" msgid "First surname" msgstr "Primer cognom" -#: web/templates/admin/booking/guest.gohtml:56 +#: web/templates/admin/booking/guest.gohtml:57 msgctxt "input" msgid "Second surname (if has one)" msgstr "Segon cognom (si en té)" -#: web/templates/admin/booking/guest.gohtml:67 +#: web/templates/admin/booking/guest.gohtml:68 msgctxt "input" msgid "Given name" msgstr "Nom" -#: web/templates/admin/booking/guest.gohtml:79 +#: web/templates/admin/booking/guest.gohtml:80 msgctxt "input" msgid "Sex" msgstr "Sexe" -#: web/templates/admin/booking/guest.gohtml:84 +#: web/templates/admin/booking/guest.gohtml:85 msgid "Choose a sex" msgstr "Esculli un sexe" -#: web/templates/admin/booking/guest.gohtml:92 +#: web/templates/admin/booking/guest.gohtml:93 msgctxt "input" msgid "Birthdate" msgstr "Data de naixement" -#: web/templates/admin/booking/guest.gohtml:104 +#: web/templates/admin/booking/guest.gohtml:106 msgctxt "input" msgid "Nationality" msgstr "Nacionalitat" @@ -2553,8 +2839,9 @@ msgstr "Rebut amb èxit el pagament de la reserva" #: pkg/legal/admin.go:258 pkg/app/user.go:249 pkg/campsite/types/option.go:365 #: pkg/campsite/types/feature.go:272 pkg/campsite/types/admin.go:577 #: pkg/campsite/feature.go:269 pkg/season/admin.go:411 -#: pkg/services/admin.go:316 pkg/surroundings/admin.go:340 -#: pkg/amenity/feature.go:269 pkg/amenity/admin.go:283 +#: pkg/invoice/admin.go:1092 pkg/services/admin.go:316 +#: pkg/surroundings/admin.go:340 pkg/amenity/feature.go:269 +#: pkg/amenity/admin.go:283 msgid "Name can not be empty." msgstr "No podeu deixar el nom en blanc." @@ -2589,12 +2876,12 @@ msgid "Slide image must be an image media type." msgstr "La imatge de la diapositiva ha de ser un mèdia de tipus imatge." #: pkg/app/login.go:56 pkg/app/user.go:246 pkg/company/admin.go:224 -#: pkg/booking/public.go:596 +#: pkg/booking/public.go:592 msgid "Email can not be empty." msgstr "No podeu deixar el correu-e en blanc." -#: pkg/app/login.go:57 pkg/app/user.go:247 pkg/company/admin.go:225 -#: pkg/booking/admin.go:437 pkg/booking/public.go:597 +#: pkg/app/login.go:57 pkg/app/user.go:247 pkg/customer/admin.go:312 +#: pkg/company/admin.go:225 pkg/booking/admin.go:438 pkg/booking/public.go:593 msgid "This email is not valid. It should be like name@domain.com." msgstr "Aquest correu-e no és vàlid. Hauria de ser similar a nom@domini.com." @@ -2623,7 +2910,7 @@ msgstr "L’idioma escollit no és vàlid." msgid "File must be a valid PNG or JPEG image." msgstr "El fitxer has de ser una imatge PNG o JPEG vàlida." -#: pkg/app/admin.go:73 +#: pkg/app/admin.go:79 msgid "Access forbidden" msgstr "Accés prohibit" @@ -2651,15 +2938,15 @@ msgstr "El valor del màxim ha de ser un número enter." msgid "Maximum must be equal or greater than minimum." msgstr "El valor del màxim ha de ser igual o superir al del mínim." -#: pkg/campsite/types/option.go:382 +#: pkg/campsite/types/option.go:382 pkg/invoice/admin.go:1093 msgid "Price can not be empty." msgstr "No podeu deixar el preu en blanc." -#: pkg/campsite/types/option.go:383 +#: pkg/campsite/types/option.go:383 pkg/invoice/admin.go:1094 msgid "Price must be a decimal number." msgstr "El preu ha de ser un número decimal." -#: pkg/campsite/types/option.go:384 +#: pkg/campsite/types/option.go:384 pkg/invoice/admin.go:1095 msgid "Price must be zero or greater." msgstr "El preu ha de ser com a mínim zero." @@ -2805,7 +3092,7 @@ msgctxt "header" msgid "Children (aged 2 to 10)" msgstr "Mainada (entre 2 i 10 anys)" -#: pkg/campsite/admin.go:280 pkg/booking/admin.go:413 pkg/booking/public.go:177 +#: pkg/campsite/admin.go:280 pkg/booking/admin.go:414 pkg/booking/public.go:177 #: pkg/booking/public.go:232 msgid "Selected campsite type is not valid." msgstr "El tipus d’allotjament escollit no és vàlid." @@ -2843,6 +3130,136 @@ msgstr "No podeu deixar la data de fi en blanc." msgid "End date must be a valid date." msgstr "La data de fi ha de ser una data vàlida." +#: pkg/customer/admin.go:293 pkg/company/admin.go:207 +#: pkg/booking/checkin.go:297 pkg/booking/public.go:577 +msgid "Selected country is not valid." +msgstr "El país escollit no és vàlid." + +#: pkg/customer/admin.go:297 pkg/booking/checkin.go:281 +msgid "Selected ID document type is not valid." +msgstr "El tipus de document d’identitat escollit no és vàlid." + +#: pkg/customer/admin.go:298 pkg/booking/checkin.go:282 +msgid "ID document number can not be empty." +msgstr "No podeu deixar el número document d’identitat en blanc." + +#: pkg/customer/admin.go:300 pkg/booking/checkin.go:288 +#: pkg/booking/checkin.go:289 pkg/booking/admin.go:426 +#: pkg/booking/public.go:581 +msgid "Full name can not be empty." +msgstr "No podeu deixar el nom i els cognoms en blanc." + +#: pkg/customer/admin.go:301 pkg/booking/admin.go:427 pkg/booking/public.go:582 +msgid "Full name must have at least one letter." +msgstr "El nom i els cognoms han de tenir com a mínim una lletra." + +#: pkg/customer/admin.go:304 pkg/company/admin.go:230 pkg/booking/public.go:585 +msgid "Address can not be empty." +msgstr "No podeu deixar l’adreça en blanc." + +#: pkg/customer/admin.go:305 pkg/booking/public.go:586 +msgid "Town or village can not be empty." +msgstr "No podeu deixar la població en blanc." + +#: pkg/customer/admin.go:306 pkg/company/admin.go:233 pkg/booking/public.go:587 +msgid "Postcode can not be empty." +msgstr "No podeu deixar el codi postal en blanc." + +#: pkg/customer/admin.go:307 pkg/company/admin.go:234 pkg/booking/admin.go:433 +#: pkg/booking/public.go:588 +msgid "This postcode is not valid." +msgstr "Aquest codi postal no és vàlid." + +#: pkg/customer/admin.go:315 pkg/company/admin.go:220 +#: pkg/booking/checkin.go:301 pkg/booking/admin.go:443 +#: pkg/booking/public.go:596 +msgid "This phone number is not valid." +msgstr "Aquest número de telèfon no és vàlid." + +#: pkg/invoice/admin.go:649 +msgctxt "filename" +msgid "invoices.zip" +msgstr "factures.zip" + +#: pkg/invoice/admin.go:664 +msgctxt "filename" +msgid "invoices.ods" +msgstr "factures.ods" + +#: pkg/invoice/admin.go:666 pkg/invoice/admin.go:1285 pkg/invoice/admin.go:1292 +msgid "Invalid action" +msgstr "Acció invàlida" + +#: pkg/invoice/admin.go:830 +msgid "Selected invoice status is not valid." +msgstr "L’estat de factura escollit no és vàlid." + +#: pkg/invoice/admin.go:831 +msgid "Selected customer is not valid." +msgstr "El client escollit no és vàlid." + +#: pkg/invoice/admin.go:832 +msgid "Invoice date can not be empty." +msgstr "No podeu deixar la data de factura en blanc." + +#: pkg/invoice/admin.go:833 +msgid "Invoice date must be a valid date." +msgstr "La data de factura ha de ser una data vàlida." + +#: pkg/invoice/admin.go:980 +#, c-format +msgid "Re: quotation #%s of %s" +msgstr "" + +#: pkg/invoice/admin.go:981 +msgctxt "to_char" +msgid "MM/DD/YYYY" +msgstr "DD/MM/YYYY" + +#: pkg/invoice/admin.go:1083 +msgid "Invoice product ID must be an integer." +msgstr "L’ID de producte de factura ha de ser enter." + +#: pkg/invoice/admin.go:1084 +msgid "Invoice product ID one or greater." +msgstr "L’ID de producte de factura ha de ser com a mínim u." + +#: pkg/invoice/admin.go:1088 +msgid "Product ID must be an integer." +msgstr "L’ID de producte ha de ser un número enter." + +#: pkg/invoice/admin.go:1089 +msgid "Product ID must zero or greater." +msgstr "L’ID de producte ha de ser com a mínim zero." + +#: pkg/invoice/admin.go:1098 +msgid "Quantity can not be empty." +msgstr "No podeu deixar la quantitat en blanc." + +#: pkg/invoice/admin.go:1099 +msgid "Quantity must be an integer." +msgstr "La quantitat ha de ser un número enter." + +#: pkg/invoice/admin.go:1100 +msgid "Quantity must one or greater." +msgstr "La quantitat ha de ser com a mínim u." + +#: pkg/invoice/admin.go:1103 +msgid "Discount can not be empty." +msgstr "No podeu deixar el descompte en blanc." + +#: pkg/invoice/admin.go:1104 +msgid "Discount must be an integer." +msgstr "El descompte ha de ser un número enter." + +#: pkg/invoice/admin.go:1105 pkg/invoice/admin.go:1106 +msgid "Discount must be a percentage between 0 and 100." +msgstr "El descompte ha de ser un percentatge entre 0 i 100" + +#: pkg/invoice/admin.go:1110 +msgid "Selected tax is not valid." +msgstr "L’impost escollit no és vàlid." + #: pkg/user/admin.go:18 msgctxt "role" msgid "guest" @@ -2902,11 +3319,6 @@ msgstr "No podeu deixar l’adreça de l’enllaç en blanc." msgid "This web address is not valid. It should be like https://domain.com/." msgstr "Aquesta adreça web no és vàlida. Hauria de ser similar a https://domini.com/." -#: pkg/company/admin.go:207 pkg/booking/checkin.go:301 -#: pkg/booking/public.go:581 -msgid "Selected country is not valid." -msgstr "El país escollit no és vàlid." - #: pkg/company/admin.go:211 msgid "Business name can not be empty." msgstr "No podeu deixar el nom d’empresa en blanc." @@ -2923,19 +3335,10 @@ msgstr "No podeu deixar el NIF en blanc." msgid "This VAT number is not valid." msgstr "Aquest NIF no és vàlid." -#: pkg/company/admin.go:219 pkg/booking/public.go:599 +#: pkg/company/admin.go:219 pkg/booking/public.go:595 msgid "Phone can not be empty." msgstr "No podeu deixar el telèfon en blanc." -#: pkg/company/admin.go:220 pkg/booking/checkin.go:305 pkg/booking/admin.go:442 -#: pkg/booking/public.go:600 -msgid "This phone number is not valid." -msgstr "Aquest número de telèfon no és vàlid." - -#: pkg/company/admin.go:230 pkg/booking/public.go:589 -msgid "Address can not be empty." -msgstr "No podeu deixar l’adreça en blanc." - #: pkg/company/admin.go:231 msgid "City can not be empty." msgstr "No podeu deixar la població en blanc." @@ -2944,14 +3347,6 @@ msgstr "No podeu deixar la població en blanc." msgid "Province can not be empty." msgstr "No podeu deixar la província en blanc." -#: pkg/company/admin.go:233 pkg/booking/public.go:591 -msgid "Postcode can not be empty." -msgstr "No podeu deixar el codi postal en blanc." - -#: pkg/company/admin.go:234 pkg/booking/admin.go:432 pkg/booking/public.go:592 -msgid "This postcode is not valid." -msgstr "Aquest codi postal no és vàlid." - #: pkg/company/admin.go:238 msgid "RTC number can not be empty." msgstr "No podeu deixar el número d’RTC en blanc." @@ -3000,40 +3395,27 @@ msgstr "No podeu deixar el fitxer del mèdia en blanc." msgid "Filename can not be empty." msgstr "No podeu deixar el nom del fitxer en blanc." -#: pkg/booking/checkin.go:285 -msgid "Selected ID document type is not valid." -msgstr "El tipus de document d’identitat escollit no és vàlid." - -#: pkg/booking/checkin.go:286 -msgid "ID document number can not be empty." -msgstr "No podeu deixar el número document d’identitat en blanc." - -#: pkg/booking/checkin.go:288 +#: pkg/booking/checkin.go:284 msgid "ID document issue date must be a valid date." msgstr "La data d’expedició del document d’identitat ha de ser una data vàlida." -#: pkg/booking/checkin.go:289 +#: pkg/booking/checkin.go:285 msgid "ID document issue date must be in the past." msgstr "La data d’expedició del document d’identitat ha de ser al passat." -#: pkg/booking/checkin.go:292 pkg/booking/checkin.go:293 -#: pkg/booking/admin.go:425 pkg/booking/public.go:585 -msgid "Full name can not be empty." -msgstr "No podeu deixar el nom i els cognoms en blanc." - -#: pkg/booking/checkin.go:294 +#: pkg/booking/checkin.go:290 msgid "Selected sex is not valid." msgstr "El sexe escollit no és vàlid." -#: pkg/booking/checkin.go:295 +#: pkg/booking/checkin.go:291 msgid "Birthdate can not be empty" msgstr "No podeu deixar la data de naixement en blanc." -#: pkg/booking/checkin.go:296 +#: pkg/booking/checkin.go:292 msgid "Birthdate must be a valid date." msgstr "La data de naixement ha de ser una data vàlida." -#: pkg/booking/checkin.go:297 +#: pkg/booking/checkin.go:293 msgid "Birthdate must be in the past." msgstr "La data de naixement ha de ser al passat." @@ -3057,28 +3439,24 @@ msgctxt "cart" msgid "Dog" msgstr "Gos" -#: pkg/booking/admin.go:217 +#: pkg/booking/admin.go:218 msgctxt "filename" msgid "bookings.ods" msgstr "reserves.ods" -#: pkg/booking/admin.go:426 pkg/booking/public.go:586 -msgid "Full name must have at least one letter." -msgstr "El nom i els cognoms han de tenir com a mínim una lletra." - -#: pkg/booking/admin.go:431 +#: pkg/booking/admin.go:432 msgid "Country can not be empty to validate the postcode." msgstr "No podeu deixar el país en blanc per validar el codi postal." -#: pkg/booking/admin.go:441 +#: pkg/booking/admin.go:442 msgid "Country can not be empty to validate the phone." msgstr "No podeu deixar el país en blanc per validar el telèfon." -#: pkg/booking/admin.go:448 +#: pkg/booking/admin.go:449 msgid "You must select at least one accommodation." msgstr "Heu d’escollir com a mínim un allotjament." -#: pkg/booking/admin.go:454 +#: pkg/booking/admin.go:455 msgid "The selected accommodations have no available openings in the requested dates." msgstr "Els allotjaments escollits no estan disponibles a les dates demanades." @@ -3191,11 +3569,7 @@ msgstr "El valor de %s ha de ser com a mínim %d." msgid "%s must be at most %d." msgstr "El valor de %s ha de ser com a màxim %d." -#: pkg/booking/public.go:590 -msgid "Town or village can not be empty." -msgstr "No podeu deixar la població en blanc." - -#: pkg/booking/public.go:605 +#: pkg/booking/public.go:601 msgid "It is mandatory to agree to the reservation conditions." msgstr "És obligatori acceptar les condicions de reserves." diff --git a/po/es.po b/po/es.po index abb8377..949db21 100644 --- a/po/es.po +++ b/po/es.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: camper\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n" -"POT-Creation-Date: 2024-04-26 16:53+0200\n" +"POT-Creation-Date: 2024-04-28 20:05+0200\n" "PO-Revision-Date: 2024-02-06 10:04+0100\n" "Last-Translator: jordi fita mas \n" "Language-Team: Spanish \n" @@ -239,6 +239,7 @@ msgstr "Opciones del tipo de alojamiento" #: web/templates/mail/payment/details.gotxt:39 #: web/templates/public/booking/fields.gohtml:146 #: web/templates/admin/payment/details.gohtml:140 +#: web/templates/admin/customer/form.gohtml:28 #: web/templates/admin/booking/fields.gohtml:188 msgctxt "title" msgid "Customer Details" @@ -247,6 +248,7 @@ msgstr "Detalles del cliente" #: web/templates/mail/payment/details.gotxt:41 #: web/templates/public/booking/fields.gohtml:149 #: web/templates/admin/payment/details.gohtml:143 +#: web/templates/admin/customer/form.gohtml:31 #: web/templates/admin/booking/fields.gohtml:191 msgctxt "input" msgid "Full name" @@ -255,6 +257,7 @@ msgstr "Nombre y apellidos" #: web/templates/mail/payment/details.gotxt:42 #: web/templates/public/booking/fields.gohtml:158 #: web/templates/admin/payment/details.gohtml:147 +#: web/templates/admin/customer/form.gohtml:69 #: web/templates/admin/taxDetails.gohtml:69 msgctxt "input" msgid "Address" @@ -263,6 +266,7 @@ msgstr "Dirección" #: web/templates/mail/payment/details.gotxt:43 #: web/templates/public/booking/fields.gohtml:167 #: web/templates/admin/payment/details.gohtml:151 +#: web/templates/admin/customer/form.gohtml:105 #: web/templates/admin/taxDetails.gohtml:93 msgctxt "input" msgid "Postcode" @@ -278,6 +282,7 @@ msgstr "Población" #: web/templates/mail/payment/details.gotxt:45 #: web/templates/public/booking/fields.gohtml:187 #: web/templates/admin/payment/details.gohtml:159 +#: web/templates/admin/customer/form.gohtml:117 #: web/templates/admin/taxDetails.gohtml:101 msgctxt "input" msgid "Country" @@ -411,11 +416,16 @@ msgid "Order Number" msgstr "Número de pedido" #: web/templates/public/payment/details.gohtml:8 +#: web/templates/admin/invoice/index.gohtml:103 +#: web/templates/admin/invoice/view.gohtml:26 msgctxt "title" msgid "Date" msgstr "Fecha" #: web/templates/public/payment/details.gohtml:12 +#: web/templates/admin/invoice/form.gohtml:119 +#: web/templates/admin/invoice/view.gohtml:63 +#: web/templates/admin/invoice/view.gohtml:103 msgctxt "title" msgid "Total" msgstr "Total" @@ -579,6 +589,11 @@ msgctxt "input" msgid "Year" msgstr "Año" +#: web/templates/public/form.gohtml:83 web/templates/admin/form.gohtml:83 +msgctxt "action" +msgid "Filters" +msgstr "Filtros" + #: web/templates/public/campsite/type.gohtml:49 #: web/templates/public/booking/fields.gohtml:278 msgctxt "action" @@ -929,7 +944,7 @@ msgstr "Menú" #: web/templates/admin/campsite/type/option/form.gohtml:16 #: web/templates/admin/campsite/type/option/index.gohtml:10 #: web/templates/admin/campsite/type/index.gohtml:10 -#: web/templates/admin/layout.gohtml:46 web/templates/admin/layout.gohtml:95 +#: web/templates/admin/layout.gohtml:46 web/templates/admin/layout.gohtml:101 #: web/templates/admin/booking/fields.gohtml:266 msgctxt "title" msgid "Campsites" @@ -1004,13 +1019,15 @@ msgid "Campground map" msgstr "Mapa del camping" #: web/templates/public/booking/fields.gohtml:176 +#: web/templates/admin/customer/form.gohtml:81 msgctxt "input" msgid "Town or village" msgstr "Población" #: web/templates/public/booking/fields.gohtml:193 +#: web/templates/admin/customer/form.gohtml:121 #: web/templates/admin/booking/fields.gohtml:204 -#: web/templates/admin/booking/guest.gohtml:109 +#: web/templates/admin/booking/guest.gohtml:111 msgid "Choose a country" msgstr "Escoja un país" @@ -1164,6 +1181,7 @@ msgstr "Álias" #: web/templates/admin/campsite/type/form.gohtml:51 #: web/templates/admin/campsite/type/option/form.gohtml:41 #: web/templates/admin/season/form.gohtml:50 +#: web/templates/admin/invoice/product-form.gohtml:16 #: web/templates/admin/services/form.gohtml:53 #: web/templates/admin/profile.gohtml:29 #: web/templates/admin/surroundings/form.gohtml:41 @@ -1188,6 +1206,8 @@ msgstr "Contenido" #: web/templates/admin/campsite/type/form.gohtml:287 #: web/templates/admin/campsite/type/option/form.gohtml:98 #: web/templates/admin/season/form.gohtml:73 +#: web/templates/admin/customer/form.gohtml:153 +#: web/templates/admin/invoice/form.gohtml:137 #: web/templates/admin/services/form.gohtml:81 #: web/templates/admin/surroundings/form.gohtml:69 #: web/templates/admin/surroundings/index.gohtml:58 @@ -1211,6 +1231,7 @@ msgstr "Actualizar" #: web/templates/admin/campsite/type/form.gohtml:289 #: web/templates/admin/campsite/type/option/form.gohtml:100 #: web/templates/admin/season/form.gohtml:75 +#: web/templates/admin/customer/form.gohtml:155 #: web/templates/admin/services/form.gohtml:83 #: web/templates/admin/surroundings/form.gohtml:71 #: web/templates/admin/amenity/feature/form.gohtml:67 @@ -1232,6 +1253,7 @@ msgstr "Añadir texto legal" #: web/templates/admin/campsite/type/option/index.gohtml:30 #: web/templates/admin/campsite/type/index.gohtml:29 #: web/templates/admin/season/index.gohtml:29 +#: web/templates/admin/customer/index.gohtml:19 #: web/templates/admin/user/index.gohtml:20 #: web/templates/admin/surroundings/index.gohtml:83 #: web/templates/admin/amenity/feature/index.gohtml:30 @@ -1725,6 +1747,7 @@ msgid "Per night" msgstr "Por noche" #: web/templates/admin/campsite/type/option/form.gohtml:84 +#: web/templates/admin/invoice/product-form.gohtml:29 msgctxt "input" msgid "Price" msgstr "Precio" @@ -1824,12 +1847,316 @@ msgctxt "action" msgid "Cancel" msgstr "Cancelar" +#: web/templates/admin/customer/form.gohtml:8 +msgctxt "title" +msgid "Edit Customer" +msgstr "Edición del cliente" + +#: web/templates/admin/customer/form.gohtml:10 +msgctxt "title" +msgid "New Customer" +msgstr "Nuevo cliente" + +#: web/templates/admin/customer/form.gohtml:15 +#: web/templates/admin/invoice/index.gohtml:105 +msgctxt "title" +msgid "Customer" +msgstr "Cliente" + +#: web/templates/admin/customer/form.gohtml:44 +#: web/templates/admin/booking/guest.gohtml:8 +msgctxt "input" +msgid "ID document number" +msgstr "Número de documento de identidad" + +#: web/templates/admin/customer/form.gohtml:56 +#: web/templates/admin/booking/guest.gohtml:20 +msgctxt "input" +msgid "ID document type" +msgstr "Tipo de documento" + +#: web/templates/admin/customer/form.gohtml:61 +#: web/templates/admin/booking/guest.gohtml:25 +msgid "Choose an ID document type" +msgstr "Escoja un tipo de documento" + +#: web/templates/admin/customer/form.gohtml:93 +#: web/templates/admin/taxDetails.gohtml:85 +msgctxt "input" +msgid "Province" +msgstr "Provincia" + +#: web/templates/admin/customer/form.gohtml:129 +#: web/templates/admin/booking/fields.gohtml:239 +msgctxt "input" +msgid "Email (optional)" +msgstr "Correo-e (opcional)" + +#: web/templates/admin/customer/form.gohtml:140 +#: web/templates/admin/booking/fields.gohtml:248 +#: web/templates/admin/booking/guest.gohtml:119 +msgctxt "input" +msgid "Phone (optional)" +msgstr "Teléfono (opcional)" + +#: web/templates/admin/customer/index.gohtml:6 +#: web/templates/admin/layout.gohtml:95 +msgctxt "title" +msgid "Customers" +msgstr "Clientes" + +#: web/templates/admin/customer/index.gohtml:14 +msgctxt "action" +msgid "Add Customer" +msgstr "Añadir cliente" + +#: web/templates/admin/customer/index.gohtml:20 +#: web/templates/admin/user/login-attempts.gohtml:20 +#: web/templates/admin/user/index.gohtml:21 +msgctxt "header" +msgid "Email" +msgstr "Correo-e" + +#: web/templates/admin/customer/index.gohtml:21 +msgctxt "header" +msgid "Phone" +msgstr "Teléfono" + +#: web/templates/admin/customer/index.gohtml:33 +msgid "No customer found." +msgstr "No se ha encontrado ningún cliente." + #: web/templates/admin/dashboard.gohtml:6 #: web/templates/admin/dashboard.gohtml:13 web/templates/admin/layout.gohtml:89 msgctxt "title" msgid "Dashboard" msgstr "Panel" +#: web/templates/admin/invoice/product-form.gohtml:11 +#: web/templates/admin/booking/guest.gohtml:5 +msgctxt "action" +msgid "Remove" +msgstr "Borrar" + +#: web/templates/admin/invoice/product-form.gohtml:44 +msgctxt "input" +msgid "Quantity" +msgstr "Cantidad" + +#: web/templates/admin/invoice/product-form.gohtml:58 +msgctxt "input" +msgid "Discount (%)" +msgstr "Descuento (%)" + +#: web/templates/admin/invoice/product-form.gohtml:73 +msgctxt "input" +msgid "Taxes" +msgstr "Impuestos" + +#: web/templates/admin/invoice/product-form.gohtml:79 +msgid "Select a TAX" +msgstr "Escoja un impuesto" + +#: web/templates/admin/invoice/form.gohtml:4 +msgctxt "title" +msgid "Edit Invoice “%s”" +msgstr "Edición de la factura «%s»" + +#: web/templates/admin/invoice/form.gohtml:6 +msgctxt "title" +msgid "New Invoice" +msgstr "Nueva factura" + +#: web/templates/admin/invoice/form.gohtml:15 +#: web/templates/admin/invoice/index.gohtml:2 +#: web/templates/admin/invoice/view.gohtml:6 +#: web/templates/admin/layout.gohtml:98 +msgctxt "title" +msgid "Invoices" +msgstr "Facturas" + +#: web/templates/admin/invoice/form.gohtml:32 +msgid "Product “%s” removed" +msgstr "Se ha borrado el producto «%s»" + +#: web/templates/admin/invoice/form.gohtml:36 +msgctxt "action" +msgid "Undo" +msgstr "Deshacer" + +#: web/templates/admin/invoice/form.gohtml:51 +#: web/templates/admin/invoice/index.gohtml:39 +msgctxt "input" +msgid "Customer" +msgstr "Cliente" + +#: web/templates/admin/invoice/form.gohtml:56 +msgid "Select a customer" +msgstr "Escoja un cliente" + +#: web/templates/admin/invoice/form.gohtml:64 +msgctxt "input" +msgid "Invoice date" +msgstr "Fecha de la factura" + +#: web/templates/admin/invoice/form.gohtml:77 +#: web/templates/admin/invoice/index.gohtml:51 +msgctxt "input" +msgid "Invoice status" +msgstr "Estado de factura" + +#: web/templates/admin/invoice/form.gohtml:92 +msgctxt "input" +msgid "Notes (optional)" +msgstr "Notas (opcional)" + +#: web/templates/admin/invoice/form.gohtml:109 +#: web/templates/admin/invoice/view.gohtml:59 +msgctxt "title" +msgid "Subtotal" +msgstr "Subtotal" + +#: web/templates/admin/invoice/form.gohtml:133 +msgctxt "action" +msgid "Add products" +msgstr "Añadir productos" + +#: web/templates/admin/invoice/form.gohtml:140 +msgctxt "action" +msgid "Save" +msgstr "Guardar" + +#: web/templates/admin/invoice/index.gohtml:25 +msgctxt "action" +msgid "Download invoices" +msgstr "Descargar facturas" + +#: web/templates/admin/invoice/index.gohtml:28 +msgctxt "action" +msgid "Export list" +msgstr "Exportar lista" + +#: web/templates/admin/invoice/index.gohtml:43 +msgid "All customers" +msgstr "Todos los clientes" + +#: web/templates/admin/invoice/index.gohtml:55 +msgid "All statuses" +msgstr "Todos los estados" + +#: web/templates/admin/invoice/index.gohtml:63 +msgctxt "input" +msgid "From date" +msgstr "De la fecha" + +#: web/templates/admin/invoice/index.gohtml:72 +msgctxt "input" +msgid "To date" +msgstr "A la fecha" + +#: web/templates/admin/invoice/index.gohtml:81 +msgctxt "input" +msgid "Invoice number" +msgstr "Número de factura" + +#: web/templates/admin/invoice/index.gohtml:91 +msgctxt "action" +msgid "Filter" +msgstr "Filtrar" + +#: web/templates/admin/invoice/index.gohtml:94 +msgctxt "action" +msgid "Reset" +msgstr "Restablecer" + +#: web/templates/admin/invoice/index.gohtml:97 +msgctxt "action" +msgid "Add invoice" +msgstr "Añadir factura" + +#: web/templates/admin/invoice/index.gohtml:102 +msgctxt "invoice" +msgid "All" +msgstr "Todas" + +#: web/templates/admin/invoice/index.gohtml:104 +msgctxt "title" +msgid "Invoice Num." +msgstr "Núm. de factura" + +#: web/templates/admin/invoice/index.gohtml:106 +msgctxt "title" +msgid "Status" +msgstr "Estado" + +#: web/templates/admin/invoice/index.gohtml:107 +msgctxt "title" +msgid "Download" +msgstr "Descarga" + +#: web/templates/admin/invoice/index.gohtml:108 +msgctxt "title" +msgid "Amount" +msgstr "Importe" + +#: web/templates/admin/invoice/index.gohtml:115 +msgctxt "action" +msgid "Select invoice %v" +msgstr "Seleccionar factura %v" + +#: web/templates/admin/invoice/index.gohtml:144 +msgctxt "action" +msgid "Download invoice %s" +msgstr "Descargar factura %s" + +#: web/templates/admin/invoice/index.gohtml:154 +msgid "No invoices added yet." +msgstr "No se ha añadido ninguna factura todavía." + +#: web/templates/admin/invoice/index.gohtml:161 +msgid "Total" +msgstr "Total" + +#: web/templates/admin/invoice/view.gohtml:2 +msgctxt "title" +msgid "Invoice %s" +msgstr "Factura %s" + +#: web/templates/admin/invoice/view.gohtml:15 +msgctxt "action" +msgid "Edit" +msgstr "Editar" + +#: web/templates/admin/invoice/view.gohtml:18 +msgctxt "action" +msgid "Download invoice" +msgstr "Descargar factura" + +#: web/templates/admin/invoice/view.gohtml:53 +msgctxt "title" +msgid "Concept" +msgstr "Concepto" + +#: web/templates/admin/invoice/view.gohtml:54 +msgctxt "title" +msgid "Price" +msgstr "Precio" + +#: web/templates/admin/invoice/view.gohtml:56 +msgctxt "title" +msgid "Discount" +msgstr "Descuento" + +#: web/templates/admin/invoice/view.gohtml:58 +msgctxt "title" +msgid "Units" +msgstr "Unidades" + +#: web/templates/admin/invoice/view.gohtml:93 +msgctxt "title" +msgid "Tax Base" +msgstr "Base imponible" + #: web/templates/admin/login.gohtml:6 web/templates/admin/login.gohtml:18 msgctxt "title" msgid "Login" @@ -1928,12 +2255,6 @@ msgctxt "title" msgid "Users" msgstr "Usuarios" -#: web/templates/admin/user/login-attempts.gohtml:20 -#: web/templates/admin/user/index.gohtml:21 -msgctxt "header" -msgid "Email" -msgstr "Correo-e" - #: web/templates/admin/user/login-attempts.gohtml:21 msgctxt "header" msgid "IP Address" @@ -1985,11 +2306,6 @@ msgctxt "input" msgid "Trade Name" msgstr "Nombre comercial" -#: web/templates/admin/taxDetails.gohtml:85 -msgctxt "input" -msgid "Province" -msgstr "Provincia" - #: web/templates/admin/taxDetails.gohtml:111 msgctxt "input" msgid "Currency" @@ -2214,11 +2530,11 @@ msgctxt "title" msgid "Bookings" msgstr "Reservas" -#: web/templates/admin/layout.gohtml:101 +#: web/templates/admin/layout.gohtml:107 msgid "Breadcrumb" msgstr "Migas de pan" -#: web/templates/admin/layout.gohtml:113 +#: web/templates/admin/layout.gohtml:119 msgid "Camper Version: %s" msgstr "Camper versión: %s" @@ -2340,7 +2656,7 @@ msgid "Country (optional)" msgstr "País (opcional)" #: web/templates/admin/booking/fields.gohtml:212 -#: web/templates/admin/booking/guest.gohtml:128 +#: web/templates/admin/booking/guest.gohtml:130 msgctxt "input" msgid "Address (optional)" msgstr "Dirección (opcional)" @@ -2355,17 +2671,6 @@ msgctxt "input" msgid "Town or village (optional)" msgstr "Población (opcional)" -#: web/templates/admin/booking/fields.gohtml:239 -msgctxt "input" -msgid "Email (optional)" -msgstr "Correo-e (opcional)" - -#: web/templates/admin/booking/fields.gohtml:248 -#: web/templates/admin/booking/guest.gohtml:117 -msgctxt "input" -msgid "Phone (optional)" -msgstr "Teléfono (opcional)" - #: web/templates/admin/booking/form.gohtml:8 msgctxt "title" msgid "Edit Booking" @@ -2430,60 +2735,41 @@ msgstr "Nombre del titular" msgid "No booking found." msgstr "No se ha encontrado ninguna reserva." -#: web/templates/admin/booking/guest.gohtml:5 -msgctxt "action" -msgid "Remove" -msgstr "Borrar" - -#: web/templates/admin/booking/guest.gohtml:8 -msgctxt "input" -msgid "ID document number" -msgstr "Número de documento de identidad" - -#: web/templates/admin/booking/guest.gohtml:20 -msgctxt "input" -msgid "ID document type" -msgstr "Tipo de documento" - -#: web/templates/admin/booking/guest.gohtml:25 -msgid "Choose an ID document type" -msgstr "Escoja un tipo de documento" - #: web/templates/admin/booking/guest.gohtml:33 msgctxt "input" msgid "ID document issue date (if any)" msgstr "Fecha expedición del documento (si hay)" -#: web/templates/admin/booking/guest.gohtml:44 +#: web/templates/admin/booking/guest.gohtml:45 msgctxt "input" msgid "First surname" msgstr "Primer apellido" -#: web/templates/admin/booking/guest.gohtml:56 +#: web/templates/admin/booking/guest.gohtml:57 msgctxt "input" msgid "Second surname (if has one)" msgstr "Segundo apellido (si tiene)" -#: web/templates/admin/booking/guest.gohtml:67 +#: web/templates/admin/booking/guest.gohtml:68 msgctxt "input" msgid "Given name" msgstr "Nombre" -#: web/templates/admin/booking/guest.gohtml:79 +#: web/templates/admin/booking/guest.gohtml:80 msgctxt "input" msgid "Sex" msgstr "Sexo" -#: web/templates/admin/booking/guest.gohtml:84 +#: web/templates/admin/booking/guest.gohtml:85 msgid "Choose a sex" msgstr "Escoja un sexo" -#: web/templates/admin/booking/guest.gohtml:92 +#: web/templates/admin/booking/guest.gohtml:93 msgctxt "input" msgid "Birthdate" msgstr "Fecha de nacimiento" -#: web/templates/admin/booking/guest.gohtml:104 +#: web/templates/admin/booking/guest.gohtml:106 msgctxt "input" msgid "Nationality" msgstr "Nacionalidad" @@ -2553,8 +2839,9 @@ msgstr "Se ha recibido correctamente el pago de la reserva" #: pkg/legal/admin.go:258 pkg/app/user.go:249 pkg/campsite/types/option.go:365 #: pkg/campsite/types/feature.go:272 pkg/campsite/types/admin.go:577 #: pkg/campsite/feature.go:269 pkg/season/admin.go:411 -#: pkg/services/admin.go:316 pkg/surroundings/admin.go:340 -#: pkg/amenity/feature.go:269 pkg/amenity/admin.go:283 +#: pkg/invoice/admin.go:1092 pkg/services/admin.go:316 +#: pkg/surroundings/admin.go:340 pkg/amenity/feature.go:269 +#: pkg/amenity/admin.go:283 msgid "Name can not be empty." msgstr "No podéis dejar el nombre en blanco." @@ -2589,12 +2876,12 @@ msgid "Slide image must be an image media type." msgstr "La imagen de la diapositiva tiene que ser un medio de tipo imagen." #: pkg/app/login.go:56 pkg/app/user.go:246 pkg/company/admin.go:224 -#: pkg/booking/public.go:596 +#: pkg/booking/public.go:592 msgid "Email can not be empty." msgstr "No podéis dejar el correo-e en blanco." -#: pkg/app/login.go:57 pkg/app/user.go:247 pkg/company/admin.go:225 -#: pkg/booking/admin.go:437 pkg/booking/public.go:597 +#: pkg/app/login.go:57 pkg/app/user.go:247 pkg/customer/admin.go:312 +#: pkg/company/admin.go:225 pkg/booking/admin.go:438 pkg/booking/public.go:593 msgid "This email is not valid. It should be like name@domain.com." msgstr "Este correo-e no es válido. Tiene que ser parecido a nombre@dominio.com." @@ -2623,7 +2910,7 @@ msgstr "El idioma escogido no es válido." msgid "File must be a valid PNG or JPEG image." msgstr "El archivo tiene que ser una imagen PNG o JPEG válida." -#: pkg/app/admin.go:73 +#: pkg/app/admin.go:79 msgid "Access forbidden" msgstr "Acceso prohibido" @@ -2651,15 +2938,15 @@ msgstr "El valor del máximo tiene que ser un número entero." msgid "Maximum must be equal or greater than minimum." msgstr "El valor del máximo tiene que ser igual o mayor al del mínimo." -#: pkg/campsite/types/option.go:382 +#: pkg/campsite/types/option.go:382 pkg/invoice/admin.go:1093 msgid "Price can not be empty." msgstr "No podéis dejar el precio en blanco." -#: pkg/campsite/types/option.go:383 +#: pkg/campsite/types/option.go:383 pkg/invoice/admin.go:1094 msgid "Price must be a decimal number." msgstr "El precio tiene que ser un número decimal." -#: pkg/campsite/types/option.go:384 +#: pkg/campsite/types/option.go:384 pkg/invoice/admin.go:1095 msgid "Price must be zero or greater." msgstr "El precio tiene que ser como mínimo cero." @@ -2805,7 +3092,7 @@ msgctxt "header" msgid "Children (aged 2 to 10)" msgstr "Niños (de 2 a 10 años)" -#: pkg/campsite/admin.go:280 pkg/booking/admin.go:413 pkg/booking/public.go:177 +#: pkg/campsite/admin.go:280 pkg/booking/admin.go:414 pkg/booking/public.go:177 #: pkg/booking/public.go:232 msgid "Selected campsite type is not valid." msgstr "El tipo de alojamiento escogido no es válido." @@ -2843,6 +3130,136 @@ msgstr "No podéis dejar la fecha final en blanco." msgid "End date must be a valid date." msgstr "La fecha final tiene que ser una fecha válida." +#: pkg/customer/admin.go:293 pkg/company/admin.go:207 +#: pkg/booking/checkin.go:297 pkg/booking/public.go:577 +msgid "Selected country is not valid." +msgstr "El país escogido no es válido." + +#: pkg/customer/admin.go:297 pkg/booking/checkin.go:281 +msgid "Selected ID document type is not valid." +msgstr "El tipo de documento de identidad escogido no es válido." + +#: pkg/customer/admin.go:298 pkg/booking/checkin.go:282 +msgid "ID document number can not be empty." +msgstr "No podéis dejar el número del documento de identidad en blanco." + +#: pkg/customer/admin.go:300 pkg/booking/checkin.go:288 +#: pkg/booking/checkin.go:289 pkg/booking/admin.go:426 +#: pkg/booking/public.go:581 +msgid "Full name can not be empty." +msgstr "No podéis dejar el nombre y los apellidos en blanco." + +#: pkg/customer/admin.go:301 pkg/booking/admin.go:427 pkg/booking/public.go:582 +msgid "Full name must have at least one letter." +msgstr "El nombre y los apellidos tienen que tener como mínimo una letra." + +#: pkg/customer/admin.go:304 pkg/company/admin.go:230 pkg/booking/public.go:585 +msgid "Address can not be empty." +msgstr "No podéis dejar la dirección en blanco." + +#: pkg/customer/admin.go:305 pkg/booking/public.go:586 +msgid "Town or village can not be empty." +msgstr "No podéis dejar la población en blanco." + +#: pkg/customer/admin.go:306 pkg/company/admin.go:233 pkg/booking/public.go:587 +msgid "Postcode can not be empty." +msgstr "No podéis dejar el código postal en blanco." + +#: pkg/customer/admin.go:307 pkg/company/admin.go:234 pkg/booking/admin.go:433 +#: pkg/booking/public.go:588 +msgid "This postcode is not valid." +msgstr "Este código postal no es válido." + +#: pkg/customer/admin.go:315 pkg/company/admin.go:220 +#: pkg/booking/checkin.go:301 pkg/booking/admin.go:443 +#: pkg/booking/public.go:596 +msgid "This phone number is not valid." +msgstr "Este teléfono no es válido." + +#: pkg/invoice/admin.go:649 +msgctxt "filename" +msgid "invoices.zip" +msgstr "facturas.zip" + +#: pkg/invoice/admin.go:664 +msgctxt "filename" +msgid "invoices.ods" +msgstr "facturas.ods" + +#: pkg/invoice/admin.go:666 pkg/invoice/admin.go:1285 pkg/invoice/admin.go:1292 +msgid "Invalid action" +msgstr "Acción inválida" + +#: pkg/invoice/admin.go:830 +msgid "Selected invoice status is not valid." +msgstr "El estado de factura escogida no es válido." + +#: pkg/invoice/admin.go:831 +msgid "Selected customer is not valid." +msgstr "El cliente escogido no es válido." + +#: pkg/invoice/admin.go:832 +msgid "Invoice date can not be empty." +msgstr "No podéis dejar la fecha de factura en blanco." + +#: pkg/invoice/admin.go:833 +msgid "Invoice date must be a valid date." +msgstr "La fecha de factura tiene que ser una fecha válida." + +#: pkg/invoice/admin.go:980 +#, c-format +msgid "Re: quotation #%s of %s" +msgstr "" + +#: pkg/invoice/admin.go:981 +msgctxt "to_char" +msgid "MM/DD/YYYY" +msgstr "DD/MM/YYYY" + +#: pkg/invoice/admin.go:1083 +msgid "Invoice product ID must be an integer." +msgstr "El ID de producto de factura tiene que ser entero." + +#: pkg/invoice/admin.go:1084 +msgid "Invoice product ID one or greater." +msgstr "El ID de producto de factura tiene que ser como mínimo uno." + +#: pkg/invoice/admin.go:1088 +msgid "Product ID must be an integer." +msgstr "El ID de producto tiene que ser un número entero." + +#: pkg/invoice/admin.go:1089 +msgid "Product ID must zero or greater." +msgstr "El ID de producto tiene que ser como mínimo cero." + +#: pkg/invoice/admin.go:1098 +msgid "Quantity can not be empty." +msgstr "No podéis dejar la cantidad en blanco." + +#: pkg/invoice/admin.go:1099 +msgid "Quantity must be an integer." +msgstr "La cantidad tiene que ser un número entero." + +#: pkg/invoice/admin.go:1100 +msgid "Quantity must one or greater." +msgstr "La cantidad tiene que ser como mínimo uno." + +#: pkg/invoice/admin.go:1103 +msgid "Discount can not be empty." +msgstr "No podéis dejar el descuento en blanco." + +#: pkg/invoice/admin.go:1104 +msgid "Discount must be an integer." +msgstr "El descuento tiene que ser un número entero." + +#: pkg/invoice/admin.go:1105 pkg/invoice/admin.go:1106 +msgid "Discount must be a percentage between 0 and 100." +msgstr "El descuento tiene que ser un porcentaje entre 1 y 100." + +#: pkg/invoice/admin.go:1110 +msgid "Selected tax is not valid." +msgstr "El impuesto escogido no es válido." + #: pkg/user/admin.go:18 msgctxt "role" msgid "guest" @@ -2902,11 +3319,6 @@ msgstr "No podéis dejar la dirección del enlace en blanco." msgid "This web address is not valid. It should be like https://domain.com/." msgstr "Esta dirección web no es válida. Tiene que ser parecido a https://dominio.com/." -#: pkg/company/admin.go:207 pkg/booking/checkin.go:301 -#: pkg/booking/public.go:581 -msgid "Selected country is not valid." -msgstr "El país escogido no es válido." - #: pkg/company/admin.go:211 msgid "Business name can not be empty." msgstr "No podéis dejar el nombre de empresa en blanco." @@ -2923,19 +3335,10 @@ msgstr "No podéis dejar el NIF en blanco." msgid "This VAT number is not valid." msgstr "Este NIF no es válido." -#: pkg/company/admin.go:219 pkg/booking/public.go:599 +#: pkg/company/admin.go:219 pkg/booking/public.go:595 msgid "Phone can not be empty." msgstr "No podéis dejar el teléfono en blanco." -#: pkg/company/admin.go:220 pkg/booking/checkin.go:305 pkg/booking/admin.go:442 -#: pkg/booking/public.go:600 -msgid "This phone number is not valid." -msgstr "Este teléfono no es válido." - -#: pkg/company/admin.go:230 pkg/booking/public.go:589 -msgid "Address can not be empty." -msgstr "No podéis dejar la dirección en blanco." - #: pkg/company/admin.go:231 msgid "City can not be empty." msgstr "No podéis dejar la población en blanco." @@ -2944,14 +3347,6 @@ msgstr "No podéis dejar la población en blanco." msgid "Province can not be empty." msgstr "No podéis dejar la provincia en blanco." -#: pkg/company/admin.go:233 pkg/booking/public.go:591 -msgid "Postcode can not be empty." -msgstr "No podéis dejar el código postal en blanco." - -#: pkg/company/admin.go:234 pkg/booking/admin.go:432 pkg/booking/public.go:592 -msgid "This postcode is not valid." -msgstr "Este código postal no es válido." - #: pkg/company/admin.go:238 msgid "RTC number can not be empty." msgstr "No podéis dejar el número RTC en blanco." @@ -3000,40 +3395,27 @@ msgstr "No podéis dejar el archivo del medio en blanco." msgid "Filename can not be empty." msgstr "No podéis dejar el nombre del archivo en blanco." -#: pkg/booking/checkin.go:285 -msgid "Selected ID document type is not valid." -msgstr "El tipo de documento de identidad escogido no es válido." - -#: pkg/booking/checkin.go:286 -msgid "ID document number can not be empty." -msgstr "No podéis dejar el número del documento de identidad en blanco." - -#: pkg/booking/checkin.go:288 +#: pkg/booking/checkin.go:284 msgid "ID document issue date must be a valid date." msgstr "La fecha de expedición del documento de identidad tiene que ser una fecha válida." -#: pkg/booking/checkin.go:289 +#: pkg/booking/checkin.go:285 msgid "ID document issue date must be in the past." msgstr "La fecha de expedición del documento de identidad tiene que ser del pasado." -#: pkg/booking/checkin.go:292 pkg/booking/checkin.go:293 -#: pkg/booking/admin.go:425 pkg/booking/public.go:585 -msgid "Full name can not be empty." -msgstr "No podéis dejar el nombre y los apellidos en blanco." - -#: pkg/booking/checkin.go:294 +#: pkg/booking/checkin.go:290 msgid "Selected sex is not valid." msgstr "El sexo escogido no es válido." -#: pkg/booking/checkin.go:295 +#: pkg/booking/checkin.go:291 msgid "Birthdate can not be empty" msgstr "No podéis dejar la fecha de nacimiento en blanco." -#: pkg/booking/checkin.go:296 +#: pkg/booking/checkin.go:292 msgid "Birthdate must be a valid date." msgstr "La fecha de nacimiento tiene que ser una fecha válida." -#: pkg/booking/checkin.go:297 +#: pkg/booking/checkin.go:293 msgid "Birthdate must be in the past." msgstr "La fecha de nacimiento tiene que ser del pasado." @@ -3057,28 +3439,24 @@ msgctxt "cart" msgid "Dog" msgstr "Perro" -#: pkg/booking/admin.go:217 +#: pkg/booking/admin.go:218 msgctxt "filename" msgid "bookings.ods" msgstr "reservas.ods" -#: pkg/booking/admin.go:426 pkg/booking/public.go:586 -msgid "Full name must have at least one letter." -msgstr "El nombre y los apellidos tienen que tener como mínimo una letra." - -#: pkg/booking/admin.go:431 +#: pkg/booking/admin.go:432 msgid "Country can not be empty to validate the postcode." msgstr "No podéis dejar el país en blanco para validar el código postal." -#: pkg/booking/admin.go:441 +#: pkg/booking/admin.go:442 msgid "Country can not be empty to validate the phone." msgstr "No podéis dejar el país en blanco para validar el teléfono." -#: pkg/booking/admin.go:448 +#: pkg/booking/admin.go:449 msgid "You must select at least one accommodation." msgstr "Tenéis que seleccionar como mínimo un alojamiento." -#: pkg/booking/admin.go:454 +#: pkg/booking/admin.go:455 msgid "The selected accommodations have no available openings in the requested dates." msgstr "Los alojamientos seleccionados no tienen disponibilidad en las fechas pedidas." @@ -3191,11 +3569,7 @@ msgstr "%s tiene que ser como mínimo %d." msgid "%s must be at most %d." msgstr "%s tiene que ser como máximo %d" -#: pkg/booking/public.go:590 -msgid "Town or village can not be empty." -msgstr "No podéis dejar la población en blanco." - -#: pkg/booking/public.go:605 +#: pkg/booking/public.go:601 msgid "It is mandatory to agree to the reservation conditions." msgstr "Es obligatorio aceptar las condiciones de reserva." diff --git a/po/fr.po b/po/fr.po index aae8f01..e5c1a79 100644 --- a/po/fr.po +++ b/po/fr.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: camper\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n" -"POT-Creation-Date: 2024-04-26 16:53+0200\n" +"POT-Creation-Date: 2024-04-28 20:05+0200\n" "PO-Revision-Date: 2024-02-06 10:05+0100\n" "Last-Translator: Oriol Carbonell \n" "Language-Team: French \n" @@ -239,6 +239,7 @@ msgstr "Options de type d’emplacement de camping" #: web/templates/mail/payment/details.gotxt:39 #: web/templates/public/booking/fields.gohtml:146 #: web/templates/admin/payment/details.gohtml:140 +#: web/templates/admin/customer/form.gohtml:28 #: web/templates/admin/booking/fields.gohtml:188 msgctxt "title" msgid "Customer Details" @@ -247,6 +248,7 @@ msgstr "Détails du client" #: web/templates/mail/payment/details.gotxt:41 #: web/templates/public/booking/fields.gohtml:149 #: web/templates/admin/payment/details.gohtml:143 +#: web/templates/admin/customer/form.gohtml:31 #: web/templates/admin/booking/fields.gohtml:191 msgctxt "input" msgid "Full name" @@ -255,6 +257,7 @@ msgstr "Nom et prénom" #: web/templates/mail/payment/details.gotxt:42 #: web/templates/public/booking/fields.gohtml:158 #: web/templates/admin/payment/details.gohtml:147 +#: web/templates/admin/customer/form.gohtml:69 #: web/templates/admin/taxDetails.gohtml:69 msgctxt "input" msgid "Address" @@ -263,6 +266,7 @@ msgstr "Adresse" #: web/templates/mail/payment/details.gotxt:43 #: web/templates/public/booking/fields.gohtml:167 #: web/templates/admin/payment/details.gohtml:151 +#: web/templates/admin/customer/form.gohtml:105 #: web/templates/admin/taxDetails.gohtml:93 msgctxt "input" msgid "Postcode" @@ -278,6 +282,7 @@ msgstr "Ville" #: web/templates/mail/payment/details.gotxt:45 #: web/templates/public/booking/fields.gohtml:187 #: web/templates/admin/payment/details.gohtml:159 +#: web/templates/admin/customer/form.gohtml:117 #: web/templates/admin/taxDetails.gohtml:101 msgctxt "input" msgid "Country" @@ -411,11 +416,16 @@ msgid "Order Number" msgstr "Numéro de commande" #: web/templates/public/payment/details.gohtml:8 +#: web/templates/admin/invoice/index.gohtml:103 +#: web/templates/admin/invoice/view.gohtml:26 msgctxt "title" msgid "Date" msgstr "Date" #: web/templates/public/payment/details.gohtml:12 +#: web/templates/admin/invoice/form.gohtml:119 +#: web/templates/admin/invoice/view.gohtml:63 +#: web/templates/admin/invoice/view.gohtml:103 msgctxt "title" msgid "Total" msgstr "Totale" @@ -579,6 +589,11 @@ msgctxt "input" msgid "Year" msgstr "Année" +#: web/templates/public/form.gohtml:83 web/templates/admin/form.gohtml:83 +msgctxt "action" +msgid "Filters" +msgstr "Filtres" + #: web/templates/public/campsite/type.gohtml:49 #: web/templates/public/booking/fields.gohtml:278 msgctxt "action" @@ -929,7 +944,7 @@ msgstr "Menu" #: web/templates/admin/campsite/type/option/form.gohtml:16 #: web/templates/admin/campsite/type/option/index.gohtml:10 #: web/templates/admin/campsite/type/index.gohtml:10 -#: web/templates/admin/layout.gohtml:46 web/templates/admin/layout.gohtml:95 +#: web/templates/admin/layout.gohtml:46 web/templates/admin/layout.gohtml:101 #: web/templates/admin/booking/fields.gohtml:266 msgctxt "title" msgid "Campsites" @@ -1004,13 +1019,15 @@ msgid "Campground map" msgstr "Plan du camping" #: web/templates/public/booking/fields.gohtml:176 +#: web/templates/admin/customer/form.gohtml:81 msgctxt "input" msgid "Town or village" msgstr "Ville" #: web/templates/public/booking/fields.gohtml:193 +#: web/templates/admin/customer/form.gohtml:121 #: web/templates/admin/booking/fields.gohtml:204 -#: web/templates/admin/booking/guest.gohtml:109 +#: web/templates/admin/booking/guest.gohtml:111 msgid "Choose a country" msgstr "Choisissez un pays" @@ -1164,6 +1181,7 @@ msgstr "Slug" #: web/templates/admin/campsite/type/form.gohtml:51 #: web/templates/admin/campsite/type/option/form.gohtml:41 #: web/templates/admin/season/form.gohtml:50 +#: web/templates/admin/invoice/product-form.gohtml:16 #: web/templates/admin/services/form.gohtml:53 #: web/templates/admin/profile.gohtml:29 #: web/templates/admin/surroundings/form.gohtml:41 @@ -1188,6 +1206,8 @@ msgstr "Contenu" #: web/templates/admin/campsite/type/form.gohtml:287 #: web/templates/admin/campsite/type/option/form.gohtml:98 #: web/templates/admin/season/form.gohtml:73 +#: web/templates/admin/customer/form.gohtml:153 +#: web/templates/admin/invoice/form.gohtml:137 #: web/templates/admin/services/form.gohtml:81 #: web/templates/admin/surroundings/form.gohtml:69 #: web/templates/admin/surroundings/index.gohtml:58 @@ -1211,6 +1231,7 @@ msgstr "Mettre à jour" #: web/templates/admin/campsite/type/form.gohtml:289 #: web/templates/admin/campsite/type/option/form.gohtml:100 #: web/templates/admin/season/form.gohtml:75 +#: web/templates/admin/customer/form.gohtml:155 #: web/templates/admin/services/form.gohtml:83 #: web/templates/admin/surroundings/form.gohtml:71 #: web/templates/admin/amenity/feature/form.gohtml:67 @@ -1232,6 +1253,7 @@ msgstr "Ajouter un texte juridique" #: web/templates/admin/campsite/type/option/index.gohtml:30 #: web/templates/admin/campsite/type/index.gohtml:29 #: web/templates/admin/season/index.gohtml:29 +#: web/templates/admin/customer/index.gohtml:19 #: web/templates/admin/user/index.gohtml:20 #: web/templates/admin/surroundings/index.gohtml:83 #: web/templates/admin/amenity/feature/index.gohtml:30 @@ -1725,6 +1747,7 @@ msgid "Per night" msgstr "Par nuit" #: web/templates/admin/campsite/type/option/form.gohtml:84 +#: web/templates/admin/invoice/product-form.gohtml:29 msgctxt "input" msgid "Price" msgstr "Prix" @@ -1824,12 +1847,316 @@ msgctxt "action" msgid "Cancel" msgstr "Annuler" +#: web/templates/admin/customer/form.gohtml:8 +msgctxt "title" +msgid "Edit Customer" +msgstr "Modifier le client" + +#: web/templates/admin/customer/form.gohtml:10 +msgctxt "title" +msgid "New Customer" +msgstr "Nouveau client" + +#: web/templates/admin/customer/form.gohtml:15 +#: web/templates/admin/invoice/index.gohtml:105 +msgctxt "title" +msgid "Customer" +msgstr "Client" + +#: web/templates/admin/customer/form.gohtml:44 +#: web/templates/admin/booking/guest.gohtml:8 +msgctxt "input" +msgid "ID document number" +msgstr "Numéro de document d’identité" + +#: web/templates/admin/customer/form.gohtml:56 +#: web/templates/admin/booking/guest.gohtml:20 +msgctxt "input" +msgid "ID document type" +msgstr "Type de document d’identité" + +#: web/templates/admin/customer/form.gohtml:61 +#: web/templates/admin/booking/guest.gohtml:25 +msgid "Choose an ID document type" +msgstr "Choisissez un type de document d’identité" + +#: web/templates/admin/customer/form.gohtml:93 +#: web/templates/admin/taxDetails.gohtml:85 +msgctxt "input" +msgid "Province" +msgstr "Province" + +#: web/templates/admin/customer/form.gohtml:129 +#: web/templates/admin/booking/fields.gohtml:239 +msgctxt "input" +msgid "Email (optional)" +msgstr "E-mail (facultatif)" + +#: web/templates/admin/customer/form.gohtml:140 +#: web/templates/admin/booking/fields.gohtml:248 +#: web/templates/admin/booking/guest.gohtml:119 +msgctxt "input" +msgid "Phone (optional)" +msgstr "Téléphone (facultatif)" + +#: web/templates/admin/customer/index.gohtml:6 +#: web/templates/admin/layout.gohtml:95 +msgctxt "title" +msgid "Customers" +msgstr "Clients" + +#: web/templates/admin/customer/index.gohtml:14 +msgctxt "action" +msgid "Add Customer" +msgstr "Ajouter un client" + +#: web/templates/admin/customer/index.gohtml:20 +#: web/templates/admin/user/login-attempts.gohtml:20 +#: web/templates/admin/user/index.gohtml:21 +msgctxt "header" +msgid "Email" +msgstr "E-mail" + +#: web/templates/admin/customer/index.gohtml:21 +msgctxt "header" +msgid "Phone" +msgstr "Téléphone" + +#: web/templates/admin/customer/index.gohtml:33 +msgid "No customer found." +msgstr "Aucun client trouvée." + #: web/templates/admin/dashboard.gohtml:6 #: web/templates/admin/dashboard.gohtml:13 web/templates/admin/layout.gohtml:89 msgctxt "title" msgid "Dashboard" msgstr "Tableau de bord" +#: web/templates/admin/invoice/product-form.gohtml:11 +#: web/templates/admin/booking/guest.gohtml:5 +msgctxt "action" +msgid "Remove" +msgstr "Retirer" + +#: web/templates/admin/invoice/product-form.gohtml:44 +msgctxt "input" +msgid "Quantity" +msgstr "Quantité" + +#: web/templates/admin/invoice/product-form.gohtml:58 +msgctxt "input" +msgid "Discount (%)" +msgstr "Rabais (%)" + +#: web/templates/admin/invoice/product-form.gohtml:73 +msgctxt "input" +msgid "Taxes" +msgstr "Taxes" + +#: web/templates/admin/invoice/product-form.gohtml:79 +msgid "Select a TAX" +msgstr "Choisissez une taxe" + +#: web/templates/admin/invoice/form.gohtml:4 +msgctxt "title" +msgid "Edit Invoice “%s”" +msgstr "Modifier la facture «%s»" + +#: web/templates/admin/invoice/form.gohtml:6 +msgctxt "title" +msgid "New Invoice" +msgstr "Nouvelle facture" + +#: web/templates/admin/invoice/form.gohtml:15 +#: web/templates/admin/invoice/index.gohtml:2 +#: web/templates/admin/invoice/view.gohtml:6 +#: web/templates/admin/layout.gohtml:98 +msgctxt "title" +msgid "Invoices" +msgstr "Factures" + +#: web/templates/admin/invoice/form.gohtml:32 +msgid "Product “%s” removed" +msgstr "Produit «%s» supprimé" + +#: web/templates/admin/invoice/form.gohtml:36 +msgctxt "action" +msgid "Undo" +msgstr "Annuler" + +#: web/templates/admin/invoice/form.gohtml:51 +#: web/templates/admin/invoice/index.gohtml:39 +msgctxt "input" +msgid "Customer" +msgstr "Client" + +#: web/templates/admin/invoice/form.gohtml:56 +msgid "Select a customer" +msgstr "Choisissez un client" + +#: web/templates/admin/invoice/form.gohtml:64 +msgctxt "input" +msgid "Invoice date" +msgstr "Date de facture" + +#: web/templates/admin/invoice/form.gohtml:77 +#: web/templates/admin/invoice/index.gohtml:51 +msgctxt "input" +msgid "Invoice status" +msgstr "Statut de la facture" + +#: web/templates/admin/invoice/form.gohtml:92 +msgctxt "input" +msgid "Notes (optional)" +msgstr "Remarques (facultatif)" + +#: web/templates/admin/invoice/form.gohtml:109 +#: web/templates/admin/invoice/view.gohtml:59 +msgctxt "title" +msgid "Subtotal" +msgstr "Sous-totale" + +#: web/templates/admin/invoice/form.gohtml:133 +msgctxt "action" +msgid "Add products" +msgstr "Ajouter des produits" + +#: web/templates/admin/invoice/form.gohtml:140 +msgctxt "action" +msgid "Save" +msgstr "Enregistrer" + +#: web/templates/admin/invoice/index.gohtml:25 +msgctxt "action" +msgid "Download invoices" +msgstr "Télécharger les factures" + +#: web/templates/admin/invoice/index.gohtml:28 +msgctxt "action" +msgid "Export list" +msgstr "Exporter la liste" + +#: web/templates/admin/invoice/index.gohtml:43 +msgid "All customers" +msgstr "Tous les clients" + +#: web/templates/admin/invoice/index.gohtml:55 +msgid "All statuses" +msgstr "Tous les statuts" + +#: web/templates/admin/invoice/index.gohtml:63 +msgctxt "input" +msgid "From date" +msgstr "Partir de la date" + +#: web/templates/admin/invoice/index.gohtml:72 +msgctxt "input" +msgid "To date" +msgstr "À ce jour" + +#: web/templates/admin/invoice/index.gohtml:81 +msgctxt "input" +msgid "Invoice number" +msgstr "Numéro de facture" + +#: web/templates/admin/invoice/index.gohtml:91 +msgctxt "action" +msgid "Filter" +msgstr "Filtrer" + +#: web/templates/admin/invoice/index.gohtml:94 +msgctxt "action" +msgid "Reset" +msgstr "Réinitialiser" + +#: web/templates/admin/invoice/index.gohtml:97 +msgctxt "action" +msgid "Add invoice" +msgstr "Nouvelle facture" + +#: web/templates/admin/invoice/index.gohtml:102 +msgctxt "invoice" +msgid "All" +msgstr "Toutes" + +#: web/templates/admin/invoice/index.gohtml:104 +msgctxt "title" +msgid "Invoice Num." +msgstr "Num. de facture" + +#: web/templates/admin/invoice/index.gohtml:106 +msgctxt "title" +msgid "Status" +msgstr "Statut" + +#: web/templates/admin/invoice/index.gohtml:107 +msgctxt "title" +msgid "Download" +msgstr "Téléchargement" + +#: web/templates/admin/invoice/index.gohtml:108 +msgctxt "title" +msgid "Amount" +msgstr "Import" + +#: web/templates/admin/invoice/index.gohtml:115 +msgctxt "action" +msgid "Select invoice %v" +msgstr "Sélectionner la facture %v" + +#: web/templates/admin/invoice/index.gohtml:144 +msgctxt "action" +msgid "Download invoice %s" +msgstr "Télécharger la facture %s" + +#: web/templates/admin/invoice/index.gohtml:154 +msgid "No invoices added yet." +msgstr "Aucune facture n’a encore été ajouté." + +#: web/templates/admin/invoice/index.gohtml:161 +msgid "Total" +msgstr "Totale" + +#: web/templates/admin/invoice/view.gohtml:2 +msgctxt "title" +msgid "Invoice %s" +msgstr "Facture %s" + +#: web/templates/admin/invoice/view.gohtml:15 +msgctxt "action" +msgid "Edit" +msgstr "Éditer" + +#: web/templates/admin/invoice/view.gohtml:18 +msgctxt "action" +msgid "Download invoice" +msgstr "Télécharger facture" + +#: web/templates/admin/invoice/view.gohtml:53 +msgctxt "title" +msgid "Concept" +msgstr "Concept" + +#: web/templates/admin/invoice/view.gohtml:54 +msgctxt "title" +msgid "Price" +msgstr "Prix" + +#: web/templates/admin/invoice/view.gohtml:56 +msgctxt "title" +msgid "Discount" +msgstr "Rabais" + +#: web/templates/admin/invoice/view.gohtml:58 +msgctxt "title" +msgid "Units" +msgstr "Unités" + +#: web/templates/admin/invoice/view.gohtml:93 +msgctxt "title" +msgid "Tax Base" +msgstr "Import imposable" + #: web/templates/admin/login.gohtml:6 web/templates/admin/login.gohtml:18 msgctxt "title" msgid "Login" @@ -1928,12 +2255,6 @@ msgctxt "title" msgid "Users" msgstr "Utilisateurs" -#: web/templates/admin/user/login-attempts.gohtml:20 -#: web/templates/admin/user/index.gohtml:21 -msgctxt "header" -msgid "Email" -msgstr "E-mail" - #: web/templates/admin/user/login-attempts.gohtml:21 msgctxt "header" msgid "IP Address" @@ -1985,11 +2306,6 @@ msgctxt "input" msgid "Trade Name" msgstr "Nom commercial" -#: web/templates/admin/taxDetails.gohtml:85 -msgctxt "input" -msgid "Province" -msgstr "Province" - #: web/templates/admin/taxDetails.gohtml:111 msgctxt "input" msgid "Currency" @@ -2214,11 +2530,11 @@ msgctxt "title" msgid "Bookings" msgstr "Réservations" -#: web/templates/admin/layout.gohtml:101 +#: web/templates/admin/layout.gohtml:107 msgid "Breadcrumb" msgstr "Fil d’Ariane" -#: web/templates/admin/layout.gohtml:113 +#: web/templates/admin/layout.gohtml:119 msgid "Camper Version: %s" msgstr "Camper version: %s" @@ -2340,7 +2656,7 @@ msgid "Country (optional)" msgstr "Pays (facultatif)" #: web/templates/admin/booking/fields.gohtml:212 -#: web/templates/admin/booking/guest.gohtml:128 +#: web/templates/admin/booking/guest.gohtml:130 msgctxt "input" msgid "Address (optional)" msgstr "Adresse (facultatif)" @@ -2355,17 +2671,6 @@ msgctxt "input" msgid "Town or village (optional)" msgstr "Ville (facultatif)" -#: web/templates/admin/booking/fields.gohtml:239 -msgctxt "input" -msgid "Email (optional)" -msgstr "E-mail (facultatif)" - -#: web/templates/admin/booking/fields.gohtml:248 -#: web/templates/admin/booking/guest.gohtml:117 -msgctxt "input" -msgid "Phone (optional)" -msgstr "Téléphone (facultatif)" - #: web/templates/admin/booking/form.gohtml:8 msgctxt "title" msgid "Edit Booking" @@ -2430,60 +2735,41 @@ msgstr "Nom du titulaire" msgid "No booking found." msgstr "Aucune réservation trouvée." -#: web/templates/admin/booking/guest.gohtml:5 -msgctxt "action" -msgid "Remove" -msgstr "Retirer" - -#: web/templates/admin/booking/guest.gohtml:8 -msgctxt "input" -msgid "ID document number" -msgstr "Numéro de document d’identité" - -#: web/templates/admin/booking/guest.gohtml:20 -msgctxt "input" -msgid "ID document type" -msgstr "Type de document d’identité" - -#: web/templates/admin/booking/guest.gohtml:25 -msgid "Choose an ID document type" -msgstr "Choisissez un type de document d’identité" - #: web/templates/admin/booking/guest.gohtml:33 msgctxt "input" msgid "ID document issue date (if any)" msgstr "Date de délivrance du document d’identité (si j'en ai)" -#: web/templates/admin/booking/guest.gohtml:44 +#: web/templates/admin/booking/guest.gohtml:45 msgctxt "input" msgid "First surname" msgstr "Premier nom" -#: web/templates/admin/booking/guest.gohtml:56 +#: web/templates/admin/booking/guest.gohtml:57 msgctxt "input" msgid "Second surname (if has one)" msgstr "Deuxième nom" -#: web/templates/admin/booking/guest.gohtml:67 +#: web/templates/admin/booking/guest.gohtml:68 msgctxt "input" msgid "Given name" msgstr "Prénom" -#: web/templates/admin/booking/guest.gohtml:79 +#: web/templates/admin/booking/guest.gohtml:80 msgctxt "input" msgid "Sex" msgstr "Sexe" -#: web/templates/admin/booking/guest.gohtml:84 +#: web/templates/admin/booking/guest.gohtml:85 msgid "Choose a sex" msgstr "Choisissez un sexe" -#: web/templates/admin/booking/guest.gohtml:92 +#: web/templates/admin/booking/guest.gohtml:93 msgctxt "input" msgid "Birthdate" msgstr "Date de naissance" -#: web/templates/admin/booking/guest.gohtml:104 +#: web/templates/admin/booking/guest.gohtml:106 msgctxt "input" msgid "Nationality" msgstr "Nationalité" @@ -2553,8 +2839,9 @@ msgstr "Paiement de réservation reçu avec succès" #: pkg/legal/admin.go:258 pkg/app/user.go:249 pkg/campsite/types/option.go:365 #: pkg/campsite/types/feature.go:272 pkg/campsite/types/admin.go:577 #: pkg/campsite/feature.go:269 pkg/season/admin.go:411 -#: pkg/services/admin.go:316 pkg/surroundings/admin.go:340 -#: pkg/amenity/feature.go:269 pkg/amenity/admin.go:283 +#: pkg/invoice/admin.go:1092 pkg/services/admin.go:316 +#: pkg/surroundings/admin.go:340 pkg/amenity/feature.go:269 +#: pkg/amenity/admin.go:283 msgid "Name can not be empty." msgstr "Le nom ne peut pas être laissé vide." @@ -2589,12 +2876,12 @@ msgid "Slide image must be an image media type." msgstr "L’image de la diapositive doit être de type média d’image." #: pkg/app/login.go:56 pkg/app/user.go:246 pkg/company/admin.go:224 -#: pkg/booking/public.go:596 +#: pkg/booking/public.go:592 msgid "Email can not be empty." msgstr "L’e-mail ne peut pas être vide." -#: pkg/app/login.go:57 pkg/app/user.go:247 pkg/company/admin.go:225 -#: pkg/booking/admin.go:437 pkg/booking/public.go:597 +#: pkg/app/login.go:57 pkg/app/user.go:247 pkg/customer/admin.go:312 +#: pkg/company/admin.go:225 pkg/booking/admin.go:438 pkg/booking/public.go:593 msgid "This email is not valid. It should be like name@domain.com." msgstr "Cette adresse e-mail n’est pas valide. Il devrait en être name@domain.com." @@ -2623,7 +2910,7 @@ msgstr "La langue sélectionnée n’est pas valide." msgid "File must be a valid PNG or JPEG image." msgstr "Le fichier doit être une image PNG ou JPEG valide." -#: pkg/app/admin.go:73 +#: pkg/app/admin.go:79 msgid "Access forbidden" msgstr "Accès interdit" @@ -2651,15 +2938,15 @@ msgstr "Le maximum doit être un nombre entier." msgid "Maximum must be equal or greater than minimum." msgstr "Le maximum doit être égal ou supérieur au minimum." -#: pkg/campsite/types/option.go:382 +#: pkg/campsite/types/option.go:382 pkg/invoice/admin.go:1093 msgid "Price can not be empty." msgstr "Le prix ne peut pas être vide." -#: pkg/campsite/types/option.go:383 +#: pkg/campsite/types/option.go:383 pkg/invoice/admin.go:1094 msgid "Price must be a decimal number." msgstr "Le prix doit être un nombre décimal." -#: pkg/campsite/types/option.go:384 +#: pkg/campsite/types/option.go:384 pkg/invoice/admin.go:1095 msgid "Price must be zero or greater." msgstr "Le prix doit être égal ou supérieur à zéro." @@ -2805,7 +3092,7 @@ msgctxt "header" msgid "Children (aged 2 to 10)" msgstr "Enfants (de 2 à 10 anys)" -#: pkg/campsite/admin.go:280 pkg/booking/admin.go:413 pkg/booking/public.go:177 +#: pkg/campsite/admin.go:280 pkg/booking/admin.go:414 pkg/booking/public.go:177 #: pkg/booking/public.go:232 msgid "Selected campsite type is not valid." msgstr "Le type d’emplacement sélectionné n’est pas valide." @@ -2843,6 +3130,136 @@ msgstr "La date de fin ne peut pas être vide." msgid "End date must be a valid date." msgstr "La date de fin doit être une date valide." +#: pkg/customer/admin.go:293 pkg/company/admin.go:207 +#: pkg/booking/checkin.go:297 pkg/booking/public.go:577 +msgid "Selected country is not valid." +msgstr "Le pays sélectionné n’est pas valide." + +#: pkg/customer/admin.go:297 pkg/booking/checkin.go:281 +msgid "Selected ID document type is not valid." +msgstr "Le type de document d’identité sélectionné n’est pas valide." + +#: pkg/customer/admin.go:298 pkg/booking/checkin.go:282 +msgid "ID document number can not be empty." +msgstr "Le numéro de documento d’identité ne peut pas être vide." + +#: pkg/customer/admin.go:300 pkg/booking/checkin.go:288 +#: pkg/booking/checkin.go:289 pkg/booking/admin.go:426 +#: pkg/booking/public.go:581 +msgid "Full name can not be empty." +msgstr "Le nom complet ne peut pas être vide." + +#: pkg/customer/admin.go:301 pkg/booking/admin.go:427 pkg/booking/public.go:582 +msgid "Full name must have at least one letter." +msgstr "Le nom complet doit comporter au moins une lettre." + +#: pkg/customer/admin.go:304 pkg/company/admin.go:230 pkg/booking/public.go:585 +msgid "Address can not be empty." +msgstr "L’adresse ne peut pas être vide." + +#: pkg/customer/admin.go:305 pkg/booking/public.go:586 +msgid "Town or village can not be empty." +msgstr "La ville ne peut pas être vide." + +#: pkg/customer/admin.go:306 pkg/company/admin.go:233 pkg/booking/public.go:587 +msgid "Postcode can not be empty." +msgstr "Le code postal ne peut pas être vide." + +#: pkg/customer/admin.go:307 pkg/company/admin.go:234 pkg/booking/admin.go:433 +#: pkg/booking/public.go:588 +msgid "This postcode is not valid." +msgstr "Ce code postal n’est pas valide." + +#: pkg/customer/admin.go:315 pkg/company/admin.go:220 +#: pkg/booking/checkin.go:301 pkg/booking/admin.go:443 +#: pkg/booking/public.go:596 +msgid "This phone number is not valid." +msgstr "Ce numéro de téléphone n’est pas valide." + +#: pkg/invoice/admin.go:649 +msgctxt "filename" +msgid "invoices.zip" +msgstr "factures.zip" + +#: pkg/invoice/admin.go:664 +msgctxt "filename" +msgid "invoices.ods" +msgstr "factures.ods" + +#: pkg/invoice/admin.go:666 pkg/invoice/admin.go:1285 pkg/invoice/admin.go:1292 +msgid "Invalid action" +msgstr "Actin invalide" + +#: pkg/invoice/admin.go:830 +msgid "Selected invoice status is not valid." +msgstr "L’statut sélectionné n’est pas valide." + +#: pkg/invoice/admin.go:831 +msgid "Selected customer is not valid." +msgstr "Le client sélectionné n’est pas valide." + +#: pkg/invoice/admin.go:832 +msgid "Invoice date can not be empty." +msgstr "La date de facture ne peut pas être vide." + +#: pkg/invoice/admin.go:833 +msgid "Invoice date must be a valid date." +msgstr "La date de facture doit être une date valide." + +#: pkg/invoice/admin.go:980 +#, c-format +msgid "Re: quotation #%s of %s" +msgstr "" + +#: pkg/invoice/admin.go:981 +msgctxt "to_char" +msgid "MM/DD/YYYY" +msgstr "DD/MM/YYYY" + +#: pkg/invoice/admin.go:1083 +msgid "Invoice product ID must be an integer." +msgstr "Le ID de produit de facture doit être un entier." + +#: pkg/invoice/admin.go:1084 +msgid "Invoice product ID one or greater." +msgstr "Le ID de produit de facture doit être égal ou supérieur à un." + +#: pkg/invoice/admin.go:1088 +msgid "Product ID must be an integer." +msgstr "Le ID de produit doit être un entier." + +#: pkg/invoice/admin.go:1089 +msgid "Product ID must zero or greater." +msgstr "Le ID de produit doit être égal ou supérieur à zéro." + +#: pkg/invoice/admin.go:1098 +msgid "Quantity can not be empty." +msgstr "La quantité ne peut pas être vide." + +#: pkg/invoice/admin.go:1099 +msgid "Quantity must be an integer." +msgstr "La quantité doit être un entier." + +#: pkg/invoice/admin.go:1100 +msgid "Quantity must one or greater." +msgstr "La quantité doit être égnal ou supérieur à zéro." + +#: pkg/invoice/admin.go:1103 +msgid "Discount can not be empty." +msgstr "Le rabais ne peut pas être vide." + +#: pkg/invoice/admin.go:1104 +msgid "Discount must be an integer." +msgstr "Le rabais doit être un entier." + +#: pkg/invoice/admin.go:1105 pkg/invoice/admin.go:1106 +msgid "Discount must be a percentage between 0 and 100." +msgstr "Le rabais doit être un pourcentage compris entre 0 et 100." + +#: pkg/invoice/admin.go:1110 +msgid "Selected tax is not valid." +msgstr "La taxe sélectionnée n’est pas valide." + #: pkg/user/admin.go:18 msgctxt "role" msgid "guest" @@ -2902,11 +3319,6 @@ msgstr "L’addresse du lien ne peut pas être vide." msgid "This web address is not valid. It should be like https://domain.com/." msgstr "Cette adresse web n’est pas valide. Il devrait en être https://domain.com/." -#: pkg/company/admin.go:207 pkg/booking/checkin.go:301 -#: pkg/booking/public.go:581 -msgid "Selected country is not valid." -msgstr "Le pays sélectionné n’est pas valide." - #: pkg/company/admin.go:211 msgid "Business name can not be empty." msgstr "Le nom de l’entreprise ne peut pas être vide." @@ -2923,19 +3335,10 @@ msgstr "Le numéro de TVA ne peut pas être vide." msgid "This VAT number is not valid." msgstr "Ce numéro de TVA n’est pas valide." -#: pkg/company/admin.go:219 pkg/booking/public.go:599 +#: pkg/company/admin.go:219 pkg/booking/public.go:595 msgid "Phone can not be empty." msgstr "Le téléphone ne peut pas être vide." -#: pkg/company/admin.go:220 pkg/booking/checkin.go:305 pkg/booking/admin.go:442 -#: pkg/booking/public.go:600 -msgid "This phone number is not valid." -msgstr "Ce numéro de téléphone n’est pas valide." - -#: pkg/company/admin.go:230 pkg/booking/public.go:589 -msgid "Address can not be empty." -msgstr "L’adresse ne peut pas être vide." - #: pkg/company/admin.go:231 msgid "City can not be empty." msgstr "La ville ne peut pas être vide." @@ -2944,14 +3347,6 @@ msgstr "La ville ne peut pas être vide." msgid "Province can not be empty." msgstr "La province ne peut pas être vide." -#: pkg/company/admin.go:233 pkg/booking/public.go:591 -msgid "Postcode can not be empty." -msgstr "Le code postal ne peut pas être vide." - -#: pkg/company/admin.go:234 pkg/booking/admin.go:432 pkg/booking/public.go:592 -msgid "This postcode is not valid." -msgstr "Ce code postal n’est pas valide." - #: pkg/company/admin.go:238 msgid "RTC number can not be empty." msgstr "Le numéro RTC ne peut pas être vide." @@ -3000,40 +3395,27 @@ msgstr "Le fichier téléchargé ne peut pas être vide." msgid "Filename can not be empty." msgstr "Le nom de fichier ne peut pas être vide." -#: pkg/booking/checkin.go:285 -msgid "Selected ID document type is not valid." -msgstr "Le type de document d’identité sélectionné n’est pas valide." - -#: pkg/booking/checkin.go:286 -msgid "ID document number can not be empty." -msgstr "Le numéro de documento d’identité ne peut pas être vide." - -#: pkg/booking/checkin.go:288 +#: pkg/booking/checkin.go:284 msgid "ID document issue date must be a valid date." msgstr "La date de délivrance du document d’identité doit être une date valide." -#: pkg/booking/checkin.go:289 +#: pkg/booking/checkin.go:285 msgid "ID document issue date must be in the past." msgstr "La ate de délivrance du document d’identité doit être du passé." -#: pkg/booking/checkin.go:292 pkg/booking/checkin.go:293 -#: pkg/booking/admin.go:425 pkg/booking/public.go:585 -msgid "Full name can not be empty." -msgstr "Le nom complet ne peut pas être vide." - -#: pkg/booking/checkin.go:294 +#: pkg/booking/checkin.go:290 msgid "Selected sex is not valid." msgstr "Le sexe sélectionné n’est pas valide." -#: pkg/booking/checkin.go:295 +#: pkg/booking/checkin.go:291 msgid "Birthdate can not be empty" msgstr "La date de naissance ne peut pas être vide." -#: pkg/booking/checkin.go:296 +#: pkg/booking/checkin.go:292 msgid "Birthdate must be a valid date." msgstr "La date de naissance doit être une date valide." -#: pkg/booking/checkin.go:297 +#: pkg/booking/checkin.go:293 msgid "Birthdate must be in the past." msgstr "La date de naissance doit être du passé." @@ -3057,28 +3439,24 @@ msgctxt "cart" msgid "Dog" msgstr "Chien" -#: pkg/booking/admin.go:217 +#: pkg/booking/admin.go:218 msgctxt "filename" msgid "bookings.ods" msgstr "reservations.ods" -#: pkg/booking/admin.go:426 pkg/booking/public.go:586 -msgid "Full name must have at least one letter." -msgstr "Le nom complet doit comporter au moins une lettre." - -#: pkg/booking/admin.go:431 +#: pkg/booking/admin.go:432 msgid "Country can not be empty to validate the postcode." msgstr "Le pays ne peut pas être vide pour valider le code postal." -#: pkg/booking/admin.go:441 +#: pkg/booking/admin.go:442 msgid "Country can not be empty to validate the phone." msgstr "Le pays ne peut pas être vide pour valider le téléphone." -#: pkg/booking/admin.go:448 +#: pkg/booking/admin.go:449 msgid "You must select at least one accommodation." msgstr "Vous devez sélectionner au moins un hébergement." -#: pkg/booking/admin.go:454 +#: pkg/booking/admin.go:455 msgid "The selected accommodations have no available openings in the requested dates." msgstr "Les hébergements sélectionnés n’ont pas de disponibilités aux dates demandées." @@ -3191,11 +3569,7 @@ msgstr "%s doit être %d ou plus." msgid "%s must be at most %d." msgstr "%s doit être tout au plus %d." -#: pkg/booking/public.go:590 -msgid "Town or village can not be empty." -msgstr "La ville ne peut pas être vide." - -#: pkg/booking/public.go:605 +#: pkg/booking/public.go:601 msgid "It is mandatory to agree to the reservation conditions." msgstr "Il est obligatoire d’accepter les conditions de réservation." diff --git a/revert/add_contact.sql b/revert/add_contact.sql new file mode 100644 index 0000000..b53e87c --- /dev/null +++ b/revert/add_contact.sql @@ -0,0 +1,17 @@ +-- Deploy camper:add_contact to pg +-- requires: schema_camper +-- requires: extension_vat +-- requires: email +-- requires: extension_pg_libphonenumber +-- requires: extension_uri +-- requires: country_code +-- requires: contact +-- requires: tag_name + +begin; + +set search_path to camper, public; + +drop function if exists add_contact(integer, text, text, text, text, text, text, text, text, text, country_code); + +commit; diff --git a/revert/add_invoice.sql b/revert/add_invoice.sql new file mode 100644 index 0000000..85ea93d --- /dev/null +++ b/revert/add_invoice.sql @@ -0,0 +1,7 @@ +-- Revert camper:add_invoice from pg + +begin; + +drop function if exists camper.add_invoice(integer, date, integer, text, integer, camper.new_invoice_product[]); + +commit; diff --git a/revert/available_currencies.sql b/revert/available_currencies.sql index f93c2f3..c9155be 100644 --- a/revert/available_currencies.sql +++ b/revert/available_currencies.sql @@ -1,4 +1,4 @@ --- Revert numerus:available_currencies from pg +-- Revert camper:available_currencies from pg begin; diff --git a/revert/available_invoice_status.sql b/revert/available_invoice_status.sql new file mode 100644 index 0000000..bdefd00 --- /dev/null +++ b/revert/available_invoice_status.sql @@ -0,0 +1,10 @@ +-- Revert camper:available_invoice_status from pg + +begin; + +set search_path to camper; + +delete from invoice_status_i18n; +delete from invoice_status; + +commit; diff --git a/revert/compute_new_invoice_amount.sql b/revert/compute_new_invoice_amount.sql new file mode 100644 index 0000000..61c9d06 --- /dev/null +++ b/revert/compute_new_invoice_amount.sql @@ -0,0 +1,7 @@ +-- Revert camper:compute_new_invoice_amount from pg + +begin; + +drop function if exists camper.compute_new_invoice_amount(integer, camper.new_invoice_product[]); + +commit; diff --git a/revert/contact.sql b/revert/contact.sql new file mode 100644 index 0000000..568282f --- /dev/null +++ b/revert/contact.sql @@ -0,0 +1,8 @@ +-- Revert camper:contact from pg + +begin; + +drop policy if exists company_policy on camper.contact; +drop table if exists camper.contact; + +commit; diff --git a/revert/contact_email.sql b/revert/contact_email.sql new file mode 100644 index 0000000..0835bda --- /dev/null +++ b/revert/contact_email.sql @@ -0,0 +1,23 @@ +-- Revert camper:contact_email from pg + +begin; + +set search_path to camper, public; + +alter table contact + add column email email +; + +update contact +set email = email.email +from contact_email as email +where email.contact_id = email.contact_id +; + +alter table contact + alter column email set not null +; + +drop table if exists contact_email; + +commit; diff --git a/revert/contact_phone.sql b/revert/contact_phone.sql new file mode 100644 index 0000000..72300a7 --- /dev/null +++ b/revert/contact_phone.sql @@ -0,0 +1,24 @@ +-- Revert camper:contact_phone from pg + +begin; + +set search_path to camper, public; + +alter table contact + add column phone packed_phone_number default '+34000000000' +; + +update contact +set phone = phone.phone +from contact_phone as phone +where phone.contact_id = contact.contact_id +; + +alter table contact + alter column phone set not null +, alter column phone drop default +; + +drop table if exists contact_phone; + +commit; diff --git a/revert/discount_rate.sql b/revert/discount_rate.sql new file mode 100644 index 0000000..6848e86 --- /dev/null +++ b/revert/discount_rate.sql @@ -0,0 +1,7 @@ +-- Revert camper:discount_rate from pg + +begin; + +drop domain if exists camper.discount_rate; + +commit; diff --git a/revert/edit_contact.sql b/revert/edit_contact.sql new file mode 100644 index 0000000..d7f43e3 --- /dev/null +++ b/revert/edit_contact.sql @@ -0,0 +1,17 @@ +-- Deploy camper:edit_contact to pg +-- requires: schema_camper +-- requires: email +-- requires: extension_uri +-- requires: country_code +-- requires: tag_name +-- requires: contact +-- requires: extension_vat +-- requires: extension_pg_libphonenumber + +begin; + +set search_path to camper, public; + +drop function if exists edit_contact(uuid, text, text, text, text, text, text, text, text, text, country_code); + +commit; diff --git a/revert/edit_invoice.sql b/revert/edit_invoice.sql new file mode 100644 index 0000000..1168713 --- /dev/null +++ b/revert/edit_invoice.sql @@ -0,0 +1,7 @@ +-- Revert camper:edit_invoice from pg + +begin; + +drop function if exists camper.edit_invoice(uuid, text, integer, text, integer, camper.edited_invoice_product[]); + +commit; diff --git a/revert/edited_invoice_product.sql b/revert/edited_invoice_product.sql new file mode 100644 index 0000000..71f33e3 --- /dev/null +++ b/revert/edited_invoice_product.sql @@ -0,0 +1,7 @@ +-- Revert camper:edited_invoice_product from pg + +begin; + +drop type if exists camper.edited_invoice_product; + +commit; diff --git a/revert/invoice.sql b/revert/invoice.sql new file mode 100644 index 0000000..fc242b6 --- /dev/null +++ b/revert/invoice.sql @@ -0,0 +1,7 @@ +-- Revert camper:invoice from pg + +begin; + +drop table if exists camper.invoice; + +commit; diff --git a/revert/invoice_amount.sql b/revert/invoice_amount.sql new file mode 100644 index 0000000..e764af7 --- /dev/null +++ b/revert/invoice_amount.sql @@ -0,0 +1,7 @@ +-- Revert camper:invoice_amount from pg + +begin; + +drop view if exists camper.invoice_amount; + +commit; diff --git a/revert/invoice_number_counter.sql b/revert/invoice_number_counter.sql new file mode 100644 index 0000000..56147e5 --- /dev/null +++ b/revert/invoice_number_counter.sql @@ -0,0 +1,7 @@ +-- Revert camper:invoice_number_counter from pg + +begin; + +drop table if exists camper.invoice_number_counter; + +commit; diff --git a/revert/invoice_product.sql b/revert/invoice_product.sql new file mode 100644 index 0000000..02753b2 --- /dev/null +++ b/revert/invoice_product.sql @@ -0,0 +1,7 @@ +-- Revert camper:invoice_product from pg + +begin; + +drop table if exists camper.invoice_product; + +commit; diff --git a/revert/invoice_product_amount.sql b/revert/invoice_product_amount.sql new file mode 100644 index 0000000..eee565f --- /dev/null +++ b/revert/invoice_product_amount.sql @@ -0,0 +1,7 @@ +-- Revert camper:invoice_product_amount from pg + +begin; + +drop view if exists camper.invoice_product_amount; + +commit; diff --git a/revert/invoice_product_product.sql b/revert/invoice_product_product.sql new file mode 100644 index 0000000..a7d1b34 --- /dev/null +++ b/revert/invoice_product_product.sql @@ -0,0 +1,7 @@ +-- Revert camper:invoice_product_product from pg + +begin; + +drop table if exists camper.invoice_product_product; + +commit; diff --git a/revert/invoice_product_tax.sql b/revert/invoice_product_tax.sql new file mode 100644 index 0000000..04be903 --- /dev/null +++ b/revert/invoice_product_tax.sql @@ -0,0 +1,7 @@ +-- Revert camper:invoice_product_tax from pg + +begin; + +drop table if exists camper.invoice_product_tax; + +commit; diff --git a/revert/invoice_status.sql b/revert/invoice_status.sql new file mode 100644 index 0000000..b4e976f --- /dev/null +++ b/revert/invoice_status.sql @@ -0,0 +1,7 @@ +-- Revert camper:invoice_status from pg + +begin; + +drop table if exists camper.invoice_status; + +commit; diff --git a/revert/invoice_status_i18n.sql b/revert/invoice_status_i18n.sql new file mode 100644 index 0000000..6a54d58 --- /dev/null +++ b/revert/invoice_status_i18n.sql @@ -0,0 +1,7 @@ +-- Revert camper:invoice_status_i18n from pg + +begin; + +drop table if exists camper.invoice_status_i18n; + +commit; diff --git a/revert/invoice_tax_amount.sql b/revert/invoice_tax_amount.sql new file mode 100644 index 0000000..5ecde96 --- /dev/null +++ b/revert/invoice_tax_amount.sql @@ -0,0 +1,7 @@ +-- Revert camper:invoice_tax_amount from pg + +begin; + +drop view if exists camper.invoice_tax_amount; + +commit; diff --git a/revert/new_invoice_amount.sql b/revert/new_invoice_amount.sql new file mode 100644 index 0000000..61f3847 --- /dev/null +++ b/revert/new_invoice_amount.sql @@ -0,0 +1,7 @@ +-- Revert camper:new_invoice_amount from pg + +begin; + +drop type if exists camper.new_invoice_amount; + +commit; diff --git a/revert/new_invoice_product.sql b/revert/new_invoice_product.sql new file mode 100644 index 0000000..07d1065 --- /dev/null +++ b/revert/new_invoice_product.sql @@ -0,0 +1,7 @@ +-- Revert camper:new_invoice_product from pg + +begin; + +drop type if exists camper.new_invoice_product; + +commit; diff --git a/revert/next_invoice_number.sql b/revert/next_invoice_number.sql new file mode 100644 index 0000000..7906aed --- /dev/null +++ b/revert/next_invoice_number.sql @@ -0,0 +1,7 @@ +-- Revert camper:next_invoice_number from pg + +begin; + +drop function if exists camper.next_invoice_number(integer, date); + +commit; diff --git a/revert/payment_method.sql b/revert/payment_method.sql new file mode 100644 index 0000000..705efec --- /dev/null +++ b/revert/payment_method.sql @@ -0,0 +1,7 @@ +-- Revert camper:payment_method from pg + +begin; + +drop table if exists camper.payment_method; + +commit; diff --git a/revert/product.sql b/revert/product.sql new file mode 100644 index 0000000..44cf30a --- /dev/null +++ b/revert/product.sql @@ -0,0 +1,7 @@ +-- Revert camper:product from pg + +begin; + +drop table if exists camper.product; + +commit; diff --git a/revert/product_tax.sql b/revert/product_tax.sql new file mode 100644 index 0000000..3db8d23 --- /dev/null +++ b/revert/product_tax.sql @@ -0,0 +1,7 @@ +-- Revert camper:product_tax from pg + +begin; + +drop table if exists camper.product_tax; + +commit; diff --git a/revert/tax.sql b/revert/tax.sql new file mode 100644 index 0000000..9e75731 --- /dev/null +++ b/revert/tax.sql @@ -0,0 +1,8 @@ +-- Revert camper:tax from pg + +begin; + +drop policy if exists company_policy on camper.tax; +drop table if exists camper.tax; + +commit; diff --git a/revert/tax_class.sql b/revert/tax_class.sql new file mode 100644 index 0000000..31e4bdc --- /dev/null +++ b/revert/tax_class.sql @@ -0,0 +1,7 @@ +-- Revert camper:tax_class from pg + +begin; + +drop table if exists camper.tax_class; + +commit; diff --git a/revert/tax_rate.sql b/revert/tax_rate.sql new file mode 100644 index 0000000..1a4113f --- /dev/null +++ b/revert/tax_rate.sql @@ -0,0 +1,7 @@ +-- Revert camper:tax_rate from pg + +begin; + +drop domain if exists camper.tax_rate; + +commit; diff --git a/sqitch.plan b/sqitch.plan index e945109..11d27cf 100644 --- a/sqitch.plan +++ b/sqitch.plan @@ -299,3 +299,33 @@ available_id_document_types [id_document_type id_document_type_i18n] 2024-04-25T booking_guest [roles schema_camper booking sex id_document_type extension_pg_libphonenumber] 2024-04-26T09:40:17Z jordi fita mas # Add relation of booking guests checked_in_guest [schema_camper] 2024-04-26T09:58:54Z jordi fita mas # Add type for checked-in guest check_in_guests [roles schema_camper booking booking_guest checked_in_guest extension_pg_libphonenumber] 2024-04-26T10:31:53Z jordi fita mas # Add function to check-in guests +contact [roles schema_camper user_profile company id_document_type country_code country] 2024-04-27T22:48:18Z jordi fita mas # Add the relation for contacts +contact_phone [roles schema_camper extension_pg_libphonenumber] 2024-04-27T23:18:19Z jordi fita mas # Add relation to keep contacts’ phone numbers +contact_email [roles schema_camper email contact] 2024-04-27T23:18:19Z jordi fita mas # Add relation to keep contacts’ emails +invoice_status [schema_camper] 2024-04-27T22:48:26Z jordi fita mas # A relation of invoice status +invoice_status_i18n [schema_camper invoice_status language] 2024-04-27T22:48:18Z jordi fita mas # Add relation for invoice status’ translatable texts +available_invoice_status [schema_camper invoice_status invoice_status_i18n] 2024-04-27T22:48:06Z jordi fita mas # Add the list of available invoice status +payment_method [roles schema_camper user_profile company] 2024-04-27T23:49:41Z jordi fita mas # Add relation of payment method +invoice [roles schema_camper user_profile company contact invoice_status payment_method currency] 2024-04-27T22:46:21Z jordi fita mas # Add relation for invoice +discount_rate [roles schema_camper] 2024-04-27T23:54:40Z jordi fita mas # Add domain for discount rates +invoice_product [roles schema_camper invoice discount_rate] 2024-04-27T23:54:08Z jordi fita mas # Add relation for invoice product +tax_class [roles schema_camper user_profile company] 2024-04-27T23:57:14Z jordi fita mas # Add the relation for tax classes +tax_rate [roles schema_camper] 2024-04-27T23:57:39Z jordi fita mas # Add domain for tax rates +tax [roles schema_camper user_profile company tax_rate tax_class] 2024-04-27T23:57:47Z jordi fita mas # Add relation for taxes +product [roles schema_camper user_profile company] 2024-04-28T00:44:24Z jordi fita mas # Add relation for products +product_tax [roles schema_camper product tax] 2024-04-28T00:44:49Z jordi fita mas # Add relation of product taxes +invoice_product_product [roles schema_camper invoice_product product] 2024-04-28T00:43:30Z jordi fita mas # Add relation of invoice products and registered products +invoice_product_tax [roles schema_camper invoice_product tax tax_rate] 2024-04-27T23:54:30Z jordi fita mas # Add relation for taxes in invoice products +new_invoice_product [roles schema_camper discount_rate] 2024-04-27T23:54:01Z jordi fita mas # Add type for passing products to new invoices +invoice_number_counter [roles schema_camper company] 2024-04-27T23:54:48Z jordi fita mas # Add relation to count invoice numbers +next_invoice_number [roles schema_camper invoice_number_counter] 2024-04-27T23:54:48Z jordi fita mas # Add function to retrieve the next invoice number +add_invoice [roles schema_camper invoice company currency parse_price new_invoice_product tax invoice_product invoice_product_product invoice_product_tax next_invoice_number] 2024-04-27T23:54:46Z jordi fita mas # Add function to create new invoices +invoice_tax_amount [roles schema_camper invoice_product invoice_product_tax] 2024-04-27T23:54:35Z jordi fita mas # Add view for invoice tax amount +invoice_product_amount [roles schema_camper invoice_product invoice_product_tax] 2024-04-27T23:54:05Z jordi fita mas # Add view for invoice product subtotal and total +invoice_amount [roles schema_camper invoice_product invoice_product_amount] 2024-04-27T23:54:46Z jordi fita mas # Add view to compute subtotal and total for invoices +new_invoice_amount [roles schema_camper] 2024-04-27T23:54:25Z jordi fita mas # Add type to return when computing new invoice amounts +compute_new_invoice_amount [roles schema_camper company currency tax new_invoice_product new_invoice_amount] 2024-04-27T23:54:13Z jordi fita mas # Add function to compute the subtotal, taxes, and total amounts for a new invoice +edited_invoice_product [roles schema_camper discount_rate] 2024-04-27T23:54:24Z jordi fita mas # Add typo for passing products to edited invoices +edit_invoice [roles schema_camper invoice currency parse_price edited_invoice_product tax invoice_product invoice_product_product invoice_product_tax] 2024-04-27T23:54:50Z jordi fita mas # Add function to edit invoices +add_contact [roles schema_camper email extension_pg_libphonenumber country_code contact contact_email contact_phone] 2024-04-28T14:21:37Z jordi fita mas # Add function to create new contacts +edit_contact [roles schema_camper email country_code contact extension_pg_libphonenumber contact_email contact_phone] 2024-04-28T14:21:27Z jordi fita mas # Add function to edit contacts diff --git a/test/add_contact.sql b/test/add_contact.sql new file mode 100644 index 0000000..5146aa0 --- /dev/null +++ b/test/add_contact.sql @@ -0,0 +1,71 @@ +-- Test add_contact +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(14); + +set search_path to auth, camper, public; + +select has_function('camper', 'add_contact', array ['integer', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'country_code']); +select function_lang_is('camper', 'add_contact', array ['integer', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'country_code'], 'plpgsql'); +select function_returns('camper', 'add_contact', array ['integer', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'country_code'], 'uuid'); +select isnt_definer('camper', 'add_contact', array ['integer', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'country_code']); +select volatility_is('camper', 'add_contact', array ['integer', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'country_code'], 'volatile'); +select function_privs_are('camper', 'add_contact', array ['integer', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'country_code'], 'guest', array []::text[]); +select function_privs_are('camper', 'add_contact', array ['integer', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'country_code'], 'employee', array ['EXECUTE']); +select function_privs_are('camper', 'add_contact', array ['integer', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'country_code'], 'admin', array ['EXECUTE']); +select function_privs_are('camper', 'add_contact', array ['integer', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'country_code'], 'authenticator', array []::text[]); + + +set client_min_messages to warning; +truncate contact_email cascade; +truncate contact_phone cascade; +truncate contact cascade; +truncate company cascade; +reset client_min_messages; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, rtc_number, tourist_tax, tourist_tax_max_days, country_code, currency_code, default_lang_tag) +values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '', 60, 7, 'ES', 'EUR', 'ca') + , (2, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', '', 60, 7, 'FR', 'USD', 'ca') +; + +select lives_ok( + $$ select add_contact(1, 'Contact 2.2', 'D', '41414141A', '977 977 977', '', 'Fake St., 123', 'Fake City', 'Fake Province', '17400', 'ES') $$, + 'Should be able to insert a second contact for the first company with no email but with phone' +); + +select lives_ok( + $$ select add_contact(2, 'Contact 4.1', 'C', '123ABC', '', 'e@e', 'Bullshit Av., 1', 'Another City', 'Another Province', 'ARBNNL22', 'FR') $$, + 'Should be able to insert a contact for the second company with no phone but with email' +); + +select bag_eq( + $$ select company_id, name, id_document_type_id, id_document_number, address, city, province, postal_code, country_code, created_at from contact $$, + $$ values (1, 'Contact 2.2', 'D', '41414141A', 'Fake St., 123', 'Fake City', 'Fake Province', '17400', 'ES', CURRENT_TIMESTAMP) + , (2, 'Contact 4.1', 'C', '123ABC', 'Bullshit Av., 1', 'Another City', 'Another Province', 'ARBNNL22', 'FR', CURRENT_TIMESTAMP) + $$, + 'Should have created all contacts' +); + +select bag_eq( + $$ select name, phone::text from contact join contact_phone using (contact_id) $$, + $$ values ('Contact 2.2', '+34 977 97 79 77') + $$, + 'Should have created all contacts’ phone' +); + +select bag_eq( + $$ select name, email::text from contact join contact_email using (contact_id) $$, + $$ values ('Contact 4.1', 'e@e') + $$, + 'Should have created all contacts’ email' +); + + +select * +from finish(); + +rollback; diff --git a/test/add_invoice.sql b/test/add_invoice.sql new file mode 100644 index 0000000..d9e7ce0 --- /dev/null +++ b/test/add_invoice.sql @@ -0,0 +1,138 @@ +-- Test add_invoice +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(16); + +set search_path to auth, camper, public; + +select has_function('camper', 'add_invoice', array ['integer', 'date', 'integer', 'text', 'integer', 'new_invoice_product[]']); +select function_lang_is('camper', 'add_invoice', array ['integer', 'date', 'integer', 'text', 'integer', 'new_invoice_product[]'], 'plpgsql'); +select function_returns('camper', 'add_invoice', array ['integer', 'date', 'integer', 'text', 'integer', 'new_invoice_product[]'], 'uuid'); +select isnt_definer('camper', 'add_invoice', array ['integer', 'date', 'integer', 'text', 'integer', 'new_invoice_product[]']); +select volatility_is('camper', 'add_invoice', array ['integer', 'date', 'integer', 'text', 'integer', 'new_invoice_product[]'], 'volatile'); +select function_privs_are('camper', 'add_invoice', array ['integer', 'date', 'integer', 'text', 'integer', 'new_invoice_product[]'], 'guest', array []::text[]); +select function_privs_are('camper', 'add_invoice', array ['integer', 'date', 'integer', 'text', 'integer', 'new_invoice_product[]'], 'employee', array ['EXECUTE']); +select function_privs_are('camper', 'add_invoice', array ['integer', 'date', 'integer', 'text', 'integer', 'new_invoice_product[]'], 'admin', array ['EXECUTE']); +select function_privs_are('camper', 'add_invoice', array ['integer', 'date', 'integer', 'text', 'integer', 'new_invoice_product[]'], 'authenticator', array []::text[]); + + +set client_min_messages to warning; +truncate invoice_number_counter cascade; +truncate invoice_product_tax cascade; +truncate invoice_product cascade; +truncate invoice cascade; +truncate contact cascade; +truncate product cascade; +truncate tax cascade; +truncate tax_class cascade; +truncate payment_method cascade; +truncate company cascade; +reset client_min_messages; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, rtc_number, tourist_tax, tourist_tax_max_days, country_code, currency_code, default_lang_tag, invoice_number_format) +values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '', 60, 7, 'ES', 'EUR', 'ca', '"F"YYYY0000') + , (2, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', '', 60, 7, 'FR', 'USD', 'ca', '"INV"000-YY') +; + +insert into payment_method (payment_method_id, company_id, name, instructions) +values (111, 1, 'cash', 'cash') + , (222, 2, 'cash', 'cash') +; + +insert into invoice_number_counter (company_id, year, currval) +values (1, 2023, '5') + , (2, 2023, '55') +; + +insert into tax_class (tax_class_id, company_id, name) +values (11, 1, 'tax') + , (22, 2, 'tax') +; + +insert into tax (tax_id, company_id, tax_class_id, name, rate) +values (3, 1, 11, 'IRPF -15 %', -0.15) + , (4, 1, 11, 'IVA 21 %', 0.21) + , (5, 2, 22, 'IRPF -7 %', -0.07) + , (6, 2, 22, 'IVA 10 %', 0.10) +; + +insert into contact (contact_id, company_id, name, id_document_type_id, id_document_number, address, city, province, postal_code, country_code) +values (12, 1, 'Contact 2.1', 'D', '41440443Q', 'Fake St', 'City', 'Province', '17600', 'ES') + , (13, 1, 'Contact 2.2', 'D', '41440443Q', 'Fake St', 'City', 'Province', '17600', 'ES') + , (14, 2, 'Contact 4.1', 'D', '41440443Q', 'Fake St', 'City', 'Province', '17600', 'ES') + , (15, 2, 'Contact 4.2', 'D', '41440443Q', 'Fake St', 'City', 'Province', '17600', 'ES') +; + +insert into product (product_id, company_id, name, price) +values ( 7, 1, 'Product 2.1', 1212) + , ( 8, 1, 'Product 2.2', 2424) + , ( 9, 2, 'Product 4.1', 4848) + , (10, 2, 'Product 4.2', 9696) + , (11, 2, 'Product 4.3', 1010) +; + +select lives_ok( + $$ select add_invoice(1, '2023-02-15', 12, 'Notes 1', 111, '{"(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, '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, '2023-02-14', 15, 'Notes 3', 222, '{"(11,Product 4.3,,11.11,1,0.0,{6})","(,Product 4.4,Description 4.4,22.22,3,0.05,{})"}') $$, + 'Should be able to insert an invoice for the second company with a product' +); + +select bag_eq( + $$ select company_id, invoice_number, invoice_date, contact_id, invoice_status, notes, payment_method_id, currency_code, created_at from invoice $$, + $$ values (1, 'F20230006', '2023-02-15'::date, 12, 'created', 'Notes 1', 111, 'EUR', current_timestamp) + , (1, 'F20230007', '2023-02-16'::date, 13, 'created', 'Notes 2', 111, 'EUR', current_timestamp) + , (2, 'INV056-23', '2023-02-14'::date, 15, 'created', 'Notes 3', 222, 'USD', current_timestamp) + $$, + 'Should have created all invoices' +); + +select bag_eq( + $$ select invoice_number, name, description, price, quantity, discount_rate from invoice_product join invoice using (invoice_id) $$, + $$ values ('F20230006', 'Product 1', 'Description 1', 1224, 2, 0.00) + , ('F20230007', 'Product 1 bis', 'Description 1 bis', 3333, 1, 0.50) + , ('F20230007', 'Product 2', 'Description 2', 2400, 3, 0.75) + , ('INV056-23', 'Product 4.3', '', 1111, 1, 0.0) + , ('INV056-23', 'Product 4.4', 'Description 4.4', 2222, 3, 0.05) + $$, + 'Should have created all invoice products' +); + +select bag_eq( + $$ select invoice_number, product_id, name from invoice_product left join invoice_product_product using (invoice_product_id) join invoice using (invoice_id) $$, + $$ values ('F20230006', 7, 'Product 1') + , ('F20230007', 7, 'Product 1 bis') + , ('F20230007', 8, 'Product 2') + , ('INV056-23', 11, 'Product 4.3') + , ('INV056-23', NULL, 'Product 4.4') + $$, + 'Should have linked all invoice products' +); + +select bag_eq( + $$ select invoice_number, name, tax_id, tax_rate from invoice_product_tax join invoice_product using (invoice_product_id) join invoice using (invoice_id) $$, + $$ values ('F20230006', 'Product 1', 4, 0.21) + , ('F20230007', 'Product 1 bis', 4, 0.21) + , ('F20230007', 'Product 1 bis', 3, -0.15) + , ('INV056-23', 'Product 4.3', 6, 0.10) + $$, + 'Should have created all invoice product taxes' +); + + +select * +from finish(); + +rollback; diff --git a/test/compute_new_invoice_amount.sql b/test/compute_new_invoice_amount.sql new file mode 100644 index 0000000..f889781 --- /dev/null +++ b/test/compute_new_invoice_amount.sql @@ -0,0 +1,71 @@ +-- Test compute_new_invoice_amount +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(14); + +set search_path to camper, auth, public; + +select has_function('camper', 'compute_new_invoice_amount', array ['integer', 'new_invoice_product[]']); +select function_lang_is('camper', 'compute_new_invoice_amount', array ['integer', 'new_invoice_product[]'], 'plpgsql'); +select function_returns('camper', 'compute_new_invoice_amount', array ['integer', 'new_invoice_product[]'], 'new_invoice_amount'); +select isnt_definer('camper', 'compute_new_invoice_amount', array ['integer', 'new_invoice_product[]']); +select volatility_is('camper', 'compute_new_invoice_amount', array ['integer', 'new_invoice_product[]'], 'stable'); +select function_privs_are('camper', 'compute_new_invoice_amount', array ['integer', 'new_invoice_product[]'], 'guest', array []::text[]); +select function_privs_are('camper', 'compute_new_invoice_amount', array ['integer', 'new_invoice_product[]'], 'employee', array ['EXECUTE']); +select function_privs_are('camper', 'compute_new_invoice_amount', array ['integer', 'new_invoice_product[]'], 'admin', array ['EXECUTE']); +select function_privs_are('camper', 'compute_new_invoice_amount', array ['integer', 'new_invoice_product[]'], 'authenticator', array []::text[]); + +set client_min_messages to warning; +truncate tax cascade; +truncate tax_class cascade; +truncate company cascade; +reset client_min_messages; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, rtc_number, tourist_tax, tourist_tax_max_days, country_code, currency_code, default_lang_tag) +values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '', 60, 7, 'ES', 'EUR', 'ca') +; + +insert into tax_class (tax_class_id, company_id, name) +values (11, 1, 'tax') +; + +insert into tax (tax_id, company_id, tax_class_id, name, rate) +values (2, 1, 11, 'IRPF -15 %', -0.15) + , (3, 1, 11, 'IVA 4 %', 0.04) + , (4, 1, 11, 'IVA 10 %', 0.10) + , (5, 1, 11, 'IVA 21 %', 0.21) +; + +select is( + compute_new_invoice_amount(1, '{}'), + '(0.00,"{}",0.00)'::new_invoice_amount +); + +select is( + compute_new_invoice_amount(1, '{"(6,P,D,1.00,1,0.0,\"{2,5}\")","(6,P,D,2.00,2,0.1,{3})"}'), + '(4.60,"{{IRPF -15 %,-0.15},{IVA 4 %,0.14},{IVA 21 %,0.21}}",4.80)'::new_invoice_amount +); + +select is( + compute_new_invoice_amount(1, '{"(6,P,D,2.22,3,0.0,\"{2,4,5}\")","(6,P,D,3.33,4,0.2,{4})"}'), + '(17.32,"{{IRPF -15 %,-1.00},{IVA 10 %,1.74},{IVA 21 %,1.40}}",19.46)'::new_invoice_amount +); + +select is( + compute_new_invoice_amount(1, '{"(6,P,D,4.44,5,0.0,\"{4,5}\")","(6,P,D,5.55,6,0.1,\"{5,3}\")"}'), + '(52.17,"{{IVA 4 %,1.20},{IVA 10 %,2.22},{IVA 21 %,10.95}}",66.54)'::new_invoice_amount +); + +select is( + compute_new_invoice_amount(1, '{"(6,P,D,7.77,8,0.0,\"{}\")"}'), + '(62.16,"{}",62.16)'::new_invoice_amount +); + +select * +from finish(); + +rollback; diff --git a/test/contact.sql b/test/contact.sql new file mode 100644 index 0000000..b27844a --- /dev/null +++ b/test/contact.sql @@ -0,0 +1,170 @@ +-- Test contact +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(68); + +set search_path to camper, auth, public; + +select has_table('contact'); +select has_pk('contact' ); +select table_privs_are('contact', 'guest', array []::text[]); +select table_privs_are('contact', 'employee', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('contact', 'admin', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('contact', 'authenticator', array []::text[]); + +select has_column('contact', 'contact_id'); +select col_is_pk('contact', 'contact_id'); +select col_type_is('contact', 'contact_id', 'integer'); +select col_not_null('contact', 'contact_id'); +select col_hasnt_default('contact', 'contact_id'); + +select has_column('contact', 'company_id'); +select col_is_fk('contact', 'company_id'); +select fk_ok('contact', 'company_id', 'company', 'company_id'); +select col_type_is('contact', 'company_id', 'integer'); +select col_not_null('contact', 'company_id'); +select col_hasnt_default('contact', 'company_id'); + +select has_column('contact', 'slug'); +select col_is_unique('contact', 'slug'); +select col_type_is('contact', 'slug', 'uuid'); +select col_not_null('contact', 'slug'); +select col_has_default('contact', 'slug'); +select col_default_is('contact', 'slug', 'gen_random_uuid()'); + +select has_column('contact', 'name'); +select col_type_is('contact', 'name', 'text'); +select col_not_null('contact', 'name'); +select col_hasnt_default('contact', 'name'); + +select has_column('contact', 'id_document_type_id'); +select col_is_fk('contact', 'id_document_type_id'); +select fk_ok('contact', 'id_document_type_id', 'id_document_type', 'id_document_type_id'); +select col_type_is('contact', 'id_document_type_id', 'character varying(1)'); +select col_not_null('contact', 'id_document_type_id'); +select col_hasnt_default('contact', 'id_document_type_id'); + +select has_column('contact', 'id_document_number'); +select col_type_is('contact', 'id_document_number', 'text'); +select col_not_null('contact', 'id_document_number'); +select col_hasnt_default('contact', 'id_document_number'); + +select has_column('contact', 'address'); +select col_type_is('contact', 'address', 'text'); +select col_not_null('contact', 'address'); +select col_hasnt_default('contact', 'address'); + +select has_column('contact', 'city'); +select col_type_is('contact', 'city', 'text'); +select col_not_null('contact', 'city'); +select col_hasnt_default('contact', 'city'); + +select has_column('contact', 'province'); +select col_type_is('contact', 'province', 'text'); +select col_not_null('contact', 'province'); +select col_hasnt_default('contact', 'province'); + +select has_column('contact', 'postal_code'); +select col_type_is('contact', 'postal_code', 'text'); +select col_not_null('contact', 'postal_code'); +select col_hasnt_default('contact', 'postal_code'); + +select has_column('contact', 'country_code'); +select col_is_fk('contact', 'country_code'); +select col_type_is('contact', 'country_code', 'country_code'); +select col_not_null('contact', 'country_code'); +select col_hasnt_default('contact', 'country_code'); + +select has_column('contact', 'created_at'); +select col_type_is('contact', 'created_at', 'timestamp with time zone'); +select col_not_null('contact', 'created_at'); +select col_has_default('contact', 'created_at'); +select col_default_is('contact', 'created_at', 'CURRENT_TIMESTAMP'); + +set client_min_messages to warning; +truncate contact cascade; +truncate company_host cascade; +truncate company_user cascade; +truncate company cascade; +truncate auth."user" cascade; +reset client_min_messages; + + +insert into auth."user" (user_id, email, name, password, cookie, cookie_expires_at) +values (1, 'demo@tandem.blog', 'Demo', 'test', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month') + , (5, 'admin@tandem.blog', 'Demo', 'test', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month') +; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, rtc_number, tourist_tax, tourist_tax_max_days, country_code, currency_code, default_lang_tag) +values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '', 60, 7, 'ES', 'EUR', 'ca') + , (4, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', '', 60, 7, 'FR', 'USD', 'ca') +; + +insert into company_user (company_id, user_id, role) +values (2, 1, 'admin') + , (4, 5, 'admin') +; + +insert into company_host (company_id, host) +values (2, 'co2') + , (4, 'co4') +; + +insert into contact (company_id, name, id_document_type_id, id_document_number, address, city, province, postal_code, country_code) +values (2, 'Contact 1', 'D', '41440443Q', 'Fake St', 'City', 'Province', '17600', 'ES') + , (4, 'Contact 2', 'D', '41440443Q', 'Fake St', 'City', 'Province', '17600', 'ES') +; + +prepare contact_data as +select company_id, name +from contact +order by company_id, name; + +set role employee; +select is_empty('contact_data', 'Should show no data when cookie is not set yet'); +reset role; + +select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog', 'co2'); +select bag_eq( + 'contact_data', + $$ values (2, 'Contact 1') + $$, + 'Should only list contacts of the companies where demo@tandem.blog is user of' +); +reset role; + +select set_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog', 'co4'); +select bag_eq( + 'contact_data', + $$ values (4, 'Contact 2') + $$, + 'Should only list contacts of the companies where admin@tandem.blog is user of' +); +reset role; + +select set_cookie('not-a-cookie', 'co2'); +select throws_ok( + 'contact_data', + '42501', 'permission denied for table contact', + 'Should not allow select to guest users' +); +reset role; + +select throws_ok( $$ + insert into contact (company_id, name, id_document_type_id, id_document_number, address, city, province, postal_code, country_code) + values (2, ' ', 'D', '41440443Q', 'Fake St', 'City', 'Province', '17600', 'ES') + $$, + '23514', 'new row for relation "contact" violates check constraint "name_not_empty"', + 'Should not allow contacts with blank trade name' +); + + +select * +from finish(); + +rollback; + diff --git a/test/contact_email.sql b/test/contact_email.sql new file mode 100644 index 0000000..f799da8 --- /dev/null +++ b/test/contact_email.sql @@ -0,0 +1,115 @@ +-- Test contact_email +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(21); + +set search_path to camper, auth, public; + +select has_table('contact_email'); +select has_pk('contact_email' ); +select table_privs_are('contact_email', 'guest', array []::text[]); +select table_privs_are('contact_email', 'employee', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('contact_email', 'admin', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('contact_email', 'authenticator', array []::text[]); + +select has_column('contact_email', 'contact_id'); +select col_is_pk('contact_email', 'contact_id'); +select col_is_fk('contact_email', 'contact_id'); +select fk_ok('contact_email', 'contact_id', 'contact', 'contact_id'); +select col_type_is('contact_email', 'contact_id', 'integer'); +select col_not_null('contact_email', 'contact_id'); +select col_hasnt_default('contact_email', 'contact_id'); + +select has_column('contact_email', 'email'); +select col_type_is('contact_email', 'email', 'email'); +select col_not_null('contact_email', 'email'); +select col_hasnt_default('contact_email', 'email'); + + +set client_min_messages to warning; +truncate contact_email cascade; +truncate contact cascade; +truncate company_host cascade; +truncate company_user cascade; +truncate company cascade; +truncate auth."user" cascade; +reset client_min_messages; + + +insert into auth."user" (user_id, email, name, password, cookie, cookie_expires_at) +values (1, 'demo@tandem.blog', 'Demo', 'test', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month') + , (5, 'admin@tandem.blog', 'Demo', 'test', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month') +; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, rtc_number, tourist_tax, tourist_tax_max_days, country_code, currency_code, default_lang_tag) +values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '', 60, 7, 'ES', 'EUR', 'ca') + , (4, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', '', 60, 7, 'FR', 'USD', 'ca') +; + +insert into company_user (company_id, user_id, role) +values (2, 1, 'admin') + , (4, 5, 'admin') +; + +insert into company_host (company_id, host) +values (2, 'co2') + , (4, 'co4') +; + +insert into contact (contact_id, company_id, name, id_document_type_id, id_document_number, address, city, province, postal_code, country_code) +values (6, 2, 'C1', 'D', '41440443Q', 'Fake St', 'City', 'Province', '17600', 'ES') + , (8, 4, 'C2', 'D', '41440443Q', 'Fake St', 'City', 'Province', '17600', 'ES') + , (9, 4, 'C3', 'D', '41440443Q', 'Fake St', 'City', 'Province', '17600', 'ES') +; + +insert into contact_email (contact_id, email) +values (6, 'c@c') + , (8, 'd@d') +; + +prepare contact_data as +select company_id, email +from contact +join contact_email using (contact_id) +order by company_id, email; + +set role employee; +select is_empty('contact_data', 'Should show no data when cookie is not set yet'); +reset role; + +select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog', 'co2'); +select bag_eq( + 'contact_data', + $$ values (2, 'c@c') + $$, + 'Should only list contacts of the companies where demo@tandem.blog is user of' +); +reset role; + +select set_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog', 'co4'); +select bag_eq( + 'contact_data', + $$ values (4, 'd@d') + $$, + 'Should only list contacts of the companies where admin@tandem.blog is user of' +); +reset role; + +select set_cookie('not-a-cookie', 'co4'); +select throws_ok( + 'contact_data', + '42501', 'permission denied for table contact', + 'Should not allow select to guest users' +); +reset role; + + +select * +from finish(); + +rollback; + diff --git a/test/contact_phone.sql b/test/contact_phone.sql new file mode 100644 index 0000000..3f400b4 --- /dev/null +++ b/test/contact_phone.sql @@ -0,0 +1,114 @@ +-- Test contact_phone +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(21); + +set search_path to camper, auth, public; + +select has_table('contact_phone'); +select has_pk('contact_phone' ); +select table_privs_are('contact_phone', 'guest', array []::text[]); +select table_privs_are('contact_phone', 'employee', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('contact_phone', 'admin', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('contact_phone', 'authenticator', array []::text[]); + +select has_column('contact_phone', 'contact_id'); +select col_is_pk('contact_phone', 'contact_id'); +select col_is_fk('contact_phone', 'contact_id'); +select fk_ok('contact_phone', 'contact_id', 'contact', 'contact_id'); +select col_type_is('contact_phone', 'contact_id', 'integer'); +select col_not_null('contact_phone', 'contact_id'); +select col_hasnt_default('contact_phone', 'contact_id'); + +select has_column('contact_phone', 'phone'); +select col_type_is('contact_phone', 'phone', 'packed_phone_number'); +select col_not_null('contact_phone', 'phone'); +select col_hasnt_default('contact_phone', 'phone'); + + +set client_min_messages to warning; +truncate contact_phone cascade; +truncate contact cascade; +truncate company_host cascade; +truncate company_user cascade; +truncate company cascade; +truncate auth."user" cascade; +reset client_min_messages; + +insert into auth."user" (user_id, email, name, password, cookie, cookie_expires_at) +values (1, 'demo@tandem.blog', 'Demo', 'test', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month') + , (5, 'admin@tandem.blog', 'Demo', 'test', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month') +; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, rtc_number, tourist_tax, tourist_tax_max_days, country_code, currency_code, default_lang_tag) +values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '', 60, 7, 'ES', 'EUR', 'ca') + , (4, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', '', 60, 7, 'FR', 'USD', 'ca') +; + +insert into company_user (company_id, user_id, role) +values (2, 1, 'admin') + , (4, 5, 'admin') +; + +insert into company_host (company_id, host) +values (2, 'co2') + , (4, 'co4') +; + +insert into contact (contact_id, company_id, name, id_document_type_id, id_document_number, address, city, province, postal_code, country_code) +values (6, 2, 'C1', 'D', '41440443Q', 'Fake St', 'City', 'Province', '17600', 'ES') + , (8, 4, 'C2', 'D', '41440443Q', 'Fake St', 'City', 'Province', '17600', 'ES') + , (9, 4, 'C3', 'D', '41440443Q', 'Fake St', 'City', 'Province', '17600', 'ES') +; + +insert into contact_phone (contact_id, phone) +values (6, '777-777-777') + , (8, '888-888-888') +; + +prepare contact_data as +select company_id, phone +from contact +join contact_phone using (contact_id) +order by company_id, phone; + +set role employee; +select is_empty('contact_data', 'Should show no data when cookie is not set yet'); +reset role; + +select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog', 'co2'); +select bag_eq( + 'contact_data', + $$ values (2, '777-777-777'::packed_phone_number) + $$, + 'Should only list contacts of the companies where demo@tandem.blog is user of' +); +reset role; + +select set_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog', 'co4'); +select bag_eq( + 'contact_data', + $$ values (4, '888-888-888'::packed_phone_number) + $$, + 'Should only list contacts of the companies where admin@tandem.blog is user of' +); +reset role; + +select set_cookie('not-a-cookie', 'co4'); +select throws_ok( + 'contact_data', + '42501', 'permission denied for table contact', + 'Should not allow select to guest users' +); +reset role; + + +select * +from finish(); + +rollback; + diff --git a/test/discount_rate.sql b/test/discount_rate.sql new file mode 100644 index 0000000..b3f51dc --- /dev/null +++ b/test/discount_rate.sql @@ -0,0 +1,34 @@ +-- Test discount_rate +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(7); + +set search_path to camper, public; + +select has_domain('discount_rate'); +select domain_type_is('discount_rate', 'numeric'); + +select lives_ok($$ select 1::discount_rate $$, 'Should be able to cast valid 100 % to discount rate'); +select lives_ok($$ select 0.21::discount_rate $$, 'Should be able to cast valid positive decimals to discount rate'); +select lives_ok($$ select 0::discount_rate $$, 'Should be able to cast valid zero to discount rate'); + +select throws_ok( + $$ SELECT (-0.01)::discount_rate $$, + 23514, null, + 'Should reject negative discount rate' +); + +select throws_ok( + $$ SELECT 1.01::discount_rate $$, + 23514, null, + 'Should not allow past the 100 % discount' +); + +select * +from finish(); + +rollback; diff --git a/test/edit_contact.sql b/test/edit_contact.sql new file mode 100644 index 0000000..bc29608 --- /dev/null +++ b/test/edit_contact.sql @@ -0,0 +1,95 @@ +-- Test edit_contact +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(15); + +set search_path to auth, camper, public; + +select has_function('camper', 'edit_contact', array ['uuid', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'country_code']); +select function_lang_is('camper', 'edit_contact', array ['uuid', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'country_code'], 'plpgsql'); +select function_returns('camper', 'edit_contact', array ['uuid', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'country_code'], 'uuid'); +select isnt_definer('camper', 'edit_contact', array ['uuid', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'country_code']); +select volatility_is('camper', 'edit_contact', array ['uuid', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'country_code'], 'volatile'); +select function_privs_are('camper', 'edit_contact', array ['uuid', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'country_code'], 'guest', array []::text[]); +select function_privs_are('camper', 'edit_contact', array ['uuid', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'country_code'], 'employee', array ['EXECUTE']); +select function_privs_are('camper', 'edit_contact', array ['uuid', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'country_code'], 'admin', array ['EXECUTE']); +select function_privs_are('camper', 'edit_contact', array ['uuid', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'text', 'country_code'], 'authenticator', array []::text[]); + + +set client_min_messages to warning; +truncate contact_email cascade; +truncate contact_phone cascade; +truncate contact cascade; +truncate company cascade; +reset client_min_messages; + + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, rtc_number, tourist_tax, tourist_tax_max_days, country_code, currency_code, default_lang_tag) +values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '', 60, 7, 'ES', 'EUR', 'ca') +; + +insert into contact (contact_id, company_id, slug, name, id_document_type_id, id_document_number, address, city, province, postal_code, country_code) +values (12, 1, '7ac3ae0e-b0c1-4206-a19b-0be20835edd4', 'Contact 1', 'C', 'XX555', '', '', '', '', 'FR') + , (13, 1, 'b57b980b-247b-4be4-a0b7-03a7819c53ae', 'Contact 2', 'C', 'XX666', '', '', '', '', 'DE') + , (14, 1, '12fd031b-8f4d-4ac1-9dde-7df336dc6d52', 'Contact 3', 'C', 'XX777', '', '', '', '', 'JP') +; + +insert into contact_phone (contact_id, phone) +values (12, '777-777-777') + , (13, '888-888-888') +; + +insert into contact_email (contact_id, email) +values (12, 'c@c') + , (13, 'd@d') +; + +select lives_ok( + $$ select edit_contact('7ac3ae0e-b0c1-4206-a19b-0be20835edd4', 'Contact 2.1', 'D', '40404040D', '999-999-999', 'c1@c1', 'Fake St., 123', 'City 2.1', 'Province 2.1', '19486', 'ES') $$, + 'Should be able to edit the first contact' +); + +select lives_ok( + $$ select edit_contact('b57b980b-247b-4be4-a0b7-03a7819c53ae', 'Contact 2.2', 'C', 'XXX666', '', '', '', '', '', '', 'GB') $$, + 'Should be able to edit the second contact' +); + +select lives_ok( + $$ select edit_contact('12fd031b-8f4d-4ac1-9dde-7df336dc6d52', 'Contact 2.3', 'D', '41414141L', '111-111-111', 'd2@d2', 'Another Fake St., 123', 'City 2.2', 'Province 2.2', '17417', 'ES') $$, + 'Should be able to edit the third contact' +); + +select bag_eq( + $$ select company_id, name, id_document_type_id, id_document_number, address, city, province, postal_code, country_code from contact $$, + $$ values (1, 'Contact 2.1', 'D', '40404040D', 'Fake St., 123', 'City 2.1', 'Province 2.1', '19486', 'ES') + , (1, 'Contact 2.2', 'C', 'XXX666', '', '', '', '', 'GB') + , (1, 'Contact 2.3', 'D', '41414141L', 'Another Fake St., 123', 'City 2.2', 'Province 2.2', '17417', 'ES') + $$, + 'Should have updated all contacts' +); + +select bag_eq( + $$ select name, phone::text from contact join contact_phone using (contact_id) $$, + $$ values ('Contact 2.1', '+34 999 99 99 99') + , ('Contact 2.3', '+34 111111111') + $$, + 'Should have updated all contacts’ phone' +); + +select bag_eq( + $$ select name, email::text from contact join contact_email using (contact_id) $$, + $$ values ('Contact 2.1', 'c1@c1') + , ('Contact 2.3', 'd2@d2') + $$, + 'Should have updated all contacts’ email' +); + + +select * +from finish(); + +rollback; diff --git a/test/edit_invoice.sql b/test/edit_invoice.sql new file mode 100644 index 0000000..b8eba15 --- /dev/null +++ b/test/edit_invoice.sql @@ -0,0 +1,149 @@ +-- Test edit_invoice +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(15); + +set search_path to auth, camper, public; + +select has_function('camper', 'edit_invoice', array ['uuid', 'text', 'integer', 'text', 'integer', 'edited_invoice_product[]']); +select function_lang_is('camper', 'edit_invoice', array ['uuid', 'text', 'integer', 'text', 'integer', 'edited_invoice_product[]'], 'plpgsql'); +select function_returns('camper', 'edit_invoice', array ['uuid', 'text', 'integer', 'text', 'integer', 'edited_invoice_product[]'], 'uuid'); +select isnt_definer('camper', 'edit_invoice', array ['uuid', 'text', 'integer', 'text', 'integer', 'edited_invoice_product[]']); +select volatility_is('camper', 'edit_invoice', array ['uuid', 'text', 'integer', 'text', 'integer', 'edited_invoice_product[]'], 'volatile'); +select function_privs_are('camper', 'edit_invoice', array ['uuid', 'text', 'integer', 'text', 'integer', 'edited_invoice_product[]'], 'guest', array []::text[]); +select function_privs_are('camper', 'edit_invoice', array ['uuid', 'text', 'integer', 'text', 'integer', 'edited_invoice_product[]'], 'employee', array ['EXECUTE']); +select function_privs_are('camper', 'edit_invoice', array ['uuid', 'text', 'integer', 'text', 'integer', 'edited_invoice_product[]'], 'admin', array ['EXECUTE']); +select function_privs_are('camper', 'edit_invoice', array ['uuid', 'text', 'integer', 'text', 'integer', 'edited_invoice_product[]'], 'authenticator', array []::text[]); + + +set client_min_messages to warning; +truncate invoice_product_tax cascade; +truncate invoice_product cascade; +truncate invoice cascade; +truncate contact cascade; +truncate product cascade; +truncate tax cascade; +truncate tax_class cascade; +truncate payment_method cascade; +truncate company cascade; +reset client_min_messages; + + + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, rtc_number, tourist_tax, tourist_tax_max_days, country_code, currency_code, default_lang_tag) +values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '', 60, 7, 'ES', 'EUR', 'ca') +; + +insert into payment_method (payment_method_id, company_id, name, instructions) +values (111, 1, 'cash', 'cash') + , (112, 1, 'bank', 'send money to my bank account') +; + +insert into tax_class (tax_class_id, company_id, name) +values (11, 1, 'tax') +; + +insert into tax (tax_id, company_id, tax_class_id, name, rate) +values (3, 1, 11, 'IRPF -15 %', -0.15) + , (4, 1, 11, 'IVA 21 %', 0.21) +; + +insert into product (product_id, company_id, name, price) +values ( 7, 1, 'Product 1.1', 1212) + , ( 8, 1, 'Product 2.2', 2424) + , ( 9, 1, 'Product 3.3', 3636) +; + +insert into contact (contact_id, company_id, name, id_document_type_id, id_document_number, address, city, province, postal_code, country_code) +values (12, 1, 'Contact 2.1', 'D', '41440443Q', 'Fake St', 'City', 'Province', '17600', 'ES') + , (13, 1, 'Contact 2.2', 'D', '41440443Q', 'Fake St', 'City', 'Province', '17600', 'ES') +; + +insert into invoice (invoice_id, company_id, slug, invoice_number, invoice_date, contact_id, payment_method_id, currency_code) +values (15, 1, '7ac3ae0e-b0c1-4206-a19b-0be20835edd4', 'INV1', '2023-03-10', 12, 111, 'EUR') + , (16, 1, 'b57b980b-247b-4be4-a0b7-03a7819c53ae', 'INV2', '2023-03-09', 13, 111, 'EUR') +; + +insert into invoice_product (invoice_product_id, invoice_id, name, price) +values (19, 15, 'P1.0', 1100) + , (20, 15, 'P2.0', 2200) + , (21, 16, 'P1.1', 1111) + , (22, 16, 'P2.1', 2211) +; + +insert into invoice_product_product (invoice_product_id, product_id) +values (19, 7) + , (20, 8) + , (21, 7) + , (22, 8) +; + +insert into invoice_product_tax (invoice_product_id, tax_id, tax_rate) +values (19, 4, 0.21) + , (20, 4, 0.21) + , (21, 3, -0.07) + , (21, 4, 0.21) + , (22, 3, -0.15) +; + +select lives_ok( + $$ select edit_invoice('7ac3ae0e-b0c1-4206-a19b-0be20835edd4', 'paid', 13, 'Notes 1', 112, '{"(20,,p1.0,D1,11.01,2,0.50,{4})","(,,p1.3,D3,33.33,3,0.05,{3})"}') $$, + 'Should be able to edit the first invoice' +); + +select lives_ok( + $$ select edit_invoice('b57b980b-247b-4be4-a0b7-03a7819c53ae', 'sent', 12, 'Notes 2', 111, '{"(21,7,P1.1,,11.11,1,0.0,{3})","(22,8,p2.1,D2,24.00,3,0.75,\"{3,4}\")","(,9,p3.3,,36.36,2,0.05,{4})"}') $$, + 'Should be able to edit the second invoice' +); + +select bag_eq( + $$ select invoice_number, invoice_date, contact_id, invoice_status, notes, payment_method_id from invoice $$, + $$ values ('INV1', '2023-03-10'::date, 13, 'paid', 'Notes 1', 112) + , ('INV2', '2023-03-09'::date, 12, 'sent', 'Notes 2', 111) + $$, + 'Should have updated all invoices' +); + +select bag_eq( + $$ select invoice_number, name, description, price, quantity, discount_rate from invoice_product join invoice using (invoice_id) $$, + $$ values ('INV1', 'p1.0', 'D1', 1101, 2, 0.50) + , ('INV1', 'p1.3', 'D3', 3333, 3, 0.05) + , ('INV2', 'P1.1', '', 1111, 1, 0.00) + , ('INV2', 'p2.1', 'D2', 2400, 3, 0.75) + , ('INV2', 'p3.3', '', 3636, 2, 0.05) + $$, + 'Should have updated all existing invoice products, added new ones, and removed the ones not give to the function' +); + +select bag_eq( + $$ select invoice_number, product_id, name from invoice_product left join invoice_product_product using (invoice_product_id) join invoice using (invoice_id) $$, + $$ values ('INV1', NULL, 'p1.0') + , ('INV1', NULL, 'p1.3') + , ('INV2', 7, 'P1.1') + , ('INV2', 8, 'p2.1') + , ('INV2', 9, 'p3.3') + $$, + 'Should have updated all existing invoice products id, added new ones, and removed the ones not give to the function' +); + +select bag_eq( + $$ select invoice_number, name, tax_id, tax_rate from invoice_product_tax join invoice_product using (invoice_product_id) join invoice using (invoice_id) $$, + $$ values ('INV1', 'p1.0', 4, 0.21) + , ('INV1', 'p1.3', 3, -0.15) + , ('INV2', 'P1.1', 3, -0.15) + , ('INV2', 'p2.1', 3, -0.15) + , ('INV2', 'p2.1', 4, 0.21) + , ('INV2', 'p3.3', 4, 0.21) + $$, + 'Should have updated all invoice product taxes, added new ones, and removed the ones not given to the function' +); + + +select * +from finish(); + +rollback; diff --git a/test/edited_invoice_product.sql b/test/edited_invoice_product.sql new file mode 100644 index 0000000..c765f41 --- /dev/null +++ b/test/edited_invoice_product.sql @@ -0,0 +1,27 @@ +-- Test edited_invoice_product +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(10); + +set search_path to camper, public; + +select has_composite('camper', 'edited_invoice_product', 'Composite type camper.edited_invoice_product should exist'); +select columns_are('camper', 'edited_invoice_product', array['invoice_product_id', 'product_id', 'name', 'description', 'price', 'quantity', 'discount_rate', 'tax']); +select col_type_is('camper'::name, 'edited_invoice_product'::name, 'invoice_product_id'::name, 'integer'); +select col_type_is('camper'::name, 'edited_invoice_product'::name, 'product_id'::name, 'integer'); +select col_type_is('camper'::name, 'edited_invoice_product'::name, 'name'::name, 'text'); +select col_type_is('camper'::name, 'edited_invoice_product'::name, 'description'::name, 'text'); +select col_type_is('camper'::name, 'edited_invoice_product'::name, 'price'::name, 'text'); +select col_type_is('camper'::name, 'edited_invoice_product'::name, 'quantity'::name, 'integer'); +select col_type_is('camper'::name, 'edited_invoice_product'::name, 'discount_rate'::name, 'discount_rate'); +select col_type_is('camper'::name, 'edited_invoice_product'::name, 'tax'::name, 'integer[]'); + + +select * +from finish(); + +rollback; diff --git a/test/invoice.sql b/test/invoice.sql new file mode 100644 index 0000000..9d38c78 --- /dev/null +++ b/test/invoice.sql @@ -0,0 +1,188 @@ +-- Test invoice +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(72); + +set search_path to camper, auth, public; + +select has_table('invoice'); +select has_pk('invoice' ); +select table_privs_are('invoice', 'guest', array []::text[]); +select table_privs_are('invoice', 'employee', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('invoice', 'admin', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('invoice', 'authenticator', array []::text[]); + +select has_column('invoice', 'invoice_id'); +select col_is_pk('invoice', 'invoice_id'); +select col_type_is('invoice', 'invoice_id', 'integer'); +select col_not_null('invoice', 'invoice_id'); +select col_hasnt_default('invoice', 'invoice_id'); + +select has_column('invoice', 'company_id'); +select col_is_fk('invoice', 'company_id'); +select fk_ok('invoice', 'company_id', 'company', 'company_id'); +select col_type_is('invoice', 'company_id', 'integer'); +select col_not_null('invoice', 'company_id'); +select col_hasnt_default('invoice', 'company_id'); + +select has_column('invoice', 'slug'); +select col_is_unique('invoice', 'slug'); +select col_type_is('invoice', 'slug', 'uuid'); +select col_not_null('invoice', 'slug'); +select col_has_default('invoice', 'slug'); +select col_default_is('invoice', 'slug', 'gen_random_uuid()'); + +select has_column('invoice', 'invoice_number'); +select col_type_is('invoice', 'invoice_number', 'text'); +select col_not_null('invoice', 'invoice_number'); +select col_hasnt_default('invoice', 'invoice_number'); + +select has_column('invoice', 'invoice_date'); +select col_type_is('invoice', 'invoice_date', 'date'); +select col_not_null('invoice', 'invoice_date'); +select col_has_default('invoice', 'invoice_date'); +select col_default_is('invoice', 'invoice_date', 'CURRENT_DATE'); + +select has_column('invoice', 'contact_id'); +select col_is_fk('invoice', 'contact_id'); +select fk_ok('invoice', 'contact_id', 'contact', 'contact_id'); +select col_type_is('invoice', 'contact_id', 'integer'); +select col_not_null('invoice', 'contact_id'); +select col_hasnt_default('invoice', 'contact_id'); + +select has_column('invoice', 'invoice_status'); +select col_is_fk('invoice', 'invoice_status'); +select fk_ok('invoice', 'invoice_status', 'invoice_status', 'invoice_status'); +select col_type_is('invoice', 'invoice_status', 'text'); +select col_not_null('invoice', 'invoice_status'); +select col_has_default('invoice', 'invoice_status'); +select col_default_is('invoice', 'invoice_status', 'created'); + +select has_column('invoice', 'notes'); +select col_type_is('invoice', 'notes', 'text'); +select col_not_null('invoice', 'notes'); +select col_has_default('invoice', 'notes'); +select col_default_is('invoice', 'notes', ''); + +select has_column('invoice', 'payment_method_id'); +select col_is_fk('invoice', 'payment_method_id'); +select fk_ok('invoice', 'payment_method_id', 'payment_method', 'payment_method_id'); +select col_type_is('invoice', 'payment_method_id', 'integer'); +select col_not_null('invoice', 'payment_method_id'); +select col_hasnt_default('invoice', 'payment_method_id'); + +select has_column('invoice', 'currency_code'); +select col_is_fk('invoice', 'currency_code'); +select fk_ok('invoice', 'currency_code', 'currency', 'currency_code'); +select col_type_is('invoice', 'currency_code', 'text'); +select col_not_null('invoice', 'currency_code'); +select col_hasnt_default('invoice', 'currency_code'); + +select has_column('invoice', 'created_at'); +select col_type_is('invoice', 'created_at', 'timestamp with time zone'); +select col_not_null('invoice', 'created_at'); +select col_has_default('invoice', 'created_at'); +select col_default_is('invoice', 'created_at', 'CURRENT_TIMESTAMP'); + + +set client_min_messages to warning; +truncate invoice cascade; +truncate contact cascade; +truncate payment_method cascade; +truncate company_host 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, cookie, cookie_expires_at) +values (1, 'demo@tandem.blog', 'Demo', 'test', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month') + , (5, 'admin@tandem.blog', 'Demo', 'test', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month') +; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, rtc_number, tourist_tax, tourist_tax_max_days, country_code, currency_code, default_lang_tag) +values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '', 60, 7, 'ES', 'EUR', 'ca') + , (4, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', '', 60, 7, 'FR', 'USD', 'ca') +; + +insert into company_user (company_id, user_id, role) +values (2, 1, 'admin') + , (4, 5, 'admin') +; + +insert into company_host (company_id, host) +values (2, 'co2') + , (4, 'co4') +; + +insert into payment_method (payment_method_id, company_id, name, instructions) +values (444, 4, 'cash', 'cash') + , (222, 2, 'cash', 'cash') +; + +insert into contact (contact_id, company_id, name, id_document_type_id, id_document_number, address, city, province, postal_code, country_code) +values (6, 2, 'Contact 1', 'D', '41440443Q', 'Fake St', 'City', 'Province', '17600', 'ES') + , (8, 4, 'Contact 2', 'D', '41440443Q', 'Fake St', 'City', 'Province', '17600', 'ES') +; + +insert into invoice (company_id, invoice_number, contact_id, currency_code, payment_method_id) +values (2, 'INV020001', 6, 'EUR', 222) + , (4, 'INV040001', 8, 'EUR', 444) +; + + +prepare invoice_data as +select company_id, invoice_number +from invoice +order by company_id, invoice_number; + +set role employee; +select is_empty('invoice_data', 'Should show no data when cookie is not set yet'); +reset role; + +select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog', 'co2'); +select bag_eq( + 'invoice_data', + $$ values (2, 'INV020001') + $$, + 'Should only list invoices of the companies where demo@tandem.blog is user of' +); +reset role; + +select set_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog', 'co4'); +select bag_eq( + 'invoice_data', + $$ values (4, 'INV040001') + $$, + 'Should only list invoices of the companies where admin@tandem.blog is user of' +); +reset role; + +select set_cookie('not-a-cookie', 'co4'); +select throws_ok( + 'invoice_data', + '42501', 'permission denied for table invoice', + 'Should not allow select to guest users' +); +reset role; + + +select throws_ok( $$ + insert into invoice (company_id, invoice_number, contact_id, currency_code, payment_method_id) + values (2, ' ', 6, 'EUR', 222) + $$, + '23514', 'new row for relation "invoice" violates check constraint "invoice_number_not_empty"', + 'Should not allow invoice with blank number' +); + + +select * +from finish(); + +rollback; + diff --git a/test/invoice_amount.sql b/test/invoice_amount.sql new file mode 100644 index 0000000..4ae20f1 --- /dev/null +++ b/test/invoice_amount.sql @@ -0,0 +1,107 @@ +-- Test invoice_amount +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(12); + +set search_path to camper, auth, public; + +select has_view('invoice_amount'); +select table_privs_are('invoice_amount', 'guest', array[]::text[]); +select table_privs_are('invoice_amount', 'employee', array['SELECT']); +select table_privs_are('invoice_amount', 'admin', array['SELECT']); +select table_privs_are('invoice_amount', 'authenticator', array[]::text[]); + +select has_column('invoice_amount', 'invoice_id'); +select col_type_is('invoice_amount', 'invoice_id', 'integer'); + +select has_column('invoice_amount', 'subtotal'); +select col_type_is('invoice_amount', 'subtotal', 'integer'); + +select has_column('invoice_amount', 'total'); +select col_type_is('invoice_amount', 'total', 'integer'); + + +set client_min_messages to warning; +truncate invoice_product_tax cascade; +truncate invoice_product cascade; +truncate invoice cascade; +truncate contact cascade; +truncate tax cascade; +truncate tax_class cascade; +truncate payment_method cascade; +truncate company cascade; +reset client_min_messages; + + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, rtc_number, tourist_tax, tourist_tax_max_days, country_code, currency_code, default_lang_tag) +values (1, 'Company 1', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '', 60, 7, 'ES', 'EUR', 'ca') +; + +insert into payment_method (payment_method_id, company_id, name, instructions) +values (111, 1, 'cash', 'cash') +; + +insert into tax_class (tax_class_id, company_id, name) +values (11, 1, 'tax') +; + +insert into tax (tax_id, company_id, tax_class_id, name, rate) +values (2, 1, 11, 'IRPF -15 %', -0.15) + , (3, 1, 11, 'IVA 4 %', 0.04) + , (4, 1, 11, 'IVA 10 %', 0.10) + , (5, 1, 11, 'IVA 21 %', 0.21) +; + +insert into contact (contact_id, company_id, name, id_document_type_id, id_document_number, address, city, province, postal_code, country_code) +values (7, 1, 'Contact', 'D', '41440443Q', 'Fake St', 'City', 'Province', '17600', 'ES') +; + +insert into invoice (invoice_id, company_id, invoice_number, invoice_date, contact_id, currency_code, payment_method_id) +values ( 8, 1, 'I1', current_date, 7, 'EUR', 111) + , ( 9, 1, 'I2', current_date, 7, 'EUR', 111) + , (10, 1, 'I3', current_date, 7, 'EUR', 111) + , (11, 1, 'I4', current_date, 7, 'EUR', 111) +; + +insert into invoice_product (invoice_product_id, invoice_id, name, price, quantity, discount_rate) +values (12, 8, 'P', 100, 1, 0.0) + , (13, 8, 'P', 200, 2, 0.1) + , (14, 9, 'P', 222, 3, 0.0) + , (15, 9, 'P', 333, 4, 0.2) + , (16, 10, 'P', 444, 5, 0.0) + , (17, 10, 'P', 555, 6, 0.1) + , (18, 11, 'P', 777, 8, 0.0) +; + +insert into invoice_product_tax (invoice_product_id, tax_id, tax_rate) +values (12, 2, -0.15) + , (12, 5, 0.21) + , (13, 3, 0.04) + , (14, 4, 0.10) + , (14, 5, 0.21) + , (14, 2, -0.07) + , (15, 4, 0.10) + , (16, 4, 0.10) + , (16, 5, 0.21) + , (17, 5, 0.21) + , (17, 3, 0.04) +; + +select bag_eq( + $$ select invoice_id, subtotal, total from invoice_amount $$, + $$ values ( 8, 460, 480) + , ( 9, 1732, 1999) + , (10, 5217, 6654) + , (11, 6216, 6216) + $$, + 'Should compute the amount for all taxes in the invoiced products.' +); + +select * +from finish(); + +rollback; diff --git a/test/invoice_number_counter.sql b/test/invoice_number_counter.sql new file mode 100644 index 0000000..d52cc5f --- /dev/null +++ b/test/invoice_number_counter.sql @@ -0,0 +1,141 @@ +-- Test invoice_number_counter +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(28); + +set search_path to camper, auth, public; + +select has_table('invoice_number_counter'); +select has_pk('invoice_number_counter' ); +select col_is_pk('invoice_number_counter', array['company_id', 'year']); + +select table_privs_are('invoice_number_counter', 'guest', array []::text[]); +select table_privs_are('invoice_number_counter', 'employee', array ['SELECT', 'INSERT', 'UPDATE']); +select table_privs_are('invoice_number_counter', 'admin', array ['SELECT', 'INSERT', 'UPDATE']); +select table_privs_are('invoice_number_counter', 'authenticator', array []::text[]); + +select has_column('invoice_number_counter', 'company_id'); +select col_is_fk('invoice_number_counter', 'company_id'); +select fk_ok('invoice_number_counter', 'company_id', 'company', 'company_id'); +select col_type_is('invoice_number_counter', 'company_id', 'integer'); +select col_not_null('invoice_number_counter', 'company_id'); +select col_hasnt_default('invoice_number_counter', 'company_id'); + +select has_column('invoice_number_counter', 'year'); +select col_type_is('invoice_number_counter', 'year', 'integer'); +select col_not_null('invoice_number_counter', 'year'); +select col_hasnt_default('invoice_number_counter', 'year'); + +select has_column('invoice_number_counter', 'currval'); +select col_type_is('invoice_number_counter', 'currval', 'integer'); +select col_not_null('invoice_number_counter', 'currval'); +select col_hasnt_default('invoice_number_counter', 'currval'); + + +set client_min_messages to warning; +truncate invoice_number_counter cascade; +truncate company_host cascade; +truncate company_user cascade; +truncate company cascade; +truncate auth."user" cascade; +reset client_min_messages; + +insert into auth."user" (user_id, email, name, password, cookie, cookie_expires_at) +values (1, 'demo@tandem.blog', 'Demo', 'test', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month') + , (5, 'admin@tandem.blog', 'Demo', 'test', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month') +; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, rtc_number, tourist_tax, tourist_tax_max_days, country_code, currency_code, default_lang_tag) +values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '', 60, 7, 'ES', 'EUR', 'ca') + , (4, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', '', 60, 7, 'FR', 'USD', 'ca') +; + +insert into company_user (company_id, user_id, role) +values (2, 1, 'admin') + , (4, 5, 'admin') +; + +insert into company_host (company_id, host) +values (2, 'co2') + , (4, 'co4') +; + +insert into invoice_number_counter (company_id, year, currval) +values (2, 2010, 6) + , (2, 2011, 8) + , (4, 2010, 8) + , (4, 2012, 10) +; + + +prepare counter_data as +select company_id, year, currval +from invoice_number_counter +; + +set role employee; +select is_empty('counter_data', 'Should show no data when cookie is not set yet'); +reset role; + +select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog', 'co2'); +select bag_eq( + 'counter_data', + $$ values (2, 2010, 6) + , (2, 2011, 8) + $$, + 'Should only list invoices of the companies where demo@tandem.blog is user of' +); +reset role; + +select set_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog', 'co4'); +select bag_eq( + 'counter_data', + $$ values (4, 2010, 8) + , (4, 2012, 10) + $$, + 'Should only list invoices of the companies where admin@tandem.blog is user of' +); +reset role; + +select set_cookie('not-a-cookie', 'co4'); +select throws_ok( + 'counter_data', + '42501', 'permission denied for table invoice_number_counter', + 'Should not allow select to guest users' +); +reset role; + + +select lives_ok( $$ + insert into invoice_number_counter (company_id, year, currval) + values (2, 2009, 0) + $$, + 'Should allow starting a counter from zero' +); + +select throws_ok( $$ + insert into invoice_number_counter (company_id, year, currval) + values (2, 2008, -1) + $$, + '23514', 'new row for relation "invoice_number_counter" violates check constraint "counter_zero_or_positive"', + 'Should not allow starting a counter from a negative value' +); + +select throws_ok( $$ + insert into invoice_number_counter (company_id, year, currval) + values (2, -2008, 1) + $$, + '23514', 'new row for relation "invoice_number_counter" violates check constraint "year_always_positive"', + 'Should not allow counters for invoices issued before Jesus Christ was born' +); + + +select * +from finish(); + +rollback; + diff --git a/test/invoice_product.sql b/test/invoice_product.sql new file mode 100644 index 0000000..3dee449 --- /dev/null +++ b/test/invoice_product.sql @@ -0,0 +1,162 @@ +-- Test invoice_product +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(45); + +set search_path to camper, auth, public; + +select has_table('invoice_product'); +select has_pk('invoice_product' ); +select table_privs_are('invoice_product', 'guest', array []::text[]); +select table_privs_are('invoice_product', 'employee', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('invoice_product', 'admin', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('invoice_product', 'authenticator', array []::text[]); + +select has_column('invoice_product', 'invoice_product_id'); +select col_is_pk('invoice_product', 'invoice_product_id'); +select col_type_is('invoice_product', 'invoice_product_id', 'integer'); +select col_not_null('invoice_product', 'invoice_product_id'); +select col_hasnt_default('invoice_product', 'invoice_product_id'); + +select has_column('invoice_product', 'invoice_id'); +select col_is_fk('invoice_product', 'invoice_id'); +select fk_ok('invoice_product', 'invoice_id', 'invoice', 'invoice_id'); +select col_type_is('invoice_product', 'invoice_id', 'integer'); +select col_not_null('invoice_product', 'invoice_id'); +select col_hasnt_default('invoice_product', 'invoice_id'); + +select has_column('invoice_product', 'name'); +select col_type_is('invoice_product', 'name', 'text'); +select col_not_null('invoice_product', 'name'); +select col_hasnt_default('invoice_product', 'name'); + +select has_column('invoice_product', 'description'); +select col_type_is('invoice_product', 'description', 'text'); +select col_not_null('invoice_product', 'description'); +select col_has_default('invoice_product', 'description'); +select col_default_is('invoice_product', 'description', ''); + +select has_column('invoice_product', 'price'); +select col_type_is('invoice_product', 'price', 'integer'); +select col_not_null('invoice_product', 'price'); +select col_hasnt_default('invoice_product', 'price'); + +select has_column('invoice_product', 'quantity'); +select col_type_is('invoice_product', 'quantity', 'integer'); +select col_not_null('invoice_product', 'quantity'); +select col_has_default('invoice_product', 'quantity'); +select col_default_is('invoice_product', 'quantity', 1); + +select has_column('invoice_product', 'discount_rate'); +select col_type_is('invoice_product', 'discount_rate', 'discount_rate'); +select col_not_null('invoice_product', 'discount_rate'); +select col_has_default('invoice_product', 'discount_rate'); +select col_default_is('invoice_product', 'discount_rate', '0.0'); + + +set client_min_messages to warning; +truncate invoice_product cascade; +truncate invoice cascade; +truncate contact cascade; +truncate payment_method cascade; +truncate company_host cascade; +truncate company_user cascade; +truncate company cascade; +truncate auth."user" cascade; +reset client_min_messages; + +insert into auth."user" (user_id, email, name, password, cookie, cookie_expires_at) +values (1, 'demo@tandem.blog', 'Demo', 'test', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month') + , (5, 'admin@tandem.blog', 'Demo', 'test', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month') +; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, rtc_number, tourist_tax, tourist_tax_max_days, country_code, currency_code, default_lang_tag) +values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '', 60, 7, 'ES', 'EUR', 'ca') + , (4, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', '', 60, 7, 'FR', 'USD', 'ca') +; + +insert into company_user (company_id, user_id, role) +values (2, 1, 'admin') + , (4, 5, 'admin') +; + +insert into company_host (company_id, host) +values (2, 'co2') + , (4, 'co4') +; + +insert into payment_method (payment_method_id, company_id, name, instructions) +values (444, 4, 'cash', 'cash') + , (222, 2, 'cash', 'cash') +; + +insert into contact (contact_id, company_id, name, id_document_type_id, id_document_number, address, city, province, postal_code, country_code) +values (6, 2, 'Contact 1', 'D', '41440443Q', 'Fake St', 'City', 'Province', '17600', 'ES') + , (8, 4, 'Contact 2', 'D', '41440443Q', 'Fake St', 'City', 'Province', '17600', '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 invoice_product (invoice_id, name, description, price, quantity) +values (10, 'product 1', 'description 1', 1212, 1) + , (12, 'product 2', 'description 2', 2424, 2) +; + + +prepare invoice_product_data as +select invoice_id, name, price, quantity +from invoice_product +order by invoice_id; + +set role employee; +select is_empty('invoice_product_data', 'Should show no data when cookie is not set yet'); +reset role; + +select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog', 'co2'); +select bag_eq( + 'invoice_product_data', + $$ values (10, 'product 1', 1212, 1) + $$, + 'Should only list products of invoices of the companies where demo@tandem.blog is user of' +); +reset role; + +select set_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog', 'co4'); +select bag_eq( + 'invoice_product_data', + $$ values (12, 'product 2', 2424, 2) + $$, + 'Should only list products of invoices of the companies where admin@tandem.blog is user of' +); +reset role; + +select set_cookie('not-a-cookie', 'co4'); +select throws_ok( + 'invoice_product_data', + '42501', 'permission denied for table invoice_product', + 'Should not allow select to guest users' +); +reset role; + + +select throws_ok( $$ + insert into invoice_product (invoice_id, name, description, price, quantity) + values (10, ' ', '', 1212, 1) + $$, + '23514', 'new row for relation "invoice_product" violates check constraint "name_not_empty"', + 'Should not allow invoice products with blank name' +); + + +select * +from finish(); + +rollback; + diff --git a/test/invoice_product_amount.sql b/test/invoice_product_amount.sql new file mode 100644 index 0000000..af96763 --- /dev/null +++ b/test/invoice_product_amount.sql @@ -0,0 +1,111 @@ +-- Test invoice_product_amount +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(12); + +set search_path to camper, auth, public; + +select has_view('invoice_product_amount'); +select table_privs_are('invoice_product_amount', 'guest', array[]::text[]); +select table_privs_are('invoice_product_amount', 'employee', array['SELECT']); +select table_privs_are('invoice_product_amount', 'admin', array['SELECT']); +select table_privs_are('invoice_product_amount', 'authenticator', array[]::text[]); + +select has_column('invoice_product_amount', 'invoice_product_id'); +select col_type_is('invoice_product_amount', 'invoice_product_id', 'integer'); + +select has_column('invoice_product_amount', 'subtotal'); +select col_type_is('invoice_product_amount', 'subtotal', 'integer'); + +select has_column('invoice_product_amount', 'total'); +select col_type_is('invoice_product_amount', 'total', 'integer'); + + +set client_min_messages to warning; +truncate invoice_product_tax cascade; +truncate invoice_product cascade; +truncate invoice cascade; +truncate contact cascade; +truncate tax cascade; +truncate tax_class cascade; +truncate payment_method cascade; +truncate company cascade; +reset client_min_messages; + + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, rtc_number, tourist_tax, tourist_tax_max_days, country_code, currency_code, default_lang_tag) +values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '', 60, 7, 'ES', 'EUR', 'ca') +; + +insert into payment_method (payment_method_id, company_id, name, instructions) +values (111, 1, 'cash', 'cash') +; + +insert into tax_class (tax_class_id, company_id, name) +values (11, 1, 'tax') +; + +insert into tax (tax_id, company_id, tax_class_id, name, rate) +values (2, 1, 11, 'IRPF -15 %', -0.15) + , (3, 1, 11, 'IVA 4 %', 0.04) + , (4, 1, 11, 'IVA 10 %', 0.10) + , (5, 1, 11, 'IVA 21 %', 0.21) +; + +insert into contact (contact_id, company_id, name, id_document_type_id, id_document_number, address, city, province, postal_code, country_code) +values (7, 1, 'Contact', 'D', '41440443Q', 'Fake St', 'City', 'Province', '17600', 'ES') +; + +insert into invoice (invoice_id, company_id, invoice_number, invoice_date, contact_id, currency_code, payment_method_id) +values ( 8, 1, 'I1', current_date, 7, 'EUR', 111) + , ( 9, 1, 'I2', current_date, 7, 'EUR', 111) + , (10, 1, 'I3', current_date, 7, 'EUR', 111) + , (11, 1, 'I4', current_date, 7, 'EUR', 111) +; + +insert into invoice_product (invoice_product_id, invoice_id, name, price, quantity, discount_rate) +values (12, 8, 'P', 100, 1, 0.0) + , (13, 8, 'P', 200, 2, 0.1) + , (14, 9, 'P', 222, 3, 0.0) + , (15, 9, 'P', 333, 4, 0.2) + , (16, 10, 'P', 444, 5, 0.0) + , (17, 10, 'P', 555, 6, 0.1) + , (18, 11, 'P', 777, 8, 0.0) +; + +insert into invoice_product_tax (invoice_product_id, tax_id, tax_rate) +values (12, 2, -0.15) + , (12, 5, 0.21) + , (13, 3, 0.04) + , (14, 4, 0.10) + , (14, 5, 0.21) + , (14, 2, -0.07) + , (15, 4, 0.10) + , (16, 4, 0.10) + , (16, 5, 0.21) + , (17, 5, 0.21) + , (17, 3, 0.04) +; + +select bag_eq( + $$ select invoice_product_id, subtotal, total from invoice_product_amount $$, + $$ values (12, 100, 106) + , (13, 360, 374) + , (14, 666, 826) + , (15, 1066, 1173) + , (16, 2220, 2908) + , (17, 2997, 3746) + , (18, 6216, 6216) + $$, + 'Should compute the subtotal and total for all products.' +); + + +select * +from finish(); + +rollback; diff --git a/test/invoice_product_product.sql b/test/invoice_product_product.sql new file mode 100644 index 0000000..e0ef509 --- /dev/null +++ b/test/invoice_product_product.sql @@ -0,0 +1,39 @@ +-- Test invoice_product_product +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(19); + +set search_path to camper, public; + +select has_table('invoice_product_product'); +select has_pk('invoice_product_product' ); +select table_privs_are('invoice_product_product', 'guest', array []::text[]); +select table_privs_are('invoice_product_product', 'employee', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('invoice_product_product', 'admin', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('invoice_product_product', 'authenticator', array []::text[]); + +select has_column('invoice_product_product', 'invoice_product_id'); +select col_is_pk('invoice_product_product', 'invoice_product_id'); +select col_is_fk('invoice_product_product', 'invoice_product_id'); +select fk_ok('invoice_product_product', 'invoice_product_id', 'invoice_product', 'invoice_product_id'); +select col_type_is('invoice_product_product', 'invoice_product_id', 'integer'); +select col_not_null('invoice_product_product', 'invoice_product_id'); +select col_hasnt_default('invoice_product_product', 'invoice_product_id'); + +select has_column('invoice_product_product', 'product_id'); +select col_is_fk('invoice_product_product', 'product_id'); +select fk_ok('invoice_product_product', 'product_id', 'product', 'product_id'); +select col_type_is('invoice_product_product', 'product_id', 'integer'); +select col_not_null('invoice_product_product', 'product_id'); +select col_hasnt_default('invoice_product_product', 'product_id'); + + +select * +from finish(); + +rollback; + diff --git a/test/invoice_product_tax.sql b/test/invoice_product_tax.sql new file mode 100644 index 0000000..e9d00b4 --- /dev/null +++ b/test/invoice_product_tax.sql @@ -0,0 +1,149 @@ +-- Test invoice_product_tax +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(27); + +set search_path to camper, auth, public; + +select has_table('invoice_product_tax'); +select has_pk('invoice_product_tax' ); +select col_is_pk('invoice_product_tax', array['invoice_product_id', 'tax_id']); +select table_privs_are('invoice_product_tax', 'guest', array []::text[]); +select table_privs_are('invoice_product_tax', 'employee', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('invoice_product_tax', 'admin', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('invoice_product_tax', 'authenticator', array []::text[]); + +select has_column('invoice_product_tax', 'invoice_product_id'); +select col_is_fk('invoice_product_tax', 'invoice_product_id'); +select fk_ok('invoice_product_tax', 'invoice_product_id', 'invoice_product', 'invoice_product_id'); +select col_type_is('invoice_product_tax', 'invoice_product_id', 'integer'); +select col_not_null('invoice_product_tax', 'invoice_product_id'); +select col_hasnt_default('invoice_product_tax', 'invoice_product_id'); + +select has_column('invoice_product_tax', 'tax_id'); +select col_is_fk('invoice_product_tax', 'tax_id'); +select fk_ok('invoice_product_tax', 'tax_id', 'tax', 'tax_id'); +select col_type_is('invoice_product_tax', 'tax_id', 'integer'); +select col_not_null('invoice_product_tax', 'tax_id'); +select col_hasnt_default('invoice_product_tax', 'tax_id'); + +select has_column('invoice_product_tax', 'tax_rate'); +select col_type_is('invoice_product_tax', 'tax_rate', 'tax_rate'); +select col_not_null('invoice_product_tax', 'tax_rate'); +select col_hasnt_default('invoice_product_tax', 'tax_rate'); + + +set client_min_messages to warning; +truncate invoice_product_tax cascade; +truncate invoice_product cascade; +truncate invoice cascade; +truncate tax cascade; +truncate tax_class cascade; +truncate contact cascade; +truncate company_host cascade; +truncate company_user cascade; +truncate payment_method cascade; +truncate company cascade; +truncate auth."user" cascade; +reset client_min_messages; + +insert into auth."user" (user_id, email, name, password, cookie, cookie_expires_at) +values (1, 'demo@tandem.blog', 'Demo', 'test', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month') + , (5, 'admin@tandem.blog', 'Demo', 'test', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month') +; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, rtc_number, tourist_tax, tourist_tax_max_days, country_code, currency_code, default_lang_tag) +values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '', 60, 7, 'ES', 'EUR', 'ca') + , (4, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', '', 60, 7, 'FR', 'USD', 'ca') +; + +insert into company_user (company_id, user_id, role) +values (2, 1, 'admin') + , (4, 5, 'admin') +; + +insert into company_host (company_id, host) +values (2, 'co2') + , (4, 'co4') +; + +insert into payment_method (payment_method_id, company_id, name, instructions) +values (444, 4, 'cash', 'cash') + , (222, 2, 'cash', 'cash') +; + +insert into tax_class (tax_class_id, company_id, name) +values (22, 2, 'vat') + , (44, 4, 'vat') +; + +insert into tax (tax_id, company_id, tax_class_id, name, rate) +values (3, 2, 22, 'IVA 21 %', 0.21) + , (6, 4, 44, 'IVA 10 %', 0.10) +; + +insert into contact (contact_id, company_id, name, id_document_type_id, id_document_number, address, city, province, postal_code, country_code) +values ( 9, 2, 'Customer 1', 'D', '41440443Q', 'Fake St', 'City', 'Province', '17600', 'ES') + , (10, 4, 'Customer 2', 'D', '41440443Q', 'Fake St', 'City', 'Province', '17600', 'ES') +; + +insert into invoice (invoice_id, company_id, invoice_number, contact_id, currency_code, payment_method_id) +values (11, 2, 'INV001', 9, 'EUR', 222) + , (12, 4, 'INV002', 10, 'EUR', 444) +; + +insert into invoice_product (invoice_product_id, invoice_id, name, price) +values (13, 11, 'Product 1', 1200) + , (14, 12, 'Product 2', 2400) +; + +insert into invoice_product_tax (invoice_product_id, tax_id, tax_rate) +values (13, 3, 0.10) + , (14, 6, -0.15) +; + +prepare product_tax_data as +select invoice_product_id, tax_id +from invoice_product_tax +order by invoice_product_id, tax_id; + +set role employee; +select is_empty('product_tax_data', 'Should show no data when cookie is not set yet'); +reset role; + +select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog', 'co2'); +select bag_eq( + 'product_tax_data', + $$ values (13, 3) + $$, + 'Should only list tax of products of the companies where demo@tandem.blog is user of' +); +reset role; + +select set_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog', 'co4'); +select bag_eq( + 'product_tax_data', + $$ values (14, 6) + $$, + 'Should only list tax of products of the companies where admin@tandem.blog is user of' +); +reset role; + +select set_cookie('not-a-cookie', 'co4'); +select throws_ok( + 'product_tax_data', + '42501', 'permission denied for table invoice_product_tax', + 'Should not allow select to guest users' +); +reset role; + + +select * +from finish(); + +rollback; + diff --git a/test/invoice_status.sql b/test/invoice_status.sql new file mode 100644 index 0000000..999e5e7 --- /dev/null +++ b/test/invoice_status.sql @@ -0,0 +1,35 @@ +-- Test invoice_status +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(15); + +set search_path to camper, public; + +select has_table('invoice_status'); +select has_pk('invoice_status' ); +select table_privs_are('invoice_status', 'guest', array []::text[]); +select table_privs_are('invoice_status', 'employee', array ['SELECT']); +select table_privs_are('invoice_status', 'admin', array ['SELECT']); +select table_privs_are('invoice_status', 'authenticator', array []::text[]); + +select has_column('invoice_status', 'invoice_status'); +select col_is_pk('invoice_status', 'invoice_status'); +select col_type_is('invoice_status', 'invoice_status', 'text'); +select col_not_null('invoice_status', 'invoice_status'); +select col_hasnt_default('invoice_status', 'invoice_status'); + +select has_column('invoice_status', 'name'); +select col_type_is('invoice_status', 'name', 'text'); +select col_not_null('invoice_status', 'name'); +select col_hasnt_default('invoice_status', 'name'); + + +select * +from finish(); + +rollback; + diff --git a/test/invoice_status_i18n.sql b/test/invoice_status_i18n.sql new file mode 100644 index 0000000..e3e3a8b --- /dev/null +++ b/test/invoice_status_i18n.sql @@ -0,0 +1,44 @@ +-- Test invoice_status_i18n +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(23); + +set search_path to camper, public; + +select has_table('invoice_status_i18n'); +select has_pk('invoice_status_i18n' ); +select col_is_pk('invoice_status_i18n', array['invoice_status', 'lang_tag']); +select table_privs_are('invoice_status_i18n', 'guest', array []::text[]); +select table_privs_are('invoice_status_i18n', 'employee', array ['SELECT']); +select table_privs_are('invoice_status_i18n', 'admin', array ['SELECT']); +select table_privs_are('invoice_status_i18n', 'authenticator', array []::text[]); + +select has_column('invoice_status_i18n', 'invoice_status'); +select col_is_fk('invoice_status_i18n', 'invoice_status'); +select fk_ok('invoice_status_i18n', 'invoice_status', 'invoice_status', 'invoice_status'); +select col_type_is('invoice_status_i18n', 'invoice_status', 'text'); +select col_not_null('invoice_status_i18n', 'invoice_status'); +select col_hasnt_default('invoice_status_i18n', 'invoice_status'); + +select has_column('invoice_status_i18n', 'lang_tag'); +select col_is_fk('invoice_status_i18n', 'lang_tag'); +select fk_ok('invoice_status_i18n', 'lang_tag', 'language', 'lang_tag'); +select col_type_is('invoice_status_i18n', 'lang_tag', 'text'); +select col_not_null('invoice_status_i18n', 'lang_tag'); +select col_hasnt_default('invoice_status_i18n', 'lang_tag'); + +select has_column('invoice_status_i18n', 'name'); +select col_type_is('invoice_status_i18n', 'name', 'text'); +select col_not_null('invoice_status_i18n', 'name'); +select col_hasnt_default('invoice_status_i18n', 'name'); + + +select * +from finish(); + +rollback; + diff --git a/test/invoice_tax_amount.sql b/test/invoice_tax_amount.sql new file mode 100644 index 0000000..e896bea --- /dev/null +++ b/test/invoice_tax_amount.sql @@ -0,0 +1,112 @@ +-- Test invoice_tax_amount +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(12); + +set search_path to camper, auth, public; + +select has_view('invoice_tax_amount'); +select table_privs_are('invoice_tax_amount', 'guest', array[]::text[]); +select table_privs_are('invoice_tax_amount', 'employee', array['SELECT']); +select table_privs_are('invoice_tax_amount', 'admin', array['SELECT']); +select table_privs_are('invoice_tax_amount', 'authenticator', array[]::text[]); + +select has_column('invoice_tax_amount', 'invoice_id'); +select col_type_is('invoice_tax_amount', 'invoice_id', 'integer'); + +select has_column('invoice_tax_amount', 'tax_id'); +select col_type_is('invoice_tax_amount', 'tax_id', 'integer'); + +select has_column('invoice_tax_amount', 'amount'); +select col_type_is('invoice_tax_amount', 'amount', 'integer'); + + +set client_min_messages to warning; +truncate invoice_product_tax cascade; +truncate invoice_product cascade; +truncate invoice cascade; +truncate contact cascade; +truncate tax cascade; +truncate tax_class cascade; +truncate payment_method cascade; +truncate company cascade; +reset client_min_messages; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, rtc_number, tourist_tax, tourist_tax_max_days, country_code, currency_code, default_lang_tag) +values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '', 60, 7, 'ES', 'EUR', 'ca') +; + +insert into payment_method (payment_method_id, company_id, name, instructions) +values (111, 1, 'cash', 'cash') +; + +insert into tax_class (tax_class_id, company_id, name) +values (11, 1, 'tax') +; + +insert into tax (tax_id, company_id, tax_class_id, name, rate) +values (2, 1, 11, 'IRPF -15 %', -0.15) + , (3, 1, 11, 'IVA 4 %', 0.04) + , (4, 1, 11, 'IVA 10 %', 0.10) + , (5, 1, 11, 'IVA 21 %', 0.21) +; + +insert into contact (contact_id, company_id, name, id_document_type_id, id_document_number, address, city, province, postal_code, country_code) +values (7, 1, 'Contact', 'D', '41440443Q', 'Fake St', 'City', 'Province', '17600', 'ES') +; + +insert into invoice (invoice_id, company_id, invoice_number, invoice_date, contact_id, currency_code, payment_method_id) +values ( 8, 1, 'I1', current_date, 7, 'EUR', 111) + , ( 9, 1, 'I2', current_date, 7, 'EUR', 111) + , (10, 1, 'I3', current_date, 7, 'EUR', 111) + , (11, 1, 'I4', current_date, 7, 'EUR', 111) +; + +insert into invoice_product (invoice_product_id, invoice_id, name, price, quantity, discount_rate) +values (12, 8, 'P', 100, 1, 0.0) + , (13, 8, 'P', 200, 2, 0.1) + , (14, 9, 'P', 222, 3, 0.0) + , (15, 9, 'P', 333, 4, 0.2) + , (16, 10, 'P', 444, 5, 0.0) + , (17, 10, 'P', 555, 6, 0.1) + , (18, 11, 'P', 777, 8, 0.0) +; + +insert into invoice_product_tax (invoice_product_id, tax_id, tax_rate) +values (12, 2, -0.15) + , (12, 5, 0.21) + , (13, 3, 0.04) + , (14, 4, 0.10) + , (14, 5, 0.21) + , (14, 2, -0.07) + , (15, 4, 0.10) + , (16, 4, 0.10) + , (16, 5, 0.21) + , (17, 5, 0.21) + , (17, 3, 0.04) +; + +select bag_eq( + $$ select invoice_id, tax_id, amount from invoice_tax_amount $$, + $$ values ( 8, 2, -15) + , ( 8, 3, 14) + , ( 8, 5, 21) + , ( 9, 2, -47) + , ( 9, 4, 174) + , ( 9, 5, 140) + , (10, 3, 120) + , (10, 4, 222) + , (10, 5, 1095) + $$, + 'Should compute the amount for all taxes in the invoiced products.' +); + + +select * +from finish(); + +rollback; diff --git a/test/new_invoice_amount.sql b/test/new_invoice_amount.sql new file mode 100644 index 0000000..f42d34a --- /dev/null +++ b/test/new_invoice_amount.sql @@ -0,0 +1,22 @@ +-- Test new_invoice_amount +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(5); + +set search_path to camper, public; + +select has_composite('camper', 'new_invoice_amount', 'Composite type camper.new_invoice_amount should exist'); +select columns_are('camper', 'new_invoice_amount', array['subtotal', 'taxes', 'total']); +select col_type_is('camper'::name, 'new_invoice_amount'::name, 'subtotal'::name, 'text'); +select col_type_is('camper'::name, 'new_invoice_amount'::name, 'taxes'::name, 'text[]'); +select col_type_is('camper'::name, 'new_invoice_amount'::name, 'total'::name, 'text'); + + +select * +from finish(); + +rollback; diff --git a/test/new_invoice_product.sql b/test/new_invoice_product.sql new file mode 100644 index 0000000..f3cb0cc --- /dev/null +++ b/test/new_invoice_product.sql @@ -0,0 +1,26 @@ +-- Test new_invoice_product +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(9); + +set search_path to camper, public; + +select has_composite('camper', 'new_invoice_product', 'Composite type camper.new_invoice_product should exist'); +select columns_are('camper', 'new_invoice_product', array['product_id', 'name', 'description', 'price', 'quantity', 'discount_rate', 'tax']); +select col_type_is('camper'::name, 'new_invoice_product'::name, 'product_id'::name, 'integer'); +select col_type_is('camper'::name, 'new_invoice_product'::name, 'name'::name, 'text'); +select col_type_is('camper'::name, 'new_invoice_product'::name, 'description'::name, 'text'); +select col_type_is('camper'::name, 'new_invoice_product'::name, 'price'::name, 'text'); +select col_type_is('camper'::name, 'new_invoice_product'::name, 'quantity'::name, 'integer'); +select col_type_is('camper'::name, 'new_invoice_product'::name, 'discount_rate'::name, 'discount_rate'); +select col_type_is('camper'::name, 'new_invoice_product'::name, 'tax'::name, 'integer[]'); + + +select * +from finish(); + +rollback; diff --git a/test/next_invoice_number.sql b/test/next_invoice_number.sql new file mode 100644 index 0000000..838df42 --- /dev/null +++ b/test/next_invoice_number.sql @@ -0,0 +1,51 @@ +-- Test next_invoice_number +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(17); + +set search_path to camper, public; + +select has_function('camper', 'next_invoice_number', array ['integer', 'date']); +select function_lang_is('camper', 'next_invoice_number', array ['integer', 'date'], 'plpgsql'); +select function_returns('camper', 'next_invoice_number', array ['integer', 'date'], 'text'); +select isnt_definer('camper', 'next_invoice_number', array ['integer', 'date']); +select volatility_is('camper', 'next_invoice_number', array ['integer', 'date'], 'volatile'); +select function_privs_are('camper', 'next_invoice_number', array ['integer', 'date'], 'guest', array []::text[]); +select function_privs_are('camper', 'next_invoice_number', array ['integer', 'date'], 'employee', array ['EXECUTE']); +select function_privs_are('camper', 'next_invoice_number', array ['integer', 'date'], 'admin', array ['EXECUTE']); +select function_privs_are('camper', 'next_invoice_number', array ['integer', 'date'], 'authenticator', array []::text[]); + + +set client_min_messages to warning; +truncate invoice_number_counter cascade; +truncate company cascade; +reset client_min_messages; + + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, rtc_number, tourist_tax, tourist_tax_max_days, country_code, currency_code, default_lang_tag, invoice_number_format) +values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '', 60, 7, 'ES', 'EUR', 'ca', '"F"YYYY0000') + , (2, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', '', 60, 7, 'FR', 'USD', 'ca', '"INV"000-YY') +; + +insert into invoice_number_counter (company_id, year, currval) +values (1, 2010, 5) + , (2, 2010, 6) +; + +select is( next_invoice_number(1, '2010-12-25'), 'F20100006' ); +select is( next_invoice_number(2, '2010-12-25'), 'INV007-10' ); +select is( next_invoice_number(1, '2010-10-17'), 'F20100007' ); +select is( next_invoice_number(2, '2010-10-17'), 'INV008-10' ); +select is( next_invoice_number(1, '2011-12-25'), 'F20110001' ); +select is( next_invoice_number(2, '2012-12-25'), 'INV001-12' ); +select is( next_invoice_number(1, '2011-12-25'), 'F20110002' ); +select is( next_invoice_number(2, '2012-12-25'), 'INV002-12' ); + +select * +from finish(); + +rollback; diff --git a/test/payment_method.sql b/test/payment_method.sql new file mode 100644 index 0000000..9eb4798 --- /dev/null +++ b/test/payment_method.sql @@ -0,0 +1,125 @@ +-- Test payment_method +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(30); + +set search_path to camper, auth, public; + +select has_table('payment_method'); +Select has_pk('payment_method' ); +select table_privs_are('payment_method', 'guest', array []::text[]); +select table_privs_are('payment_method', 'employee', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('payment_method', 'admin', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('payment_method', 'authenticator', array []::text[]); + +select has_column('payment_method', 'payment_method_id'); +select col_is_pk('payment_method', 'payment_method_id'); +select col_type_is('payment_method', 'payment_method_id', 'integer'); +select col_not_null('payment_method', 'payment_method_id'); +select col_hasnt_default('payment_method', 'payment_method_id'); + +select has_column('payment_method', 'company_id'); +select col_is_fk('payment_method', 'company_id'); +select fk_ok('payment_method', 'company_id', 'company', 'company_id'); +select col_type_is('payment_method', 'company_id', 'integer'); +select col_not_null('payment_method', 'company_id'); +select col_hasnt_default('payment_method', 'company_id'); + +select has_column('payment_method', 'name'); +select col_type_is('payment_method', 'name', 'text'); +select col_not_null('payment_method', 'name'); +select col_hasnt_default('payment_method', 'name'); + +select has_column('payment_method', 'instructions'); +select col_type_is('payment_method', 'instructions', 'text'); +select col_not_null('payment_method', 'instructions'); +select col_hasnt_default('payment_method', 'instructions'); + + +set client_min_messages to warning; +truncate payment_method cascade; +truncate company_host cascade; +truncate company_user cascade; +truncate company cascade; +truncate auth."user" cascade; +reset client_min_messages; + +insert into auth."user" (user_id, email, name, password, cookie, cookie_expires_at) +values (1, 'demo@tandem.blog', 'Demo', 'test', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month') + , (5, 'admin@tandem.blog', 'Demo', 'test', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month') +; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, rtc_number, tourist_tax, tourist_tax_max_days, country_code, currency_code, default_lang_tag) +values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '', 60, 7, 'ES', 'EUR', 'ca') + , (4, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', '', 60, 7, 'FR', 'USD', 'ca') +; + +insert into company_user (company_id, user_id, role) +values (2, 1, 'admin') + , (4, 5, 'admin') +; + +insert into company_host (company_id, host) +values (2, 'co2') + , (4, 'co4') +; + +insert into payment_method(payment_method_id, company_id, name, instructions) +values (222, 2, 'Cash', 'Payment in cash.') + , (223, 2, 'Wire Transfer', 'Please, transfer money to my Nigerian bank') + , (444, 4, 'Pigeon', 'Send money via carrier pigeon.') +; + +prepare payment_method_data as +select company_id, name, instructions +from payment_method; + +set role employee; +select is_empty('payment_method_data', 'Should show no data when cookie is not set yet'); +reset role; + +select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog', 'co2'); +select bag_eq( + 'payment_method_data', + $$ values ( 2, 'Cash', 'Payment in cash.' ) + , ( 2, 'Wire Transfer', 'Please, transfer money to my Nigerian bank' ) + $$, + 'Should only list payment methods of the companies where demo@tandem.blog is user of' +); +reset role; + +select set_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog', 'co4'); +select bag_eq( + 'payment_method_data', + $$ values (4, 'Pigeon', 'Send money via carrier pigeon.' ) + $$, + 'Should only list payment methods of the companies where admin@tandem.blog is user of' +); +reset role; + +select set_cookie('not-a-cookie', 'co4'); +select throws_ok( + 'payment_method_data', + '42501', 'permission denied for table payment_method', + 'Should not allow select to guest users' +); +reset role; + +select throws_ok( $$ + insert into payment_method (company_id, name, instructions) + values (2, ' ', 'atasht') + $$, + '23514', 'new row for relation "payment_method" violates check constraint "name_not_empty"', + 'Should not allow payment methods with blank name' +); + + +select * +from finish(); + +rollback; + diff --git a/test/product.sql b/test/product.sql new file mode 100644 index 0000000..817ab51 --- /dev/null +++ b/test/product.sql @@ -0,0 +1,143 @@ +-- Test product +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(45); + +set search_path to camper, auth, public; + +select has_table('product'); +select has_pk('product' ); +select table_privs_are('product', 'guest', array []::text[]); +select table_privs_are('product', 'employee', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('product', 'admin', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('product', 'authenticator', array []::text[]); + +select has_column('product', 'product_id'); +select col_is_pk('product', 'product_id'); +select col_type_is('product', 'product_id', 'integer'); +select col_not_null('product', 'product_id'); +select col_hasnt_default('product', 'product_id'); + +select has_column('product', 'company_id'); +select col_is_fk('product', 'company_id'); +select fk_ok('product', 'company_id', 'company', 'company_id'); +select col_type_is('product', 'company_id', 'integer'); +select col_not_null('product', 'company_id'); +select col_hasnt_default('product', 'company_id'); + +select has_column('product', 'slug'); +select col_type_is('product', 'slug', 'uuid'); +select col_not_null('product', 'slug'); +select col_has_default('product', 'slug'); +select col_default_is('product', 'slug', 'gen_random_uuid()'); + +select has_column('product', 'name'); +select col_type_is('product', 'name', 'text'); +select col_not_null('product', 'name'); +select col_hasnt_default('product', 'name'); + +select has_column('product', 'description'); +select col_type_is('product', 'description', 'text'); +select col_not_null('product', 'description'); +select col_has_default('product', 'description'); +select col_default_is('product', 'description', ''); + +select has_column('product', 'price'); +select col_type_is('product', 'price', 'integer'); +select col_not_null('product', 'price'); +select col_hasnt_default('product', 'price'); + +select has_column('product', 'created_at'); +select col_type_is('product', 'created_at', 'timestamp with time zone'); +select col_not_null('product', 'created_at'); +select col_has_default('product', 'created_at'); +select col_default_is('product', 'created_at', 'CURRENT_TIMESTAMP'); + + +set client_min_messages to warning; +truncate product cascade; +truncate company_host cascade; +truncate company_user cascade; +truncate company cascade; +truncate auth."user" cascade; +reset client_min_messages; + +insert into auth."user" (user_id, email, name, password, cookie, cookie_expires_at) +values (1, 'demo@tandem.blog', 'Demo', 'test', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month') + , (5, 'admin@tandem.blog', 'Demo', 'test', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month') +; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, rtc_number, tourist_tax, tourist_tax_max_days, country_code, currency_code, default_lang_tag) +values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '', 60, 7, 'ES', 'EUR', 'ca') + , (4, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', '', 60, 7, 'FR', 'USD', 'ca') +; + +insert into company_user (company_id, user_id, role) +values (2, 1, 'admin') + , (4, 5, 'admin') +; + +insert into company_host (company_id, host) +values (2, 'co2') + , (4, 'co4') +; + +insert into product (company_id, name, description, price) +values (2, 'Product 1', 'Description 1', 1200) + , (4, 'Product 2', 'Description 2', 2400) +; + +prepare product_data as +select company_id, name +from product +order by company_id, name; + +set role employee; +select is_empty('product_data', 'Should show no data when cookie is not set yet'); +reset role; + +select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog', 'co2'); +select bag_eq( + 'product_data', + $$ values (2, 'Product 1') + $$, + 'Should only list products of the companies where demo@tandem.blog is user of' +); +reset role; + +select set_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog', 'co4'); +select bag_eq( + 'product_data', + $$ values (4, 'Product 2') + $$, + 'Should only list products of the companies where admin@tandem.blog is user of' +); +reset role; + +select set_cookie('not-a-cookie', 'co4'); +select throws_ok( + 'product_data', + '42501', 'permission denied for table product', + 'Should not allow select to guest users' +); +reset role; + + +select throws_ok( $$ + insert into product (company_id, name, description, price) + values (2, ' ', '', 1200) + $$, + '23514', 'new row for relation "product" violates check constraint "name_not_empty"', + 'Should not allow product with blank name' +); + + +select * +from finish(); + +rollback; + diff --git a/test/product_tax.sql b/test/product_tax.sql new file mode 100644 index 0000000..aac3a27 --- /dev/null +++ b/test/product_tax.sql @@ -0,0 +1,126 @@ +-- Test product_tax +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(23); + +set search_path to camper, auth, public; + +select has_table('product_tax'); +select has_pk('product_tax' ); +select col_is_pk('product_tax', array['product_id', 'tax_id']); +select table_privs_are('product_tax', 'guest', array []::text[]); +select table_privs_are('product_tax', 'employee', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('product_tax', 'admin', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('product_tax', 'authenticator', array []::text[]); + +select has_column('product_tax', 'product_id'); +select col_is_fk('product_tax', 'product_id'); +select fk_ok('product_tax', 'product_id', 'product', 'product_id'); +select col_type_is('product_tax', 'product_id', 'integer'); +select col_not_null('product_tax', 'product_id'); +select col_hasnt_default('product_tax', 'product_id'); + +select has_column('product_tax', 'tax_id'); +select col_is_fk('product_tax', 'tax_id'); +select fk_ok('product_tax', 'tax_id', 'tax', 'tax_id'); +select col_type_is('product_tax', 'tax_id', 'integer'); +select col_not_null('product_tax', 'tax_id'); +select col_hasnt_default('product_tax', 'tax_id'); + + +set client_min_messages to warning; +truncate product_tax cascade; +truncate product cascade; +truncate tax cascade; +truncate tax_class cascade; +truncate company_host cascade; +truncate company_user cascade; +truncate company cascade; +truncate auth."user" cascade; +reset client_min_messages; + +insert into auth."user" (user_id, email, name, password, cookie, cookie_expires_at) +values (1, 'demo@tandem.blog', 'Demo', 'test', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month') + , (5, 'admin@tandem.blog', 'Demo', 'test', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month') +; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, rtc_number, tourist_tax, tourist_tax_max_days, country_code, currency_code, default_lang_tag) +values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '', 60, 7, 'ES', 'EUR', 'ca') + , (4, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', '', 60, 7, 'FR', 'USD', 'ca') +; + +insert into company_user (company_id, user_id, role) +values (2, 1, 'admin') + , (4, 5, 'admin') +; + +insert into company_host (company_id, host) +values (2, 'co2') + , (4, 'co4') +; + +insert into tax_class (tax_class_id, company_id, name) +values (22, 2, 'iva') + , (44, 4, 'iva') +; + +insert into tax (tax_id, company_id, tax_class_id, name, rate) +values (3, 2, 22, 'IVA 21 %', 0.21) + , (6, 4, 44, 'IVA 10 %', 0.10) +; + +insert into product (product_id, company_id, name, description, price) +values (7, 2, 'Product 1', 'Description 1', 1200) + , (8, 4, 'Product 2', 'Description 2', 2400) +; + +insert into product_tax (product_id, tax_id) +values (7, 3) + , (8, 6) +; + +prepare product_tax_data as +select product_id, tax_id +from product_tax +order by product_id, tax_id; + +set role employee; +select is_empty('product_tax_data', 'Should show no data when cookie is not set yet'); +reset role; + +select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog', 'co2'); +select bag_eq( + 'product_tax_data', + $$ values (7, 3) + $$, + 'Should only list tax of products of the companies where demo@tandem.blog is user of' +); +reset role; + +select set_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog', 'co4'); +select bag_eq( + 'product_tax_data', + $$ values (8, 6) + $$, + 'Should only list tax of products of the companies where admin@tandem.blog is user of' +); +reset role; + +select set_cookie('not-a-cookie', 'co2'); +select throws_ok( + 'product_tax_data', + '42501', 'permission denied for table product_tax', + 'Should not allow select to guest users' +); +reset role; + + +select * +from finish(); + +rollback; + diff --git a/test/tax.sql b/test/tax.sql new file mode 100644 index 0000000..e4c00bc --- /dev/null +++ b/test/tax.sql @@ -0,0 +1,147 @@ +-- Test tax +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(36); + +set search_path to camper, auth, public; + +select has_table('tax'); +select has_pk('tax' ); +select table_privs_are('tax', 'guest', array []::text[]); +select table_privs_are('tax', 'employee', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('tax', 'admin', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('tax', 'authenticator', array []::text[]); + +select has_column('tax', 'tax_id'); +select col_is_pk('tax', 'tax_id'); +select col_type_is('tax', 'tax_id', 'integer'); +select col_not_null('tax', 'tax_id'); +select col_hasnt_default('tax', 'tax_id'); + +select has_column('tax', 'company_id'); +select col_is_fk('tax', 'company_id'); +select fk_ok('tax', 'company_id', 'company', 'company_id'); +select col_type_is('tax', 'company_id', 'integer'); +select col_not_null('tax', 'company_id'); +select col_hasnt_default('tax', 'company_id'); + +select has_column('tax', 'tax_class_id'); +select col_is_fk('tax', 'tax_class_id'); +select fk_ok('tax', 'tax_class_id', 'tax_class', 'tax_class_id'); +select col_type_is('tax', 'tax_class_id', 'integer'); +select col_not_null('tax', 'tax_class_id'); +select col_hasnt_default('tax', 'tax_class_id'); + +select has_column('tax', 'name'); +select col_type_is('tax', 'name', 'text'); +select col_not_null('tax', 'name'); +select col_hasnt_default('tax', 'name'); + +select has_column('tax', 'rate'); +select col_type_is('tax', 'rate', 'tax_rate'); +select col_not_null('tax', 'rate'); +select col_hasnt_default('tax', 'rate'); + + +set client_min_messages to warning; +truncate tax cascade; +truncate tax_class cascade; +truncate company_host cascade; +truncate company_user cascade; +truncate company cascade; +truncate auth."user" cascade; +reset client_min_messages; + +insert into auth."user" (user_id, email, name, password, cookie, cookie_expires_at) +values (1, 'demo@tandem.blog', 'Demo', 'test', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month') + , (5, 'admin@tandem.blog', 'Demo', 'test', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month') +; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, rtc_number, tourist_tax, tourist_tax_max_days, country_code, currency_code, default_lang_tag) +values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '', 60, 7, 'ES', 'EUR', 'ca') + , (4, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', '', 60, 7, 'FR', 'USD', 'ca') +; + +insert into company_user (company_id, user_id, role) +values (2, 1, 'admin') + , (4, 5, 'admin') +; + +insert into company_host (company_id, host) +values (2, 'co2') + , (4, 'co4') +; + +insert into tax_class(tax_class_id, company_id, name) +values (5, 2, 'vat') + , (6, 2, 'irpf') + , (7, 4, 'vat') +; + +insert into tax (company_id, tax_class_id, name, rate) +values (2, 5, 'VAT 21 %', 0.21) + , (2, 6, 'IRPF -15 %', -0.15) + , (4, 7, 'VAT 21 %', 0.21) + , (4, 7, 'VAT 10 %', 0.10) + , (4, 7, 'VAT 5 %', 0.05) + , (4, 7, 'VAT 4 %', 0.04) + , (4, 7, 'VAT 0 %', 0.00) +; + +prepare tax_data as +select company_id, name, rate +from tax +order by company_id, rate; + +set role employee; +select is_empty('tax_data', 'Should show no data when cookie is not set yet'); +reset role; + +select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog', 'co2'); +select bag_eq( + 'tax_data', + $$ values ( 2, 'IRPF -15 %', -0.15::tax_rate ) + , ( 2, 'VAT 21 %', 0.21::tax_rate ) + $$, + 'Should only list taxes of the companies where demo@tandem.blog is user of' +); +reset role; + +select set_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog', 'co4'); +select bag_eq( + 'tax_data', + $$ values (4, 'VAT 0 %', 0.00::tax_rate) + , (4, 'VAT 4 %', 0.04::tax_rate) + , (4, 'VAT 5 %', 0.05::tax_rate) + , (4, 'VAT 10 %', 0.10::tax_rate) + , (4, 'VAT 21 %', 0.21::tax_rate) + $$, + 'Should only list taxes of the companies where admin@tandem.blog is user of' +); +reset role; + +select set_cookie('not-a-cookie', 'co4'); +select throws_ok( + 'tax_data', + '42501', 'permission denied for table tax', + 'Should not allow select to guest users' +); +reset role; + +select throws_ok( $$ + insert into tax (company_id, tax_class_id, name, rate) + values (2, 6, ' ', 0.22) + $$, + '23514', 'new row for relation "tax" violates check constraint "name_not_empty"', + 'Should not allow taxs with blank name' +); + +select * +from finish(); + +rollback; + diff --git a/test/tax_class.sql b/test/tax_class.sql new file mode 100644 index 0000000..cdfd728 --- /dev/null +++ b/test/tax_class.sql @@ -0,0 +1,123 @@ +-- Test tax_class +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(26); + +set search_path to camper, auth, public; + +select has_table('tax_class'); +select has_pk('tax_class' ); +select table_privs_are('tax_class', 'guest', array []::text[]); +select table_privs_are('tax_class', 'employee', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('tax_class', 'admin', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('tax_class', 'authenticator', array []::text[]); + +select has_column('tax_class', 'tax_class_id'); +select col_is_pk('tax_class', 'tax_class_id'); +select col_type_is('tax_class', 'tax_class_id', 'integer'); +select col_not_null('tax_class', 'tax_class_id'); +select col_hasnt_default('tax_class', 'tax_class_id'); + +select has_column('tax_class', 'company_id'); +select col_is_fk('tax_class', 'company_id'); +select fk_ok('tax_class', 'company_id', 'company', 'company_id'); +select col_type_is('tax_class', 'company_id', 'integer'); +select col_not_null('tax_class', 'company_id'); +select col_hasnt_default('tax_class', 'company_id'); + +select has_column('tax_class', 'name'); +select col_type_is('tax_class', 'name', 'text'); +select col_not_null('tax_class', 'name'); +select col_hasnt_default('tax_class', 'name'); + + +set client_min_messages to warning; +truncate tax_class cascade; +truncate company_host cascade; +truncate company_user cascade; +truncate company cascade; +truncate auth."user" cascade; +reset client_min_messages; + +insert into auth."user" (user_id, email, name, password, cookie, cookie_expires_at) +values (1, 'demo@tandem.blog', 'Demo', 'test', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month') + , (5, 'admin@tandem.blog', 'Demo', 'test', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month') +; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, rtc_number, tourist_tax, tourist_tax_max_days, country_code, currency_code, default_lang_tag) +values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '', 60, 7, 'ES', 'EUR', 'ca') + , (4, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', '', 60, 7, 'FR', 'USD', 'ca') +; + +insert into company_user (company_id, user_id, role) +values (2, 1, 'admin') + , (4, 5, 'admin') +; + +insert into company_host (company_id, host) +values (2, 'co2') + , (4, 'co4') +; + +insert into tax_class (company_id, name) +values (2, 'VAT') + , (2, 'IRPF') + , (4, 'VAT') + , (4, 'import') +; + +prepare tax_class_data as +select company_id, name +from tax_class +order by company_id; + +set role employee; +select is_empty('tax_class_data', 'Should show no data when cookie is not set yet'); +reset role; + +select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog', 'co2'); +select bag_eq( + 'tax_class_data', + $$ values (2, 'VAT') + , (2, 'IRPF') + $$, + 'Should only list taxes of the companies where demo@tandem.blog is user of' +); +reset role; + +select set_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog', 'co4'); +select bag_eq( + 'tax_class_data', + $$ values (4, 'VAT') + , (4, 'import') + $$, + 'Should only list taxes of the companies where admin@tandem.blog is user of' +); +reset role; + +select set_cookie('not-a-cookie', 'co2'); +select throws_ok( + 'tax_class_data', + '42501', 'permission denied for table tax_class', + 'Should not allow select to guest users' +); +reset role; + +select throws_ok( $$ + insert into tax_class (company_id, name) + values (2, ' ') + $$, + '23514', 'new row for relation "tax_class" violates check constraint "name_not_empty"', + 'Should not allow classes with blank name' +); + + +select * +from finish(); + +rollback; + diff --git a/test/tax_rate.sql b/test/tax_rate.sql new file mode 100644 index 0000000..10d1dae --- /dev/null +++ b/test/tax_rate.sql @@ -0,0 +1,34 @@ +-- Test tax_rate +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(7); + +set search_path to camper, public; + +select has_domain('tax_rate'); +select domain_type_is('tax_rate', 'numeric'); + +select lives_ok($$ select 0.21::tax_rate $$, 'Should be able to cast valid positive decimals to tax rate'); +select lives_ok($$ select (-0.15)::tax_rate $$, 'Should be able to cast valid negative decimals to tax rate'); +select lives_ok($$ select 0::tax_rate $$, 'Should be able to cast valid zero to tax rate'); + +select throws_ok( + $$ SELECT 1::tax_rate $$, + 23514, null, + 'Should reject 100 % tax rate' +); + +select throws_ok( + $$ SELECT (-1)::tax_rate $$, + 23514, null, + 'Should reject -100 % tax rate' +); + +select * +from finish(); + +rollback; diff --git a/verify/add_contact.sql b/verify/add_contact.sql new file mode 100644 index 0000000..acb1bee --- /dev/null +++ b/verify/add_contact.sql @@ -0,0 +1,9 @@ +-- Verify camper:add_contact on pg + +begin; + +set search_path to camper, public; + +select has_function_privilege('add_contact(integer, text, text, text, text, text, text, text, text, text, country_code)', 'execute'); + +rollback; diff --git a/verify/add_invoice.sql b/verify/add_invoice.sql new file mode 100644 index 0000000..1fdf7e5 --- /dev/null +++ b/verify/add_invoice.sql @@ -0,0 +1,7 @@ +-- Verify camper:add_invoice on pg + +begin; + +select has_function_privilege('camper.add_invoice(integer, date, integer, text, integer, camper.new_invoice_product[])', 'execute'); + +rollback; diff --git a/verify/available_currencies.sql b/verify/available_currencies.sql index 252e846..d507ba8 100644 --- a/verify/available_currencies.sql +++ b/verify/available_currencies.sql @@ -1,4 +1,4 @@ --- Verify numerus:available_currencies on pg +-- Verify camper:available_currencies on pg begin; diff --git a/verify/available_invoice_status.sql b/verify/available_invoice_status.sql new file mode 100644 index 0000000..e169528 --- /dev/null +++ b/verify/available_invoice_status.sql @@ -0,0 +1,21 @@ +-- Verify camper:available_invoice_status on pg + +begin; + +set search_path to camper; + +select 1 / count(*) from invoice_status where invoice_status = 'created' and name ='Created'; +select 1 / count(*) from invoice_status where invoice_status = 'sent' and name ='Sent'; +select 1 / count(*) from invoice_status where invoice_status = 'paid' and name ='Paid'; +select 1 / count(*) from invoice_status where invoice_status = 'unpaid' and name ='Unpaid'; + +select 1 / count(*) from invoice_status_i18n where invoice_status = 'created' and name ='Creada' and lang_tag = 'ca'; +select 1 / count(*) from invoice_status_i18n where invoice_status = 'created' and name ='Creada' and lang_tag = 'es'; +select 1 / count(*) from invoice_status_i18n where invoice_status = 'sent' and name ='Enviada' and lang_tag= 'ca'; +select 1 / count(*) from invoice_status_i18n where invoice_status = 'sent' and name ='Enviada' and lang_tag= 'es'; +select 1 / count(*) from invoice_status_i18n where invoice_status = 'paid' and name ='Cobrada' and lang_tag= 'ca'; +select 1 / count(*) from invoice_status_i18n where invoice_status = 'paid' and name ='Cobrada' and lang_tag= 'es'; +select 1 / count(*) from invoice_status_i18n where invoice_status = 'unpaid' and name ='No cobrada' and lang_tag= 'ca'; +select 1 / count(*) from invoice_status_i18n where invoice_status = 'unpaid' and name ='No cobrada' and lang_tag= 'es'; + +rollback; diff --git a/verify/compute_new_invoice_amount.sql b/verify/compute_new_invoice_amount.sql new file mode 100644 index 0000000..10d70b9 --- /dev/null +++ b/verify/compute_new_invoice_amount.sql @@ -0,0 +1,7 @@ +-- Verify camper:compute_new_invoice_amount on pg + +begin; + +select has_function_privilege('camper.compute_new_invoice_amount(integer, camper.new_invoice_product[])', 'execute'); + +rollback; diff --git a/verify/contact.sql b/verify/contact.sql new file mode 100644 index 0000000..fdd469f --- /dev/null +++ b/verify/contact.sql @@ -0,0 +1,23 @@ +-- Verify camper:contact on pg + +begin; + +select contact_id + , company_id + , slug + , name + , id_document_type_id + , id_document_number + , address + , city + , province + , postal_code + , country_code + , created_at +from camper.contact +where false; + +select 1 / count(*) from pg_class where oid = 'camper.contact'::regclass and relrowsecurity; +select 1 / count(*) from pg_policy where polname = 'company_policy' and polrelid = 'camper.contact'::regclass; + +rollback; diff --git a/verify/contact_email.sql b/verify/contact_email.sql new file mode 100644 index 0000000..456b6c2 --- /dev/null +++ b/verify/contact_email.sql @@ -0,0 +1,13 @@ +-- Verify camper:contact_email on pg + +begin; + +select contact_id + , email +from camper.contact_email +where false; + +select 1 / count(*) from pg_class where oid = 'camper.contact_email'::regclass and relrowsecurity; +select 1 / count(*) from pg_policy where polname = 'company_policy' and polrelid = 'camper.contact_email'::regclass; + +rollback; diff --git a/verify/contact_phone.sql b/verify/contact_phone.sql new file mode 100644 index 0000000..1741221 --- /dev/null +++ b/verify/contact_phone.sql @@ -0,0 +1,13 @@ +-- Verify camper:contact_phone on pg + +begin; + +select contact_id + , phone +from camper.contact_phone +where false; + +select 1 / count(*) from pg_class where oid = 'camper.contact_phone'::regclass and relrowsecurity; +select 1 / count(*) from pg_policy where polname = 'company_policy' and polrelid = 'camper.contact_phone'::regclass; + +rollback; diff --git a/verify/discount_rate.sql b/verify/discount_rate.sql new file mode 100644 index 0000000..01b0fec --- /dev/null +++ b/verify/discount_rate.sql @@ -0,0 +1,7 @@ +-- Verify camper:discount_rate on pg + +begin; + +select pg_catalog.has_type_privilege('camper.discount_rate', 'usage'); + +rollback; diff --git a/verify/edit_contact.sql b/verify/edit_contact.sql new file mode 100644 index 0000000..008567a --- /dev/null +++ b/verify/edit_contact.sql @@ -0,0 +1,9 @@ +-- Verify camper:edit_contact on pg + +begin; + +set search_path to camper, public; + +select has_function_privilege('edit_contact(uuid, text, text, text, text, text, text, text, text, text, country_code)', 'execute'); + +rollback; diff --git a/verify/edit_invoice.sql b/verify/edit_invoice.sql new file mode 100644 index 0000000..af05c31 --- /dev/null +++ b/verify/edit_invoice.sql @@ -0,0 +1,7 @@ +-- Verify camper:edit_invoice on pg + +begin; + +select has_function_privilege('camper.edit_invoice(uuid, text, integer, text, integer, camper.edited_invoice_product[])', 'execute'); + +rollback; diff --git a/verify/edited_invoice_product.sql b/verify/edited_invoice_product.sql new file mode 100644 index 0000000..e3464c9 --- /dev/null +++ b/verify/edited_invoice_product.sql @@ -0,0 +1,7 @@ +-- Verify camper:edited_invoice_product on pg + +begin; + +select pg_catalog.has_type_privilege('camper.edited_invoice_product', 'usage'); + +rollback; diff --git a/verify/invoice.sql b/verify/invoice.sql new file mode 100644 index 0000000..cde3158 --- /dev/null +++ b/verify/invoice.sql @@ -0,0 +1,22 @@ +-- Verify camper:invoice on pg + +begin; + +select invoice_id + , company_id + , slug + , invoice_number + , invoice_date + , contact_id + , invoice_status + , notes + , payment_method_id + , currency_code + , created_at +from camper.invoice +where false; + +select 1 / count(*) from pg_class where oid = 'camper.invoice'::regclass and relrowsecurity; +select 1 / count(*) from pg_policy where polname = 'company_policy' and polrelid = 'camper.invoice'::regclass; + +rollback; diff --git a/verify/invoice_amount.sql b/verify/invoice_amount.sql new file mode 100644 index 0000000..297d876 --- /dev/null +++ b/verify/invoice_amount.sql @@ -0,0 +1,11 @@ +-- Verify camper:invoice_amount on pg + +begin; + +select invoice_id + , subtotal + , total +from camper.invoice_amount +where false; + +rollback; diff --git a/verify/invoice_number_counter.sql b/verify/invoice_number_counter.sql new file mode 100644 index 0000000..16ba51c --- /dev/null +++ b/verify/invoice_number_counter.sql @@ -0,0 +1,14 @@ +-- Verify camper:invoice_number_counter on pg + +begin; + +select company_id + , year + , currval +from camper.invoice_number_counter +where false; + +select 1 / count(*) from pg_class where oid = 'camper.invoice_number_counter'::regclass and relrowsecurity; +select 1 / count(*) from pg_policy where polname = 'company_policy' and polrelid = 'camper.invoice_number_counter'::regclass; + +rollback; diff --git a/verify/invoice_product.sql b/verify/invoice_product.sql new file mode 100644 index 0000000..09c5e4e --- /dev/null +++ b/verify/invoice_product.sql @@ -0,0 +1,18 @@ +-- Verify camper:invoice_product on pg + +begin; + +select invoice_product_id + , invoice_id + , name + , description + , price + , quantity + , discount_rate +from camper.invoice_product +where false; + +select 1 / count(*) from pg_class where oid = 'camper.invoice_product'::regclass and relrowsecurity; +select 1 / count(*) from pg_policy where polname = 'company_policy' and polrelid = 'camper.invoice_product'::regclass; + +rollback; diff --git a/verify/invoice_product_amount.sql b/verify/invoice_product_amount.sql new file mode 100644 index 0000000..37a68bd --- /dev/null +++ b/verify/invoice_product_amount.sql @@ -0,0 +1,11 @@ +-- Verify camper:invoice_product_amount on pg + +begin; + +select invoice_product_id + , subtotal + , total +from camper.invoice_product_amount +where false; + +rollback; diff --git a/verify/invoice_product_product.sql b/verify/invoice_product_product.sql new file mode 100644 index 0000000..225924b --- /dev/null +++ b/verify/invoice_product_product.sql @@ -0,0 +1,10 @@ +-- Verify camper:invoice_product_product on pg + +begin; + +select invoice_product_id + , product_id +from camper.invoice_product_product +where false; + +rollback; diff --git a/verify/invoice_product_tax.sql b/verify/invoice_product_tax.sql new file mode 100644 index 0000000..ce3d532 --- /dev/null +++ b/verify/invoice_product_tax.sql @@ -0,0 +1,14 @@ +-- Verify camper:invoice_product_tax on pg + +begin; + +select invoice_product_id + , tax_id + , tax_rate +from camper.invoice_product_tax +where false; + +select 1 / count(*) from pg_class where oid = 'camper.invoice_product_tax'::regclass and relrowsecurity; +select 1 / count(*) from pg_policy where polname = 'company_policy' and polrelid = 'camper.invoice_product_tax'::regclass; + +rollback; diff --git a/verify/invoice_status.sql b/verify/invoice_status.sql new file mode 100644 index 0000000..0f08730 --- /dev/null +++ b/verify/invoice_status.sql @@ -0,0 +1,10 @@ +-- Verify camper:invoice_status on pg + +begin; + +select invoice_status + , name +from camper.invoice_status +where false; + +rollback; diff --git a/verify/invoice_status_i18n.sql b/verify/invoice_status_i18n.sql new file mode 100644 index 0000000..8ab1894 --- /dev/null +++ b/verify/invoice_status_i18n.sql @@ -0,0 +1,11 @@ +-- Verify camper:invoice_status_i18n on pg + +begin; + +select invoice_status + , lang_tag + , name +from camper.invoice_status_i18n +where false; + +rollback; diff --git a/verify/invoice_tax_amount.sql b/verify/invoice_tax_amount.sql new file mode 100644 index 0000000..48abf36 --- /dev/null +++ b/verify/invoice_tax_amount.sql @@ -0,0 +1,11 @@ +-- Verify camper:invoice_tax_amount on pg + +begin; + +select invoice_id + , tax_id + , amount +from camper.invoice_tax_amount +where false; + +rollback; diff --git a/verify/new_invoice_amount.sql b/verify/new_invoice_amount.sql new file mode 100644 index 0000000..43733d9 --- /dev/null +++ b/verify/new_invoice_amount.sql @@ -0,0 +1,7 @@ +-- Verify camper:new_invoice_amount on pg + +begin; + +select pg_catalog.has_type_privilege('camper.new_invoice_amount', 'usage'); + +rollback; diff --git a/verify/new_invoice_product.sql b/verify/new_invoice_product.sql new file mode 100644 index 0000000..63b79ab --- /dev/null +++ b/verify/new_invoice_product.sql @@ -0,0 +1,7 @@ +-- Verify camper:new_invoice_product on pg + +begin; + +select pg_catalog.has_type_privilege('camper.new_invoice_product', 'usage'); + +rollback; diff --git a/verify/next_invoice_number.sql b/verify/next_invoice_number.sql new file mode 100644 index 0000000..cc955a1 --- /dev/null +++ b/verify/next_invoice_number.sql @@ -0,0 +1,7 @@ +-- Verify camper:next_invoice_number on pg + +begin; + +select has_function_privilege('camper.next_invoice_number(integer, date)', 'execute'); + +rollback; diff --git a/verify/payment_method.sql b/verify/payment_method.sql new file mode 100644 index 0000000..7eea1ff --- /dev/null +++ b/verify/payment_method.sql @@ -0,0 +1,15 @@ +-- Verify camper:payment_method on pg + +begin; + +select payment_method_id + , company_id + , name + , instructions +from camper.payment_method +where false; + +select 1 / count(*) from pg_class where oid = 'camper.payment_method'::regclass and relrowsecurity; +select 1 / count(*) from pg_policy where polname = 'company_policy' and polrelid = 'camper.payment_method'::regclass; + +rollback; diff --git a/verify/product.sql b/verify/product.sql new file mode 100644 index 0000000..5e4d5fc --- /dev/null +++ b/verify/product.sql @@ -0,0 +1,18 @@ +-- Verify camper:product on pg + +begin; + +select product_id + , company_id + , slug + , name + , description + , price + , created_at +from camper.product +where false; + +select 1 / count(*) from pg_class where oid = 'camper.product'::regclass and relrowsecurity; +select 1 / count(*) from pg_policy where polname = 'company_policy' and polrelid = 'camper.product'::regclass; + +rollback; diff --git a/verify/product_tax.sql b/verify/product_tax.sql new file mode 100644 index 0000000..c082d4c --- /dev/null +++ b/verify/product_tax.sql @@ -0,0 +1,13 @@ +-- Verify camper:product_tax on pg + +begin; + +select product_id + , tax_id +from camper.product_tax +where false; + +select 1 / count(*) from pg_class where oid = 'camper.product_tax'::regclass and relrowsecurity; +select 1 / count(*) from pg_policy where polname = 'company_policy' and polrelid = 'camper.product_tax'::regclass; + +rollback; diff --git a/verify/tax.sql b/verify/tax.sql new file mode 100644 index 0000000..3423913 --- /dev/null +++ b/verify/tax.sql @@ -0,0 +1,16 @@ +-- Verify camper:tax on pg + +begin; + +select tax_id + , company_id + , tax_class_id + , name + , rate +from camper.tax +where false; + +select 1 / count(*) from pg_class where oid = 'camper.tax'::regclass and relrowsecurity; +select 1 / count(*) from pg_policy where polname = 'company_policy' and polrelid = 'camper.tax'::regclass; + +rollback; diff --git a/verify/tax_class.sql b/verify/tax_class.sql new file mode 100644 index 0000000..804c995 --- /dev/null +++ b/verify/tax_class.sql @@ -0,0 +1,14 @@ +-- Verify camper:tax_class on pg + +begin; + +select tax_class_id + , company_id + , name +from camper.tax_class +where false; + +select 1 / count(*) from pg_class where oid = 'camper.tax_class'::regclass and relrowsecurity; +select 1 / count(*) from pg_policy where polname = 'company_policy' and polrelid = 'camper.tax_class'::regclass; + +rollback; diff --git a/verify/tax_rate.sql b/verify/tax_rate.sql new file mode 100644 index 0000000..0f3533d --- /dev/null +++ b/verify/tax_rate.sql @@ -0,0 +1,7 @@ +-- Verify camper:tax_rate on pg + +begin; + +select pg_catalog.has_type_privilege('camper.tax_rate', 'usage'); + +rollback; diff --git a/web/static/camper.css b/web/static/camper.css index 306e969..db9f23d 100644 --- a/web/static/camper.css +++ b/web/static/camper.css @@ -349,7 +349,7 @@ nav details summary::-webkit-details-marker { display: none; } -nav details[open] summary::before { +nav details[open] summary::before, .invoice-status[open] summary::before { background-color: var(--camper--header--background-color); position: fixed; top: 0; @@ -728,21 +728,25 @@ label[x-show] > span, label[x-show] > br { /**/ /**/ +.invoice-status-created, .booking-created .booking-status, .payment-pending .payment-status { background-color: var(--camper--color--light-blue); } +.invoice-status-unpaid, .booking-cancelled .booking-status, .payment-failed .payment-status { background-color: var(--camper--color--rosy); } +.invoice-status-sent, .booking-confirmed .booking-status, .payment-preauth .payment-status { background-color: var(--camper--color--hay); } +.invoice-status-paid, .booking-checked-in .booking-status, .payment-completed .payment-status { background-color: var(--camper--color--light-green); @@ -958,7 +962,8 @@ label[x-show] > span, label[x-show] > br { display: none; } -#checkin-guests fieldset button { +#checkin-guests fieldset button, +.new-invoice-product .delete-product { position: absolute; right: 0; width: min-content; @@ -969,15 +974,18 @@ label[x-show] > span, label[x-show] > br { } #checkin-guests > fieldset > fieldset::before, +.new-invoice-product .delete-product, #checkin-guests fieldset button { top: .25em; } -#checkin-guests fieldset button:hover { +#checkin-guests fieldset button:hover, +.new-invoice-product .delete-product:hover { background-color: var(--camper--color--light-gray); } -#checkin-guests fieldset button::before { +#checkin-guests fieldset button::before, +.new-invoice-product .delete-product:before { content: '⌫'; } @@ -999,3 +1007,149 @@ label[x-show] > span, label[x-show] > br { } /**/ +/**/ + +.filters { + display: none; +} + +.filters-visible .filters { + display: initial; +} + +.filters > fieldset { + display: flex; + gap: 1rem; + float: none; + margin: 1rem 0 1rem; + padding: 0; + border: none; +} + +.filters-visible #filters-toggle { + background-color: var(--camper--header--background-color); +} + +.filters label + label { + margin-top: 0; +} + +.invoice-data, .product-data { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1ch; +} + +.invoice-data label + label, .new-invoice-product label + label { + margin-top: 0; +} + +:is(.invoice-data, .new-invoice-product) :is(input, select, textarea) { + width: 100%; +} + +.invoice-data label:last-child { + grid-column: 1 / -1; +} + +.new-invoice-product { + display: grid; + grid-template-columns: 3fr repeat(4, 1fr); + gap: 1ch; + position: relative; + padding: 2rem 0 0; + margin-top: 3rem; + border-top: 1px solid var(--camper--color--light-gray); +} + +.new-invoice-product .delete-product { + position: absolute; + right: 0; + top: .75rem; +} + +#invoice-summary { + margin-bottom: 1rem; +} + +#invoice-summary th { + text-align: left; + padding: 0 0 0 1rem; + border-bottom: none; +} + +#invoice-summary td { + padding: 0.25em 0; + border-bottom: none; +} + +#invoice-summary tr:last-child { + background-color: var(--camper--header--background-color); +} + +.invoice-status { + position: relative; +} + +.invoice-status summary { + height: 3rem; + display: flex; + cursor: pointer; + justify-content: start; + align-items: center; +} + +.invoice-status ul { + list-style: none; + background-color: var(--camper--background-color); + z-index: 20; + position: absolute; + top: 0; + left: 100%; + padding: 2rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.invoice-status button { + border: 0; + min-width: 15rem; +} + +[class^='invoice-status-'] { + cursor: pointer; +} + +.invoice-download { + text-align: center; +} + +.invoice-download a { + color: inherit; + text-decoration: none; +} + +/**/ +/* */ + +#contact-form label + label { + margin-top: 0; +} + +#contact-form :is(input, select) { + width: 100%; +} + +#contact-form .customer-details { + display: grid; + gap: .5em; + grid-template-columns: repeat(3, 1fr); + align-content: start; +} + +#contact-form .customer-details label:nth-of-type(4) { + grid-column: 1 / -1; +} + +/**/ diff --git a/web/static/camper.js b/web/static/camper.js index 0695faf..1d514bd 100644 --- a/web/static/camper.js +++ b/web/static/camper.js @@ -270,3 +270,19 @@ htmx.onLoad((content) => { } }) +htmx.on('htmx:configRequest', function (e) { + const element = e.detail.elt; + if (element && element.nodeName === 'FORM') { + let submitter = e.detail.triggeringEvent.submitter; + if (submitter) { + const action = submitter.attributes['formaction']; + if (action && action.value) { + e.detail.path = action.value; + } + const method = submitter.attributes['formmethod']; + if (method && method.value) { + e.detail.verb = method.value; + } + } + } +}) diff --git a/web/static/invoice.css b/web/static/invoice.css new file mode 100644 index 0000000..1052084 --- /dev/null +++ b/web/static/invoice.css @@ -0,0 +1,176 @@ +.invoice { + display: flex; +} + +.invoice header { + flex: 1; + min-width: 18rem; + padding: 0 1rem 0 0; + display: block; + background: initial; + border-right: 1px solid black; +} + +.invoice h1 { + font-size: 1em; +} + +.invoice > div { + flex: 3; + padding: 0 0 0 2.5rem; +} + +.invoice .invoicee, .invoice .invoicer, .invoice .quotee, .invoice .quoter { + font-style: normal; +} + +.invoice header > div { + margin-top: 2em; +} + +.invoice .invoicer, .invoice .quoter { + margin-top: 9rem; +} + +.invoice .invoicee, .invoice .quotee { + margin-top: 1em; + text-align: right; +} + +.invoice table { + margin: 5rem 0; +} + +.invoice th { + font-weight: normal; + text-transform: uppercase; +} + +.invoice thead th { + text-align: left; + border-bottom: 1px solid black; +} + +.invoice .tfoot th, .invoice .numeric { + text-align: right; +} + +.invoice .notes, .invoice .payment-instructions, .invoice .terms_and_conditions { + white-space: pre-line; + text-align: left; +} + +.invoice .terms_and_conditions { + text-transform: uppercase; + font-weight: bold; +} + +.invoice .quotee + .terms_and_conditions { + margin-top: 2rem; +} + +.invoice .notes + .payment-instructions { + margin-top: 5rem; +} + +.invoice td { + vertical-align: top; +} + +.invoice tbody, .invoice .legal { + page-break-inside: avoid; +} + +.invoice tbody tr { + background-color: initial; +} + +.invoice tbody:not(:first-of-type) tr:first-child td, .invoice .tfoot th, .invoice .tfoot td { + padding-top: 1em; +} + +.invoice .tfoot.separator th, .invoice .tfoot.separator td { + padding-top: 3em; +} + +.invoice tbody .name td:first-child { + font-weight: bold; +} + +.invoice tbody td:first-child { + max-width: 15em; + white-space: pre-wrap; +} + +.invoice .legal { + font-size: .75em; + text-align: justify; +} + +@media screen { + .invoice .legal { + margin-top: 16rem; + } +} + +@media print { + @page { + size: A4; + margin: 64px 48px; + } + + *, *::before, *::after { + box-sizing: border-box; + } + + * { + margin: 0; + } + + .sr-only, body > a[href="#content"] { + display: none; + } + + html, body { + height: 100%; + } + + html { + font-family: sans-serif; + font-size: 62.5%; + } + + body { + background-color: white; + color: black; + font-size: 1rem; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + } + + p, h1, h2, h3, h4, h5, h6 { + overflow-wrap: break-word; + } + + table { + width: 100%; + border-collapse: collapse; + } + + body > header, nav, body > footer { + display: none; + } + + .invoice header { + height: 260mm; + } + + .invoice .legal { + position: absolute; + width: 40em; + left: 0; + top: 0; + transform-origin: top left; + transform: translateY(250mm) rotate(-90deg); + } +} diff --git a/web/templates/admin/customer/form.gohtml b/web/templates/admin/customer/form.gohtml new file mode 100644 index 0000000..3782ced --- /dev/null +++ b/web/templates/admin/customer/form.gohtml @@ -0,0 +1,160 @@ + +{{ define "title" -}} + {{- /*gotype: dev.tandem.ws/tandem/camper/pkg/customer.customerForm*/ -}} + {{ if .Slug }} + {{( pgettext "Edit Customer" "title" )}} + {{ else }} + {{( pgettext "New Customer" "title" )}} + {{ end }} +{{- end }} + +{{ define "breadcrumb" -}} +
  • {{( pgettext "Customer" "title" )}}
  • +{{- end }} + +{{ define "content" -}} + {{- /*gotype: dev.tandem.ws/tandem/camper/pkg/customer.customerForm*/ -}} +

    {{ template "title" .}}

    +
    + {{ CSRFInput }} +
    + {{( pgettext "Customer Details" "title" )}} + {{ with .FullName -}} + + {{- end }} + {{ with .IDDocumentNumber -}} + + {{- end }} + {{ with .IDDocumentType -}} + + {{- end }} + {{ with .Address -}} + + {{- end }} + {{ with .City -}} + + {{- end }} + {{ with .Province -}} + + {{- end }} + {{ with .PostalCode -}} + + {{- end }} + {{ with .Country -}} + + {{- end }} + {{ with .Email -}} + + {{- end }} + {{ with .Phone -}} + + {{- end }} +
    +
    + +
    +
    +{{- end }} diff --git a/web/templates/admin/customer/index.gohtml b/web/templates/admin/customer/index.gohtml new file mode 100644 index 0000000..5704538 --- /dev/null +++ b/web/templates/admin/customer/index.gohtml @@ -0,0 +1,38 @@ + +{{ define "title" -}} + {{( pgettext "Customers" "title" )}} +{{- end }} + +{{ define "breadcrumb" -}} +{{- end }} + +{{ define "content" -}} + {{- /*gotype: dev.tandem.ws/tandem/camper/pkg/customer.customerIndex*/ -}} + {{( pgettext "Add Customer" "action" )}} +

    {{ template "title" . }}

    + + + + + + + + + + {{ range .Customers -}} + + + + + + {{- else -}} + + + + {{- end }} + +
    {{( pgettext "Name" "header" )}}{{( pgettext "Email" "header" )}}{{( pgettext "Phone" "header" )}}
    {{ .Name }}{{ .Email }}{{ .Phone }}
    {{( gettext "No customer found." )}}
    +{{- end }} diff --git a/web/templates/admin/form.gohtml b/web/templates/admin/form.gohtml index 3ec7a56..4bfb2f1 100644 --- a/web/templates/admin/form.gohtml +++ b/web/templates/admin/form.gohtml @@ -76,3 +76,9 @@ {{- end }} {{- end }} + +{{ define "filters-toggle" -}} + +{{- end }} diff --git a/web/templates/admin/invoice/form.gohtml b/web/templates/admin/invoice/form.gohtml new file mode 100644 index 0000000..48f5548 --- /dev/null +++ b/web/templates/admin/invoice/form.gohtml @@ -0,0 +1,150 @@ +{{ define "title" -}} + {{- /*gotype: dev.tandem.ws/tandem/camper/pkg/invoice.invoiceForm*/ -}} + {{- if .Slug }} + {{ printf ( pgettext "Edit Invoice “%s”" "title" ) .Number }} + {{- else -}} + {{( pgettext "New Invoice" "title" )}} + {{- end -}} +{{- end }} + +{{ define "head" -}} + +{{- end }} + +{{ define "breadcrumb" -}} +
  • {{( pgettext "Invoices" "title" )}}
  • +{{- end }} + +{{ define "content" }} + {{- /*gotype: dev.tandem.ws/tandem/camper/pkg/invoice.invoiceForm*/ -}} +
    +

    {{ template "title" . }}

    +
    + {{ CSRFInput }} + + {{ with .RemovedProduct -}} +
    +

    {{printf (gettext "Product “%s” removed") .Name.Val }}

    + + {{ with .InvoiceProductId }}{{ end }} + {{ with .ProductId }}{{ end }} + {{ with .Name }}{{ end }} + {{ with .Price }}{{ end }} + {{ with .Quantity }}{{ end }} + {{ with .Discount }}{{ end }} + {{ with .Description }}{{ end }} + {{ with .Tax }}{{ end }} +
    + {{- end }} + +
    + {{ with .Customer -}} + + {{- end }} + {{ with .Date -}} + + {{- end }} + {{ with .InvoiceStatus }} + {{ if $.Slug -}} + + {{- else -}} + + {{- end }} + {{- end }} + {{ with .Notes -}} + + {{- end }} +
    + + {{- range .Products }} + {{ template "product-form.gohtml" . }} + {{- end }} + + + + + + + + {{- range $tax := .Taxes }} + + + + + {{- end }} + + + + + +
    {{(pgettext "Subtotal" "title")}}{{ .Subtotal | formatPrice }}
    {{ index . 0 }}{{ index . 1 | formatPrice }}
    {{(pgettext "Total" "title")}}{{ .Total | formatPrice }}
    + +
    + + + +
    +
    +
    + + +{{- end }} diff --git a/web/templates/admin/invoice/index.gohtml b/web/templates/admin/invoice/index.gohtml new file mode 100644 index 0000000..6f62515 --- /dev/null +++ b/web/templates/admin/invoice/index.gohtml @@ -0,0 +1,168 @@ +{{ define "title" -}} + {{( pgettext "Invoices" "title" )}} +{{- end }} + +{{ define "breadcrumb" -}} +{{- end }} + +{{ define "content" }} + {{- /*gotype: dev.tandem.ws/tandem/camper/pkg/invoice.invoiceIndex*/ -}} +
    + {{ CSRFInput }} +
    + {{ with .Filters }} + {{ with .Customer }}{{ end }} + {{ with .InvoiceStatus }}{{ end }} + {{ with .FromDate }}{{ end }} + {{ with .ToDate }}{{ end }} + {{ with .InvoiceStatus }}{{ end }} + {{ end }} +
    +
    + {{ template "filters-toggle" }} + + +
    +
    +
    +
    + {{ with .Filters }} + {{ with .Customer -}} + + {{- end }} + {{ with .InvoiceStatus -}} + + {{- end }} + {{ with .FromDate -}} + + {{- end }} + {{ with .ToDate -}} + + {{- end }} + {{ with .InvoiceNumber -}} + + {{- end }} + {{ end }} +
    + + {{ if .Filters.HasValue }} + {{( pgettext "Reset" "action" )}} + {{ end }} +
    + {{( pgettext "Add invoice" "action" )}} +

    {{ template "title" . }}

    + + + + + + + + + + + + + + {{ with .Invoices }} + {{- range $invoice := . }} + + {{ $title := .Number | printf (pgettext "Select invoice %v" "action") }} + + + + + + {{- $title = .Number | printf (pgettext "Download invoice %s" "action") -}} + + + + {{- end }} + {{ else }} + + + + {{ end }} + + {{ if .Invoices }} + + + + + + + + {{ end }} +
    {{( pgettext "All" "invoice" )}}{{( pgettext "Date" "title" )}}{{( pgettext "Invoice Num." "title" )}}{{( pgettext "Customer" "title" )}}{{( pgettext "Status" "title" )}}{{( pgettext "Download" "title" )}}{{( pgettext "Amount" "title" )}}
    {{ .Date|formatDate }}{{ .Number }}{{ .CustomerName }} + + {{ .Total|formatPrice }}
    {{( gettext "No invoices added yet." )}}
    {{( gettext "Total" )}}{{ .TotalAmount|formatPrice }}
    +{{- end }} diff --git a/web/templates/admin/invoice/product-form.gohtml b/web/templates/admin/invoice/product-form.gohtml new file mode 100644 index 0000000..1555624 --- /dev/null +++ b/web/templates/admin/invoice/product-form.gohtml @@ -0,0 +1,85 @@ +{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/invoice.invoiceProductForm*/ -}} +
    + + {{ with .InvoiceProductId }}{{ end }} + {{ with .ProductId }}{{ end }} + {{ with .Name -}} + + {{- end }} + {{ with .Price -}} + + {{- end }} + {{ with .Quantity -}} + + {{- end }} + {{ with .Discount -}} + + {{- end }} + {{ with .Tax -}} + + {{- end }} +
    diff --git a/web/templates/admin/invoice/view.gohtml b/web/templates/admin/invoice/view.gohtml new file mode 100644 index 0000000..f063375 --- /dev/null +++ b/web/templates/admin/invoice/view.gohtml @@ -0,0 +1,118 @@ +{{ define "title" -}} + {{ .Number | printf ( pgettext "Invoice %s" "title" )}} +{{- end }} + +{{ define "breadcrumb" -}} +
  • {{( pgettext "Invoices" "title" )}}
  • +{{- end }} + +{{ define "content" }} + {{- /*gotype: dev.tandem.ws/tandem/camper/pkg/invoice.Invoice*/ -}} + + + +
    +
    +
    +

    {{ template "title" . }}

    +

    {{( pgettext "Date" "title" )}} {{ .Date | formatDate }}

    +
    + +
    + {{ .Invoicer.Name }}
    + {{ .Invoicer.VATIN }}
    + {{ .Invoicer.Address }}
    + {{ .Invoicer.City }} ({{ .Invoicer.PostalCode}}), {{ .Invoicer.Province }}
    + {{ .Invoicer.Email }}
    + {{ .Invoicer.Phone }}
    +
    + + +
    + +
    +
    + {{ .Invoicee.Name }}
    + {{ .Invoicee.VATIN }}
    + {{ .Invoicee.Address }}
    + {{ .Invoicee.City }} ({{ .Invoicee.PostalCode}}), {{ .Invoicee.Province }}
    +
    + + {{- $columns := 5 | add (len .TaxClasses) | add (int .HasDiscounts) -}} + + + + + + {{ if .HasDiscounts -}} + + {{ end -}} + + + {{ range $class := .TaxClasses -}} + + {{ end -}} + + + + {{ $lastIndex := len .Products | sub 1 }} + {{ range $index, $product := .Products -}} + + {{- if .Description }} + + + + {{ end -}} + + {{- if .Description }} + + {{- else }} + + {{- end -}} + + {{ if $.HasDiscounts -}} + + {{ end -}} + + + {{ range $class := $.TaxClasses -}} + + {{ end -}} + + + {{ if (eq $index $lastIndex) }} + + + + + {{ range $tax := $.Taxes -}} + + + + + {{- end }} + + + + + {{ end }} + + {{- end }} +
    {{( pgettext "Concept" "title" )}}{{( pgettext "Price" "title" )}}{{( pgettext "Discount" "title" )}}{{( pgettext "Units" "title" )}}{{( pgettext "Subtotal" "title" )}}{{ . }}{{( pgettext "Total" "title" )}}
    {{ .Name }}
    {{ .Description }}{{ .Name }}{{ .Price | formatPrice }}{{ $product.Discount | formatPercent }}{{ .Quantity }}{{ .Subtotal | formatPrice }}{{ index $product.Taxes $class | formatPercent }}{{ .Total | formatPrice }}
    {{( pgettext "Tax Base" "title" )}}{{ $.Subtotal | formatPrice }}
    {{ index . 0 }}{{ index . 1 | formatPrice }}
    {{( pgettext "Total" "title" )}}{{ $.Total | formatPrice }}
    + + {{ if .Notes -}} +

    {{ .Notes }}

    + {{- end }} +

    {{ .PaymentInstructions }}

    + +
    +
    +{{- end}} diff --git a/web/templates/admin/layout.gohtml b/web/templates/admin/layout.gohtml index 263a93e..75c49f3 100644 --- a/web/templates/admin/layout.gohtml +++ b/web/templates/admin/layout.gohtml @@ -91,6 +91,12 @@
  • {{( pgettext "Bookings" "title" )}}
  • +
  • + {{( pgettext "Customers" "title" )}} +
  • +
  • + {{( pgettext "Invoices" "title" )}} +
  • {{( pgettext "Campsites" "title" )}}