Add the tax relation with very rough form and handler

This commit is contained in:
jordi fita mas 2023-01-28 14:18:58 +01:00
parent 0b8107748c
commit 666935b54c
16 changed files with 463 additions and 11 deletions

View File

@ -15,4 +15,9 @@ values (1, 1)
, (1, 2) , (1, 2)
; ;
insert into tax (company_id, name, rate)
values (1, 'Retenció 15 %', -0.15)
, (1, 'IVA 21 %', 0.21)
;
commit; commit;

36
deploy/tax.sql Normal file
View File

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

14
deploy/tax_rate.sql Normal file
View File

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

View File

@ -5,6 +5,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"strings" "strings"
) )
@ -75,6 +76,12 @@ type CountryOption struct {
Name string Name string
} }
type Tax struct {
Id int
Name string
Rate int
}
type TaxDetailsPage struct { type TaxDetailsPage struct {
Title string Title string
BusinessName string BusinessName string
@ -91,6 +98,7 @@ type TaxDetailsPage struct {
Countries []CountryOption Countries []CountryOption
CurrencyCode string CurrencyCode string
Currencies []CurrencyOption Currencies []CurrencyOption
Taxes []Tax
} }
func CompanyTaxDetailsHandler() http.Handler { func CompanyTaxDetailsHandler() http.Handler {
@ -125,6 +133,7 @@ func CompanyTaxDetailsHandler() http.Handler {
} }
page.Countries = mustGetCountryOptions(r.Context(), conn, locale) page.Countries = mustGetCountryOptions(r.Context(), conn, locale)
page.Currencies = mustGetCurrencyOptions(r.Context(), conn) page.Currencies = mustGetCurrencyOptions(r.Context(), conn)
page.Taxes = mustGetTaxes(r.Context(), conn, company)
mustRenderAppTemplate(w, r, "tax-details.html", page) mustRenderAppTemplate(w, r, "tax-details.html", page)
}) })
} }
@ -182,3 +191,46 @@ func mustGetCurrencyOptions(ctx context.Context, conn *Conn) []CurrencyOption {
return currencies 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)
})
}

View File

@ -7,6 +7,8 @@ import (
func NewRouter(db *Db) http.Handler { func NewRouter(db *Db) http.Handler {
companyRouter := http.NewServeMux() companyRouter := http.NewServeMux()
companyRouter.Handle("/tax-details", CompanyTaxDetailsHandler()) companyRouter.Handle("/tax-details", CompanyTaxDetailsHandler())
companyRouter.Handle("/tax/", CompanyTaxHandler())
companyRouter.Handle("/tax", CompanyTaxHandler())
companyRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { companyRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
mustRenderAppTemplate(w, r, "dashboard.html", nil) mustRenderAppTemplate(w, r, "dashboard.html", nil)
}) })

View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: numerus\n" "Project-Id-Version: numerus\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\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" "PO-Revision-Date: 2023-01-18 17:08+0100\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n" "Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Catalan <ca@dodds.net>\n" "Language-Team: Catalan <ca@dodds.net>\n"
@ -77,12 +77,12 @@ msgctxt "language option"
msgid "Automatic" msgid "Automatic"
msgstr "Automàtic" 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" msgctxt "action"
msgid "Save changes" msgid "Save changes"
msgstr "Desa canvis" 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" msgctxt "title"
msgid "Tax Details" msgid "Tax Details"
msgstr "Configuració fiscal" msgstr "Configuració fiscal"
@ -142,6 +142,40 @@ msgctxt "input"
msgid "Currency" msgid "Currency"
msgstr "Moneda" 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 #: web/template/app.html:20
msgctxt "menu" msgctxt "menu"
msgid "Account" msgid "Account"

View File

@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: numerus\n" "Project-Id-Version: numerus\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\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" "PO-Revision-Date: 2023-01-18 17:45+0100\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n" "Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Spanish <es@tp.org.es>\n" "Language-Team: Spanish <es@tp.org.es>\n"
@ -77,12 +77,12 @@ msgctxt "language option"
msgid "Automatic" msgid "Automatic"
msgstr "Automático" 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" msgctxt "action"
msgid "Save changes" msgid "Save changes"
msgstr "Guardar cambios" 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" msgctxt "title"
msgid "Tax Details" msgid "Tax Details"
msgstr "Configuración fiscal" msgstr "Configuración fiscal"
@ -142,6 +142,40 @@ msgctxt "input"
msgid "Currency" msgid "Currency"
msgstr "Moneda" 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 #: web/template/app.html:20
msgctxt "menu" msgctxt "menu"
msgid "Account" msgid "Account"

8
revert/tax.sql Normal file
View File

@ -0,0 +1,8 @@
-- Revert numerus:tax from pg
begin;
drop policy company_policy on numerus.tax;
drop table if exists numerus.tax;
commit;

7
revert/tax_rate.sql Normal file
View File

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

View File

