From 666935b54c8f2d735d8fe5591407ca231bba7e4c Mon Sep 17 00:00:00 2001 From: jordi fita mas Date: Sat, 28 Jan 2023 14:18:58 +0100 Subject: [PATCH] Add the tax relation with very rough form and handler --- demo/demo.sql | 5 ++ deploy/tax.sql | 36 ++++++++++ deploy/tax_rate.sql | 14 ++++ pkg/company.go | 52 ++++++++++++++ pkg/router.go | 2 + po/ca.po | 40 ++++++++++- po/es.po | 40 ++++++++++- revert/tax.sql | 8 +++ revert/tax_rate.sql | 7 ++ sqitch.plan | 2 + test/tax.sql | 127 ++++++++++++++++++++++++++++++++++ test/tax_rate.sql | 34 +++++++++ verify/tax.sql | 15 ++++ verify/tax_rate.sql | 7 ++ web/static/numerus.css | 17 ++++- web/template/tax-details.html | 68 ++++++++++++++++-- 16 files changed, 463 insertions(+), 11 deletions(-) create mode 100644 deploy/tax.sql create mode 100644 deploy/tax_rate.sql create mode 100644 revert/tax.sql create mode 100644 revert/tax_rate.sql create mode 100644 test/tax.sql create mode 100644 test/tax_rate.sql create mode 100644 verify/tax.sql create mode 100644 verify/tax_rate.sql diff --git a/demo/demo.sql b/demo/demo.sql index 782c6ab..d3311c5 100644 --- a/demo/demo.sql +++ b/demo/demo.sql @@ -15,4 +15,9 @@ values (1, 1) , (1, 2) ; +insert into tax (company_id, name, rate) +values (1, 'Retenció 15 %', -0.15) + , (1, 'IVA 21 %', 0.21) +; + commit; diff --git a/deploy/tax.sql b/deploy/tax.sql new file mode 100644 index 0000000..0894333 --- /dev/null +++ b/deploy/tax.sql @@ -0,0 +1,36 @@ +-- Deploy numerus:tax to pg +-- requires: schema_numerus +-- requires: company +-- requires: tax_rate + +begin; + +set search_path to numerus, public; + +create table tax ( + tax_id serial primary key, + company_id integer not null references company, + name text not null, + rate tax_rate not null +); + +grant select, insert, update, delete on table tax to invoicer; +grant select, insert, update, delete on table tax to admin; + +grant usage on sequence tax_tax_id_seq to invoicer; +grant usage on sequence tax_tax_id_seq 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_rate.sql b/deploy/tax_rate.sql new file mode 100644 index 0000000..a9495cc --- /dev/null +++ b/deploy/tax_rate.sql @@ -0,0 +1,14 @@ +-- Deploy numerus:tax_rate to pg +-- requires: schema_numerus + +begin; + +set search_path to numerus, public; + +create domain tax_rate as numeric +check (value > -1 and value < 1); + +comment on domain country_code is +'A tax rate in the range (-1, 1)'; + +commit; diff --git a/pkg/company.go b/pkg/company.go index f2fdac8..288e30d 100644 --- a/pkg/company.go +++ b/pkg/company.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "net/url" + "strconv" "strings" ) @@ -75,6 +76,12 @@ type CountryOption struct { Name string } +type Tax struct { + Id int + Name string + Rate int +} + type TaxDetailsPage struct { Title string BusinessName string @@ -91,6 +98,7 @@ type TaxDetailsPage struct { Countries []CountryOption CurrencyCode string Currencies []CurrencyOption + Taxes []Tax } func CompanyTaxDetailsHandler() http.Handler { @@ -125,6 +133,7 @@ func CompanyTaxDetailsHandler() http.Handler { } page.Countries = mustGetCountryOptions(r.Context(), conn, locale) page.Currencies = mustGetCurrencyOptions(r.Context(), conn) + page.Taxes = mustGetTaxes(r.Context(), conn, company) mustRenderAppTemplate(w, r, "tax-details.html", page) }) } @@ -182,3 +191,46 @@ func mustGetCurrencyOptions(ctx context.Context, conn *Conn) []CurrencyOption { return currencies } + +func mustGetTaxes(ctx context.Context, conn *Conn, company *Company) []Tax { + rows, err := conn.Query(ctx, "select tax_id, name, (rate * 100)::integer from tax where company_id = $1 order by rate, name", company.Id) + if err != nil { + panic(err) + } + defer rows.Close() + + var taxes []Tax + for rows.Next() { + var tax Tax + err = rows.Scan(&tax.Id, &tax.Name, &tax.Rate) + if err != nil { + panic(err) + } + taxes = append(taxes, tax) + } + if rows.Err() != nil { + panic(rows.Err()) + } + + return taxes +} + +func CompanyTaxHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + param := r.URL.Path + if idx := strings.LastIndexByte(param, '/'); idx >= 0 { + param = param[idx+1:] + } + conn := getConn(r) + company := mustGetCompany(r) + if taxId, err := strconv.Atoi(param); err == nil { + conn.MustExec(r.Context(), "delete from tax where tax_id = $1", taxId) + } else { + r.ParseForm() + name := r.FormValue("name") + rate, _ := strconv.Atoi(r.FormValue("rate")) + conn.MustExec(r.Context(), "insert into tax (company_id, name, rate) values ($1, $2, $3 / 100::decimal)", company.Id, name, rate) + } + http.Redirect(w, r, "/company/"+company.Slug+"/tax-details", http.StatusSeeOther) + }) +} diff --git a/pkg/router.go b/pkg/router.go index 04a2e50..ff43332 100644 --- a/pkg/router.go +++ b/pkg/router.go @@ -7,6 +7,8 @@ import ( func NewRouter(db *Db) http.Handler { companyRouter := http.NewServeMux() companyRouter.Handle("/tax-details", CompanyTaxDetailsHandler()) + companyRouter.Handle("/tax/", CompanyTaxHandler()) + companyRouter.Handle("/tax", CompanyTaxHandler()) companyRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { mustRenderAppTemplate(w, r, "dashboard.html", nil) }) diff --git a/po/ca.po b/po/ca.po index 9e8f8fa..3150c2e 100644 --- a/po/ca.po +++ b/po/ca.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: numerus\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n" -"POT-Creation-Date: 2023-01-28 12:22+0100\n" +"POT-Creation-Date: 2023-01-28 14:14+0100\n" "PO-Revision-Date: 2023-01-18 17:08+0100\n" "Last-Translator: jordi fita mas \n" "Language-Team: Catalan \n" @@ -77,12 +77,12 @@ msgctxt "language option" msgid "Automatic" msgstr "Automàtic" -#: web/template/profile.html:42 web/template/tax-details.html:66 +#: web/template/profile.html:42 web/template/tax-details.html:127 msgctxt "action" msgid "Save changes" msgstr "Desa canvis" -#: web/template/tax-details.html:3 pkg/company.go:100 +#: web/template/tax-details.html:3 pkg/company.go:108 msgctxt "title" msgid "Tax Details" msgstr "Configuració fiscal" @@ -142,6 +142,40 @@ msgctxt "input" msgid "Currency" msgstr "Moneda" +#: web/template/tax-details.html:74 +msgctxt "title" +msgid "Tax Name" +msgstr "Nom import" + +#: web/template/tax-details.html:75 +msgctxt "title" +msgid "Rate (%)" +msgstr "Percentatge" + +#: web/template/tax-details.html:96 +msgid "No taxes added yet." +msgstr "No hi ha cap impost." + +#: web/template/tax-details.html:102 +msgctxt "title" +msgid "New Line" +msgstr "Nova línia" + +#: web/template/tax-details.html:106 +msgctxt "input" +msgid "Tax name" +msgstr "Nom impost" + +#: web/template/tax-details.html:112 +msgctxt "input" +msgid "Rate (%)" +msgstr "Percentatge" + +#: web/template/tax-details.html:119 +msgctxt "action" +msgid "Add new tax" +msgstr "Afegeix nou impost" + #: web/template/app.html:20 msgctxt "menu" msgid "Account" diff --git a/po/es.po b/po/es.po index fa92b45..c9fe320 100644 --- a/po/es.po +++ b/po/es.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: numerus\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n" -"POT-Creation-Date: 2023-01-28 12:22+0100\n" +"POT-Creation-Date: 2023-01-28 14:14+0100\n" "PO-Revision-Date: 2023-01-18 17:45+0100\n" "Last-Translator: jordi fita mas \n" "Language-Team: Spanish \n" @@ -77,12 +77,12 @@ msgctxt "language option" msgid "Automatic" msgstr "Automático" -#: web/template/profile.html:42 web/template/tax-details.html:66 +#: web/template/profile.html:42 web/template/tax-details.html:127 msgctxt "action" msgid "Save changes" msgstr "Guardar cambios" -#: web/template/tax-details.html:3 pkg/company.go:100 +#: web/template/tax-details.html:3 pkg/company.go:108 msgctxt "title" msgid "Tax Details" msgstr "Configuración fiscal" @@ -142,6 +142,40 @@ msgctxt "input" msgid "Currency" msgstr "Moneda" +#: web/template/tax-details.html:74 +msgctxt "title" +msgid "Tax Name" +msgstr "Nombre impuesto" + +#: web/template/tax-details.html:75 +msgctxt "title" +msgid "Rate (%)" +msgstr "Porcentage" + +#: web/template/tax-details.html:96 +msgid "No taxes added yet." +msgstr "No hay impuestos." + +#: web/template/tax-details.html:102 +msgctxt "title" +msgid "New Line" +msgstr "Nueva línea" + +#: web/template/tax-details.html:106 +msgctxt "input" +msgid "Tax name" +msgstr "Nombre impuesto" + +#: web/template/tax-details.html:112 +msgctxt "input" +msgid "Rate (%)" +msgstr "Porcentage" + +#: web/template/tax-details.html:119 +msgctxt "action" +msgid "Add new tax" +msgstr "Añadir nuevo impuesto" + #: web/template/app.html:20 msgctxt "menu" msgid "Account" diff --git a/revert/tax.sql b/revert/tax.sql new file mode 100644 index 0000000..23c992e --- /dev/null +++ b/revert/tax.sql @@ -0,0 +1,8 @@ +-- Revert numerus:tax from pg + +begin; + +drop policy company_policy on numerus.tax; +drop table if exists numerus.tax; + +commit; diff --git a/revert/tax_rate.sql b/revert/tax_rate.sql new file mode 100644 index 0000000..c7a35c1 --- /dev/null +++ b/revert/tax_rate.sql @@ -0,0 +1,7 @@ +-- Revert numerus:tax_rate from pg + +begin; + +drop domain if exists numerus.tax_rate; + +commit; diff --git a/sqitch.plan b/sqitch.plan index 659c873..cea9d37 100644 --- a/sqitch.plan +++ b/sqitch.plan @@ -36,3 +36,5 @@ country_i18n [schema_numerus country_code language country] 2023-01-27T19:20:43Z available_countries [schema_numerus country] 2023-01-27T18:49:28Z jordi fita mas # Add the list of available countries company [schema_numerus extension_vat email extension_pg_libphonenumber extension_uri currency_code currency country_code country] 2023-01-24T15:03:15Z jordi fita mas # Add the relation for companies company_user [schema_numerus user company] 2023-01-24T17:50:06Z jordi fita mas # Add the relation of companies and their users +tax_rate [schema_numerus] 2023-01-28T11:33:39Z jordi fita mas # Add domain for tax rates +tax [schema_numerus company tax_rate] 2023-01-28T11:45:47Z jordi fita mas # Add relation for taxes diff --git a/test/tax.sql b/test/tax.sql new file mode 100644 index 0000000..3ca72cb --- /dev/null +++ b/test/tax.sql @@ -0,0 +1,127 @@ +-- Test tax +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(35); + +set search_path to numerus, auth, public; + +select has_table('tax'); +select has_pk('tax' ); +select table_privs_are('tax', 'guest', array []::text[]); +select table_privs_are('tax', 'invoicer', 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_sequence('tax_tax_id_seq'); +SELECT sequence_privs_are('tax_tax_id_seq', 'guest', array[]::text[]); +SELECT sequence_privs_are('tax_tax_id_seq', 'invoicer', array['USAGE']); +SELECT sequence_privs_are('tax_tax_id_seq', 'admin', array['USAGE']); +SELECT sequence_privs_are('tax_tax_id_seq', '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_has_default('tax', 'tax_id'); +select col_default_is('tax', 'tax_id', 'nextval(''tax_tax_id_seq''::regclass)'); + +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', '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 company_user cascade; +truncate company cascade; +truncate auth."user" cascade; +reset client_min_messages; + +insert into auth."user" (user_id, email, name, password, role, cookie, cookie_expires_at) +values (1, 'demo@tandem.blog', 'Demo', 'test', 'invoicer', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month') + , (5, 'admin@tandem.blog', 'Demo', 'test', 'admin', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month') +; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code) +values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR') + , (4, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', 'FR', 'USD') +; + +insert into company_user (company_id, user_id) +values (2, 1) + , (4, 5) +; + +insert into tax (company_id, name, rate) +values (2, 'VAT 21 %', 0.21) + , (2, 'IRPF -15 %', -0.15) + , (4, 'VAT 21 %', 0.21) + , (4, 'VAT 10 %', 0.10) + , (4, 'VAT 5 %', 0.05) + , (4, 'VAT 4 %', 0.04) + , (4, 'VAT 0 %', 0.00) +; + +prepare tax_data as +select company_id, name, rate +from tax +order by company_id, rate; + +set role invoicer; +select is_empty('tax_data', 'Should show no data when cookie is not set yet'); +reset role; + +select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog'); +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'); +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'); +select throws_ok( + 'tax_data', + '42501', 'permission denied for table tax', + 'Should not allow select to guest users' +); +reset role; + + +select * +from finish(); + +rollback; + diff --git a/test/tax_rate.sql b/test/tax_rate.sql new file mode 100644 index 0000000..d738533 --- /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 numerus, 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/tax.sql b/verify/tax.sql new file mode 100644 index 0000000..53a2036 --- /dev/null +++ b/verify/tax.sql @@ -0,0 +1,15 @@ +-- Verify numerus:tax on pg + +begin; + +select tax_id + , company_id + , name + , rate +from numerus.tax +where false; + +select 1 / count(*) from pg_class where oid = 'numerus.tax'::regclass and relrowsecurity; +select 1 / count(*) from pg_policy where polname = 'company_policy' and polrelid = 'numerus.tax'::regclass; + +rollback; diff --git a/verify/tax_rate.sql b/verify/tax_rate.sql new file mode 100644 index 0000000..c6d6453 --- /dev/null +++ b/verify/tax_rate.sql @@ -0,0 +1,7 @@ +-- Verify numerus:tax_rate on pg + +begin; + +select pg_catalog.has_type_privilege('numerus.tax_rate', 'usage'); + +rollback; diff --git a/web/static/numerus.css b/web/static/numerus.css index e15b1b4..d126314 100644 --- a/web/static/numerus.css +++ b/web/static/numerus.css @@ -205,6 +205,15 @@ input[type="submit"]:active, button:active { text-color: var(--numerus--color--white); } +button.icon { + min-width: 0; + border: none; +} + +table { + width: 100%; +} + .web { margin: 8.5rem 4rem; background-color: var(--numerus--header--background-color); @@ -248,7 +257,13 @@ main { margin-top: 2rem; } -input[type="text"], input[type="password"], input[type="email"], input[type="tel"], input[type="url"], select { + input[type="text"] +, input[type="password"] +, input[type="email"] +, input[type="tel"] +, input[type="url"] +, input[type="number"] +, select { background-color: var(--numerus--background-color); border: 1px solid var(--numerus--color--black); border-radius: 0; diff --git a/web/template/tax-details.html b/web/template/tax-details.html index 742bd16..df5283e 100644 --- a/web/template/tax-details.html +++ b/web/template/tax-details.html @@ -1,7 +1,7 @@ {{ define "content" }}

{{(pgettext "Tax Details" "title")}}

-
+
@@ -49,7 +49,7 @@ {{- end }} -
@@ -61,10 +61,70 @@ {{- end }}
+
+ +
+
+ +
+ + + + + + + + + + + {{ with .Taxes }} + {{- range $tax := . }} + + + + + + + {{- end }} + {{ else }} + + + + {{ end }} + + + + + + + + + + + + +
{{( pgettext "Tax Name" "title" )}}{{( pgettext "Rate (%)" "title" )}}
{{ .Name }}{{ .Rate }} +
+ + +
+
{{( gettext "No taxes added yet." )}}
{{( pgettext "New Line" "title")}} +
+ + +
+
+
+ + +
+
+ +
+
- +
-
{{- end }}