Introduce the concept of tax class

We want to show the percentage of the tax as columns in the invoice,
but until now it was not possible to have a single VAT column when
products have different VAT (e.g., 4 % and 10 %), because, as far
as the application is concerned, these where ”different taxes”.  We
also think it would be hard later on to compute the tax due to the
government.

So, tax classes is just a taxonomy to be able to have different names
and rates for the same type of tax, mostly VAT and retention in our
case.
This commit is contained in:
jordi fita mas 2023-02-28 12:02:27 +01:00
parent 0d4fb124b4
commit 11d51df7fa
21 changed files with 443 additions and 144 deletions

View File

@ -17,12 +17,18 @@ values (1, 1)
, (1, 2)
;
alter sequence tax_class_tax_class_id_seq restart;
insert into tax_class (company_id, name)
values (1, 'IRPF')
, (1, 'IVA')
;
alter sequence tax_tax_id_seq restart;
insert into tax (company_id, name, rate)
values (1, 'Retenció 15 %', -0.15)
, (1, 'IVA 21 %', 0.21)
, (1, 'IVA 10 %', 0.10)
, (1, 'IVA 4 %', 0.04)
insert into tax (company_id, tax_class_id, name, rate)
values (1, 1, 'Retenció 15 %', -0.15)
, (1, 2, 'IVA 21 %', 0.21)
, (1, 2, 'IVA 10 %', 0.10)
, (1, 2, 'IVA 4 %', 0.04)
;
alter sequence contact_contact_id_seq restart;

View File

@ -2,6 +2,7 @@
-- requires: schema_numerus
-- requires: company
-- requires: tax_rate
-- requires: tax_class
begin;
@ -10,6 +11,7 @@ set search_path to numerus, public;
create table tax (
tax_id serial primary key,
company_id integer not null references company,
tax_class_id integer not null references tax_class,
name text not null constraint name_not_empty check(length(trim(name)) > 0),
rate tax_rate not null
);

34
deploy/tax_class.sql Normal file
View File

@ -0,0 +1,34 @@
-- Deploy numerus:tax_class to pg
-- requires: schema_numerus
-- requires: company
begin;
set search_path to numerus, public;
create table tax_class (
tax_class_id serial not null primary key,
company_id integer not null references company,
name text not null constraint name_not_empty check(length(trim(name)) > 0)
);
grant select, insert, update, delete on table tax_class to invoicer;
grant select, insert, update, delete on table tax_class to admin;
grant usage on sequence tax_class_tax_class_id_seq to invoicer;
grant usage on sequence tax_class_tax_class_id_seq to admin;
alter table tax_class enable row level security;
create policy company_policy
on tax_class
using (
exists(
select 1
from company_user
join user_profile using (user_id)
where company_user.company_id = tax_class.company_id
)
);
commit;

View File

