Add the company relation and read-only form to edit

I do not have more time to update the update to the company today, but i
believe this is already a good amount of work for a commit.

The company is going to be used for row level security, as users will
only have access to the data from companies they are granted access, by
virtue of being in the company_user relation.

I did not know how add a row level security policy to the company_user
because i needed the to select on the same relation and this is not
allowed, because it would create an infinite loop.

Had to add the vat, pg_libphonenumber, and uri extensions in order to
validate VAT identification numbers, phone numbers, and URIs,
repectively.  These libraries are not in Debian, but i created packages
for them all in https://dev.tandem.ws/tandem.
This commit is contained in:
jordi fita mas 2023-01-24 21:46:07 +01:00
parent c037f671f8
commit 627841d4dd
39 changed files with 939 additions and 33 deletions

View File

@ -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 lHort', 'Castelló dEmpúries', 'Alt Empordà', '17486', 'Espanya', 'EUR');
insert into company_user (company_id, user_id)
values (1, 1)
, (1, 2)
;
commit;

View File

@ -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;

36
deploy/company.sql Normal file
View File

@ -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;

38
deploy/company_user.sql Normal file
View File

@ -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;

18
deploy/currency.sql Normal file
View File

@ -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;

14
deploy/currency_code.sql Normal file
View File

@ -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;

View File

@ -0,0 +1,8 @@
-- Deploy numerus:extension_pg_libphonenumber to pg
-- requires: schema_public
begin;
create extension if not exists pg_libphonenumber;
commit;

8
deploy/extension_uri.sql Normal file
View File

@ -0,0 +1,8 @@
-- Deploy numerus:extension_uri to pg
-- requires: schema_public
begin;
create extension if not exists uri;
commit;

8
deploy/extension_vat.sql Normal file
View File

@ -0,0 +1,8 @@
-- Deploy numerus:extension_vat to pg
-- requires: schema_public
begin;
create extension if not exists vat;
commit;

105
pkg/company.go Normal file
View File

@ -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;
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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 <jordi@tandem.blog>\n"
"Language-Team: Catalan <ca@dodds.net>\n"
@ -26,12 +26,13 @@ msgstr "Entrada"
msgid "Invalid user or password"
msgstr "Nom dusuari 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 dusuari"
#: 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"

View File

@ -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 <jordi@tandem.blog>\n"
"Language-Team: Spanish <es@tp.org.es>\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"

View File

@ -0,0 +1,7 @@
-- Revert numerus:available_currencies from pg
begin;
delete from numerus.currency;
commit;

7
revert/company.sql Normal file
View File

@ -0,0 +1,7 @@
-- Revert numerus:company from pg
begin;
drop table numerus.company;
commit;

9
revert/company_user.sql Normal file
View File

@ -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;

7
revert/currency.sql Normal file
View File

@ -0,0 +1,7 @@
-- Revert numerus:currency from pg
begin;
drop table numerus.currency;
commit;

7
revert/currency_code.sql Normal file
View File

@ -0,0 +1,7 @@
-- Revert numerus:currency_code from pg
begin;
drop domain if exists numerus.currency_code;
commit;

View File

@ -0,0 +1,7 @@
-- Revert numerus:extension_pg_libphonenumber from pg
begin;
drop extension if exists pg_libphonenumber;
commit;

7
revert/extension_uri.sql Normal file
View File

@ -0,0 +1,7 @@
-- Revert numerus:extension_uri from pg
begin;
drop extension if exists uri;
commit;

7
revert/extension_vat.sql Normal file
View File

@ -0,0 +1,7 @@
-- Revert numerus:extension_vat from pg
begin;
drop extension if exists vat;
commit;

View File