@ -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 <jordi@tandem.blog> # Add the list of available countries available_countries [schema_numerus country] 2023-01-27T18:49:28Z jordi fita mas <jordi@tandem.blog> # 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 <jordi@tandem.blog> # Add the relation for companies 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 <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 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
tax_rate [schema_numerus] 2023-01-28T11:33:39Z jordi fita mas <jordi@tandem.blog> # Add domain for tax rates
tax [schema_numerus company tax_rate] 2023-01-28T11:45:47Z jordi fita mas <jordi@tandem.blog> # Add relation for taxes

127
test/tax.sql Normal file
View File

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

34
test/tax_rate.sql Normal file
View File

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

15
verify/tax.sql Normal file
View File

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

7
verify/tax_rate.sql Normal file
View File

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

View File

@ -205,6 +205,15 @@ input[type="submit"]:active, button:active {
text-color: var(--numerus--color--white); text-color: var(--numerus--color--white);
} }
button.icon {
min-width: 0;
border: none;
}
table {
width: 100%;
}
.web { .web {
margin: 8.5rem 4rem; margin: 8.5rem 4rem;
background-color: var(--numerus--header--background-color); background-color: var(--numerus--header--background-color);
@ -248,7 +257,13 @@ main {
margin-top: 2rem; 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); background-color: var(--numerus--background-color);
border: 1px solid var(--numerus--color--black); border: 1px solid var(--numerus--color--black);
border-radius: 0; border-radius: 0;

View File

@ -1,7 +1,7 @@
{{ define "content" }} {{ define "content" }}
<section class="dialog-content"> <section class="dialog-content">
<h2>{{(pgettext "Tax Details" "title")}}</h2> <h2>{{(pgettext "Tax Details" "title")}}</h2>
<form method="POST"> <form id="details" method="POST">
<div class="input"> <div class="input">
<input type="text" name="business_name" id="business_name" required="required" value="{{ .BusinessName }}" placeholder="{{( pgettext "Business name" "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> <label for="business_name">{{( pgettext "Business name" "input" )}}</label>
@ -49,7 +49,7 @@
<option value="{{ .Code }}" {{ if eq .Code $.CountryCode }}selected="selected"{{ end }}>{{ .Name }}</option> <option value="{{ .Code }}" {{ if eq .Code $.CountryCode }}selected="selected"{{ end }}>{{ .Name }}</option>
{{- end }} {{- end }}
</select> </select>
<label for="country">{{( pgettext "Country" "input" )}}<label> <label for="country">{{( pgettext "Country" "input" )}}</label>
</div> </div>
<fieldset> <fieldset>
@ -61,10 +61,70 @@
{{- end }} {{- end }}
</select> </select>
</fieldset> </fieldset>
</form>
<form id="newtax" method="POST" action="{{ companyURI "/tax" }}">
</form>
<fieldset>
<table>
<thead>
<tr>
<th width="50%"></th>
<th>{{( pgettext "Tax Name" "title" )}}</th>
<th>{{( pgettext "Rate (%)" "title" )}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{ with .Taxes }}
{{- range $tax := . }}
<tr>
<td></td>
<td>{{ .Name }}</td>
<td>{{ .Rate }}</td>
<td>
<form method="POST" action="{{ companyURI "/tax"}}/{{ .Id }}">
<input type="hidden" name="_method" name="DELETE"/>
<button class="icon" aria-label="{{( gettext "Delete tax" )}}" type="submit"><i class="ri-delete-back-2-line"></i></button>
</form>
</td>
</tr>
{{- end }}
{{ else }}
<tr>
<td colspan="4">{{( gettext "No taxes added yet." )}}</td>
</tr>
{{ end }}
</tbody>
<tfoot>
<tr>
<th scope="row">{{( pgettext "New Line" "title")}}</th>
<td>
<div class="input">
<input form="newtax" type="text" name="name" id="tax_name" required="required" placeholder="{{( pgettext "Tax name" "input" )}}">
<label for="tax_name">{{( pgettext "Tax name" "input" )}}</label>
</div>
</td>
<td colspan="2">
<div class="input">
<input form="newtax" type="number" name="rate" id="tax_rate" min="-99" max="99" required="required" placeholder="{{( pgettext "Rate (%)" "input" )}}">
<label form="newtax" for="tax_rate">{{( pgettext "Rate (%)" "input" )}}</label>
</div>
</td>
</tr>
<tr>
<td colspan="2"></td>
<td colspan="2">
<button form="newtax" type="submit">{{( pgettext "Add new tax" "action" )}}</button>
</td>
</tr>
</tfoot>
</table>
</fieldset>
<fieldset> <fieldset>
<button type="submit">{{( pgettext "Save changes" "action" )}}</button> <button form="details" type="submit">{{( pgettext "Save changes" "action" )}}</button>
</fieldset> </fieldset>
</form>
</section> </section>
{{- end }} {{- end }}