@ -71,9 +71,10 @@ type CountryOption struct {
}
type Tax struct {
Id int
Name string
Rate int
Id int
Name string
Class string
Rate int
}
type taxDetailsForm struct {
@ -161,10 +162,9 @@ func HandleCompanyTaxDetailsForm(w http.ResponseWriter, r *http.Request, _ httpr
}
func mustRenderTaxDetailsForm(w http.ResponseWriter, r *http.Request, form *taxDetailsForm) {
locale := getLocale(r)
page := &TaxDetailsPage{
DetailsForm: form,
NewTaxForm: newTaxForm(locale),
NewTaxForm: newTaxForm(r.Context(), getConn(r), mustGetCompany(r), getLocale(r)),
}
mustRenderTexDetailsPage(w, r, page)
}
@ -193,7 +193,7 @@ func mustGetCompany(r *http.Request) *Company {
}
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)
rows, err := conn.Query(ctx, "select tax_id, tax.name, tax_class.name, (rate * 100)::integer from tax join tax_class using (tax_class_id) where tax.company_id = $1 order by rate, tax.name", company.Id)
if err != nil {
panic(err)
}
@ -202,7 +202,7 @@ func mustGetTaxes(ctx context.Context, conn *Conn, company *Company) []*Tax {
var taxes []*Tax
for rows.Next() {
tax := &Tax{}
err = rows.Scan(&tax.Id, &tax.Name, &tax.Rate)
err = rows.Scan(&tax.Id, &tax.Name, &tax.Class, &tax.Rate)
if err != nil {
panic(err)
}
@ -218,10 +218,11 @@ func mustGetTaxes(ctx context.Context, conn *Conn, company *Company) []*Tax {
type taxForm struct {
locale *Locale
Name *InputField
Class *SelectField
Rate *InputField
}
func newTaxForm(locale *Locale) *taxForm {
func newTaxForm(ctx context.Context, conn *Conn, company *Company, locale *Locale) *taxForm {
return &taxForm{
locale: locale,
Name: &InputField{
@ -230,6 +231,13 @@ func newTaxForm(locale *Locale) *taxForm {
Type: "text",
Required: true,
},
Class: &SelectField{
Name: "tax_class",
Label: pgettext("input", "Tax Class", locale),
Options: MustGetOptions(ctx, conn, "select tax_class_id::text, name from tax_class where company_id = $1 order by name", company.Id),
Required: true,
EmptyLabel: gettext("Select a tax class", locale),
},
Rate: &InputField{
Name: "tax_rate",
Label: pgettext("input", "Rate (%)", locale),
@ -248,6 +256,7 @@ func (form *taxForm) Parse(r *http.Request) error {
return err
}
form.Name.FillValue(r)
form.Class.FillValue(r)
form.Rate.FillValue(r)
return nil
}
@ -255,6 +264,7 @@ func (form *taxForm) Parse(r *http.Request) error {
func (form *taxForm) Validate() bool {
validator := newFormValidator()
validator.CheckRequiredInput(form.Name, gettext("Tax name can not be empty.", form.locale))
validator.CheckValidSelectOption(form.Class, gettext("Selected tax class is not valid.", form.locale))
if validator.CheckRequiredInput(form.Rate, gettext("Tax rate can not be empty.", form.locale)) {
validator.CheckValidInteger(form.Rate, -99, 99, gettext("Tax rate must be an integer between -99 and 99.", form.locale))
}
@ -278,7 +288,9 @@ func HandleDeleteCompanyTax(w http.ResponseWriter, r *http.Request, params httpr
func HandleAddCompanyTax(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
locale := getLocale(r)
form := newTaxForm(locale)
conn := getConn(r)
company := mustGetCompany(r)
form := newTaxForm(r.Context(), conn, company, locale)
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@ -292,8 +304,6 @@ func HandleAddCompanyTax(w http.ResponseWriter, r *http.Request, _ httprouter.Pa
mustRenderTaxForm(w, r, form)
return
}
conn := getConn(r)
company := mustGetCompany(r)
conn.MustExec(r.Context(), "insert into tax (company_id, name, rate) values ($1, $2, $3 / 100::decimal)", company.Id, form.Name, form.Rate.Integer())
conn.MustExec(r.Context(), "insert into tax (company_id, tax_class_id, name, rate) values ($1, $2, $3, $4 / 100::decimal)", company.Id, form.Class, form.Name, form.Rate.Integer())
http.Redirect(w, r, companyURI(company, "/tax-details"), http.StatusSeeOther)
}

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-02-26 17:11+0100\n"
"POT-Creation-Date: 2023-02-28 11:56+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"
@ -69,7 +69,7 @@ msgstr "Preu"
msgid "No products added yet."
msgstr "No hi ha cap producte."
#: web/template/invoices/products.gohtml:64 web/template/invoices/new.gohtml:59
#: web/template/invoices/products.gohtml:65 web/template/invoices/new.gohtml:60
msgctxt "action"
msgid "Add products"
msgstr "Afegeix productes"
@ -85,12 +85,12 @@ msgctxt "title"
msgid "Total"
msgstr "Total"
#: web/template/invoices/new.gohtml:61
#: web/template/invoices/new.gohtml:63
msgctxt "action"
msgid "Update"
msgstr "Actualitza"
#: web/template/invoices/new.gohtml:63 web/template/invoices/index.gohtml:13
#: web/template/invoices/new.gohtml:65 web/template/invoices/index.gohtml:13
msgctxt "action"
msgid "New invoice"
msgstr "Nova factura"
@ -276,7 +276,7 @@ msgctxt "title"
msgid "Language"
msgstr "Idioma"
#: web/template/profile.gohtml:35 web/template/tax-details.gohtml:96
#: web/template/profile.gohtml:35 web/template/tax-details.gohtml:101
msgctxt "action"
msgid "Save changes"
msgstr "Desa canvis"
@ -302,16 +302,21 @@ msgctxt "title"
msgid "Rate (%)"
msgstr "Percentatge"
#: web/template/tax-details.gohtml:71
#: web/template/tax-details.gohtml:49
msgctxt "title"
msgid "Class"
msgstr "Classe"
#: web/template/tax-details.gohtml:73
msgid "No taxes added yet."
msgstr "No hi ha cap impost."
#: web/template/tax-details.gohtml:77
#: web/template/tax-details.gohtml:79
msgctxt "title"
msgid "New Line"
msgstr "Nova línia"
#: web/template/tax-details.gohtml:88
#: web/template/tax-details.gohtml:93
msgctxt "action"
msgid "Add new tax"
msgstr "Afegeix nou impost"
@ -369,71 +374,84 @@ msgstr "No podeu deixar la contrasenya en blanc."
msgid "Invalid user or password."
msgstr "Nom dusuari o contrasenya incorrectes."
#: pkg/products.go:165 pkg/invoices.go:435
#: pkg/products.go:165 pkg/invoices.go:446
msgctxt "input"
msgid "Name"
msgstr "Nom"
#: pkg/products.go:171 pkg/invoices.go:440
#: pkg/products.go:171 pkg/invoices.go:451
msgctxt "input"
msgid "Description"
msgstr "Descripció"
#: pkg/products.go:176 pkg/invoices.go:444
#: pkg/products.go:176 pkg/invoices.go:455
msgctxt "input"
msgid "Price"
msgstr "Preu"
#: pkg/products.go:186 pkg/invoices.go:350 pkg/invoices.go:470
#: pkg/products.go:186 pkg/invoices.go:344 pkg/invoices.go:481
msgctxt "input"
msgid "Taxes"
msgstr "Imposts"
#: pkg/products.go:206 pkg/profile.go:92 pkg/invoices.go:383
#: pkg/invoices.go:506
#: pkg/products.go:206 pkg/profile.go:92 pkg/invoices.go:377
#: pkg/invoices.go:517
msgid "Name can not be empty."
msgstr "No podeu deixar el nom en blanc."
#: pkg/products.go:207 pkg/invoices.go:507
#: pkg/products.go:207 pkg/invoices.go:518
msgid "Price can not be empty."
msgstr "No podeu deixar el preu en blanc."
#: pkg/products.go:208 pkg/invoices.go:508
#: pkg/products.go:208 pkg/invoices.go:519
msgid "Price must be a number greater than zero."
msgstr "El preu ha de ser un número major a zero."
#: pkg/products.go:210 pkg/invoices.go:387 pkg/invoices.go:516
#: pkg/products.go:210 pkg/invoices.go:381 pkg/invoices.go:527
msgid "Selected tax is not valid."
msgstr "Heu seleccionat un impost que no és vàlid."
#: pkg/company.go:89
#: pkg/company.go:90
msgctxt "input"
msgid "Currency"
msgstr "Moneda"
#: pkg/company.go:107
#: pkg/company.go:108
msgid "Selected currency is not valid."
msgstr "Heu seleccionat una moneda que no és vàlida."
#: pkg/company.go:229
#: pkg/company.go:230
msgctxt "input"
msgid "Tax name"
msgstr "Nom impost"
#: pkg/company.go:235
#: pkg/company.go:236
msgctxt "input"
msgid "Tax Class"
msgstr "Classe dimpost"
#: pkg/company.go:239
msgid "Select a tax class"
msgstr "Escolliu una classe dimpost"
#: pkg/company.go:243
msgctxt "input"
msgid "Rate (%)"
msgstr "Percentatge"
#: pkg/company.go:257
#: pkg/company.go:266
msgid "Tax name can not be empty."
msgstr "No podeu deixar el nom de limpost en blanc."
#: pkg/company.go:258
#: pkg/company.go:267
msgid "Selected tax class is not valid."
msgstr "Heu seleccionat una classe dimpost que no és vàlida."
#: pkg/company.go:268
msgid "Tax rate can not be empty."
msgstr "No podeu deixar percentatge en blanc."
#: pkg/company.go:259
#: pkg/company.go:269
msgid "Tax rate must be an integer between -99 and 99."
msgstr "El percentatge ha de ser entre -99 i 99."
@ -465,70 +483,70 @@ msgstr "La confirmació no és igual a la contrasenya."
msgid "Selected language is not valid."
msgstr "Heu seleccionat un idioma que no és vàlid."
#: pkg/invoices.go:201
#: pkg/invoices.go:207
msgid "Select a customer to bill."
msgstr "Escolliu un client a facturar."
#: pkg/invoices.go:276
#: pkg/invoices.go:300
msgid "Invalid action"
msgstr "Acció invàlida."
#: pkg/invoices.go:327
#: pkg/invoices.go:321
msgctxt "input"
msgid "Customer"
msgstr "Client"
#: pkg/invoices.go:333
#: pkg/invoices.go:327
msgctxt "input"
msgid "Number"
msgstr "Número"
#: pkg/invoices.go:339
#: pkg/invoices.go:333
msgctxt "input"
msgid "Invoice Date"
msgstr "Data de factura"
#: pkg/invoices.go:345
#: pkg/invoices.go:339
msgctxt "input"
msgid "Notes"
msgstr "Notes"
#: pkg/invoices.go:384
#: pkg/invoices.go:378
msgid "Invoice date can not be empty."
msgstr "No podeu deixar la data de la factura en blanc."
#: pkg/invoices.go:385
#: pkg/invoices.go:379
msgid "Invoice date must be a valid date."
msgstr "La data de facturació ha de ser vàlida."
#: pkg/invoices.go:430
#: pkg/invoices.go:441
msgctxt "input"
msgid "Id"
msgstr "Identificador"
#: pkg/invoices.go:453
#: pkg/invoices.go:464
msgctxt "input"
msgid "Quantity"
msgstr "Quantitat"
#: pkg/invoices.go:461
#: pkg/invoices.go:472
msgctxt "input"
msgid "Discount (%)"
msgstr "Descompte (%)"
#: pkg/invoices.go:510
#: pkg/invoices.go:521
msgid "Quantity can not be empty."
msgstr "No podeu deixar la quantitat en blanc."
#: pkg/invoices.go:511
#: pkg/invoices.go:522
msgid "Quantity must be a number greater than zero."
msgstr "La quantitat ha de ser un número major a zero."
#: pkg/invoices.go:513
#: pkg/invoices.go:524
msgid "Discount can not be empty."
msgstr "No podeu deixar el descompte en blanc."
#: pkg/invoices.go:514
#: pkg/invoices.go:525
msgid "Discount must be a percentage between 0 and 100."
msgstr "El descompte ha de ser un percentatge entre 0 i 100."

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-02-26 17:11+0100\n"
"POT-Creation-Date: 2023-02-28 11:56+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"
@ -69,7 +69,7 @@ msgstr "Precio"
msgid "No products added yet."
msgstr "No hay productos."
#: web/template/invoices/products.gohtml:64 web/template/invoices/new.gohtml:59
#: web/template/invoices/products.gohtml:65 web/template/invoices/new.gohtml:60
msgctxt "action"
msgid "Add products"
msgstr "Añadir productos"
@ -85,12 +85,12 @@ msgctxt "title"
msgid "Total"
msgstr "Total"
#: web/template/invoices/new.gohtml:61
#: web/template/invoices/new.gohtml:63
msgctxt "action"
msgid "Update"
msgstr "Actualizar"
#: web/template/invoices/new.gohtml:63 web/template/invoices/index.gohtml:13
#: web/template/invoices/new.gohtml:65 web/template/invoices/index.gohtml:13
msgctxt "action"
msgid "New invoice"
msgstr "Nueva factura"
@ -276,7 +276,7 @@ msgctxt "title"
msgid "Language"
msgstr "Idioma"
#: web/template/profile.gohtml:35 web/template/tax-details.gohtml:96
#: web/template/profile.gohtml:35 web/template/tax-details.gohtml:101
msgctxt "action"
msgid "Save changes"
msgstr "Guardar cambios"
@ -302,16 +302,21 @@ msgctxt "title"
msgid "Rate (%)"
msgstr "Porcentaje"
#: web/template/tax-details.gohtml:71
#: web/template/tax-details.gohtml:49
msgctxt "title"
msgid "Class"
msgstr "Clase"
#: web/template/tax-details.gohtml:73
msgid "No taxes added yet."
msgstr "No hay impuestos."
#: web/template/tax-details.gohtml:77
#: web/template/tax-details.gohtml:79
msgctxt "title"
msgid "New Line"
msgstr "Nueva línea"
#: web/template/tax-details.gohtml:88
#: web/template/tax-details.gohtml:93
msgctxt "action"
msgid "Add new tax"
msgstr "Añadir nuevo impuesto"
@ -369,71 +374,84 @@ msgstr "No podéis dejar la contraseña en blanco."
msgid "Invalid user or password."
msgstr "Nombre de usuario o contraseña inválido."
#: pkg/products.go:165 pkg/invoices.go:435
#: pkg/products.go:165 pkg/invoices.go:446
msgctxt "input"
msgid "Name"
msgstr "Nombre"
#: pkg/products.go:171 pkg/invoices.go:440
#: pkg/products.go:171 pkg/invoices.go:451
msgctxt "input"
msgid "Description"
msgstr "Descripción"
#: pkg/products.go:176 pkg/invoices.go:444
#: pkg/products.go:176 pkg/invoices.go:455
msgctxt "input"
msgid "Price"
msgstr "Precio"
#: pkg/products.go:186 pkg/invoices.go:350 pkg/invoices.go:470
#: pkg/products.go:186 pkg/invoices.go:344 pkg/invoices.go:481
msgctxt "input"
msgid "Taxes"
msgstr "Impuestos"
#: pkg/products.go:206 pkg/profile.go:92 pkg/invoices.go:383
#: pkg/invoices.go:506
#: pkg/products.go:206 pkg/profile.go:92 pkg/invoices.go:377
#: pkg/invoices.go:517
msgid "Name can not be empty."
msgstr "No podéis dejar el nombre en blanco."
#: pkg/products.go:207 pkg/invoices.go:507
#: pkg/products.go:207 pkg/invoices.go:518
msgid "Price can not be empty."
msgstr "No podéis dejar el precio en blanco."
#: pkg/products.go:208 pkg/invoices.go:508
#: pkg/products.go:208 pkg/invoices.go:519
msgid "Price must be a number greater than zero."
msgstr "El precio tiene que ser un número mayor a cero."
#: pkg/products.go:210 pkg/invoices.go:387 pkg/invoices.go:516
#: pkg/products.go:210 pkg/invoices.go:381 pkg/invoices.go:527
msgid "Selected tax is not valid."
msgstr "Habéis escogido un impuesto que no es válido."
#: pkg/company.go:89
#: pkg/company.go:90
msgctxt "input"
msgid "Currency"
msgstr "Moneda"
#: pkg/company.go:107
#: pkg/company.go:108
msgid "Selected currency is not valid."
msgstr "Habéis escogido una moneda que no es válida."
#: pkg/company.go:229
#: pkg/company.go:230
msgctxt "input"
msgid "Tax name"
msgstr "Nombre impuesto"
#: pkg/company.go:235
#: pkg/company.go:236
msgctxt "input"
msgid "Tax Class"
msgstr "Clase de impuesto"
#: pkg/company.go:239
msgid "Select a tax class"
msgstr "Escoged una clase de impuesto"
#: pkg/company.go:243
msgctxt "input"
msgid "Rate (%)"
msgstr "Porcentaje"
#: pkg/company.go:257
#: pkg/company.go:266
msgid "Tax name can not be empty."
msgstr "No podéis dejar el nombre del impuesto en blanco."
#: pkg/company.go:258
#: pkg/company.go:267
msgid "Selected tax class is not valid."
msgstr "Habéis escogido una clase impuesto que no es válida."
#: pkg/company.go:268
msgid "Tax rate can not be empty."
msgstr "No podéis dejar el porcentaje en blanco."
#: pkg/company.go:259
#: pkg/company.go:269
msgid "Tax rate must be an integer between -99 and 99."
msgstr "El porcentaje tiene que estar entre -99 y 99."
@ -465,70 +483,70 @@ msgstr "La confirmación no corresponde con la contraseña."
msgid "Selected language is not valid."
msgstr "Habéis escogido un idioma que no es válido."
#: pkg/invoices.go:201
#: pkg/invoices.go:207
msgid "Select a customer to bill."
msgstr "Escoged un cliente a facturar."
#: pkg/invoices.go:276
#: pkg/invoices.go:300
msgid "Invalid action"
msgstr "Acción inválida."
#: pkg/invoices.go:327
#: pkg/invoices.go:321
msgctxt "input"
msgid "Customer"
msgstr "Cliente"
#: pkg/invoices.go:333
#: pkg/invoices.go:327
msgctxt "input"
msgid "Number"
msgstr "Número"
#: pkg/invoices.go:339
#: pkg/invoices.go:333
msgctxt "input"
msgid "Invoice Date"
msgstr "Fecha de factura"
#: pkg/invoices.go:345
#: pkg/invoices.go:339
msgctxt "input"
msgid "Notes"
msgstr "Notas"
#: pkg/invoices.go:384
#: pkg/invoices.go:378
msgid "Invoice date can not be empty."
msgstr "No podéis dejar la fecha de la factura en blanco."
#: pkg/invoices.go:385
#: pkg/invoices.go:379
msgid "Invoice date must be a valid date."
msgstr "La fecha de factura debe ser válida."
#: pkg/invoices.go:430
#: pkg/invoices.go:441
msgctxt "input"
msgid "Id"
msgstr "Identificador"
#: pkg/invoices.go:453
#: pkg/invoices.go:464
msgctxt "input"
msgid "Quantity"
msgstr "Cantidad"
#: pkg/invoices.go:461
#: pkg/invoices.go:472
msgctxt "input"
msgid "Discount (%)"
msgstr "Descuento (%)"
#: pkg/invoices.go:510
#: pkg/invoices.go:521
msgid "Quantity can not be empty."
msgstr "No podéis dejar la cantidad en blanco."
#: pkg/invoices.go:511
#: pkg/invoices.go:522
msgid "Quantity must be a number greater than zero."
msgstr "La cantidad tiene que ser un número mayor a cero."
#: pkg/invoices.go:513
#: pkg/invoices.go:524
msgid "Discount can not be empty."
msgstr "No podéis dejar el descuento en blanco."
#: pkg/invoices.go:514
#: pkg/invoices.go:525
msgid "Discount must be a percentage between 0 and 100."
msgstr "El descuento tiene que ser un percentage entre 0 y 100."

7
revert/tax_class.sql Normal file
View File

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

View File

@ -36,8 +36,9 @@ country_i18n [schema_numerus country_code language country] 2023-01-27T19:20:43Z
available_countries [schema_numerus country country_i18n] 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_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_class [schema_numerus company] 2023-02-28T10:13:14Z jordi fita mas <jordi@tandem.blog> # Add the relation for tax classes
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
tax [schema_numerus company tax_rate tax_class] 2023-01-28T11:45:47Z jordi fita mas <jordi@tandem.blog> # Add relation for taxes
contact [schema_numerus company extension_vat email extension_pg_libphonenumber extension_uri currency_code currency country_code country] 2023-01-29T12:59:18Z jordi fita mas <jordi@tandem.blog> # Add the relation for contacts
product [schema_numerus company tax] 2023-02-04T09:17:24Z jordi fita mas <jordi@tandem.blog> # Add relation for products
parse_price [schema_public] 2023-02-05T11:04:54Z jordi fita mas <jordi@tandem.blog> # Add function to convert from price to cents

View File

@ -28,6 +28,7 @@ truncate invoice cascade;
truncate contact cascade;
truncate product cascade;
truncate tax cascade;
truncate tax_class cascade;
truncate company cascade;
reset client_min_messages;
@ -41,11 +42,16 @@ values (1, 2023, '5')
, (2, 2023, '55')
;
insert into tax (tax_id, company_id, name, rate)
values (3, 1, 'IRPF -15 %', -0.15)
, (4, 1, 'IVA 21 %', 0.21)
, (5, 2, 'IRPF -7 %', -0.07)
, (6, 2, 'IVA 10 %', 0.10)
insert into tax_class (tax_class_id, company_id, name)
values (11, 1, 'tax')
, (22, 2, 'tax')
;
insert into tax (tax_id, company_id, tax_class_id, name, rate)
values (3, 1, 11, 'IRPF -15 %', -0.15)
, (4, 1, 11, 'IVA 21 %', 0.21)
, (5, 2, 22, 'IRPF -7 %', -0.07)
, (6, 2, 22, 'IVA 10 %', 0.10)
;
insert into product (product_id, company_id, name, price)

View File

@ -24,6 +24,7 @@ set client_min_messages to warning;
truncate product_tax cascade;
truncate product cascade;
truncate tax cascade;
truncate tax_class cascade;
truncate company cascade;
reset client_min_messages;
@ -33,11 +34,16 @@ values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '
, (2, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', 'FR', 'USD')
;
insert into tax (tax_id, company_id, name, rate)
values (3, 1, 'IRPF -15 %', -0.15)
, (4, 1, 'IVA 21 %', 0.21)
, (5, 2, 'IRPF -7 %', -0.07)
, (6, 2, 'IVA 10 %', 0.10)
insert into tax_class (tax_class_id, company_id, name)
values (11, 1, 'tax')
, (22, 2, 'tax')
;
insert into tax (tax_id, company_id, tax_class_id, name, rate)
values (3, 1, 11, 'IRPF -15 %', -0.15)
, (4, 1, 11, 'IVA 21 %', 0.21)
, (5, 2, 22, 'IRPF -7 %', -0.07)
, (6, 2, 22, 'IVA 10 %', 0.10)
;

View File

@ -21,6 +21,7 @@ select function_privs_are('numerus', 'compute_new_invoice_amount', array ['integ
set client_min_messages to warning;
truncate tax cascade;
truncate tax_class cascade;
truncate company cascade;
reset client_min_messages;
@ -28,11 +29,15 @@ insert into company (company_id, business_name, vatin, trade_name, phone, email,
values (1, 'Company 1', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR')
;
insert into tax (tax_id, company_id, name, rate)
values (2, 1, 'IRPF -15 %', -0.15)
, (3, 1, 'IVA 4 %', 0.04)
, (4, 1, 'IVA 10 %', 0.10)
, (5, 1, 'IVA 21 %', 0.21)
insert into tax_class (tax_class_id, company_id, name)
values (11, 1, 'tax')
;
insert into tax (tax_id, company_id, tax_class_id, name, rate)
values (2, 1, 11, 'IRPF -15 %', -0.15)
, (3, 1, 11, 'IVA 4 %', 0.04)
, (4, 1, 11, 'IVA 10 %', 0.10)
, (5, 1, 11, 'IVA 21 %', 0.21)
;
select is(

View File

@ -24,6 +24,7 @@ set client_min_messages to warning;
truncate product_tax cascade;
truncate product cascade;
truncate tax cascade;
truncate tax_class cascade;
truncate company cascade;
reset client_min_messages;
@ -33,11 +34,16 @@ values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '
, (2, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', 'FR', 'USD')
;
insert into tax (tax_id, company_id, name, rate)
values (3, 1, 'IRPF -15 %', -0.15)
, (4, 1, 'IVA 21 %', 0.21)
, (5, 2, 'IRPF -7 %', -0.07)
, (6, 2, 'IVA 10 %', 0.10)
insert into tax_class (tax_class_id, company_id, name)
values (11, 1, 'tax')
, (22, 2, 'tax')
;
insert into tax (tax_id, company_id, tax_class_id, name, rate)
values (3, 1, 11, 'IRPF -15 %', -0.15)
, (4, 1, 11, 'IVA 21 %', 0.21)
, (5, 2, 22, 'IRPF -7 %', -0.07)
, (6, 2, 22, 'IVA 10 %', 0.10)
;
insert into product (product_id, company_id, slug, name, description, price)

View File

@ -32,6 +32,7 @@ truncate invoice cascade;
truncate contact cascade;
truncate product cascade;
truncate tax cascade;
truncate tax_class cascade;
truncate company cascade;
reset client_min_messages;
@ -39,11 +40,15 @@ insert into company (company_id, business_name, vatin, trade_name, phone, email,
values (1, 'Company 1', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR')
;
insert into tax (tax_id, company_id, name, rate)
values (2, 1, 'IRPF -15 %', -0.15)
, (3, 1, 'IVA 4 %', 0.04)
, (4, 1, 'IVA 10 %', 0.10)
, (5, 1, 'IVA 21 %', 0.21)
insert into tax_class (tax_class_id, company_id, name)
values (11, 1, 'tax')
;
insert into tax (tax_id, company_id, tax_class_id, name, rate)
values (2, 1, 11, 'IRPF -15 %', -0.15)
, (3, 1, 11, 'IVA 4 %', 0.04)
, (4, 1, 11, 'IVA 10 %', 0.10)
, (5, 1, 11, 'IVA 21 %', 0.21)
;
insert into product (product_id, company_id, name, price)

View File

@ -43,6 +43,7 @@ truncate invoice_product cascade;
truncate invoice cascade;
truncate product cascade;
truncate tax cascade;
truncate tax_class cascade;
truncate contact cascade;
truncate company_user cascade;
truncate company cascade;
@ -64,9 +65,14 @@ values (2, 1)
, (4, 5)
;
insert into tax (tax_id, company_id, name, rate)
values (3, 2, 'IVA 21 %', 0.21)
, (6, 4, 'IVA 10 %', 0.10)
insert into tax_class (tax_class_id, company_id, name)
values (22, 2, 'vat')
, (44, 4, 'vat')
;
insert into tax (tax_id, company_id, tax_class_id, name, rate)
values (3, 2, 22, 'IVA 21 %', 0.21)
, (6, 4, 44, 'IVA 10 %', 0.10)
;
insert into product (product_id, company_id, name, price)

View File

@ -32,6 +32,7 @@ truncate invoice cascade;
truncate contact cascade;
truncate product cascade;
truncate tax cascade;
truncate tax_class cascade;
truncate company cascade;
reset client_min_messages;
@ -39,11 +40,15 @@ insert into company (company_id, business_name, vatin, trade_name, phone, email,
values (1, 'Company 1', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR')
;
insert into tax (tax_id, company_id, name, rate)
values (2, 1, 'IRPF -15 %', -0.15)
, (3, 1, 'IVA 4 %', 0.04)
, (4, 1, 'IVA 10 %', 0.10)
, (5, 1, 'IVA 21 %', 0.21)
insert into tax_class (tax_class_id, company_id, name)
values (11, 1, 'tax')
;
insert into tax (tax_id, company_id, tax_class_id, name, rate)
values (2, 1, 11, 'IRPF -15 %', -0.15)
, (3, 1, 11, 'IVA 4 %', 0.04)
, (4, 1, 11, 'IVA 10 %', 0.10)
, (5, 1, 11, 'IVA 21 %', 0.21)
;
insert into product (product_id, company_id, name, price)

View File

@ -36,6 +36,7 @@ set client_min_messages to warning;
truncate product_tax cascade;
truncate product cascade;
truncate tax cascade;
truncate tax_class cascade;
truncate company_user cascade;
truncate company cascade;
truncate auth."user" cascade;
@ -56,9 +57,14 @@ values (2, 1)
, (4, 5)
;
insert into tax (tax_id, company_id, name, rate)
values (3, 2, 'IVA 21 %', 0.21)
, (6, 4, 'IVA 10 %', 0.10)
insert into tax_class (tax_class_id, company_id, name)
values (22, 2, 'iva')
, (44, 4, 'iva')
;
insert into tax (tax_id, company_id, tax_class_id, name, rate)
values (3, 2, 22, 'IVA 21 %', 0.21)
, (6, 4, 44, 'IVA 10 %', 0.10)
;
insert into product (product_id, company_id, name, description, price)

View File

@ -5,7 +5,7 @@ reset client_min_messages;
begin;
select plan(36);
select plan(42);
set search_path to numerus, auth, public;
@ -36,6 +36,13 @@ select col_type_is('tax', 'company_id', 'integer');
select col_not_null('tax', 'company_id');
select col_hasnt_default('tax', 'company_id');
select has_column('tax', 'tax_class_id');
select col_is_fk('tax', 'tax_class_id');
select fk_ok('tax', 'tax_class_id', 'tax_class', 'tax_class_id');
select col_type_is('tax', 'tax_class_id', 'integer');
select col_not_null('tax', 'tax_class_id');
select col_hasnt_default('tax', 'tax_class_id');
select has_column('tax', 'name');
select col_type_is('tax', 'name', 'text');
select col_not_null('tax', 'name');
@ -49,6 +56,7 @@ select col_hasnt_default('tax', 'rate');
set client_min_messages to warning;
truncate tax cascade;
truncate tax_class cascade;
truncate company_user cascade;
truncate company cascade;
truncate auth."user" cascade;
@ -69,14 +77,20 @@ 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)
insert into tax_class(tax_class_id, company_id, name)
values (5, 2, 'vat')
, (6, 2, 'irpf')
, (7, 4, 'vat')
;
insert into tax (company_id, tax_class_id, name, rate)
values (2, 5, 'VAT 21 %', 0.21)
, (2, 6, 'IRPF -15 %', -0.15)
, (4, 7, 'VAT 21 %', 0.21)
, (4, 7, 'VAT 10 %', 0.10)
, (4, 7, 'VAT 5 %', 0.05)
, (4, 7, 'VAT 4 %', 0.04)
, (4, 7, 'VAT 0 %', 0.00)
;
prepare tax_data as
@ -120,8 +134,8 @@ select throws_ok(
reset role;
select throws_ok( $$
insert into tax (company_id, name, rate)
values (2, ' ', 0.22)
insert into tax (company_id, tax_class_id, name, rate)
values (2, 6, ' ', 0.22)
$$,
'23514', 'new row for relation "tax" violates check constraint "name_not_empty"',
'Should not allow taxs with blank name'

124
test/tax_class.sql Normal file
View File

@ -0,0 +1,124 @@
-- Test tax_class
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(32);
set search_path to numerus, auth, public;
select has_table('tax_class');
select has_pk('tax_class' );
select table_privs_are('tax_class', 'guest', array []::text[]);
select table_privs_are('tax_class', 'invoicer', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']);
select table_privs_are('tax_class', 'admin', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']);
select table_privs_are('tax_class', 'authenticator', array []::text[]);
select has_sequence('tax_class_tax_class_id_seq');
select sequence_privs_are('tax_class_tax_class_id_seq', 'guest', array[]::text[]);
select sequence_privs_are('tax_class_tax_class_id_seq', 'invoicer', array['USAGE']);
select sequence_privs_are('tax_class_tax_class_id_seq', 'admin', array['USAGE']);
select sequence_privs_are('tax_class_tax_class_id_seq', 'authenticator', array[]::text[]);
select has_column('tax_class', 'tax_class_id');
select col_is_pk('tax_class', 'tax_class_id');
select col_type_is('tax_class', 'tax_class_id', 'integer');
select col_not_null('tax_class', 'tax_class_id');
select col_has_default('tax_class', 'tax_class_id');
select col_default_is('tax_class', 'tax_class_id', 'nextval(''tax_class_tax_class_id_seq''::regclass)');
select has_column('tax_class', 'company_id');
select col_is_fk('tax_class', 'company_id');
select fk_ok('tax_class', 'company_id', 'company', 'company_id');
select col_type_is('tax_class', 'company_id', 'integer');
select col_not_null('tax_class', 'company_id');
select col_hasnt_default('tax_class', 'company_id');
select has_column('tax_class', 'name');
select col_type_is('tax_class', 'name', 'text');
select col_not_null('tax_class', 'name');
select col_hasnt_default('tax_class', 'name');
set client_min_messages to warning;
truncate tax_class cascade;
truncate company_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_class (company_id, name)
values (2, 'VAT')
, (2, 'IRPF')
, (4, 'VAT')
, (4, 'import')
;
prepare tax_class_data as
select company_id, name
from tax_class
order by company_id;
set role invoicer;
select is_empty('tax_class_data', 'Should show no data when cookie is not set yet');
reset role;
select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog');
select bag_eq(
'tax_class_data',
$$ values (2, 'VAT')
, (2, 'IRPF')
$$,
'Should only list taxes of the companies where demo@tandem.blog is user of'
);
reset role;
select set_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog');
select bag_eq(
'tax_class_data',
$$ values (4, 'VAT')
, (4, 'import')
$$,
'Should only list taxes of the companies where admin@tandem.blog is user of'
);
reset role;
select set_cookie('not-a-cookie');
select throws_ok(
'tax_class_data',
'42501', 'permission denied for table tax_class',
'Should not allow select to guest users'
);
reset role;
select throws_ok( $$
insert into tax_class (company_id, name)
values (2, ' ')
$$,
'23514', 'new row for relation "tax_class" violates check constraint "name_not_empty"',
'Should not allow classes with blank name'
);
select *
from finish();
rollback;

View File

@ -4,6 +4,7 @@ begin;
select tax_id
, company_id
, tax_class_id
, name
, rate
from numerus.tax

14
verify/tax_class.sql Normal file
View File

@ -0,0 +1,14 @@
-- Verify numerus:tax_class on pg
begin;
select tax_class_id
, company_id
, name
from numerus.tax_class
where false;
select 1 / count(*) from pg_class where oid = 'numerus.tax_class'::regclass and relrowsecurity;
select 1 / count(*) from pg_policy where polname = 'company_policy' and polrelid = 'numerus.tax_class'::regclass;
rollback;

View File

@ -46,6 +46,7 @@
<th width="50%"></th>
<th>{{( pgettext "Tax Name" "title" )}}</th>
<th>{{( pgettext "Rate (%)" "title" )}}</th>
<th>{{( pgettext "Class" "title" )}}</th>
<th></th>
</tr>
</thead>
@ -56,6 +57,7 @@
<td></td>
<td>{{ .Name }}</td>
<td>{{ .Rate }}</td>
<td>{{ .Class }}</td>
<td>
<form method="POST" action="{{ companyURI "/tax"}}/{{ .Id }}">
{{ csrfToken }}
@ -78,9 +80,12 @@
<td>
{{ template "input-field" .NewTaxForm.Name | addInputAttr `form="newtax"` }}
</td>
<td colspan="2">
<td>
{{ template "input-field" .NewTaxForm.Rate | addInputAttr `form="newtax"` }}
</td>
<td>
{{ template "select-field" .NewTaxForm.Class | addSelectAttr `form="newtax"` }}
</td>
</tr>
<tr>
<td colspan="2"></td>