@ -9,11 +9,11 @@ schema_numerus [roles] 2023-01-12T22:57:22Z jordi fita mas <jordi@tandem.blog> #
extension_citext [schema_public] 2023-01-12T23:03:33Z jordi fita mas <jordi@tandem.blog> # Add citext extension
email [schema_numerus extension_citext] 2023-01-12T23:09:59Z jordi fita mas <jordi@tandem.blog> # Add email domain
language [schema_numerus] 2023-01-21T20:55:49Z jordi fita mas <jordi@tandem.blog> # Add relation of available languages
user [roles schema_auth email language] 2023-01-12T23:44:03Z jordi fita mas <jordi@tandem.blog> # Create user table
user [roles schema_auth email language] 2023-01-12T23:44:03Z jordi fita mas <jordi@tandem.blog> # Create user relation
ensure_role_exists [schema_auth user] 2023-01-12T23:57:59Z jordi fita mas <jordi@tandem.blog> # Add trigger to ensure the users role exists
extension_pgcrypto [schema_auth] 2023-01-13T00:11:50Z jordi fita mas <jordi@tandem.blog> # Add pgcrypto extension
encrypt_password [schema_auth user extension_pgcrypto] 2023-01-13T00:14:30Z jordi fita mas <jordi@tandem.blog> # Add trigger to encrypt users password
login_attempt [schema_auth] 2023-01-17T14:05:49Z jordi fita mas <jordi@tandem.blog> # Add table to log login attempts
login_attempt [schema_auth] 2023-01-17T14:05:49Z jordi fita mas <jordi@tandem.blog> # 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 <jordi@tandem.blog> # Add function to login
current_user_cookie [schema_numerus] 2023-01-21T20:16:28Z jordi fita mas <jordi@tandem.blog> # Add function to get the cookie of the current Numerus user
current_user_email [schema_numerus] 2023-01-23T19:11:53Z jordi fita mas <jordi@tandem.blog> # 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 <jor
available_languages [schema_numerus language] 2023-01-21T21:11:08Z jordi fita mas <jordi@tandem.blog> # Add the initial available languages
user_profile [schema_numerus user current_user_email current_user_cookie] 2023-01-21T23:18:20Z jordi fita mas <jordi@tandem.blog> # Add view for user profile
change_password [schema_numerus user] 2023-01-23T20:22:45Z jordi fita mas <jordi@tandem.blog> # Add function to change the current users password
extension_vat [schema_public] 2023-01-24T10:28:17Z jordi fita mas <jordi@tandem.blog> # Add vat extension
extension_pg_libphonenumber [schema_public] 2023-01-24T13:50:14Z jordi fita mas <jordi@tandem.blog> # Add extension for phone numbers
extension_uri [schema_public] 2023-01-24T14:29:29Z jordi fita mas <jordi@tandem.blog> # Add extension for URIs
currency_code [schema_numerus] 2023-01-24T14:36:04Z jordi fita mas <jordi@tandem.blog> # Add the domain for currency code in ISO 4217
currency [schema_numerus currency_code] 2023-01-24T14:45:26Z jordi fita mas <jordi@tandem.blog> # Add the relation for currencies
available_currencies [schema_numerus currency] 2023-01-24T14:54:18Z jordi fita mas <jordi@tandem.blog> # 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 <jordi@tandem.blog> # Add the relation for companies
company_user [schema_numerus user company] 2023-01-24T17:50:06Z jordi fita mas <jordi@tandem.blog> # Add the relation of companies and their users

164
test/company.sql Normal file
View File

@ -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;

39
test/company_user.sql Normal file
View File

@ -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;

42
test/currency.sql Normal file
View File

@ -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;

38
test/currency_code.sql Normal file
View File

@ -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;

View File

@ -9,9 +9,12 @@ select plan(1);
select extensions_are(array[
'citext'
, 'pg_libphonenumber'
, 'pgtap'
, 'pgcrypto'
, 'plpgsql'
, 'uri'
, 'vat'
]);
select *

View File

@ -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;

23
verify/company.sql Normal file
View File

@ -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;

10
verify/company_user.sql Normal file
View File

@ -0,0 +1,10 @@
-- Verify numerus:company_user on pg
begin;
select company_id
, user_id
from numerus.company_user
where false;
rollback;

11
verify/currency.sql Normal file
View File

@ -0,0 +1,11 @@
-- Verify numerus:currency on pg
begin;
select currency_code
, currency_symbol
, decimal_digits
from numerus.currency
where false;
rollback;

7
verify/currency_code.sql Normal file
View File

