diff --git a/demo/demo.sql b/demo/demo.sql index 8aa58b8..c048de9 100644 --- a/demo/demo.sql +++ b/demo/demo.sql @@ -2,9 +2,17 @@ begin; set search_path to auth, numerus, public; -insert into auth."user" (email, name, password, role) -values ('demo@numerus', 'Demo User', 'demo', 'invoicer') - , ('admin@numerus', 'Demo Admin', 'admin', 'admin') +insert into auth."user" (user_id, email, name, password, role) +values (1, 'demo@numerus', 'Demo User', 'demo', 'invoicer') + , (2, 'admin@numerus', 'Demo Admin', 'admin', 'admin') +; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country, currency_code) +values (1, 'Juli Verd', 'ES40404040D', 'Pesebre', parse_packed_phone_number('972 50 60 70', 'ES'), 'info@numerus.cat', 'https://numerus.cat/', 'C/ de l’Hort', 'Castelló d’Empúries', 'Alt Empordà', '17486', 'Espanya', 'EUR'); + +insert into company_user (company_id, user_id) +values (1, 1) + , (1, 2) ; commit; diff --git a/deploy/available_currencies.sql b/deploy/available_currencies.sql new file mode 100644 index 0000000..4d56aed --- /dev/null +++ b/deploy/available_currencies.sql @@ -0,0 +1,12 @@ +-- Deploy numerus:available_currencies to pg +-- requires: schema_numerus +-- requires: currency + +begin; + +insert into numerus.currency(currency_code, currency_symbol) +values ('EUR', '€') + , ('USD', '$') +; + +commit; diff --git a/deploy/company.sql b/deploy/company.sql new file mode 100644 index 0000000..5ddb299 --- /dev/null +++ b/deploy/company.sql @@ -0,0 +1,36 @@ +-- Deploy numerus:company to pg +-- requires: schema_numerus +-- requires: extension_vat +-- requires: email +-- requires: extension_pg_libphonenumber +-- requires: extension_uri +-- requires: currency_code +-- requires: currency + +begin; + +set search_path to numerus,public; + +create table company ( + company_id serial primary key, + slug uuid not null unique default gen_random_uuid(), + business_name text not null, + vatin vatin not null, + trade_name text not null, + phone packed_phone_number not null, + email email not null, + web uri not null, + address text not null, + city text not null, + province text not null, + postal_code text not null, + country text not null, + currency_code currency_code not null references currency, + created_at timestamptz not null default current_timestamp +); + +grant select on table company to invoicer; +grant select on table company to admin; + + +commit; diff --git a/deploy/company_user.sql b/deploy/company_user.sql new file mode 100644 index 0000000..1a1595c --- /dev/null +++ b/deploy/company_user.sql @@ -0,0 +1,38 @@ +-- Deploy numerus:company_user to pg +-- requires: schema_numerus +-- requires: user +-- requires: company + +begin; + +set search_path to numerus, auth, public; + +create table company_user ( + company_id integer not null references company, + user_id integer not null references "user", + primary key (company_id, user_id) +); + +grant select on table company_user to invoicer; +grant select on table company_user to admin; + + +alter table company enable row level security; + +create policy company_policy +on company +using ( + exists( + select 1 + from company_user + join user_profile using (user_id) + where company_user.company_id = company.company_id + ) +); + +-- TODO: +-- I think we can not do the same for company_user because it would be +-- an infinite loop, but in this case i think it is fine because we can +-- only see ids, nothing more. + +commit; diff --git a/deploy/currency.sql b/deploy/currency.sql new file mode 100644 index 0000000..97b1e9b --- /dev/null +++ b/deploy/currency.sql @@ -0,0 +1,18 @@ +-- Deploy numerus:currency to pg +-- requires: schema_numerus +-- requires: currency_code + +begin; + +set search_path to numerus, public; + +create table currency ( + currency_code currency_code not null primary key, + currency_symbol text not null, + decimal_digits integer not null default 2 +); + +grant select on table currency to invoicer; +grant select on table currency to admin; + +commit; diff --git a/deploy/currency_code.sql b/deploy/currency_code.sql new file mode 100644 index 0000000..6268539 --- /dev/null +++ b/deploy/currency_code.sql @@ -0,0 +1,14 @@ +-- Deploy numerus:currency_code to pg +-- requires: schema_numerus + +begin; + +set search_path to numerus, public; + +create domain currency_code as text +check (value ~ '^[A-Z]{3}$'); + +comment on domain currency_code is +'A correctly formated, but not necessarily valid, ISO 4217 currency code'; + +commit; diff --git a/deploy/extension_pg_libphonenumber.sql b/deploy/extension_pg_libphonenumber.sql new file mode 100644 index 0000000..d607135 --- /dev/null +++ b/deploy/extension_pg_libphonenumber.sql @@ -0,0 +1,8 @@ +-- Deploy numerus:extension_pg_libphonenumber to pg +-- requires: schema_public + +begin; + +create extension if not exists pg_libphonenumber; + +commit; diff --git a/deploy/extension_uri.sql b/deploy/extension_uri.sql new file mode 100644 index 0000000..199f6a5 --- /dev/null +++ b/deploy/extension_uri.sql @@ -0,0 +1,8 @@ +-- Deploy numerus:extension_uri to pg +-- requires: schema_public + +begin; + +create extension if not exists uri; + +commit; diff --git a/deploy/extension_vat.sql b/deploy/extension_vat.sql new file mode 100644 index 0000000..ee63181 --- /dev/null +++ b/deploy/extension_vat.sql @@ -0,0 +1,8 @@ +-- Deploy numerus:extension_vat to pg +-- requires: schema_public + +begin; + +create extension if not exists vat; + +commit; diff --git a/pkg/company.go b/pkg/company.go new file mode 100644 index 0000000..5fc6642 --- /dev/null +++ b/pkg/company.go @@ -0,0 +1,105 @@ +package pkg + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" +) + +const ( + ContextCompanyKey = "numerus-company" +) + +type Company struct { + Id int + Slug string +} + +func CompanyHandler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + slug := r.URL.Path + if idx := strings.IndexByte(slug, '/'); idx >= 0 { + slug = slug[:idx] + } + + conn := getConn(r) + company := &Company{ + Slug: slug, + } + err := conn.QueryRow(r.Context(), "select company_id from company where slug = $1", slug).Scan(&company.Id) + if err != nil { + http.NotFound(w, r) + return + } + ctx := context.WithValue(r.Context(), ContextCompanyKey, company) + r = r.WithContext(ctx) + + // Same as StripPrefix + p := strings.TrimPrefix(r.URL.Path, slug) + rp := strings.TrimPrefix(r.URL.RawPath, slug) + if len(p) < len(r.URL.Path) && (r.URL.RawPath == "" || len(rp) < len(r.URL.RawPath)) { + r2 := new(http.Request) + *r2 = *r + r2.URL = new(url.URL) + *r2.URL = *r.URL + if p == "" { + r2.URL.Path = "/" + } else { + r2.URL.Path = p + } + r2.URL.RawPath = rp + next.ServeHTTP(w, r2) + } else { + http.NotFound(w, r) + } + }) +} + +func getCompany(r *http.Request) *Company { + company := r.Context().Value(ContextCompanyKey) + if company == nil { + return nil + } + return company.(*Company) +} + +type TaxDetailsPage struct { + Title string + BusinessName string + VATIN string + TradeName string + Phone string + Email string + Web string + Address string + City string + Province string + PostalCode string + Country string +} + +func CompanyTaxDetailsHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + locale := getLocale(r) + page := &TaxDetailsPage{ + Title: pgettext("title", "Tax Details", locale), + } + company := mustGetCompany(r) + conn := getConn(r) + err := conn.QueryRow(r.Context(), "select business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country from company where company_id = $1", company.Id).Scan(&page.BusinessName, &page.VATIN, &page.TradeName, &page.Phone, &page.Email, &page.Web, &page.Address, &page.City, &page.Province, &page.PostalCode, &page.Country); + if err != nil { + panic(err) + } + mustRenderAppTemplate(w, r, "tax-details.html", page) + }); +} + +func mustGetCompany(r *http.Request) *Company { + company := getCompany(r) + if company == nil { + panic(errors.New("company: required but not found")) + } + return company; +} diff --git a/pkg/router.go b/pkg/router.go index dc83b62..ee17151 100644 --- a/pkg/router.go +++ b/pkg/router.go @@ -5,15 +5,28 @@ import ( ) func NewRouter(db *Db) http.Handler { + companyRouter := http.NewServeMux() + companyRouter.Handle("/tax-details", CompanyTaxDetailsHandler()) + companyRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + mustRenderAppTemplate(w, r, "dashboard.html", nil) + }) + router := http.NewServeMux() router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static")))) router.Handle("/login", LoginHandler()) router.Handle("/logout", Authenticated(LogoutHandler())) router.Handle("/profile", Authenticated(ProfileHandler())) + router.Handle("/company/", Authenticated(http.StripPrefix("/company/", CompanyHandler(companyRouter)))) router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { user := getUser(r) if user.LoggedIn { - mustRenderAppTemplate(w, r, "dashboard.html", nil) + conn := getConn(r) + var slug string + err := conn.QueryRow(r.Context(), "select slug::text from company order by company_id limit 1").Scan(&slug) + if err != nil { + panic(err) + } + http.Redirect(w, r, "/company/"+slug, http.StatusFound) } else { http.Redirect(w, r, "/login", http.StatusSeeOther) } diff --git a/pkg/template.go b/pkg/template.go index 2dee2de..7ec1db0 100644 --- a/pkg/template.go +++ b/pkg/template.go @@ -12,10 +12,17 @@ func templateFile (name string) string { func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename string, data interface{}) { locale := getLocale(r) + company := getCompany(r) t := template.New(filename) t.Funcs(template.FuncMap{ "gettext": locale.Get, "pgettext": locale.GetC, + "companyURI": func(uri string) string { + if company == nil { + return uri; + } + return "/company/" + company.Slug + uri; + }, }) if _, err := t.ParseFiles(templateFile(filename), templateFile(layout)); err != nil { panic(err) diff --git a/po/ca.po b/po/ca.po index 5f435f3..4b040ab 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-23 18:50+0100\n" +"POT-Creation-Date: 2023-01-24 21:37+0100\n" "PO-Revision-Date: 2023-01-18 17:08+0100\n" "Last-Translator: jordi fita mas \n" "Language-Team: Catalan \n" @@ -26,12 +26,13 @@ msgstr "Entrada" msgid "Invalid user or password" msgstr "Nom d’usuari o contrasenya incorrectes" -#: web/template/login.html:13 web/template/profile.html:14 +#: web/template/login.html:13 web/template/profile.html:15 +#: web/template/tax-details.html:23 msgctxt "input" msgid "Email" msgstr "Correu-e" -#: web/template/login.html:18 web/template/profile.html:22 +#: web/template/login.html:18 web/template/profile.html:23 msgctxt "input" msgid "Password" msgstr "Contrasenya" @@ -41,51 +42,112 @@ msgctxt "action" msgid "Login" msgstr "Entra" -#: web/template/profile.html:2 pkg/profile.go:33 +#: web/template/profile.html:3 pkg/profile.go:29 msgctxt "title" msgid "User Settings" msgstr "Configuració usuari" -#: web/template/profile.html:5 +#: web/template/profile.html:6 msgctxt "title" msgid "User Access Data" msgstr "Dades accés usuari" -#: web/template/profile.html:9 +#: web/template/profile.html:10 msgctxt "input" msgid "User name" msgstr "Nom d’usuari" -#: web/template/profile.html:18 +#: web/template/profile.html:19 msgctxt "title" msgid "Password Change" msgstr "Canvi contrasenya" -#: web/template/profile.html:27 +#: web/template/profile.html:28 msgctxt "input" msgid "Password Confirmation" msgstr "Confirmació contrasenya" -#: web/template/profile.html:31 +#: web/template/profile.html:33 msgctxt "input" msgid "Language" msgstr "Idioma" -#: web/template/profile.html:33 +#: web/template/profile.html:36 msgctxt "language option" msgid "Automatic" msgstr "Automàtic" -#: web/template/profile.html:38 +#: web/template/profile.html:42 msgctxt "action" msgid "Save changes" msgstr "Desa canvis" +#: web/template/tax-details.html:3 pkg/company.go:87 +msgctxt "title" +msgid "Tax Details" +msgstr "Configuració fiscal" + +#: web/template/tax-details.html:7 +msgctxt "input" +msgid "Business name" +msgstr "Nom i cognom" + +#: web/template/tax-details.html:11 +msgctxt "input" +msgid "VAT number" +msgstr "DNI / NIF" + +#: web/template/tax-details.html:15 +msgctxt "input" +msgid "Trade name" +msgstr "Nom comercial" + +#: web/template/tax-details.html:19 +msgctxt "input" +msgid "Phone" +msgstr "Telèfon" + +#: web/template/tax-details.html:27 +msgctxt "input" +msgid "Web" +msgstr "Web" + +#: web/template/tax-details.html:31 +msgctxt "input" +msgid "Address" +msgstr "Adreça" + +#: web/template/tax-details.html:35 +msgctxt "input" +msgid "City" +msgstr "Població" + +#: web/template/tax-details.html:39 +msgctxt "input" +msgid "Province" +msgstr "Província" + +#: web/template/tax-details.html:43 +msgctxt "input" +msgid "Postal code" +msgstr "Codi postal" + +#: web/template/tax-details.html:47 +msgctxt "input" +msgid "Country" +msgstr "País" + #: web/template/app.html:20 +msgctxt "menu" msgid "Account" msgstr "Compte" -#: web/template/app.html:27 +#: web/template/app.html:26 +msgctxt "menu" +msgid "Tax Details" +msgstr "Configuració fiscal" + +#: web/template/app.html:33 msgctxt "action" msgid "Logout" msgstr "Surt" diff --git a/po/es.po b/po/es.po index 17d02f0..212b929 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-23 18:50+0100\n" +"POT-Creation-Date: 2023-01-24 21:37+0100\n" "PO-Revision-Date: 2023-01-18 17:45+0100\n" "Last-Translator: jordi fita mas \n" "Language-Team: Spanish \n" @@ -26,12 +26,13 @@ msgstr "Entrada" msgid "Invalid user or password" msgstr "Nombre de usuario o contraseña inválido" -#: web/template/login.html:13 web/template/profile.html:14 +#: web/template/login.html:13 web/template/profile.html:15 +#: web/template/tax-details.html:23 msgctxt "input" msgid "Email" msgstr "Correo-e" -#: web/template/login.html:18 web/template/profile.html:22 +#: web/template/login.html:18 web/template/profile.html:23 msgctxt "input" msgid "Password" msgstr "Contraseña" @@ -41,51 +42,112 @@ msgctxt "action" msgid "Login" msgstr "Entrar" -#: web/template/profile.html:2 pkg/profile.go:33 +#: web/template/profile.html:3 pkg/profile.go:29 msgctxt "title" msgid "User Settings" msgstr "Configuración usuario" -#: web/template/profile.html:5 +#: web/template/profile.html:6 msgctxt "title" msgid "User Access Data" msgstr "Datos acceso usuario" -#: web/template/profile.html:9 +#: web/template/profile.html:10 msgctxt "input" msgid "User name" msgstr "Nombre de usuario" -#: web/template/profile.html:18 +#: web/template/profile.html:19 msgctxt "title" msgid "Password Change" msgstr "Cambio de contraseña" -#: web/template/profile.html:27 +#: web/template/profile.html:28 msgctxt "input" msgid "Password Confirmation" msgstr "Confirmación contrasenya" -#: web/template/profile.html:31 +#: web/template/profile.html:33 msgctxt "input" msgid "Language" msgstr "Idioma" -#: web/template/profile.html:33 +#: web/template/profile.html:36 msgctxt "language option" msgid "Automatic" msgstr "Automático" -#: web/template/profile.html:38 +#: web/template/profile.html:42 msgctxt "action" msgid "Save changes" msgstr "Guardar cambios" +#: web/template/tax-details.html:3 pkg/company.go:87 +msgctxt "title" +msgid "Tax Details" +msgstr "Configuración fiscal" + +#: web/template/tax-details.html:7 +msgctxt "input" +msgid "Business name" +msgstr "Nombre y apellidos" + +#: web/template/tax-details.html:11 +msgctxt "input" +msgid "VAT number" +msgstr "DNI / NIF" + +#: web/template/tax-details.html:15 +msgctxt "input" +msgid "Trade name" +msgstr "Nombre comercial" + +#: web/template/tax-details.html:19 +msgctxt "input" +msgid "Phone" +msgstr "Teléfono" + +#: web/template/tax-details.html:27 +msgctxt "input" +msgid "Web" +msgstr "Web" + +#: web/template/tax-details.html:31 +msgctxt "input" +msgid "Address" +msgstr "Dirección" + +#: web/template/tax-details.html:35 +msgctxt "input" +msgid "City" +msgstr "Población" + +#: web/template/tax-details.html:39 +msgctxt "input" +msgid "Province" +msgstr "Provincia" + +#: web/template/tax-details.html:43 +msgctxt "input" +msgid "Postal code" +msgstr "Código postal" + +#: web/template/tax-details.html:47 +msgctxt "input" +msgid "Country" +msgstr "País" + #: web/template/app.html:20 +msgctxt "menu" msgid "Account" msgstr "Cuenta" -#: web/template/app.html:27 +#: web/template/app.html:26 +msgctxt "menu" +msgid "Tax Details" +msgstr "Configuración fiscal" + +#: web/template/app.html:33 msgctxt "action" msgid "Logout" msgstr "Salir" diff --git a/revert/available_currencies.sql b/revert/available_currencies.sql new file mode 100644 index 0000000..9d5f891 --- /dev/null +++ b/revert/available_currencies.sql @@ -0,0 +1,7 @@ +-- Revert numerus:available_currencies from pg + +begin; + +delete from numerus.currency; + +commit; diff --git a/revert/company.sql b/revert/company.sql new file mode 100644 index 0000000..5fd60b3 --- /dev/null +++ b/revert/company.sql @@ -0,0 +1,7 @@ +-- Revert numerus:company from pg + +begin; + +drop table numerus.company; + +commit; diff --git a/revert/company_user.sql b/revert/company_user.sql new file mode 100644 index 0000000..eb53c28 --- /dev/null +++ b/revert/company_user.sql @@ -0,0 +1,9 @@ +-- Revert numerus:company_user from pg + +begin; + +drop policy if exists company_policy on numerus.company; +drop policy if exists company_policy on numerus.company_user; +drop table if exists numerus.company_user; + +commit; diff --git a/revert/currency.sql b/revert/currency.sql new file mode 100644 index 0000000..d6cf2df --- /dev/null +++ b/revert/currency.sql @@ -0,0 +1,7 @@ +-- Revert numerus:currency from pg + +begin; + +drop table numerus.currency; + +commit; diff --git a/revert/currency_code.sql b/revert/currency_code.sql new file mode 100644 index 0000000..f3572ce --- /dev/null +++ b/revert/currency_code.sql @@ -0,0 +1,7 @@ +-- Revert numerus:currency_code from pg + +begin; + +drop domain if exists numerus.currency_code; + +commit; diff --git a/revert/extension_pg_libphonenumber.sql b/revert/extension_pg_libphonenumber.sql new file mode 100644 index 0000000..782c623 --- /dev/null +++ b/revert/extension_pg_libphonenumber.sql @@ -0,0 +1,7 @@ +-- Revert numerus:extension_pg_libphonenumber from pg + +begin; + +drop extension if exists pg_libphonenumber; + +commit; diff --git a/revert/extension_uri.sql b/revert/extension_uri.sql new file mode 100644 index 0000000..b7155ae --- /dev/null +++ b/revert/extension_uri.sql @@ -0,0 +1,7 @@ +-- Revert numerus:extension_uri from pg + +begin; + +drop extension if exists uri; + +commit; diff --git a/revert/extension_vat.sql b/revert/extension_vat.sql new file mode 100644 index 0000000..1af9496 --- /dev/null +++ b/revert/extension_vat.sql @@ -0,0 +1,7 @@ +-- Revert numerus:extension_vat from pg + +begin; + +drop extension if exists vat; + +commit; diff --git a/sqitch.plan b/sqitch.plan index 4560fc6..0cae201 100644 --- a/sqitch.plan +++ b/sqitch.plan @@ -9,11 +9,11 @@ schema_numerus [roles] 2023-01-12T22:57:22Z jordi fita mas # extension_citext [schema_public] 2023-01-12T23:03:33Z jordi fita mas # Add citext extension email [schema_numerus extension_citext] 2023-01-12T23:09:59Z jordi fita mas # Add email domain language [schema_numerus] 2023-01-21T20:55:49Z jordi fita mas # Add relation of available languages -user [roles schema_auth email language] 2023-01-12T23:44:03Z jordi fita mas # Create user table +user [roles schema_auth email language] 2023-01-12T23:44:03Z jordi fita mas # Create user relation ensure_role_exists [schema_auth user] 2023-01-12T23:57:59Z jordi fita mas # Add trigger to ensure the user’s role exists extension_pgcrypto [schema_auth] 2023-01-13T00:11:50Z jordi fita mas # Add pgcrypto extension encrypt_password [schema_auth user extension_pgcrypto] 2023-01-13T00:14:30Z jordi fita mas # Add trigger to encrypt user’s password -login_attempt [schema_auth] 2023-01-17T14:05:49Z jordi fita mas # Add table to log login attempts +login_attempt [schema_auth] 2023-01-17T14:05:49Z jordi fita mas # Add relation to log login attempts login [roles schema_numerus schema_auth extension_pgcrypto email user login_attempt] 2023-01-13T00:32:32Z jordi fita mas # Add function to login current_user_cookie [schema_numerus] 2023-01-21T20:16:28Z jordi fita mas # Add function to get the cookie of the current Numerus’ user current_user_email [schema_numerus] 2023-01-23T19:11:53Z jordi fita mas # Add function to get the email of the current Numerus’ user @@ -24,3 +24,11 @@ set_cookie [schema_public check_cookie] 2023-01-19T11:00:22Z jordi fita mas # Add the initial available languages user_profile [schema_numerus user current_user_email current_user_cookie] 2023-01-21T23:18:20Z jordi fita mas # Add view for user profile change_password [schema_numerus user] 2023-01-23T20:22:45Z jordi fita mas # Add function to change the current user’s password +extension_vat [schema_public] 2023-01-24T10:28:17Z jordi fita mas # Add vat extension +extension_pg_libphonenumber [schema_public] 2023-01-24T13:50:14Z jordi fita mas # Add extension for phone numbers +extension_uri [schema_public] 2023-01-24T14:29:29Z jordi fita mas # Add extension for URIs +currency_code [schema_numerus] 2023-01-24T14:36:04Z jordi fita mas # Add the domain for currency code in ISO 4217 +currency [schema_numerus currency_code] 2023-01-24T14:45:26Z jordi fita mas # Add the relation for currencies +available_currencies [schema_numerus currency] 2023-01-24T14:54:18Z jordi fita mas # Add the initial list of available currencies +company [schema_numerus extension_vat email extension_pg_libphonenumber extension_uri currency_code currency] 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 diff --git a/test/company.sql b/test/company.sql new file mode 100644 index 0000000..cdcc25d --- /dev/null +++ b/test/company.sql @@ -0,0 +1,164 @@ +-- Test company +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(77); + +set search_path to numerus, auth, public; + +select has_table('company'); +select has_pk('company'); +select table_privs_are('company', 'guest', array []::text[]); +select table_privs_are('company', 'invoicer', array ['SELECT']); +select table_privs_are('company', 'admin', array ['SELECT']); +select table_privs_are('company', 'authenticator', array []::text[]); + +select has_column('company', 'company_id'); +select col_is_pk('company', 'company_id'); +select col_type_is('company', 'company_id', 'integer'); +select col_not_null('company', 'company_id'); +select col_has_default('company', 'company_id'); +select col_default_is('company', 'company_id', 'nextval(''company_company_id_seq''::regclass)'); + +select has_column('company', 'slug'); +select col_is_unique('company', 'slug'); +select col_type_is('company', 'slug', 'uuid'); +select col_not_null('company', 'slug'); +select col_has_default('company', 'slug'); +select col_default_is('company', 'slug', 'gen_random_uuid()'); + +select has_column('company', 'business_name'); +select col_type_is('company', 'business_name', 'text'); +select col_not_null('company', 'business_name'); +select col_hasnt_default('company', 'business_name'); + +select has_column('company', 'vatin'); +select col_type_is('company', 'vatin', 'vatin'); +select col_not_null('company', 'vatin'); +select col_hasnt_default('company', 'vatin'); + +select has_column('company', 'trade_name'); +select col_type_is('company', 'trade_name', 'text'); +select col_not_null('company', 'trade_name'); +select col_hasnt_default('company', 'trade_name'); + +select has_column('company', 'phone'); +select col_type_is('company', 'phone', 'packed_phone_number'); +select col_not_null('company', 'phone'); +select col_hasnt_default('company', 'phone'); + +select has_column('company', 'email'); +select col_type_is('company', 'email', 'email'); +select col_not_null('company', 'email'); +select col_hasnt_default('company', 'email'); + +select has_column('company', 'web'); +select col_type_is('company', 'web', 'uri'); +select col_not_null('company', 'web'); +select col_hasnt_default('company', 'web'); + +select has_column('company', 'address'); +select col_type_is('company', 'address', 'text'); +select col_not_null('company', 'address'); +select col_hasnt_default('company', 'address'); + +select has_column('company', 'city'); +select col_type_is('company', 'city', 'text'); +select col_not_null('company', 'city'); +select col_hasnt_default('company', 'city'); + +select has_column('company', 'province'); +select col_type_is('company', 'province', 'text'); +select col_not_null('company', 'province'); +select col_hasnt_default('company', 'province'); + +select has_column('company', 'postal_code'); +select col_type_is('company', 'postal_code', 'text'); +select col_not_null('company', 'postal_code'); +select col_hasnt_default('company', 'postal_code'); + +select has_column('company', 'country'); +select col_type_is('company', 'country', 'text'); +select col_not_null('company', 'country'); +select col_hasnt_default('company', 'country'); + +select has_column('company', 'currency_code'); +select col_is_fk('company', 'currency_code'); +select fk_ok('company', 'currency_code', 'currency', 'currency_code'); +select col_type_is('company', 'currency_code', 'currency_code'); +select col_not_null('company', 'currency_code'); +select col_hasnt_default('company', 'currency_code'); + +select has_column('company', 'created_at'); +select col_type_is('company', 'created_at', 'timestamp with time zone'); +select col_not_null('company', 'created_at'); +select col_has_default('company', 'created_at'); +select col_default_is('company', 'created_at', current_timestamp); + + +set client_min_messages to warning; +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, currency_code) +values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '', 'EUR') + , (4, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', '', 'USD') + , (6, 'Company 6', 'XX345', '', '777-777-777', 'c@c', '', '', '', '', '', '', 'USD') +; + +insert into company_user (company_id, user_id) +values (2, 1) + , (2, 5) + , (4, 1) + , (6, 5) +; + +prepare company_data as +select company_id, business_name +from company +order by company_id; + +set role invoicer; +select is_empty('company_data', 'Should show no data when cookie is not set yet'); +reset role; + +select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog'); +select results_eq( + 'company_data', + $$ values ( 2, 'Company 2' ) + , ( 4, 'Company 4' ) + $$, + 'Should only list companies where demo@tandem.blog is user of' +); +reset role; + +select set_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog'); +select results_eq( + 'company_data', + $$ values ( 2, 'Company 2' ) + , ( 6, 'Company 6' ) + $$, + 'Should only list companies where admin@tandem.blog is user of' +); +reset role; + +select set_cookie('not-a-cookie'); +select throws_ok( + 'company_data', + '42501', 'permission denied for table company', + 'Should not allow select to guest users' +); +reset role; + +select finish(); +rollback; diff --git a/test/company_user.sql b/test/company_user.sql new file mode 100644 index 0000000..b14d290 --- /dev/null +++ b/test/company_user.sql @@ -0,0 +1,39 @@ +-- Test company_user +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(19); + +set search_path to numerus, auth, public; + +select has_table('company_user'); +select has_pk('company_user' ); +select col_is_pk('company_user', array['company_id', 'user_id']); +select table_privs_are('company_user', 'guest', array []::text[]); +select table_privs_are('company_user', 'invoicer', array ['SELECT']); +select table_privs_are('company_user', 'admin', array ['SELECT']); +select table_privs_are('company_user', 'authenticator', array []::text[]); + +select has_column('company_user', 'company_id'); +select col_is_fk('company_user', 'company_id'); +select fk_ok('company_user', 'company_id', 'company', 'company_id'); +select col_type_is('company_user', 'company_id', 'integer'); +select col_not_null('company_user', 'company_id'); +select col_hasnt_default('company_user', 'company_id'); + +select has_column('company_user', 'user_id'); +select col_is_fk('company_user', 'user_id'); +select fk_ok('company_user', 'user_id', 'user', 'user_id'); +select col_type_is('company_user', 'user_id', 'integer'); +select col_not_null('company_user', 'user_id'); +select col_hasnt_default('company_user', 'user_id'); + + +select * +from finish(); + +rollback; + diff --git a/test/currency.sql b/test/currency.sql new file mode 100644 index 0000000..11224eb --- /dev/null +++ b/test/currency.sql @@ -0,0 +1,42 @@ +-- Test currency +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(20); + +set search_path to numerus, public; + +select has_table('currency'); +select has_pk('currency'); +select table_privs_are('currency', 'guest', array []::text[]); +select table_privs_are('currency', 'invoicer', array ['SELECT']); +select table_privs_are('currency', 'admin', array ['SELECT']); +select table_privs_are('currency', 'authenticator', array []::text[]); + +select has_column('currency', 'currency_code'); +select col_is_pk('currency', 'currency_code'); +select col_type_is('currency', 'currency_code', 'currency_code'); +select col_not_null('currency', 'currency_code'); +select col_hasnt_default('currency', 'currency_code'); + +select has_column('currency', 'currency_symbol'); +select col_type_is('currency', 'currency_symbol', 'text'); +select col_not_null('currency', 'currency_symbol'); +select col_hasnt_default('currency', 'currency_symbol'); + +select has_column('currency', 'decimal_digits'); +select col_type_is('currency', 'decimal_digits', 'integer'); +select col_not_null('currency', 'decimal_digits'); +select col_has_default('currency', 'decimal_digits'); +select col_default_is('currency', 'decimal_digits', 2); + +set client_min_messages to warning; +truncate currency cascade; +reset client_min_messages; + + +select finish(); +rollback; diff --git a/test/currency_code.sql b/test/currency_code.sql new file mode 100644 index 0000000..a1eb3f2 --- /dev/null +++ b/test/currency_code.sql @@ -0,0 +1,38 @@ +-- Test currency_code +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(6); + +set search_path to numerus, public; + +select has_domain('currency_code'); +select domain_type_is('currency_code', 'text'); + +select lives_ok($$ select 'EUR'::currency_code $$, 'Should be able to cast valid text to currency code'); + +select throws_ok( + $$ SELECT '123'::currency_code $$, + 23514, null, + 'Should reject numeric text' +); + +select throws_ok( + $$ SELECT 'eur'::currency_code $$, + 23514, null, + 'Should reject lowecase text' +); + +select throws_ok( + $$ SELECT 'EURO'::currency_code $$, + 23514, null, + 'Should reject text longer than three letters' +); + +select * +from finish(); + +rollback; diff --git a/test/extensions.sql b/test/extensions.sql index c36b625..99d5fd7 100644 --- a/test/extensions.sql +++ b/test/extensions.sql @@ -9,9 +9,12 @@ select plan(1); select extensions_are(array[ 'citext' + , 'pg_libphonenumber' , 'pgtap' , 'pgcrypto' , 'plpgsql' + , 'uri' + , 'vat' ]); select * diff --git a/verify/available_currencies.sql b/verify/available_currencies.sql new file mode 100644 index 0000000..fc3ef0e --- /dev/null +++ b/verify/available_currencies.sql @@ -0,0 +1,21 @@ +-- Verify numerus:available_currencies on pg + +begin; + +set search_path to numerus; + +select 1 / count(*) +from currency +where currency_code = 'EUR' + and currency_symbol = '€' + and decimal_digits = 2 +; + +select 1 / count(*) +from currency +where currency_code = 'USD' + and currency_symbol = '$' + and decimal_digits = 2 +; + +rollback; diff --git a/verify/company.sql b/verify/company.sql new file mode 100644 index 0000000..1859c0c --- /dev/null +++ b/verify/company.sql @@ -0,0 +1,23 @@ +-- Verify numerus:company on pg + +begin; + +select company_id + , slug + , business_name + , vatin + , trade_name + , phone + , email + , web + , address + , city + , province + , postal_code + , country + , currency_code + , created_at +from numerus.company +where false; + +rollback; diff --git a/verify/company_user.sql b/verify/company_user.sql new file mode 100644 index 0000000..5bd8f1a --- /dev/null +++ b/verify/company_user.sql @@ -0,0 +1,10 @@ +-- Verify numerus:company_user on pg + +begin; + +select company_id + , user_id +from numerus.company_user +where false; + +rollback; diff --git a/verify/currency.sql b/verify/currency.sql new file mode 100644 index 0000000..6b70476 --- /dev/null +++ b/verify/currency.sql @@ -0,0 +1,11 @@ +-- Verify numerus:currency on pg + +begin; + +select currency_code + , currency_symbol + , decimal_digits +from numerus.currency +where false; + +rollback; diff --git a/verify/currency_code.sql b/verify/currency_code.sql new file mode 100644 index 0000000..319da2f --- /dev/null +++ b/verify/currency_code.sql @@ -0,0 +1,7 @@ +-- Verify numerus:currency_code on pg + +begin; + +select pg_catalog.has_type_privilege('numerus.currency_code', 'usage'); + +rollback; diff --git a/verify/extension_pg_libphonenumber.sql b/verify/extension_pg_libphonenumber.sql new file mode 100644 index 0000000..600d8d0 --- /dev/null +++ b/verify/extension_pg_libphonenumber.sql @@ -0,0 +1,7 @@ +-- Verify numerus:extension_pg_libphonenumber on pg + +begin; + +select 1/count(*) from pg_extension where extname = 'pg_libphonenumber'; + +rollback; diff --git a/verify/extension_uri.sql b/verify/extension_uri.sql new file mode 100644 index 0000000..5b4e865 --- /dev/null +++ b/verify/extension_uri.sql @@ -0,0 +1,7 @@ +-- Verify numerus:extension_uri on pg + +begin; + +select 1/count(*) from pg_extension where extname = 'uri'; + +rollback; diff --git a/verify/extension_vat.sql b/verify/extension_vat.sql new file mode 100644 index 0000000..61b249d --- /dev/null +++ b/verify/extension_vat.sql @@ -0,0 +1,7 @@ +-- Verify numerus:extension_vat on pg + +begin; + +select 1/count(*) from pg_extension where extname = 'vat'; + +rollback; diff --git a/web/static/numerus.css b/web/static/numerus.css index 4958bab..f65038b 100644 --- a/web/static/numerus.css +++ b/web/static/numerus.css @@ -245,9 +245,10 @@ main { .input { position: relative; display: inline-block; + margin-top: 2rem; } -input[type="text"], input[type="password"], input[type="email"], select { +input[type="text"], input[type="password"], input[type="email"], input[type="tel"], input[type="url"], select { background-color: var(--numerus--background-color); border: 1px solid var(--numerus--color--black); border-radius: 0; @@ -255,6 +256,10 @@ input[type="text"], input[type="password"], input[type="email"], select { min-width: 30rem; } +input.width-2x { + min-width: 60.95rem; +} + .input input::placeholder { color: transparent; } @@ -309,7 +314,7 @@ fieldset { } .full-width legend { - margin-bottom: initial; + margin-bottom: -1rem; } diff --git a/web/template/app.html b/web/template/app.html index 32ee97e..47eb1f3 100644 --- a/web/template/app.html +++ b/web/template/app.html @@ -17,7 +17,13 @@
  • - {{( gettext "Account" )}} + {{( pgettext "Account" "menu" )}} + +
  • +
  • + + + {{( pgettext "Tax Details" "menu" )}}
  • diff --git a/web/template/tax-details.html b/web/template/tax-details.html new file mode 100644 index 0000000..d7047fd --- /dev/null +++ b/web/template/tax-details.html @@ -0,0 +1,51 @@ +{{ define "content" }} +
    +

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

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +{{- end }}