@ -0,0 +1,7 @@
-- Verify numerus:currency_code on pg
begin;
select pg_catalog.has_type_privilege('numerus.currency_code', 'usage');
rollback;

View File

@ -0,0 +1,7 @@
-- Verify numerus:extension_pg_libphonenumber on pg
begin;
select 1/count(*) from pg_extension where extname = 'pg_libphonenumber';
rollback;

7
verify/extension_uri.sql Normal file
View File

@ -0,0 +1,7 @@
-- Verify numerus:extension_uri on pg
begin;
select 1/count(*) from pg_extension where extname = 'uri';
rollback;

7
verify/extension_vat.sql Normal file
View File

@ -0,0 +1,7 @@
-- Verify numerus:extension_vat on pg
begin;
select 1/count(*) from pg_extension where extname = 'vat';
rollback;

View File

@ -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;
}

View File

@ -17,7 +17,13 @@
<li role="presentation">
<a role="menuitem" href="/profile">
<i class="ri-account-circle-line"></i>
{{( gettext "Account" )}}
{{( pgettext "Account" "menu" )}}
</a>
</li>
<li role="presentation">
<a role="menuitem" href="{{ companyURI "/tax-details" }}">
<i class="ri-vip-diamond-line"></i>
{{( pgettext "Tax Details" "menu" )}}
</a>
</li>
<li role="presentation">

View File

@ -0,0 +1,51 @@
{{ define "content" }}
<section class="dialog-content">
<h2>{{(pgettext "Tax Details" "title")}}</h2>
<form method="POST">
<div class="input">
<input type="text" name="business_name" id="business_name" required="required" value="{{ .BusinessName }}" placeholder="{{( pgettext "Business name" "input" )}}">
<label for="business_name">{{( pgettext "Business name" "input" )}}</label>
</div>
<div class="input">
<input type="text" name="vatin" id="vatin" required="required" value="{{ .VATIN }}" placeholder="{{( pgettext "VAT number" "input" )}}">
<label for="vatin">{{( pgettext "VAT number" "input" )}}</label>
</div>
<div class="input">
<input type="text" name="trade_name" id="trade_name" value="{{ .TradeName }}" placeholder="{{( pgettext "Trade name" "input" )}}">
<label for="trade_name">{{( pgettext "Trade name" "input" )}}</label>
</div>
<div class="input">
<input type="tel" name="phone" id="phone" required="required" value="{{ .Phone }}" placeholder="{{( pgettext "Phone" "input" )}}">
<label for="phone">{{( pgettext "Phone" "input" )}}</label>
</div>
<div class="input">
<input type="email" name="email" id="email" required="required" value="{{ .Email }}" placeholder="{{( pgettext "Email" "input" )}}">
<label for="email">{{( pgettext "Email" "input" )}}</label>
</div>
<div class="input">
<input type="url" name="web" id="web" value="{{ .Web }}" placeholder="{{( pgettext "Web" "input" )}}">
<label for="web">{{( pgettext "Web" "input" )}}</label>
</div>
<div class="input">
<input type="text" name="address" id="address" class="width-2x" required="required" value="{{ .Address }}" placeholder="{{( pgettext "Address" "input" )}}">
<label for="address">{{( pgettext "Address" "input" )}}</label>
</div>
<div class="input">
<input type="text" name="city" id="city" required="required" value="{{ .City }}" placeholder="{{( pgettext "City" "input" )}}">
<label for="city">{{( pgettext "City" "input" )}}</label>
</div>
<div class="input">
<input type="text" name="province" id="province" required="required" value="{{ .City }}" placeholder="{{( pgettext "Province" "input" )}}">
<label for="province">{{( pgettext "Province" "input" )}}</label>
</div>
<div class="input">
<input type="text" name="postal_code" id="postal_code" required="required" value="{{ .PostalCode }}" placeholder="{{( pgettext "Postal code" "input" )}}">
<label for="postal_code">{{( pgettext "Postal code" "input" )}}</label>
</div>
<div class="input">
<input type="text" name="country" id="country" required="required" value="{{ .Country }}" placeholder="{{( pgettext "Country" "input" )}}">
<label for="country">{{( pgettext "Country" "input" )}}</label>
</div>
</form>
</section>
{{- end }}