Compare commits

..

No commits in common. "master" and "v1" have entirely different histories.
master ... v1

282 changed files with 2034 additions and 14714 deletions

View File

@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"errors"
"log" "log"
"net/http" "net/http"
"os" "os"
@ -20,12 +19,9 @@ func main() {
} }
defer db.Close() defer db.Close()
var demo bool
_ = db.QueryRow(context.Background(), "select database_is_numerus_demo()").Scan(&demo)
srv := http.Server{ srv := http.Server{
Addr: ":8080", Addr: ":8080",
Handler: numerus.NewRouter(db, demo), Handler: numerus.NewRouter(db),
ReadTimeout: 5 * time.Second, ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second,
IdleTimeout: 2 * time.Minute, IdleTimeout: 2 * time.Minute,
@ -33,7 +29,7 @@ func main() {
go func() { go func() {
log.Printf("INFO - listening on %s\n", srv.Addr) log.Printf("INFO - listening on %s\n", srv.Addr)
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("http server: %v", err) log.Fatalf("http server: %v", err)
} }
}() }()

3
debian/control vendored
View File

@ -10,9 +10,8 @@ Build-Depends:
golang-github-jackc-pgx-v4-dev, golang-github-jackc-pgx-v4-dev,
golang-github-julienschmidt-httprouter-dev, golang-github-julienschmidt-httprouter-dev,
golang-github-leonelquinteros-gotext-dev, golang-github-leonelquinteros-gotext-dev,
golang-github-rainycape-unidecode-dev,
golang-github-tealeg-xlsx-dev,
golang-golang-x-text-dev, golang-golang-x-text-dev,
golang-github-tealeg-xlsx-dev,
postgresql-all (>= 217~), postgresql-all (>= 217~),
sqitch, sqitch,
pgtap, pgtap,

4
debian/rules vendored
View File

@ -1,13 +1,9 @@
#!/usr/bin/make -f #!/usr/bin/make -f
include /usr/share/dpkg/pkg-info.mk
%: %:
dh $@ --builddirectory=_build --buildsystem=golang --with=golang dh $@ --builddirectory=_build --buildsystem=golang --with=golang
execute_before_dh_auto_build: execute_before_dh_auto_build:
printf 'package pkg\n\nconst Version = "%s"\n' "$(DEB_VERSION_UPSTREAM)" > pkg/build.go
cp pkg/build.go _build/src/dev.tandem.ws/tandem/numerus/pkg/build.go
make make
execute_after_dh_auto_test: execute_after_dh_auto_test:

View File

@ -2,8 +2,6 @@ begin;
set search_path to auth, numerus, public; set search_path to auth, numerus, public;
create or replace function public.database_is_numerus_demo() returns bool as $$ select true $$ language sql;
alter sequence user_user_id_seq restart with 123; alter sequence user_user_id_seq restart with 123;
insert into auth."user" (email, name, password, role) insert into auth."user" (email, name, password, role)
values ('demo@numerus', 'Demo User', 'demo', 'invoicer') values ('demo@numerus', 'Demo User', 'demo', 'invoicer')
@ -14,16 +12,12 @@ set constraints "company_default_payment_method_id_fkey" deferred;
alter sequence company_company_id_seq restart with 123; alter sequence company_company_id_seq restart with 123;
insert into company (business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_payment_method_id, legal_disclaimer) insert into company (business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_payment_method_id, legal_disclaimer)
values ('Juli Verd', 'ES40404040D', 'Pessebre', parse_packed_phone_number('972 50 60 70', 'ES'), 'info@numerus.cat', 'https://numerus.cat/', 'C/ de lHort', 'Castelló dEmpúries', 'Girona', '17486', 'ES', 'EUR', 124, 'Juli Verd és responsable del tractament de les seves dades dacord amb el RGPD i la LOPDGDD, i les tracta per a mantenir una relació mercantil/comercial amb vostè. Les conservarà mentre es mantingui aquesta relació i no es comunicaran a tercers. Pot exercir els drets daccés, rectificació, portabilitat, supressió, limitació i oposició a Juli Verd, amb domicili Carrer de lHort 71, 17486 Castelló dEmpúries o enviant un correu electrònic a info@numerus.cat. Per a qualsevol reclamació pot acudir a agpd.es. Per a més informació pot consultar la nostra política de privacitat a numerus.cat.') values ('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', 'Girona', '17486', 'ES', 'EUR', 124, 'Juli Verd és responsable del tractament de les seves dades dacord amb el RGPD i la LOPDGDD, i les tracta per a mantenir una relació mercantil/comercial amb vostè. Les conservarà mentre es mantingui aquesta relació i no es comunicaran a tercers. Pot exercir els drets daccés, rectificació, portabilitat, supressió, limitació i oposició a Juli Verd, amb domicili Carrer de lHort 71, 17486 Castelló dEmpúries o enviant un correu electrònic a info@numerus.cat. Per a qualsevol reclamació pot acudir a agpd.es. Per a més informació pot consultar la nostra política de privacitat a numerus.cat.');
, ('Pere Gil', 'ES41414141L', 'Betlem', parse_packed_phone_number('972 80 90 00', 'ES'), 'info@numerus.cat', 'https://numerus.cat/', 'C/ de lHort', 'Castelló dEmpúries', 'Girona', '17486', 'ES', 'EUR', 126, 'Pere Gil és responsable del tractament de les seves dades dacord amb el RGPD i la LOPDGDD, i les tracta per a mantenir una relació mercantil/comercial amb vostè. Les conservarà mentre es mantingui aquesta relació i no es comunicaran a tercers. Pot exercir els drets daccés, rectificació, portabilitat, supressió, limitació i oposició a Pere Gil, amb domicili Carrer de lHort 71, 17486 Castelló dEmpúries o enviant un correu electrònic a info@numerus.cat. Per a qualsevol reclamació pot acudir a agpd.es. Per a més informació pot consultar la nostra política de privacitat a numerus.cat.')
;
alter sequence payment_method_payment_method_id_seq restart with 123; alter sequence payment_method_payment_method_id_seq restart with 123;
insert into payment_method (company_id, name, instructions) insert into payment_method (company_id, name, instructions)
values (123, 'Efectiu', 'Pagament en efectiu al comptat.') values (123, 'Efectiu', 'Pagament en efectiu al comptat.')
, (123, 'Transferència', E'Pagament per transferència bancària al compte:\n\nES0123456789012345678901\n\nBIC AAAABBCCDD') , (123, 'Transferència', E'Pagament per transferència bancària al compte:\n\nES0123456789012345678901\n\nBIC AAAABBCCDD')
, (124, 'Efectiu', 'Pagament en efectiu al comptat.')
, (124, 'Transferència', E'Pagament per transferència bancària al compte:\n\nES0123456789012345678901\n\nBIC AAAABBCCDD')
; ;
set constraints "company_default_payment_method_id_fkey" immediate; set constraints "company_default_payment_method_id_fkey" immediate;
@ -31,16 +25,12 @@ set constraints "company_default_payment_method_id_fkey" immediate;
insert into company_user (company_id, user_id) insert into company_user (company_id, user_id)
values (123, 123) values (123, 123)
, (123, 124) , (123, 124)
, (124, 123)
, (124, 124)
; ;
alter sequence tax_class_tax_class_id_seq restart with 123; alter sequence tax_class_tax_class_id_seq restart with 123;
insert into tax_class (company_id, name) insert into tax_class (company_id, name)
values (123, 'IRPF') values (123, 'IRPF')
, (123, 'IVA') , (123, 'IVA')
, (124, 'IRPF')
, (124, 'IVA')
; ;
alter sequence tax_tax_id_seq restart with 123; alter sequence tax_tax_id_seq restart with 123;
@ -49,25 +39,15 @@ values (123, 123, 'Retenció 15 %', -0.15)
, (123, 124, 'IVA 21 %', 0.21) , (123, 124, 'IVA 21 %', 0.21)
, (123, 124, 'IVA 10 %', 0.10) , (123, 124, 'IVA 10 %', 0.10)
, (123, 124, 'IVA 4 %', 0.04) , (123, 124, 'IVA 4 %', 0.04)
, (124, 123, 'Retenció 15 %', -0.15)
, (124, 124, 'IVA 21 %', 0.21)
, (124, 124, 'IVA 10 %', 0.10)
, (124, 124, 'IVA 4 %', 0.04)
; ;
alter sequence contact_contact_id_seq restart with 123; alter sequence contact_contact_id_seq restart with 123;
-- customers select add_contact (123, 'Melcior', '0732621', 'melcio@reismags.cat', '', '(Rei Blanc,1,"C/ Principal, 1",Shiraz,Fars,1,IR)', '', '', array['pesebre', 'mag']);
select add_contact (123, 'Melcior', '0732621', 'melcio@reismags.cat', '', '(Rei Blanc,1,"C/ Principal, 1",Shiraz,Fars,1,IR)', '', '', array['pesebre', 'mag', 'client']); select add_contact (123, 'Gaspar', '111', 'gaspar@reismags.cat', '', '(Rei Ros,2,"C/ Principal, 2",Nova Delhi,Delhi,2,IN)', '', '', array['pesebre', 'mag']);
select add_contact (123, 'Gaspar', '111', 'gaspar@reismags.cat', '', '(Rei Ros,2,"C/ Principal, 2",Nova Delhi,Delhi,2,IN)', '', '', array['pesebre', 'mag', 'client']); select add_contact (123, 'Baltasar', '1-111-111', 'baltasar@reismags.cat', '', '(Rei Negre,3,"C/ Principal, 3",Sanaa,Sanaa,3,YE)', '', '', array['pesebre', 'mag']);
select add_contact (123, 'Baltasar', '1-111-111', 'baltasar@reismags.cat', '', '(Rei Negre,3,"C/ Principal, 3",Sanaa,Sanaa,3,YE)', '', '', array['pesebre', 'mag', 'client']); select add_contact (123, 'Caganera', '222 222 222', 'caganera@pesebre.cat', '', '(Caganera,41414141L,"C/ De lHort, 4",Olot,Girona,17800,ES)', '', '', array['pesebre', 'persona']);
select add_contact (123, 'Caganera', '222 222 222', 'caganera@pesebre.cat', '', '(Caganera,41414141L,"C/ De lHort, 4",Olot,Girona,17800,ES)', '', '', array['pesebre', 'persona', 'client']); select add_contact (123, 'Bou', '333 333 333', 'bou@pesebre.cat', '', '(Bou,41414142C,"C/ De la Palla, 5",Sant Climent Sescebes,Girona,17751,ES)', '', '', array['pesebre', 'bestia']);
select add_contact (123, 'Bou', '333 333 333', 'bou@pesebre.cat', '', '(Bou,41414142C,"C/ De la Palla, 5",Sant Climent Sescebes,Girona,17751,ES)', '', '', array['pesebre', 'bestia', 'client']); select add_contact (123, 'Rabadà', '444 444 444', 'rabada@pesebre.cat', '', '(Rabadà,41414143K,"C/ De les Ovelles, 6",Fornells de la Selva,Girona,17458,ES)', '', '', array['pesebre', 'persona']);
select add_contact (123, 'Rabadà', '444 444 444', 'rabada@pesebre.cat', '', '(Rabadà,41414143K,"C/ De les Ovelles, 6",Fornells de la Selva,Girona,17458,ES)', '', '', array['pesebre', 'persona', 'client']);
-- suppliers
select add_contact(123, 'TGSS', '', '', '', null, '', '', array['govern']);
select add_contact(123, 'Quadre Estable', '', '', '', null, '', '', array['proveidor']);
select add_contact(123, 'De tot i +', '', '', '', null, '', '', array['proveidor']);
select add_contact(123, 'Els números', '', '', '', null, '', '', array['gestoria']);
alter sequence product_product_id_seq restart with 123; alter sequence product_product_id_seq restart with 123;
select add_product(123, 'Or', 'Metall de transició tou, brillant, groc, pesant, mal·leable, dúctil i que no reacciona amb la majoria de productes químics, però és sensible al clor i a laigua règia.', '55.92', array[124], array['metall']); select add_product(123, 'Or', 'Metall de transició tou, brillant, groc, pesant, mal·leable, dúctil i que no reacciona amb la majoria de productes químics, però és sensible al clor i a laigua règia.', '55.92', array[124], array['metall']);
@ -78,122 +58,17 @@ select add_product(123, 'Cavall Fort', 'Revista quinzenal en llengua catalana i
select add_product(123, 'Palla', 'Tija seca dels cereals després que el gra o llavor ha estat separat mitjançant la trilla.', '25.00', array[125], array['necessitat']); select add_product(123, 'Palla', 'Tija seca dels cereals després que el gra o llavor ha estat separat mitjançant la trilla.', '25.00', array[125], array['necessitat']);
select add_product(123, 'Teia', 'Fusta resinosa de pi i daltres arbres, provinent sobretot del cor de larbre, que crema amb molta facilitat.', '7.00', array[124], array['obsolet']); select add_product(123, 'Teia', 'Fusta resinosa de pi i daltres arbres, provinent sobretot del cor de larbre, que crema amb molta facilitat.', '7.00', array[124], array['obsolet']);
alter table payment_account alter column payment_account_id restart with 123;
select add_payment_account_bank(123, 'Guardiola', 'ES2820958297603648596978');
select add_payment_account_cash(123, 'Matalàs');
alter sequence invoice_invoice_id_seq restart with 123; alter sequence invoice_invoice_id_seq restart with 123;
alter sequence invoice_product_invoice_product_id_seq restart with 123; alter sequence invoice_product_invoice_product_id_seq restart with 123;
select add_invoice(123, (current_date - '338 days'::interval)::date, 124, '', 123, '{producte,mag}','{"(124,Encens,Goma resina fragrant que desprèn una olor característica quan es crema.,2.26,460,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.53,1,0.0,{124})"}');
select add_collection(123, 123, (current_date - '337 days'::interval)::date, 123, 'Cobrament de FRA123', '1200.50', '{}');
select add_invoice(123, (current_date - '334 days'::interval)::date, 124, '', 123, '{producte,mag}','{"(124,Encens,Goma resina fragrant que desprèn una olor característica quan es crema.,2.26,460,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.53,1,0.0,{124})"}');
select add_collection(123, 124, (current_date - '330 days'::interval)::date, 123, 'Cobrament de FRA124', '1200.50', '{}');
select add_invoice(123, (current_date - '327 days'::interval)::date, 123, '', 123, '{producte,mag}','{"(123,Or,\"Metall de transició tou, brillant, groc, pesant, mal·leable, dúctil i que no reacciona amb la majoria de productes químics, però és sensible al clor i a laigua règia.\",57.82,18,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.43,1,0.0,{124})"}');
select add_collection(123, 125, (current_date - '317 days'::interval)::date, 123, 'Cobrament de FRA125', '1200.50', '{}');
select add_invoice(123, (current_date - '317 days'::interval)::date, 128, 'Vol esmorzar!', 123, '{producte}','{"(129,Teia,\"Fusta resinosa de pi i daltres arbres, provinent sobretot del cor de larbre, que crema amb molta facilitat.\",7.00,1,0.0,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 126, (current_date - '316 days'::interval)::date, 124, 'Cobrament de FRA126', '12.87', '{}');
select add_invoice(123, (current_date - '314 days'::interval)::date, 123, '', 123, '{producte,mag}','{"(123,Or,\"Metall de transició tou, brillant, groc, pesant, mal·leable, dúctil i que no reacciona amb la majoria de productes químics, però és sensible al clor i a laigua règia.\",57.82,18,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.43,1,0.0,{124})"}');
select add_collection(123, 127, (current_date - '310 days'::interval)::date, 123, 'Cobrament de FRA127', '1200.50', '{}');
select add_invoice(123, (current_date - '311 days'::interval)::date, 128, 'Vol esmorzar!', 123, '{producte}','{"(129,Teia,\"Fusta resinosa de pi i daltres arbres, provinent sobretot del cor de larbre, que crema amb molta facilitat.\",7.00,1,0.0,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 128, (current_date - '311 days'::interval)::date, 124, 'Cobrament de FRA128', '12.87', '{}');
select add_invoice(123, (current_date - '278 days'::interval)::date, 125, '', 123, '{producte,mag}','{"(125,Mirra,Goma resinosa aromàtica de color gris groguenc i gust amargant.,7.22,144,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.45,1,0.0,{124})"}');
select add_collection(123, 129, (current_date - '270 days'::interval)::date, 123, 'Cobrament de FRA129', '1200.50', '{}');
select add_invoice(123, (current_date - '274 days'::interval)::date, 127, '', 123, '{producte,bestia}','{"(128,Palla,Tija seca dels cereals després que el gra o llavor ha estat separat mitjançant la trilla.,25.00,25,0.0,{125})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 130, (current_date - '272 days'::interval)::date, 123, 'Cobrament de FRA130', '691.90', '{}');
select add_invoice(123, (current_date - '267 days'::interval)::date, 126, '', 123, '{producte,higiene}','{"(126,\"Paper higiènic (pack de 32 U)\",Paper que susa per mantenir la higiene personal després de defecar o orinar.,7.99,10,0.0,{126})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 131, (current_date - '265 days'::interval)::date, 123, 'Cobrament de FRA131', '87.50', '{}');
select add_invoice(123, (current_date - '257 days'::interval)::date, 126, '', 123, '{producte,higiene}','{"(126,\"Paper higiènic (pack de 32 U)\",Paper que susa per mantenir la higiene personal després de defecar o orinar.,7.99,10,0.0,{126})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 132, (current_date - '250 days'::interval)::date, 124, 'Cobrament de FRA132', '87.50', '{}');
select add_invoice(123, (current_date - '254 days'::interval)::date, 127, '', 123, '{producte,bestia}','{"(128,Palla,Tija seca dels cereals després que el gra o llavor ha estat separat mitjançant la trilla.,25.00,25,0.0,{125})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 133, (current_date - '220 days'::interval)::date, 123, 'Cobrament de FRA133', '691.90', '{}');
select add_invoice(123, (current_date - '251 days'::interval)::date, 125, '', 123, '{producte,mag}','{"(125,Mirra,Goma resinosa aromàtica de color gris groguenc i gust amargant.,7.22,144,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.45,1,0.0,{124})"}');
select add_collection(123, 134, (current_date - '245 days'::interval)::date, 123, 'Cobrament de FRA134', '1200.50', '{}');
select add_invoice(123, (current_date - '208 days'::interval)::date, 127, '', 123, '{producte,bestia}','{"(128,Palla,Tija seca dels cereals després que el gra o llavor ha estat separat mitjançant la trilla.,25.00,25,0.0,{125})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 135, (current_date - '190 days'::interval)::date, 123, 'Cobrament de FRA135', '691.90', '{}');
select add_invoice(123, (current_date - '204 days'::interval)::date, 127, '', 123, '{producte,bestia}','{"(128,Palla,Tija seca dels cereals després que el gra o llavor ha estat separat mitjançant la trilla.,25.00,25,0.0,{125})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 136, (current_date - '200 days'::interval)::date, 123, 'Cobrament de FRA136', '691.90', '{}');
select add_invoice(123, (current_date - '197 days'::interval)::date, 125, '', 123, '{producte,mag}','{"(125,Mirra,Goma resinosa aromàtica de color gris groguenc i gust amargant.,7.22,144,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.45,1,0.0,{124})"}');
select add_collection(123, 137, (current_date - '190 days'::interval)::date, 123, 'Cobrament de FRA137', '1200.50', '{}');
select add_invoice(123, (current_date - '187 days'::interval)::date, 128, 'Vol esmorzar!', 123, '{producte}','{"(129,Teia,\"Fusta resinosa de pi i daltres arbres, provinent sobretot del cor de larbre, que crema amb molta facilitat.\",7.00,1,0.0,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 138, (current_date - '186 days'::interval)::date, 124, 'Cobrament de FRA138', '12.87', '{}');
select add_invoice(123, (current_date - '184 days'::interval)::date, 123, '', 123, '{producte,mag}','{"(123,Or,\"Metall de transició tou, brillant, groc, pesant, mal·leable, dúctil i que no reacciona amb la majoria de productes químics, però és sensible al clor i a laigua règia.\",57.82,18,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.43,1,0.0,{124})"}');
select add_collection(123, 139, (current_date - '181 days'::interval)::date, 123, 'Cobrament de FRA139', '1200.50', '{}');
select add_invoice(123, (current_date - '181 days'::interval)::date, 125, '', 123, '{producte,mag}','{"(125,Mirra,Goma resinosa aromàtica de color gris groguenc i gust amargant.,7.22,144,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.45,1,0.0,{124})"}');
select add_collection(123, 140, (current_date - '177 days'::interval)::date, 123, 'Cobrament de FRA140', '1200.50', '{}');
select add_invoice(123, (current_date - '148 days'::interval)::date, 126, '', 123, '{producte,higiene}','{"(126,\"Paper higiènic (pack de 32 U)\",Paper que susa per mantenir la higiene personal després de defecar o orinar.,7.99,10,0.0,{126})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 141, (current_date - '140 days'::interval)::date, 123, 'Cobrament de FRA141', '87.50', '{}');
select add_invoice(123, (current_date - '144 days'::interval)::date, 126, '', 123, '{producte,higiene}','{"(126,\"Paper higiènic (pack de 32 U)\",Paper que susa per mantenir la higiene personal després de defecar o orinar.,7.99,10,0.0,{126})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 142, (current_date - '143 days'::interval)::date, 123, 'Cobrament de FRA142', '87.50', '{}');
select add_invoice(123, (current_date - '137 days'::interval)::date, 123, '', 123, '{producte,mag}','{"(123,Or,\"Metall de transició tou, brillant, groc, pesant, mal·leable, dúctil i que no reacciona amb la majoria de productes químics, però és sensible al clor i a laigua règia.\",57.82,18,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.43,1,0.0,{124})"}');
select add_collection(123, 143, (current_date - '137 days'::interval)::date, 123, 'Cobrament de FRA143', '1200.50', '{}');
select add_invoice(123, (current_date - '127 days'::interval)::date, 124, '', 123, '{producte,mag}','{"(124,Encens,Goma resina fragrant que desprèn una olor característica quan es crema.,2.26,460,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.53,1,0.0,{124})"}');
select add_collection(123, 144, (current_date - '120 days'::interval)::date, 123, 'Cobrament de FRA144', '1200.50', '{}');
select add_invoice(123, (current_date - '124 days'::interval)::date, 124, '', 123, '{producte,mag}','{"(124,Encens,Goma resina fragrant que desprèn una olor característica quan es crema.,2.26,460,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.53,1,0.0,{124})"}');
select add_collection(123, 145, (current_date - '123 days'::interval)::date, 123, 'Cobrament de FRA145', '1200.50', '{}');
select add_invoice(123, (current_date - '121 days'::interval)::date, 128, 'Vol esmorzar!', 123, '{producte}','{"(129,Teia,\"Fusta resinosa de pi i daltres arbres, provinent sobretot del cor de larbre, que crema amb molta facilitat.\",7.00,1,0.0,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 146, (current_date - '101 days'::interval)::date, 124, 'Cobrament de FRA146', '12.87', '{}');
select add_invoice(123, (current_date - '78 days'::interval)::date, 126, '', 123, '{producte,higiene}','{"(126,\"Paper higiènic (pack de 32 U)\",Paper que susa per mantenir la higiene personal després de defecar o orinar.,7.99,10,0.0,{126})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 147, (current_date - '60 days'::interval)::date, 123, 'Cobrament de FRA147', '87.50', '{}');
select add_invoice(123, (current_date - '74 days'::interval)::date, 124, '', 123, '{producte,mag}','{"(124,Encens,Goma resina fragrant que desprèn una olor característica quan es crema.,2.26,460,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.53,1,0.0,{124})"}');
select add_collection(123, 148, (current_date - '61 days'::interval)::date, 123, 'Cobrament de FRA148', '1200.50', '{}');
select add_invoice(123, (current_date - '67 days'::interval)::date, 128, 'Vol esmorzar!', 123, '{producte}','{"(129,Teia,\"Fusta resinosa de pi i daltres arbres, provinent sobretot del cor de larbre, que crema amb molta facilitat.\",7.00,1,0.0,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 149, (current_date - '66 days'::interval)::date, 124, 'Cobrament de FRA149', '12.87', '{}');
select add_invoice(123, (current_date - '57 days'::interval)::date, 123, '', 123, '{producte,mag}','{"(123,Or,\"Metall de transició tou, brillant, groc, pesant, mal·leable, dúctil i que no reacciona amb la majoria de productes químics, però és sensible al clor i a laigua règia.\",57.82,18,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.43,1,0.0,{124})"}');
select add_collection(123, 150, (current_date - '55 days'::interval)::date, 123, 'Cobrament de FRA150', '1200.50', '{}');
select add_invoice(123, (current_date - '54 days'::interval)::date, 127, '', 123, '{producte,bestia}','{"(128,Palla,Tija seca dels cereals després que el gra o llavor ha estat separat mitjançant la trilla.,25.00,25,0.0,{125})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 151, (current_date - '50 days'::interval)::date, 124, 'Cobrament de FRA151', '691.90', '{}');
select add_invoice(123, (current_date - '51 days'::interval)::date, 125, '', 123, '{producte,mag}','{"(125,Mirra,Goma resinosa aromàtica de color gris groguenc i gust amargant.,7.22,144,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.45,1,0.0,{124})"}');
select add_collection(123, 152, (current_date - '44 days'::interval)::date, 123, 'Cobrament de FRA152', '1200.50', '{}');
select add_invoice(123, (current_date - '28 days'::interval)::date, 128, 'Vol esmorzar!', 123, '{producte}','{"(129,Teia,\"Fusta resinosa de pi i daltres arbres, provinent sobretot del cor de larbre, que crema amb molta facilitat.\",7.00,1,0.0,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}'); select add_invoice(123, (current_date - '28 days'::interval)::date, 128, 'Vol esmorzar!', 123, '{producte}','{"(129,Teia,\"Fusta resinosa de pi i daltres arbres, provinent sobretot del cor de larbre, que crema amb molta facilitat.\",7.00,1,0.0,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_collection(123, 153, (current_date - '28 days'::interval)::date, 124, 'Cobrament de FRA153', '12.87', '{}');
select add_invoice(123, (current_date - '24 days'::interval)::date, 127, '', 123, '{producte,bestia}','{"(128,Palla,Tija seca dels cereals després que el gra o llavor ha estat separat mitjançant la trilla.,25.00,25,0.0,{125})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}'); select add_invoice(123, (current_date - '24 days'::interval)::date, 127, '', 123, '{producte,bestia}','{"(128,Palla,Tija seca dels cereals després que el gra o llavor ha estat separat mitjançant la trilla.,25.00,25,0.0,{125})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_invoice(123, (current_date - '17 days'::interval)::date, 126, '', 123, '{producte,higiene}','{"(126,\"Paper higiènic (pack de 32 U)\",Paper que susa per mantenir la higiene personal després de defecar o orinar.,7.99,10,0.0,{126})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}'); select add_invoice(123, (current_date - '17 days'::interval)::date, 126, '', 123, '{producte,higiene}','{"(126,\"Paper higiènic (pack de 32 U)\",Paper que susa per mantenir la higiene personal després de defecar o orinar.,7.99,10,0.0,{126})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.64,1,0.0,{124})"}');
select add_invoice(123, (current_date - '7 days'::interval)::date, 125, '', 123, '{producte,mag}','{"(125,Mirra,Goma resinosa aromàtica de color gris groguenc i gust amargant.,7.22,144,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.45,1,0.0,{124})"}'); select add_invoice(123, (current_date - '7 days'::interval)::date, 125, '', 123, '{producte,mag}','{"(125,Mirra,Goma resinosa aromàtica de color gris groguenc i gust amargant.,7.22,144,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.45,1,0.0,{124})"}');
select add_invoice(123, (current_date - '4 days'::interval)::date, 124, '', 123, '{producte,mag}','{"(124,Encens,Goma resina fragrant que desprèn una olor característica quan es crema.,2.26,460,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.53,1,0.0,{124})"}'); select add_invoice(123, (current_date - '4 days'::interval)::date, 124, '', 123, '{producte,mag}','{"(124,Encens,Goma resina fragrant que desprèn una olor característica quan es crema.,2.26,460,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",4.53,1,0.0,{124})"}');
select add_collection(123, 157, (current_date - '2 days'::interval)::date, 123, 'Primer cobrament de FRA157', '1000.00', '{}');
select add_invoice(123, (current_date - '1 days'::interval)::date, 123, '', 123, '{producte,mag}','{"(123,Or,\"Metall de transició tou, brillant, groc, pesant, mal·leable, dúctil i que no reacciona amb la majoria de productes químics, però és sensible al clor i a laigua règia.\",57.82,18,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.43,1,0.0,{124})"}'); select add_invoice(123, (current_date - '1 days'::interval)::date, 123, '', 123, '{producte,mag}','{"(123,Or,\"Metall de transició tou, brillant, groc, pesant, mal·leable, dúctil i que no reacciona amb la majoria de productes químics, però és sensible al clor i a laigua règia.\",57.82,18,0.05,{124})","(127,Cavall Fort,\"Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.\",3.43,1,0.0,{124})"}');
alter sequence expense_expense_id_seq restart with 123; update invoice set invoice_status = 'paid' where invoice_id in (123, 127);
select add_expense(123, (date_trunc('month', current_date) - '11 months + 14 day'::interval)::date, 130, 'ABC123', '256.12', '{124}', '{}'); update invoice set invoice_status = 'unpaid' where invoice_id = 125;
select add_payment(123, 123, (date_trunc('month', current_date) - '11 months + 04 day'::interval)::date, 123, 'Pagament dABC123', '256.12', '{}'); update invoice set invoice_status = 'sent' where invoice_id = 126;
select add_expense(123, (date_trunc('month', current_date) - '11 months + 8 day'::interval)::date, 131, '123ABC', '1023.17', '{124}', '{}');
select add_payment(123, 124, (date_trunc('month', current_date) - '11 months'::interval)::date, 123, 'Pagament d123ABC', '1023.17', '{}');
select add_expense(123, (date_trunc('month', current_date) - '11 months + 1 day'::interval)::date, 129, 'N CMDPGGNZG', '299.17', '{}', array['autonoms']);
select add_payment(123, 125, (date_trunc('month', current_date) - '10 months + 15 day'::interval)::date, 123, 'Pagament dN CMDPGGNZG', '299.17', '{}');
select add_expense(123, (date_trunc('month', current_date) - '10 months + 20 day'::interval)::date, 131, '123XYZ', '23.17', '{124}', '{}');
select add_payment(123, 126, (date_trunc('month', current_date) - '10 months + 15 day'::interval)::date, 124, 'Pagament d123XYZ', '23.17', '{}');
select add_expense(123, (date_trunc('month', current_date) - '10 months + 1 day'::interval)::date, 129, 'N QHVLDAN29', '299.17', '{}', array['autonoms']);
select add_payment(123, 127, (date_trunc('month', current_date) - '10 months'::interval)::date, 123, 'Pagament dN QHVLDAN29', '299.17', '{}');
select add_expense(123, (date_trunc('month', current_date) - '9 months + 2 day'::interval)::date, 130, 'XYZ123', '62.21', '{124}', '{}');
select add_payment(123, 128, (date_trunc('month', current_date) - '8 months + 28 day'::interval)::date, 123, 'Pagament dXYZ123', '62.21', '{}');
select add_expense(123, (date_trunc('month', current_date) - '9 months + 1 day'::interval)::date, 129, 'N WXMHH1R5Q', '299.17', '{}', array['autonoms']);
select add_payment(123, 129, (date_trunc('month', current_date) - '8 months + 28 day'::interval)::date, 123, 'Pagament dN WXMHH1R5Q', '299.17', '{}');
select add_expense(123, (current_date - '9 months'::interval)::date, 132, '00/0001', '117.74', '{124}', array['gestor']);
select add_payment(123, 130, (date_trunc('month', current_date) - '8 months + 15 day'::interval)::date, 124, 'Pagament de 00/0001', '117.74', '{}');
select add_expense(123, (date_trunc('month', current_date) - '8 months + 1 day'::interval)::date, 129, 'N NRP28PWY8', '299.17', '{}', array['autonoms']);
select add_payment(123, 131, (date_trunc('month', current_date) - '8 months'::interval)::date, 123, 'Pagament dN NRP28PWY8', '299.17', '{}');
select add_expense(123, (date_trunc('month', current_date) - '7 months + 1 day'::interval)::date, 129, 'N D256225DF', '299.17', '{}', array['autonoms']);
select add_payment(123, 132, (date_trunc('month', current_date) - '6 months + 15 day'::interval)::date, 123, 'Pagament dN D256225DF', '299.17', '{}');
select add_expense(123, (date_trunc('month', current_date) - '6 months + 15 day'::interval)::date, 130, 'ZZZ888', '162.21', '{124}', '{}');
select add_payment(123, 133, (date_trunc('month', current_date) - '6 months + 15 day'::interval)::date, 124, 'Pagament de ZZZ888', '80.00', '{}');
select add_expense(123, (date_trunc('month', current_date) - '6 months + 1 day'::interval)::date, 129, 'N K90XS7C3Q', '299.17', '{}', array['autonoms']);
select add_payment(123, 134, (date_trunc('month', current_date) - '5 months + 25 day'::interval)::date, 123, 'Pagament dN K90XS7C3Q', '299.17', '{}');
select add_expense(123, (current_date - '6 months'::interval)::date, 132, '00/0054', '117.74', '{124}', array['gestor']);
select add_payment(123, 135, (date_trunc('month', current_date) - '5 months + 29 day'::interval)::date, 123, 'Pagament dN ', '299.17', '{}');
select add_expense(123, (date_trunc('month', current_date) - '5 months + 1 day'::interval)::date, 129, 'N MCPDGGZNG', '299.17', '{}', array['autonoms']);
select add_payment(123, 136, (date_trunc('month', current_date) - '4 months + 29 day'::interval)::date, 123, 'Pagament dN MCPDGGZNG', '299.17', '{}');
select add_expense(123, (date_trunc('month', current_date) - '4 months + 1 day'::interval)::date, 129, 'N HQLVAD2N9', '299.17', '{}', array['autonoms']);
select add_payment(123, 137, (date_trunc('month', current_date) - '4 months + 1 day'::interval)::date, 123, 'Pagament dN HQLVAD2N9', '299.17', '{}');
select add_expense(123, (date_trunc('month', current_date) - '3 months + 1 day'::interval)::date, 129, 'N QXWHM1H5R', '299.17', '{}', array['autonoms']);
select add_payment(123, 138, (date_trunc('month', current_date) - '2 months + 15 day'::interval)::date, 123, 'Pagament dN QXWHM1H5R', '299.17', '{}');
select add_expense(123, (current_date - '3 months'::interval)::date, 132, '00/0331', '117.74', '{124}', array['gestor']);
select add_expense(123, (date_trunc('month', current_date) - '2 months + 1 day'::interval)::date, 129, 'N 8RN2PP8YW', '299.17', '{}', array['autonoms']);
select add_payment(123, 140, (date_trunc('month', current_date) - '1 months'::interval)::date, 123, 'Pagament dN 8RN2PP8YW', '299.17', '{}');
select add_expense(123, (date_trunc('month', current_date) - '1 months + 1 day'::interval)::date, 129, 'N F2D6522D5', '299.17', '{}', array['autonoms']);
select add_payment(123, 141, (date_trunc('month', current_date) - '1 months + 1 day'::interval)::date, 123, 'Pagament dN F2D6522D5', '299.17', '{}');
select add_expense(123, (date_trunc('month', current_date) - '1 day'::interval)::date, 129, 'N F2D6522D5', '299.17', '{}', array['autonoms']);
select add_payment(123, 142, (date_trunc('month', current_date) - '18 day'::interval)::date, 123, 'Pagament dN F2D6522D5', '299.17', '{}');
select add_expense(123, (current_date - '22 day'::interval)::date, 131, '321ABC', '1023.17', '{124}', '{}');
select add_expense(123, (current_date - '11 day'::interval)::date, 130, 'ABC321', '256.12', '{124}', '{}');
commit; commit;

View File

@ -1,68 +0,0 @@
-- Deploy numerus:add_collection to pg
-- requires: roles
-- requires: schema_numerus
-- requires: collection
-- requires: invoice_collection
-- requires: company
-- requires: currency
-- requires: parse_price
-- requires: tag_name
-- requires: update_invoice_collection_status
begin;
set search_path to numerus, public;
create or replace function add_collection(company integer, invoice_id integer, collection_date date, payment_account_id integer, description text, amount text, tags tag_name[]) returns uuid as
$$
declare
cid integer;
cslug uuid;
amount_cents integer;
begin
insert into collection
( company_id
, payment_account_id
, description
, collection_date
, amount
, currency_code
, payment_status
, tags
)
select company_id
, payment_account_id
, description
, collection_date
, parse_price(amount, currency.decimal_digits)
, currency_code
, 'complete'
, tags
from company
join currency using (currency_code)
where company.company_id = add_collection.company
returning collection_id, slug, collection.amount
into cid, cslug, amount_cents
;
if invoice_id is not null then
-- must be inserted before updating statuses, so that it can see this
-- collections amount too.
insert into invoice_collection (invoice_id, collection_id)
values (invoice_id, cid)
;
perform update_invoice_collection_status(cid, invoice_id, amount_cents);
end if;
return cslug;
end
$$
language plpgsql
;
revoke execute on function add_collection(integer, integer, date, integer, text, text, tag_name[]) from public;
grant execute on function add_collection(integer, integer, date, integer, text, text, tag_name[]) to invoicer;
grant execute on function add_collection(integer, integer, date, integer, text, text, tag_name[]) to admin;
commit;

View File

@ -8,15 +8,11 @@
-- requires: parse_price -- requires: parse_price
-- requires: tax -- requires: tax
-- requires: tag_name -- requires: tag_name
-- requires: expense_status
-- requires: expense_expense_status
begin; begin;
set search_path to numerus, public; set search_path to numerus, public;
drop function if exists add_expense(integer, text, date, integer, text, text, integer[], tag_name[]);
create or replace function add_expense(company integer, invoice_date date, contact_id integer, invoice_number text, amount text, taxes integer[], tags tag_name[]) returns uuid as create or replace function add_expense(company integer, invoice_date date, contact_id integer, invoice_number text, amount text, taxes integer[], tags tag_name[]) returns uuid as
$$ $$
declare declare

View File

@ -1,50 +0,0 @@
-- Deploy numerus:add_expense to pg
-- requires: schema_numerus
-- requires: expense
-- requires: expense_tax
-- requires: tax
-- requires: company
-- requires: currency
-- requires: parse_price
-- requires: tax
-- requires: tag_name
begin;
set search_path to numerus, public;
create or replace function add_expense(company integer, invoice_date date, contact_id integer, invoice_number text, amount text, taxes integer[], tags tag_name[]) returns uuid as
$$
declare
eid integer;
eslug uuid;
begin
insert into expense (company_id, contact_id, invoice_number, invoice_date, amount, currency_code, tags)
select company_id
, contact_id
, invoice_number
, invoice_date
, parse_price(amount, currency.decimal_digits)
, currency_code
, tags
from company
join currency using (currency_code)
where company.company_id = add_expense.company
returning expense_id, slug
into eid, eslug;
insert into expense_tax (expense_id, tax_id, tax_rate)
select eid, tax_id, tax.rate
from tax
join unnest(taxes) as etax(tax_id) using (tax_id);
return eslug;
end;
$$
language plpgsql;
revoke execute on function add_expense(integer, date, integer, text, text, integer[], tag_name[]) from public;
grant execute on function add_expense(integer, date, integer, text, text, integer[], tag_name[]) to invoicer;
grant execute on function add_expense(integer, date, integer, text, text, integer[], tag_name[]) to admin;
commit;

View File

@ -1,55 +0,0 @@
-- Deploy numerus:add_expense to pg
-- requires: schema_numerus
-- requires: expense
-- requires: expense_tax
-- requires: tax
-- requires: company
-- requires: currency
-- requires: parse_price
-- requires: tax
-- requires: tag_name
-- requires: expense_status
-- requires: expense_expense_status
begin;
set search_path to numerus, public;
create or replace function add_expense(company integer, status text, invoice_date date, contact_id integer, invoice_number text, amount text, taxes integer[], tags tag_name[]) returns uuid as
$$
declare
eid integer;
eslug uuid;
begin
insert into expense (company_id, contact_id, invoice_number, invoice_date, amount, currency_code, expense_status, tags)
select company_id
, contact_id
, invoice_number
, invoice_date
, parse_price(amount, currency.decimal_digits)
, currency_code
, status
, tags
from company
join currency using (currency_code)
where company.company_id = add_expense.company
returning expense_id, slug
into eid, eslug;
insert into expense_tax (expense_id, tax_id, tax_rate)
select eid, tax_id, tax.rate
from tax
join unnest(taxes) as etax(tax_id) using (tax_id);
return eslug;
end;
$$
language plpgsql;
revoke execute on function add_expense(integer, text, date, integer, text, text, integer[], tag_name[]) from public;
grant execute on function add_expense(integer, text, date, integer, text, text, integer[], tag_name[]) to invoicer;
grant execute on function add_expense(integer, text, date, integer, text, text, integer[], tag_name[]) to admin;
drop function if exists add_expense(integer, date, integer, text, text, integer[], tag_name[]);
commit;

View File

@ -1,67 +0,0 @@
-- Deploy numerus:add_payment to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment
-- requires: expense_payment
-- requires: company
-- requires: currency
-- requires: parse_price
-- requires: tag_name
-- requires: update_expense_payment_status
begin;
set search_path to numerus, public;
create or replace function add_payment(company integer, expense_id integer, payment_date date, payment_account_id integer, description text, amount text, tags tag_name[]) returns uuid as
$$
declare
pslug uuid;
pid integer;
amount_cents integer;
begin
insert into payment
( company_id
, payment_account_id
, description
, payment_date
, amount
, currency_code
, payment_status
, tags
)
select company_id
, payment_account_id
, description
, payment_date
, parse_price(amount, currency.decimal_digits)
, currency_code
, 'complete'
, tags
from company
join currency using (currency_code)
where company.company_id = add_payment.company
returning payment_id, slug, payment.amount
into pid, pslug, amount_cents
;
if expense_id is not null then
-- must be inserted before updating statuses, so that it can see this
-- payments amount too.
insert into expense_payment (expense_id, payment_id)
values (expense_id, pid);
perform update_expense_payment_status(pid, expense_id, amount_cents);
end if;
return pslug;
end
$$
language plpgsql
;
revoke execute on function add_payment(integer, integer, date, integer, text, text, tag_name[]) from public;
grant execute on function add_payment(integer, integer, date, integer, text, text, tag_name[]) to invoicer;
grant execute on function add_payment(integer, integer, date, integer, text, text, tag_name[]) to admin;
commit;

View File

@ -1,35 +0,0 @@
-- Deploy numerus:add_payment_account_bank to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment_account
-- requires: payment_account_bank
begin;
set search_path to numerus, public;
create or replace function add_payment_account_bank(company integer, name text, iban iban) returns uuid as
$$
declare
account_id integer;
account_slug uuid;
begin
insert into payment_account (company_id, payment_account_type, name)
select company, 'bank', add_payment_account_bank.name
returning payment_account_id, slug into account_id, account_slug;
insert into payment_account_bank (payment_account_id, iban)
values (account_id, iban)
;
return account_slug;
end;
$$
language plpgsql
;
revoke execute on function add_payment_account_bank(integer, text, iban) from public;
grant execute on function add_payment_account_bank(integer, text, iban) to invoicer;
grant execute on function add_payment_account_bank(integer, text, iban) to admin;
commit;

View File

@ -1,34 +0,0 @@
-- Deploy numerus:add_payment_account_card to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment_account
-- requires: payment_account_card
begin;
set search_path to numerus, public;
create or replace function add_payment_account_card(company integer, name text, four_digits text, exp_date date) returns uuid as
$$
declare
account_id integer;
account_slug uuid;
begin
insert into payment_account (company_id, payment_account_type, name)
select company, 'card', add_payment_account_card.name
returning payment_account_id, slug into account_id, account_slug;
insert into payment_account_card (payment_account_id, last_four_digits, expiration_date)
values (account_id, four_digits, exp_date);
return account_slug;
end
$$
language plpgsql
;
revoke execute on function add_payment_account_card(integer, text, text, date) from public;
grant execute on function add_payment_account_card(integer, text, text, date) to invoicer;
grant execute on function add_payment_account_card(integer, text, text, date) to admin;
commit;

View File

@ -1,23 +0,0 @@
-- Deploy numerus:add_payment_account_cash to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment_account
begin;
set search_path to numerus, public;
create or replace function add_payment_account_cash(company integer, name text) returns uuid as
$$
insert into payment_account (company_id, payment_account_type, name)
values (company, 'cash', name)
returning slug;
$$
language sql
;
revoke execute on function add_payment_account_cash(integer, text) from public;
grant execute on function add_payment_account_cash(integer, text) to invoicer;
grant execute on function add_payment_account_cash(integer, text) to admin;
commit;

View File

@ -1,23 +0,0 @@
-- Deploy numerus:add_payment_account_other to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment_account
begin;
set search_path to numerus, public;
create or replace function add_payment_account_other(company integer, name text) returns uuid as
$$
insert into payment_account (company_id, payment_account_type, name)
values (company, 'other', name)
returning slug;
$$
language sql
;
revoke execute on function add_payment_account_other(integer, text) from public;
grant execute on function add_payment_account_other(integer, text) to invoicer;
grant execute on function add_payment_account_other(integer, text) to admin;
commit;

View File

@ -1,30 +0,0 @@
-- Deploy numerus:attach_to_collection to pg
-- requires: roles
-- requires: schema_numerus
-- requires: collection
-- requires: collection_attachment
begin;
set search_path to numerus, public;
create or replace function attach_to_collection(collection_slug uuid, original_filename text, mime_type text, content bytea) returns void as
$$
insert into collection_attachment (collection_id, original_filename, mime_type, content)
select collection_id, original_filename, mime_type, content
from collection
where slug = collection_slug
on conflict (collection_id) do update
set original_filename = excluded.original_filename
, mime_type = excluded.mime_type
, content = excluded.content
;
$$
language sql
;
revoke execute on function attach_to_collection(uuid, text, text, bytea) from public;
grant execute on function attach_to_collection(uuid, text, text, bytea) to invoicer;
grant execute on function attach_to_collection(uuid, text, text, bytea) to admin;
commit;

View File

@ -1,30 +0,0 @@
-- Deploy numerus:attach_to_invoice to pg
-- requires: schema_numerus
-- requires: roles
-- requires: invoice
-- requires: invoice_attachment
begin;
set search_path to numerus, public;
create or replace function attach_to_invoice(invoice_slug uuid, original_filename text, mime_type text, content bytea) returns void as
$$
insert into invoice_attachment (invoice_id, original_filename, mime_type, content)
select invoice_id, original_filename, mime_type, content
from invoice
where slug = invoice_slug
on conflict (invoice_id) do update
set original_filename = excluded.original_filename
, mime_type = excluded.mime_type
, content = excluded.content
;
$$
language sql
;
revoke execute on function attach_to_invoice(uuid, text, text, bytea) from public;
grant execute on function attach_to_invoice(uuid, text, text, bytea) to invoicer;
grant execute on function attach_to_invoice(uuid, text, text, bytea) to admin;
commit;

View File

@ -1,30 +0,0 @@
-- Deploy numerus:attach_to_payment to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment
-- requires: payment_attachment
begin;
set search_path to numerus, public;
create or replace function attach_to_payment(payment_slug uuid, original_filename text, mime_type text, content bytea) returns void as
$$
insert into payment_attachment (payment_id, original_filename, mime_type, content)
select payment_id, original_filename, mime_type, content
from payment
where slug = payment_slug
on conflict (payment_id) do update
set original_filename = excluded.original_filename
, mime_type = excluded.mime_type
, content = excluded.content
;
$$
language sql
;
revoke execute on function attach_to_payment(uuid, text, text, bytea) from public;
grant execute on function attach_to_payment(uuid, text, text, bytea) to invoicer;
grant execute on function attach_to_payment(uuid, text, text, bytea) to admin;
commit;

View File

@ -1,27 +0,0 @@
-- Deploy numerus:available_expense_status to pg
-- requires: schema_numerus
-- requires: expense_status
-- requires: expense_status_i18n
begin;
set search_path to numerus;
insert into expense_status (expense_status, name)
values ('pending', 'Pending')
, ('partial', 'Partial')
, ('paid', 'Paid')
on conflict (expense_status) do nothing
;
insert into expense_status_i18n (expense_status, lang_tag, name)
values ('pending', 'ca', 'Pendent')
, ('partial', 'ca', 'Parcial')
, ('paid', 'ca', 'Pagada')
, ('pending', 'es', 'Pendiente')
, ('partial', 'es', 'Parcial')
, ('paid', 'es', 'Pagada')
on conflict (expense_status, lang_tag) do nothing
;
commit;

View File

@ -1,22 +0,0 @@
-- Deploy numerus:available_expense_status to pg
-- requires: schema_numerus
-- requires: expense_status
-- requires: expense_status_i18n
begin;
set search_path to numerus;
insert into expense_status (expense_status, name)
values ('pending', 'Pending')
, ('paid', 'Paid')
;
insert into expense_status_i18n (expense_status, lang_tag, name)
values ('pending', 'ca', 'Pendent')
, ('paid', 'ca', 'Pagada')
, ('pending', 'es', 'Pendiente')
, ('paid', 'es', 'Pagada')
;
commit;

View File

@ -10,28 +10,19 @@ set search_path to numerus;
insert into invoice_status (invoice_status, name) insert into invoice_status (invoice_status, name)
values ('created', 'Created') values ('created', 'Created')
, ('sent', 'Sent') , ('sent', 'Sent')
, ('partial', 'Partial')
, ('paid', 'Paid') , ('paid', 'Paid')
, ('unpaid', 'Unpaid') , ('unpaid', 'Unpaid')
on conflict (invoice_status) do nothing
; ;
insert into invoice_status_i18n (invoice_status, lang_tag, name) insert into invoice_status_i18n (invoice_status, lang_tag, name)
values ('created', 'ca', 'Creada') values ('created', 'ca', 'Creada')
, ('sent', 'ca', 'Enviada') , ('sent', 'ca', 'Enviada')
, ('partial', 'ca', 'Parcial')
, ('paid', 'ca', 'Cobrada') , ('paid', 'ca', 'Cobrada')
, ('unpaid', 'ca', 'No cobrada') , ('unpaid', 'ca', 'No cobrada')
, ('created', 'es', 'Creada') , ('created', 'es', 'Creada')
, ('sent', 'es', 'Enviada') , ('sent', 'es', 'Enviada')
, ('partial', 'es', 'Parcial')
, ('paid', 'es', 'Cobrada') , ('paid', 'es', 'Cobrada')
, ('unpaid', 'es', 'No cobrada') , ('unpaid', 'es', 'No cobrada')
on conflict (invoice_status, lang_tag) do nothing
; ;
update invoice set invoice_status = 'created' where invoice_status in ('sent', 'unpaid');
delete from invoice_status_i18n where invoice_status in ('sent', 'unpaid');
delete from invoice_status where invoice_status in ('sent', 'unpaid');
commit; commit;

View File

@ -1,28 +0,0 @@
-- Deploy numerus:available_invoice_status to pg
-- requires: schema_numerus
-- requires: invoice_status
-- requires: invoice_status_i18n
begin;
set search_path to numerus;
insert into invoice_status (invoice_status, name)
values ('created', 'Created')
, ('sent', 'Sent')
, ('paid', 'Paid')
, ('unpaid', 'Unpaid')
;
insert into invoice_status_i18n (invoice_status, lang_tag, name)
values ('created', 'ca', 'Creada')
, ('sent', 'ca', 'Enviada')
, ('paid', 'ca', 'Cobrada')
, ('unpaid', 'ca', 'No cobrada')
, ('created', 'es', 'Creada')
, ('sent', 'es', 'Enviada')
, ('paid', 'es', 'Cobrada')
, ('unpaid', 'es', 'No cobrada')
;
commit;

View File

@ -1,28 +0,0 @@
-- Deploy numerus:available_payment_account_types to pg
-- requires: schema_numerus
-- requires: payment_account_type
-- requires: payment_account_type_i18n
begin;
set search_path to numerus;
insert into payment_account_type (payment_account_type, name)
values ('bank', 'Bank')
, ('card', 'Credit Card')
, ('cash', 'Cash')
, ('other', 'Other')
;
insert into payment_account_type_i18n (payment_account_type, lang_tag, name)
values ('bank', 'ca', 'Banc')
, ('card', 'ca', 'Targeta de crèdit')
, ('cash', 'ca', 'Efectiu')
, ('other', 'ca', 'Altres')
, ('bank', 'es', 'Banco')
, ('card', 'es', 'Tarjeta de crédito')
, ('cash', 'es', 'Efectivo')
, ('other', 'es', 'Otros')
;
commit;

View File

@ -1,22 +0,0 @@
-- Deploy numerus:available_payment_status to pg
-- requires: schema_numerus
-- requires: payment_status
-- requires: payment_status_i18n
begin;
set search_path to numerus, public;
insert into payment_status (payment_status, name)
values ('partial', 'Partial')
, ('complete', 'Complete')
;
insert into payment_status_i18n (payment_status, lang_tag, name)
values ('partial', 'ca', 'Parcial')
, ('partial', 'es', 'Parcial')
, ('complete', 'ca', 'Complet')
, ('complete', 'es', 'Completo')
;
commit;

View File

@ -1,45 +0,0 @@
-- Deploy numerus:collection to pg
-- requires: roles
-- requires: schema_numerus
-- requires: company
-- requires: payment_account
-- requires: currency
-- requires: tag_name
-- requires: payment_status
-- requires: extension_pgcrypto
begin;
set search_path to numerus, public;
create table collection (
collection_id integer generated by default as identity primary key,
company_id integer not null references company,
slug uuid not null unique default gen_random_uuid(),
description text not null,
collection_date date not null default current_date,
payment_account_id integer not null references payment_account,
amount integer not null constraint collection_amount_positive check (amount > 0),
currency_code text not null references currency,
tags tag_name[] not null default '{}',
payment_status text not null default 'complete' references payment_status,
created_at timestamptz not null default current_timestamp
);
grant select, insert, update, delete on table collection to invoicer;
grant select, insert, update, delete on table collection to admin;
alter table collection enable row level security;
create policy company_policy
on collection
using (
exists(
select 1
from company_user
join user_profile using (user_id)
where company_user.company_id = collection.company_id
)
);
commit;

View File

@ -1,32 +0,0 @@
-- Deploy numerus:collection_attachment to pg
-- requires: roles
-- requires: schema_numerus
-- requires: collection
begin;
set search_path to numerus, public;
create table collection_attachment (
collection_id integer primary key references collection,
original_filename text not null,
mime_type text not null,
content bytea not null
);
grant select, insert, update, delete on table collection_attachment to invoicer;
grant select, insert, update, delete on table collection_attachment to admin;
alter table collection_attachment enable row level security;
create policy company_policy
on collection_attachment
using (
exists(
select 1
from collection
where collection.collection_id = collection_attachment.collection_id
)
);
commit;

View File

@ -1,68 +0,0 @@
-- Deploy numerus:compute_new_expense_amount to pg
-- requires: schema_numerus
-- requires: roles
-- requires: company
-- requires: tax
-- requires: new_expense_amount
begin;
set search_path to numerus, public;
create or replace function compute_new_expense_amount(company_id integer, subtotal text, taxes integer[]) returns new_expense_amount as
$$
declare
result new_expense_amount;
begin
if trim(subtotal) = '' then
subtotal = '0';
end if;
if array_length(taxes, 1) > 0 then
with line as (
select round(parse_price(subtotal, currency.decimal_digits)) as price
, tax_id
, decimal_digits
from unnest (taxes) as tax(tax_id)
join company on company.company_id = compute_new_expense_amount.company_id
join currency using (currency_code)
)
, tax_amount as (
select tax_id
, sum(round(price * tax.rate)::integer)::integer as amount
, decimal_digits
from line
join tax using (tax_id)
group by tax_id, decimal_digits
)
, tax_total as (
select sum(amount)::integer as amount
, array_agg(array[name, to_price(amount, decimal_digits)]) as taxes
from tax_amount
join tax using (tax_id)
)
select coalesce(tax_total.taxes, array[]::text[][])
, to_price(price::integer + coalesce(tax_total.amount, 0), decimal_digits) as total
from line, tax_total
into result.taxes, result.total;
else
select array[]::text[][]
, to_price(coalesce(parse_price(subtotal, decimal_digits), 0), decimal_digits)
from company
join currency using (currency_code)
where company.company_id = compute_new_expense_amount.company_id
into result.taxes, result.total
;
end if;
return result;
end;
$$
language plpgsql
stable
;
revoke execute on function compute_new_expense_amount(integer, text, integer[]) from public;
grant execute on function compute_new_expense_amount(integer, text, integer[]) to invoicer;
grant execute on function compute_new_expense_amount(integer, text, integer[]) to admin;
commit;

View File

@ -1,53 +0,0 @@
-- Deploy numerus:edit_collection to pg
-- requires: roles
-- requires: schema_numerus
-- requires: collection
-- requires: invoice_collection
-- requires: currency
-- requires: parse_price
-- requires: tag_name
-- requires: update_invoice_collection_status
begin;
set search_path to numerus, public;
create or replace function edit_collection(collection_slug uuid, collection_date date, payment_account_id integer, description text, amount text, tags tag_name[]) returns uuid as
$$
declare
cid integer;
iid integer;
amount_cents integer;
begin
update collection
set collection_date = edit_collection.collection_date
, payment_account_id = edit_collection.payment_account_id
, description = edit_collection.description
, amount = parse_price(edit_collection.amount, decimal_digits)
, tags = edit_collection.tags
from currency
where slug = collection_slug
and currency.currency_code = collection.currency_code
returning collection_id, collection.amount
into cid, amount_cents
;
select invoice_id into iid
from invoice_collection
where collection_id = cid;
if iid is not null then
perform update_invoice_collection_status(cid, iid, amount_cents);
end if;
return collection_slug;
end
$$
language plpgsql
;
revoke execute on function edit_collection(uuid, date, integer, text, text, tag_name[]) from public;
grant execute on function edit_collection(uuid, date, integer, text, text, tag_name[]) to invoicer;
grant execute on function edit_collection(uuid, date, integer, text, text, tag_name[]) to admin;
commit;

View File

@ -5,15 +5,11 @@
-- requires: parse_price -- requires: parse_price
-- requires: tax -- requires: tax
-- requires: tag_name -- requires: tag_name
-- requires: expense_status
-- requires: expense_expense_status
begin; begin;
set search_path to numerus, public; set search_path to numerus, public;
drop function if exists edit_expense(uuid, text, date, integer, text, text, integer[], tag_name[]);
create or replace function edit_expense(expense_slug uuid, invoice_date date, contact_id integer, invoice_number text, amount text, taxes integer[], tags tag_name[]) returns uuid as create or replace function edit_expense(expense_slug uuid, invoice_date date, contact_id integer, invoice_number text, amount text, taxes integer[], tags tag_name[]) returns uuid as
$$ $$
declare declare

View File

@ -1,50 +0,0 @@
-- Deploy numerus:edit_expense to pg
-- requires: schema_numerus
-- requires: expense
-- requires: currency
-- requires: parse_price
-- requires: tax
-- requires: tag_name
begin;
set search_path to numerus, public;
create or replace function edit_expense(expense_slug uuid, invoice_date date, contact_id integer, invoice_number text, amount text, taxes integer[], tags tag_name[]) returns uuid as
$$
declare
eid integer;
begin
update expense
set invoice_date = edit_expense.invoice_date
, contact_id = edit_expense.contact_id
, invoice_number = edit_expense.invoice_number
, amount = parse_price(edit_expense.amount, decimal_digits)
, tags = edit_expense.tags
from currency
where slug = expense_slug
and currency.currency_code = expense.currency_code
returning expense_id
into eid;
if eid is null then
return null;
end if;
delete from expense_tax where expense_id = eid;
insert into expense_tax (expense_id, tax_id, tax_rate)
select eid, tax_id, tax.rate
from tax
join unnest(taxes) as etax(tax_id) using (tax_id);
return expense_slug;
end;
$$
language plpgsql;
revoke execute on function edit_expense(uuid, date, integer, text, text, integer[], tag_name[]) from public;
grant execute on function edit_expense(uuid, date, integer, text, text, integer[], tag_name[]) to invoicer;
grant execute on function edit_expense(uuid, date, integer, text, text, integer[], tag_name[]) to admin;
commit;

View File

@ -1,53 +0,0 @@
-- Deploy numerus:edit_expense to pg
-- requires: schema_numerus
-- requires: expense
-- requires: currency
-- requires: parse_price
-- requires: tax
-- requires: tag_name
-- requires: expense_status
-- requires: expense_expense_status
begin;
set search_path to numerus, public;
create or replace function edit_expense(expense_slug uuid, status text, invoice_date date, contact_id integer, invoice_number text, amount text, taxes integer[], tags tag_name[]) returns uuid as
$$
declare
eid integer;
begin
update expense
set invoice_date = edit_expense.invoice_date
, contact_id = edit_expense.contact_id
, invoice_number = edit_expense.invoice_number
, amount = parse_price(edit_expense.amount, decimal_digits)
, expense_status = status
, tags = edit_expense.tags
from currency
where slug = expense_slug
and currency.currency_code = expense.currency_code
returning expense_id
into eid;
if eid is null then
return null;
end if;
delete from expense_tax where expense_id = eid;
insert into expense_tax (expense_id, tax_id, tax_rate)
select eid, tax_id, tax.rate
from tax
join unnest(taxes) as etax(tax_id) using (tax_id);
return expense_slug;
end;
$$
language plpgsql;
revoke execute on function edit_expense(uuid, text, date, integer, text, text, integer[], tag_name[]) from public;
grant execute on function edit_expense(uuid, text, date, integer, text, text, integer[], tag_name[]) to invoicer;
grant execute on function edit_expense(uuid, text, date, integer, text, text, integer[], tag_name[]) to admin;
commit;

View File

@ -14,9 +14,7 @@ begin;
set search_path to numerus, public; set search_path to numerus, public;
drop function if exists edit_invoice(uuid, text, integer, text, integer, tag_name[], edited_invoice_product[]); create or replace function edit_invoice(invoice_slug uuid, invoice_status text, contact_id integer, notes text, payment_method_id integer, tags tag_name[], products edited_invoice_product[]) returns uuid as
create or replace function edit_invoice(invoice_slug uuid, contact_id integer, notes text, payment_method_id integer, tags tag_name[], products edited_invoice_product[]) returns uuid as
$$ $$
declare declare
iid integer; iid integer;
@ -29,6 +27,7 @@ declare
begin begin
update invoice update invoice
set contact_id = edit_invoice.contact_id set contact_id = edit_invoice.contact_id
, invoice_status = edit_invoice.invoice_status
, notes = edit_invoice.notes , notes = edit_invoice.notes
, payment_method_id = edit_invoice.payment_method_id , payment_method_id = edit_invoice.payment_method_id
, tags = edit_invoice.tags , tags = edit_invoice.tags
@ -104,9 +103,9 @@ end;
$$ $$
language plpgsql; language plpgsql;
revoke execute on function edit_invoice(uuid, integer, text, integer, tag_name[], edited_invoice_product[]) from public; revoke execute on function edit_invoice(uuid, text, integer, text, integer, tag_name[], edited_invoice_product[]) from public;
grant execute on function edit_invoice(uuid, integer, text, integer, tag_name[], edited_invoice_product[]) to invoicer; grant execute on function edit_invoice(uuid, text, integer, text, integer, tag_name[], edited_invoice_product[]) to invoicer;
grant execute on function edit_invoice(uuid, integer, text, integer, tag_name[], edited_invoice_product[]) to admin; grant execute on function edit_invoice(uuid, text, integer, text, integer, tag_name[], edited_invoice_product[]) to admin;
commit; commit;

View File

@ -1,111 +0,0 @@
-- Deploy numerus:edit_invoice to pg
-- requires: schema_numerus
-- requires: invoice
-- requires: currency
-- requires: parse_price
-- requires: edited_invoice_product
-- requires: tax
-- requires: invoice_product
-- requires: invoice_product_product
-- requires: invoice_product_tax
-- requires: tag_name
begin;
set search_path to numerus, public;
create or replace function edit_invoice(invoice_slug uuid, invoice_status text, contact_id integer, notes text, payment_method_id integer, tags tag_name[], products edited_invoice_product[]) returns uuid as
$$
declare
iid integer;
products_to_keep integer[];
products_to_delete integer[];
company integer;
ccode text;
product edited_invoice_product;
ipid integer;
begin
update invoice
set contact_id = edit_invoice.contact_id
, invoice_status = edit_invoice.invoice_status
, notes = edit_invoice.notes
, payment_method_id = edit_invoice.payment_method_id
, tags = edit_invoice.tags
where slug = invoice_slug
returning invoice_id, company_id, currency_code
into iid, company, ccode
;
if iid is null then
return null;
end if;
foreach product in array products
loop
if product.invoice_product_id is null then
insert into invoice_product (invoice_id, name, description, price, quantity, discount_rate)
select iid
, product.name
, coalesce(product.description, '')
, parse_price(product.price, currency.decimal_digits)
, product.quantity
, product.discount_rate
from currency
where currency_code = ccode
returning invoice_product_id
into ipid;
else
ipid := product.invoice_product_id;
update invoice_product
set name = product.name
, description = coalesce(product.description, '')
, price = parse_price(product.price, currency.decimal_digits)
, quantity = product.quantity
, discount_rate = product.discount_rate
from currency
where invoice_product_id = ipid
and currency_code = ccode;
end if;
products_to_keep := array_append(products_to_keep, ipid);
if product.product_id is null then
delete from invoice_product_product where invoice_product_id = ipid;
else
insert into invoice_product_product (invoice_product_id, product_id)
values (ipid, product.product_id)
on conflict (invoice_product_id) do update
set product_id = product.product_id;
end if;
delete from invoice_product_tax where invoice_product_id = ipid;
insert into invoice_product_tax (invoice_product_id, tax_id, tax_rate)
select ipid, tax_id, tax.rate
from tax
join unnest(product.tax) as ptax(tax_id) using (tax_id);
end loop;
select array_agg(invoice_product_id)
into products_to_delete
from invoice_product
where invoice_id = iid
and not (invoice_product_id = any(products_to_keep));
if array_length(products_to_delete, 1) > 0 then
delete from invoice_product_tax where invoice_product_id = any(products_to_delete);
delete from invoice_product_product where invoice_product_id = any(products_to_delete);
delete from invoice_product where invoice_product_id = any(products_to_delete);
end if;
return invoice_slug;
end;
$$
language plpgsql;
revoke execute on function edit_invoice(uuid, text, integer, text, integer, tag_name[], edited_invoice_product[]) from public;
grant execute on function edit_invoice(uuid, text, integer, text, integer, tag_name[], edited_invoice_product[]) to invoicer;
grant execute on function edit_invoice(uuid, text, integer, text, integer, tag_name[], edited_invoice_product[]) to admin;
commit;

View File

@ -1,53 +0,0 @@
-- Deploy numerus:edit_payment to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment
-- requires: expense_payment
-- requires: currency
-- requires: parse_price
-- requires: tag_name
-- requires: update_expense_payment_status
begin;
set search_path to numerus, public;
create or replace function edit_payment(payment_slug uuid, payment_date date, payment_account_id integer, description text, amount text, tags tag_name[]) returns uuid as
$$
declare
pid integer;
eid integer;
amount_cents integer;
begin
update payment
set payment_date = edit_payment.payment_date
, payment_account_id = edit_payment.payment_account_id
, description = edit_payment.description
, amount = parse_price(edit_payment.amount, decimal_digits)
, tags = edit_payment.tags
from currency
where slug = payment_slug
and currency.currency_code = payment.currency_code
returning payment_id, payment.amount
into pid, amount_cents
;
select expense_id into eid
from expense_payment
where payment_id = pid;
if eid is not null then
perform update_expense_payment_status(pid, eid, amount_cents);
end if;
return payment_slug;
end
$$
language plpgsql
;
revoke execute on function edit_payment(uuid, date, integer, text, text, tag_name[]) from public;
grant execute on function edit_payment(uuid, date, integer, text, text, tag_name[]) to invoicer;
grant execute on function edit_payment(uuid, date, integer, text, text, tag_name[]) to admin;
commit;

View File

@ -1,43 +0,0 @@
-- Deploy numerus:edit_payment_account_bank to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment_account
-- requires: payment_account_bank
-- requires: extension_iban
begin;
set search_path to numerus, public;
create or replace function edit_payment_account_bank(account_slug uuid, new_name text, new_iban iban) returns uuid as
$$
declare
account_id int;
begin
update payment_account
set name = new_name
where slug = account_slug
and payment_account_type = 'bank'
returning payment_account_id into account_id
;
if account_id is null then
return null;
end if;
update payment_account_bank
set iban = new_iban
where payment_account_id = account_id
;
return account_slug;
end
$$
language plpgsql
;
revoke execute on function edit_payment_account_bank(uuid, text, iban) from public;
grant execute on function edit_payment_account_bank(uuid, text, iban) to invoicer;
grant execute on function edit_payment_account_bank(uuid, text, iban) to admin;
commit;

View File

@ -1,43 +0,0 @@
-- Deploy numerus:edit_payment_account_card to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment_account
-- requires: payment_account_card
begin;
set search_path to numerus, public;
create or replace function edit_payment_account_card(account_slug uuid, new_name text, new_last_digits text, new_exp_date date) returns uuid as
$$
declare
account_id integer;
begin
update payment_account
set name = new_name
where slug = account_slug
and payment_account_type = 'card'
returning payment_account_id into account_id
;
if account_id is null then
return null;
end if;
update payment_account_card
set last_four_digits = new_last_digits
, expiration_date = new_exp_date
where payment_account_id = account_id
;
return account_slug;
end
$$
language plpgsql
;
revoke execute on function edit_payment_account_card(uuid, text, text, date) from public;
grant execute on function edit_payment_account_card(uuid, text, text, date) to invoicer;
grant execute on function edit_payment_account_card(uuid, text, text, date) to admin;
commit;

View File

@ -1,26 +0,0 @@
-- Deploy numerus:edit_payment_account_cash to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment_account
begin;
set search_path to numerus, public;
create or replace function edit_payment_account_cash(account_slug uuid, new_name text) returns uuid as
$$
update payment_account
set name = new_name
where slug = account_slug
and payment_account_type = 'cash'
returning slug
;
$$
language sql
;
revoke execute on function edit_payment_account_cash(uuid, text) from public;
grant execute on function edit_payment_account_cash(uuid, text) to invoicer;
grant execute on function edit_payment_account_cash(uuid, text) to admin;
commit;

View File

@ -1,26 +0,0 @@
-- Deploy numerus:edit_payment_account_other to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment_account
begin;
set search_path to numerus, public;
create or replace function edit_payment_account_other(account_slug uuid, new_name text) returns uuid as
$$
update payment_account
set name = new_name
where slug = account_slug
and payment_account_type = 'other'
returning slug
;
$$
language sql
;
revoke execute on function edit_payment_account_other(uuid, text) from public;
grant execute on function edit_payment_account_other(uuid, text) to invoicer;
grant execute on function edit_payment_account_other(uuid, text) to admin;
commit;

View File

@ -1,12 +0,0 @@
-- Deploy numerus:expense_expense_status to pg
-- requires: expense
begin;
set search_path to numerus, public;
alter table expense
add column expense_status text not null default 'pending' references expense_status
;
commit;

View File

@ -1,32 +0,0 @@
-- Deploy numerus:expense_payment to pg
-- requires: roles
-- requires: schema_numerus
-- requires: expense
-- requires: payment
begin;
set search_path to numerus, public;
create table expense_payment (
expense_id integer not null references expense,
payment_id integer not null references payment,
primary key (expense_id, payment_id)
);
grant select, insert, update, delete on table expense_payment to invoicer;
grant select, insert, update, delete on table expense_payment to admin;
alter table expense_payment enable row level security;
create policy company_policy
on expense_payment
using (
exists(
select 1
from expense
where expense.expense_id = expense_payment.expense_id
)
);
commit;

View File

@ -1,17 +0,0 @@
-- Deploy numerus:expense_status to pg
-- requires: schema_numerus
-- requires: roles
begin;
set search_path to numerus, public;
create table expense_status (
expense_status text primary key,
name text not null
);
grant select on table expense_status to invoicer;
grant select on table expense_status to admin;
commit;

View File

@ -1,21 +0,0 @@
-- Deploy numerus:expense_status_i18n to pg
-- requires: schema_numerus
-- requires: roles
-- requires: expense_status
-- requires: language
begin;
set search_path to numerus, public;
create table expense_status_i18n (
expense_status text not null references expense_status,
lang_tag text not null references language,
name text not null,
primary key (expense_status, lang_tag)
);
grant select on table expense_status_i18n to invoicer;
grant select on table expense_status_i18n to admin;
commit;

View File

@ -1,32 +0,0 @@
-- Deploy numerus:invoice_attachment to pg
-- requires: schema_numerus
-- requires: roles
-- requires: invoice
begin;
set search_path to numerus, public;
create table invoice_attachment (
invoice_id integer primary key references invoice,
original_filename text not null,
mime_type text not null,
content bytea not null
);
grant select, insert, update, delete on table invoice_attachment to invoicer;
grant select, insert, update, delete on table invoice_attachment to admin;
alter table invoice_attachment enable row level security;
create policy company_policy
on invoice_attachment
using (
exists(
select 1
from invoice
where invoice.invoice_id = invoice_attachment.invoice_id
)
);
commit;

View File

@ -1,32 +0,0 @@
-- Deploy numerus:invoice_collection to pg
-- requires: roles
-- requires: schema_numerus
-- requires: invoice
-- requires: collection
begin;
set search_path to numerus, public;
create table invoice_collection (
invoice_id integer not null references invoice,
collection_id integer not null references collection,
primary key (invoice_id, collection_id)
);
grant select, insert, update, delete on table invoice_collection to invoicer;
grant select, insert, update, delete on table invoice_collection to admin;
alter table invoice_collection enable row level security;
create policy company_policy
on invoice_collection
using (
exists(
select 1
from invoice
where invoice.invoice_id = invoice_collection.invoice_id
)
);
commit;

View File

@ -1,13 +0,0 @@
-- Deploy numerus:new_expense_amount to pg
-- requires: schema_numerus
begin;
set search_path to numerus, public;
create type new_expense_amount as (
taxes text[][],
total text
);
commit;

View File

@ -24,9 +24,6 @@ begin
end if; end if;
result := parts[1]::integer; result := parts[1]::integer;
if result is null then
raise invalid_parameter_value using message = price || ' is not a valid price representation.';
end if;
for d in 1..decimal_digits loop for d in 1..decimal_digits loop
result := result * 10; result := result * 10;
end loop; end loop;

View File

@ -1,53 +0,0 @@
-- Deploy numerus:parse_price to pg
-- requires: schema_public
begin;
set search_path to numerus, public;
create or replace function parse_price(price text, decimal_digits integer) returns integer as
$$
declare
parts text[];
result int;
frac_part text;
sign int := 1;
begin
if price like '-%' Then
sign := -1;
price := substring(price from 2);
end if;
parts := string_to_array(price, '.');
if array_length(parts, 1) > 2 then
raise invalid_parameter_value using message = price || ' is not a valid price representation.';
end if;
result := parts[1]::integer;
for d in 1..decimal_digits loop
result := result * 10;
end loop;
if array_length(parts, 1) = 2 then
frac_part := rtrim(parts[2], '0');
if length(frac_part) > decimal_digits then
raise invalid_parameter_value using message = price || ' has too many digits in the fraction part.';
end if;
frac_part := rpad(frac_part, decimal_digits, '0');
result := result + frac_part::integer;
end if;
return sign * result;
end;
$$
language plpgsql
immutable;
comment on function parse_price(text, integer) is
'Converts the string representation of a price in decimal form to cents, according to the number of decimal digits passed.';
revoke execute on function parse_price(text, integer) from public;
grant execute on function parse_price(text, integer) to invoicer;
grant execute on function parse_price(text, integer) to admin;
commit;

View File

@ -1,47 +0,0 @@
-- Deploy numerus:payment to pg
-- requires: roles
-- requires: schema_numerus
-- requires: company
-- requires: payment_account
-- requires: currency
-- requires: tag_name
-- requires: payment_status
-- requires: extension_pgcrypto
begin;
set search_path to numerus, public;
create table payment (
payment_id integer generated by default as identity primary key,
company_id integer not null references company,
slug uuid not null unique default gen_random_uuid(),
description text not null,
payment_date date not null default current_date,
payment_account_id integer not null references payment_account,
amount integer not null constraint payment_amount_positive check (amount > 0),
currency_code text not null references currency,
tags tag_name[] not null default '{}',
payment_status text not null default 'complete' references payment_status,
created_at timestamptz not null default current_timestamp
);
create index on payment using gin (tags);
grant select, insert, update, delete on table payment to invoicer;
grant select, insert, update, delete on table payment to admin;
alter table payment enable row level security;
create policy company_policy
on payment
using (
exists(
select 1
from company_user
join user_profile using (user_id)
where company_user.company_id = payment.company_id
)
);
commit;

View File

@ -1,38 +0,0 @@
-- Deploy numerus:payment_account to pg
-- requires: roles
-- requires: schema_numerus
-- requires: company
-- requires: payment_account_type
-- requires: extension_pgcrypto
begin;
set search_path to numerus, public;
create table payment_account (
payment_account_id integer generated by default as identity primary key,
company_id integer not null references company,
slug uuid not null unique default gen_random_uuid(),
payment_account_type text not null references payment_account_type,
name text not null constraint payment_account_name_not_empty check(length(trim(name)) > 0),
unique (payment_account_id, payment_account_type)
);
grant select, insert, update, delete on table payment_account to invoicer;
grant select, insert, update, delete on table payment_account to admin;
alter table payment_account enable row level security;
create policy company_policy
on payment_account
using (
exists(
select 1
from company_user
join user_profile using (user_id)
where company_user.company_id = payment_account.company_id
)
);
commit;

View File

@ -1,33 +0,0 @@
-- Deploy numerus:payment_account_bank to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment_account
-- requires: extension_iban
begin;
set search_path to numerus, public;
create table payment_account_bank (
payment_account_id integer primary key,
payment_account_type text not null default 'bank' constraint payment_account_type_is_bank check (payment_account_type = 'bank'),
iban iban not null,
foreign key (payment_account_id, payment_account_type) references payment_account (payment_account_id, payment_account_type)
);
grant select, insert, update, delete on table payment_account_bank to invoicer;
grant select, insert, update, delete on table payment_account_bank to admin;
alter table payment_account_bank enable row level security;
create policy company_policy
on payment_account_bank
using (
exists(
select 1
from payment_account
where payment_account.payment_account_id = payment_account_bank.payment_account_id
)
);
commit;

View File

@ -1,33 +0,0 @@
-- Deploy numerus:payment_account_card to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment_account
begin;
set search_path to numerus, public;
create table payment_account_card (
payment_account_id integer primary key,
payment_account_type text not null default 'card' constraint payment_account_type_is_card check (payment_account_type = 'card'),
last_four_digits text not null constraint last_four_digits_are_digits check ( last_four_digits ~ '^\d{4}$'),
expiration_date date not null,
foreign key (payment_account_id, payment_account_type) references payment_account (payment_account_id, payment_account_type)
);
grant select, insert, update, delete on table payment_account_card to invoicer;
grant select, insert, update, delete on table payment_account_card to admin;
alter table payment_account_card enable row level security;
create policy company_policy
on payment_account_card
using (
exists(
select 1
from payment_account
where payment_account.payment_account_id = payment_account_card.payment_account_id
)
);
commit;

View File

@ -1,17 +0,0 @@
-- Deploy numerus:payment_account_type to pg
-- requires: roles
-- requires: schema_numerus
begin;
set search_path to numerus, public;
create table payment_account_type (
payment_account_type text primary key,
name text not null
);
grant select on table payment_account_type to invoicer;
grant select on table payment_account_type to admin;
commit;

View File

@ -1,21 +0,0 @@
-- Deploy numerus:payment_account_type_i18n to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment_account_type
-- requires: language
begin;
set search_path to numerus, public;
create table payment_account_type_i18n (
payment_account_type text not null references payment_account_type,
lang_tag text not null references language,
name text not null,
primary key (payment_account_type, lang_tag)
);
grant select on table payment_account_type_i18n to invoicer;
grant select on table payment_account_type_i18n to admin;
commit;

View File

@ -1,33 +0,0 @@
-- Deploy numerus:payment_attachment to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment
begin;
set search_path to numerus, public;
create table payment_attachment (
payment_id integer primary key references payment,
original_filename text not null,
mime_type text not null,
content bytea not null
);
grant select, insert, update, delete on table payment_attachment to invoicer;
grant select, insert, update, delete on table payment_attachment to admin;
alter table payment_attachment enable row level security;
create policy company_policy
on payment_attachment
using (
exists(
select 1
from payment
where payment.payment_id = payment_attachment.payment_id
)
);
commit;

View File

@ -1,17 +0,0 @@
-- Deploy numerus:payment_status to pg
-- requires: roles
-- requires: schema_numerus
begin;
set search_path to numerus, public;
create table payment_status (
payment_status text primary key,
name text not null
);
grant select on table payment_status to invoicer;
grant select on table payment_status to admin;
commit;

View File

@ -1,21 +0,0 @@
-- Deploy numerus:payment_status_i18n to pg
-- requires: roles
-- requires: schema_numerus
-- requires: payment_status
-- requires: language
begin;
set search_path to numerus, public;
create table payment_status_i18n (
payment_status text not null references payment_status,
lang_tag text not null references language,
name text not null,
primary key (payment_status, lang_tag)
);
grant select on table payment_status_i18n to invoicer;
grant select on table payment_status_i18n to admin;
commit;

View File

@ -1,40 +0,0 @@
-- Deploy numerus:remove_collection to pg
-- requires: roles
-- requires: schema_numerus
-- requires: invoice_collection
-- requires: collection
-- requires: collection_attachment
-- requires: update_invoice_collection_status
begin;
set search_path to numerus, public;
create or replace function remove_collection(collection_slug uuid) returns void as
$$
declare
cid integer;
iid integer;
begin
select collection_id into cid from collection where slug = collection_slug;
if not found then
return;
end if;
delete from invoice_collection where collection_id = cid returning invoice_id into iid;
if iid is not null then
perform update_invoice_collection_status(null, iid, 0);
end if;
delete from collection_attachment where collection_id = cid;
delete from collection where collection_id = cid;
end
$$
language plpgsql
;
revoke execute on function remove_collection(uuid) from public;
grant execute on function remove_collection(uuid) to invoicer;
grant execute on function remove_collection(uuid) to admin;
commit;

View File

@ -1,34 +0,0 @@
-- Deploy numerus:remove_expense to pg
-- requires: roles
-- requires: schema_numerus
-- requires: expense_tax
-- requires: expense_attachment
-- requires: expense
begin;
set search_path to numerus, public;
create or replace function remove_expense(expense_slug uuid) returns void as
$$
declare
eid integer;
begin
select expense_id into eid from expense where slug = expense_slug;
if not found then
return;
end if;
delete from expense_tax where expense_id = eid;
delete from expense_attachment where expense_id = eid;
delete from expense where expense_id = eid;
end
$$
language plpgsql
;
revoke execute on function remove_expense(uuid) from public;
grant execute on function remove_expense(uuid) to invoicer;
grant execute on function remove_expense(uuid) to admin;
commit;

View File

@ -1,40 +0,0 @@
-- Deploy numerus:remove_payment to pg
-- requires: roles
-- requires: schema_numerus
-- requires: expense_payment
-- requires: payment
-- requires: payment_attachment
-- requires: update_expense_payment_status
begin;
set search_path to numerus, public;
create or replace function remove_payment(payment_slug uuid) returns void as
$$
declare
pid integer;
eid integer;
begin
select payment_id into pid from payment where slug = payment_slug;
if not found then
return;
end if;
delete from expense_payment where payment_id = pid returning expense_id into eid;
if eid is not null then
perform update_expense_payment_status(null, eid, 0);
end if;
delete from payment_attachment where payment_id = pid;
delete from payment where payment_id = pid;
end
$$
language plpgsql
;
revoke execute on function remove_payment(uuid) from public;
grant execute on function remove_payment(uuid) to invoicer;
grant execute on function remove_payment(uuid) to admin;
commit;

View File

@ -1,51 +0,0 @@
-- Deploy numerus:update_expense_payment_status to pg
-- requires: roles
-- requires: schema_numerus
-- requires: expense
-- requires: payment
-- requires: expense_payment
-- requires: expense_tax_amount
-- requires: available_expense_status
-- requires: available_payment_status
begin;
set search_path to numerus, public;
create or replace function update_expense_payment_status(pid integer, eid integer, amount_cents integer) returns void as
$$
update payment
set payment_status = case when expense.amount + coalesce(tax.amount, 0) > amount_cents or exists (select 1 from expense_payment as ep where ep.expense_id = expense.expense_id and payment_id <> pid) then 'partial' else 'complete' end
from expense
left join ( select expense_id, sum(amount) as amount from expense_tax_amount group by expense_id) as tax using (expense_id)
where expense.expense_id = eid
and payment_id = pid
;
update expense
set expense_status = case
when paid_amount >= expense.amount + tax_amount then 'paid'
when paid_amount = 0 then 'pending'
else 'partial' end
from (
select coalesce (sum(payment.amount), 0) as paid_amount
from expense_payment
join payment using (payment_id)
where expense_payment.expense_id = eid
) as payment,
(
select coalesce (sum(amount), 0) as tax_amount
from expense_tax_amount
where expense_id = eid
) as tax
where expense.expense_id = eid
;
$$
language sql
;
revoke execute on function update_expense_payment_status(integer, integer, integer) from public;
grant execute on function update_expense_payment_status(integer, integer, integer) to invoicer;
grant execute on function update_expense_payment_status(integer, integer, integer) to admin;
commit;

View File

@ -1,49 +0,0 @@
-- Deploy numerus:update_invoice_collection_status to pg
-- requires: roles
-- requires: schema_numerus
-- requires: invoice
-- requires: collection
-- requires: invoice_collection
-- requires: invoice_amount
-- requires: available_invoice_status
-- requires: available_payment_status
begin;
set search_path to numerus, public;
create or replace function update_invoice_collection_status(cid integer, iid integer, amount_cents integer) returns void as
$$
update collection
set payment_status = case when invoice_amount.total > amount_cents or exists (select 1 from invoice_collection as ic where ic.collection_id = invoice_amount.invoice_id and collection_id <> cid) then 'partial' else 'complete' end
from invoice_amount
where invoice_id = iid
and collection_id = cid
;
update invoice
set invoice_status = case
when collected_amount >= total_amount then 'paid'
when collected_amount = 0 then 'created'
else 'partial' end
from (
select coalesce(sum(collection.amount), 0) as collected_amount
from invoice_collection
join collection using (collection_id)
where invoice_collection.invoice_id = iid
) as collection,
(
select total as total_amount
from invoice_amount
where invoice_id = iid
) as amount
where invoice.invoice_id = iid;
$$
language sql
;
revoke execute on function update_invoice_collection_status(integer, integer, integer) from public;
grant execute on function update_invoice_collection_status(integer, integer, integer) to invoicer;
grant execute on function update_invoice_collection_status(integer, integer, integer) to admin;
commit;

1
go.mod
View File

@ -7,7 +7,6 @@ require (
github.com/jackc/pgx/v4 v4.15.0 github.com/jackc/pgx/v4 v4.15.0
github.com/julienschmidt/httprouter v1.3.0 github.com/julienschmidt/httprouter v1.3.0
github.com/leonelquinteros/gotext v1.5.0 github.com/leonelquinteros/gotext v1.5.0
github.com/rainycape/unidecode v0.0.0-20150906181237-c9cf8cdbbfe8
github.com/tealeg/xlsx v0.0.0-20181024002044-dbf71b6a931e github.com/tealeg/xlsx v0.0.0-20181024002044-dbf71b6a931e
golang.org/x/text v0.7.0 golang.org/x/text v0.7.0
) )

2
go.sum
View File

@ -92,8 +92,6 @@ github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rainycape/unidecode v0.0.0-20150906181237-c9cf8cdbbfe8 h1:iZTHFqK/oFrjyFDkiw5U/RjQxkMlkpq6tHQIO407i+s=
github.com/rainycape/unidecode v0.0.0-20150906181237-c9cf8cdbbfe8/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=

257
guix.scm
View File

@ -1,257 +0,0 @@
(use-modules (gnu packages)
(gnu packages base)
(gnu packages compression)
(gnu packages databases)
(gnu packages geo)
(gnu packages messaging)
(gnu packages perl)
(gnu packages perl-check)
(gnu packages pkg-config)
(gnu packages protobuf)
(gnu packages web)
(gnu packages xml)
(guix build-system gnu)
(guix build-system perl)
(guix download)
(guix git-download)
(guix licenses)
(guix packages))
(define vat
(package
(name "vat")
(version "0.3")
(source (origin
(method url-fetch)
(uri (string-append
"https://dev.tandem.ws/tandem/" name "/archive/v" version ".tar.gz"))
(sha256
(base32
"0jbgakz7ip09hrnbbg1dm02n5zx7sv0magvw7s6g7rbbpy6wpqwh"))))
(build-system gnu-build-system)
(arguments
`(#:tests? #f
#:make-flags
(list (string-append "datadir=" (assoc-ref %outputs "out") "/share")
(string-append "docdir="(assoc-ref %outputs "out") "/share/doc")
(string-append "pkglibdir="(assoc-ref %outputs "out") "/lib")
(string-append "bindir=" (assoc-ref %outputs "out") "/bin"))
#:phases
(modify-phases %standard-phases
(delete 'configure))))
(inputs
`(("postgresql" ,postgresql-15)))
(home-page "https://dev.tandem.ws/tandem/vat/")
(synopsis "VAT identification numbers for PostgreSQL")
(description "VAT identification numbers library for PostgreSQL")
(license (x11-style "https://www.postgresql.org/about/licence/"))))
(define pg-libphonenumber
(let ((commit "753e2fa4be452620455a099aeda917648f2da70a")
(revision "1"))
(package
(name "pg-libphonenumber")
(version (git-version "0.1.0" revision commit))
(source (origin
(method git-fetch)
(uri (git-reference
(url "https://github.com/blm768/pg-libphonenumber")
(commit commit)))
(file-name (git-file-name name version))
(sha256
(base32
"01syw93giq0pz80qzrdr79cr4p6w8lx129y1gsn2avc97r7pqanj"))))
(build-system gnu-build-system)
(arguments
`(#:tests? #f
#:make-flags
(list (string-append "datadir=" (assoc-ref %outputs "out") "/share")
(string-append "docdir="(assoc-ref %outputs "out") "/share/doc")
(string-append "pkglibdir="(assoc-ref %outputs "out") "/lib")
(string-append "bindir=" (assoc-ref %outputs "out") "/bin"))
#:phases
(modify-phases %standard-phases
(delete 'configure))))
(inputs
`(("postgresql" ,postgresql-15)
("libphonenumber" ,libphonenumber)
("protobuf" ,protobuf)))
(home-page "https://github.com/blm768/pg-libphonenumber")
(synopsis "PostgreSQL extension for libphonenumber")
(description "pg_libphonenumber is a (partially implemented!) PostgreSQL extension that provides access to Googles libphonenumber.")
(license asl2.0))))
(define pguri
(let ((commit "00241b96b8a285aa7ec0a81b5a4c0a664a044192")
(revision "1"))
(package
(name "pguri")
(version (git-version "1.20151224" revision commit))
(source (origin
(method git-fetch)
(uri (git-reference
(url "https://github.com/petere/pguri")
(commit commit)))
(file-name (git-file-name name version))
(sha256
(base32
"0lz5nlqix60mxcjkqn3zn7q62xx0qbmybng3v0h049mf68l80ch9"))))
(build-system gnu-build-system)
(arguments
`(#:tests? #f
#:make-flags
(list (string-append "datadir=" (assoc-ref %outputs "out") "/share")
(string-append "docdir="(assoc-ref %outputs "out") "/share/doc")
(string-append "pkglibdir="(assoc-ref %outputs "out") "/lib")
(string-append "bindir=" (assoc-ref %outputs "out") "/bin"))
#:phases
(modify-phases %standard-phases
(delete 'configure))))
(inputs
`(("postgresql" ,postgresql-15)
("pkg-config" ,pkg-config)
("uriparser" ,uriparser)))
(home-page "https://github.com/petere/pguri")
(synopsis "uri type for PostgreSQL ")
(description "This is an extension for PostgreSQL that provides a uri data type. Advantages over using plain text for storing URIs include: URI syntax checking, functions for extracting URI components, and human-friendly sorting.")
(license asl2.0))))
(define postgresql-iban
(let ((commit "0e533afb4d6bdb5af615d71ee16db9528e501ba6")
(revision "1"))
(package
(name "PostgreSQL-IBAN")
(version (git-version "1.0.0" revision commit))
(source (origin
(method git-fetch)
(uri (git-reference
(url "https://github.com/yorickdewid/PostgreSQL-IBAN.git")
(commit commit)))
(file-name (git-file-name name version))
(sha256
(base32
"1fqjk0amdr3mvhq6n7ig6lxs8xckm6vh5nxm8m1rlar82081agh2"))
(patches (search-patches "postgresql-iban-enable-nls.patch"))))
(build-system gnu-build-system)
(arguments
`(#:tests? #f
#:make-flags
(list (string-append "datadir=" (assoc-ref %outputs "out") "/share")
(string-append "docdir="(assoc-ref %outputs "out") "/share/doc")
(string-append "pkglibdir="(assoc-ref %outputs "out") "/lib")
(string-append "bindir=" (assoc-ref %outputs "out") "/bin"))
#:phases
(modify-phases %standard-phases
(delete 'configure))))
(inputs
`(("postgresql" ,postgresql-15)))
(home-page "https://github.com/yorickdewid/PostgreSQL-IBAN")
(synopsis "PostgreSQL extension that can verify International Bank Account Numbers")
(description "PostgreSQL IBAN extension that can verify International Bank Account Numbers. This ensures that only valid bank account numbers are stored.")
(license gpl3+))))
(define postgresql-15/xml
(package
(inherit postgresql-15)
(arguments
`(#:configure-flags '("--with-uuid=e2fs" "--with-openssl" "--with-libxml")
#:phases
(modify-phases %standard-phases
(add-before 'configure 'patch-/bin/sh
(lambda _
;; Refer to the actual shell.
(substitute* '("src/bin/pg_ctl/pg_ctl.c"
"src/bin/psql/command.c")
(("/bin/sh") (which "sh")))
#t))
(add-after 'build 'build-contrib
(lambda _
(invoke "make" "-C" "contrib")))
(add-after 'install 'install-contrib
(lambda _
(invoke "make" "-C" "contrib" "install"))))))
(inputs
`(("libxml2" ,libxml2)
,@(package-inputs postgresql-15)))))
(define perl-tap-parser-sourcehandler-pgtap
(package
(name "perl-tap-parser-sourcehandler-pgtap")
(version "3.36")
(source
(origin
(method url-fetch)
(uri (string-append "mirror://cpan/authors/id/D/DW/DWHEELER/"
"TAP-Parser-SourceHandler-pgTAP-" version ".tar.gz"))
(sha256
(base32 "0rwcx6z0xg1jrwnsyhb4a3aq3g7ff1a510g5v1paqgh65r9m3gh7"))))
(build-system perl-build-system)
(inputs
`(("perl-module-build" ,perl-module-build)
("perl-test-pod" ,perl-test-pod)
("perl-test-pod-coverage" ,perl-test-pod-coverage)))
(home-page "https://metacpan.org/pod/TAP::Parser::SourceHandler::pgTAP")
(synopsis "Stream TAP from pgTAP test scripts")
(description "This source handler executes pgTAP tests. It does two things: 1) Looks at the TAP::Parser::Source passed to it to determine whether or not the source in question is in fact a pgTAP test (\"can_handle\"). And, 2) Creates an iterator that will call psql to run the pgTAP tests (\"make_iterator\"). Unless you're writing a plugin or subclassing TAP::Parser, you probably won't need to use this module directly.")
(license perl-license)))
(define pgtap
(package
(name "pgtap")
(version "1.2.0")
(source (origin
(method url-fetch)
(uri (string-append
"https://api.pgxn.org/dist/pgtap/" version
"/pgtap-" version ".zip"))
(sha256
(base32
"106p24wslq39h9hrscf415x7s1nb6l21xjdzpg3dh73gawslfmqv"))))
(build-system gnu-build-system)
(arguments
`(#:tests? #f
#:make-flags
(list (string-append "datadir=" (assoc-ref %outputs "out") "/share")
(string-append "docdir="(assoc-ref %outputs "out") "/share/doc")
(string-append "pkglibdir="(assoc-ref %outputs "out") "/lib")
(string-append "bindir=" (assoc-ref %outputs "out") "/bin"))
#:phases
(modify-phases %standard-phases
(delete 'configure))))
(inputs
`(("postgresql" ,postgresql)))
(native-inputs
`(("perl" ,perl)
("which" ,which)
("unzip" ,unzip)))
(home-page "https://pgtap.org")
(synopsis "Unit testining for PostgreSQL")
(description "pgTAP is a suite of database functions that make it easy to write TAP-emitting unit tests in psql scripts or xUnit-style test functions.")
(license (x11-style "https://www.postgresql.org/about/licence/"))))
(package
(name "numerus")
(version "0.1.0")
(source (origin
(method git-fetch)
(uri (git-reference
(url "https://dev.tandem.ws/tandem/numerus.git")
(commit "666935b54c8f2d735d8fe5591407ca231bba7e4c")))
(sha256
(base32
"0p31j7qing7nhnpngsnnfvx6wz70ryj4q5k7l81anh2z19nzwbk8"))))
(build-system gnu-build-system)
(inputs
`(("sqitch" ,sqitch)
("pgtap" ,pgtap)
("vat" ,vat)
("perl-tap-parser-sourcehandler-pgtap" ,perl-tap-parser-sourcehandler-pgtap)
("perl" ,perl)
("pg-libphonenumber" ,pg-libphonenumber)
("pguri" ,pguri)
("PostgreSQL-IBAN" ,postgresql-iban)
("postgresql" ,postgresql-15/xml)))
(synopsis "Simple invoicing and accounting web application")
(description "A simple web application to keep invoice and accouting records, intended for freelancers working in Spain.")
(home-page "https://dev.tandem.ws/tandem/numerus")
(license agpl3+))

View File

@ -1,338 +0,0 @@
package pkg
import (
"context"
"github.com/jackc/pgtype"
"github.com/julienschmidt/httprouter"
"html/template"
"net/http"
"time"
)
const (
ExpirationDateFormat = "01/06"
AccountTypeBank = "bank"
AccountTypeCard = "card"
AccountTypeCash = "cash"
AccountTypeOther = "other"
)
func servePaymentAccountIndex(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
conn := getConn(r)
company := mustGetCompany(r)
locale := getLocale(r)
page := NewPaymentAccountIndexPage(r.Context(), conn, company, locale)
page.MustRender(w, r)
}
type PaymentAccountIndexPage struct {
Accounts []*PaymentAccountEntry
}
func NewPaymentAccountIndexPage(ctx context.Context, conn *Conn, company *Company, locale *Locale) *PaymentAccountIndexPage {
return &PaymentAccountIndexPage{
Accounts: mustCollectPaymentAccountEntries(ctx, conn, company, locale),
}
}
func (page *PaymentAccountIndexPage) MustRender(w http.ResponseWriter, r *http.Request) {
mustRenderMainTemplate(w, r, "payments/accounts/index.gohtml", page)
}
type PaymentAccountEntry struct {
ID int
Slug string
Type string
TypeLabel string
Name string
IBAN string
LastFourDigits string
ExpirationDate string
}
func mustCollectPaymentAccountEntries(ctx context.Context, conn *Conn, company *Company, locale *Locale) []*PaymentAccountEntry {
rows := conn.MustQuery(ctx, `
select payment_account_id
, slug
, payment_account.payment_account_type
, coalesce(i18n.name, payment_account_type.name)
, payment_account.name
, coalesce(iban::text, '') as iban
, coalesce(last_four_digits, '') as last_four_digits
, expiration_date
from payment_account
left join payment_account_bank using (payment_account_id, payment_account_type)
left join payment_account_card using (payment_account_id, payment_account_type)
join payment_account_type using (payment_account_type)
left join payment_account_type_i18n as i18n on payment_account_type.payment_account_type = i18n.payment_account_type and i18n.lang_tag = $1
where company_id = $2
order by payment_account_id
`, locale.Language.String(), company.Id)
defer rows.Close()
var entries []*PaymentAccountEntry
for rows.Next() {
entry := &PaymentAccountEntry{}
var expirationDate pgtype.Date
if err := rows.Scan(&entry.ID, &entry.Slug, &entry.Type, &entry.TypeLabel, &entry.Name, &entry.IBAN, &entry.LastFourDigits, &expirationDate); err != nil {
panic(err)
}
if expirationDate.Status == pgtype.Present {
entry.ExpirationDate = expirationDate.Time.Format(ExpirationDateFormat)
}
entries = append(entries, entry)
}
if rows.Err() != nil {
panic(rows.Err())
}
return entries
}
func servePaymentAccountForm(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
locale := getLocale(r)
conn := getConn(r)
company := mustGetCompany(r)
form := newPaymentAccountForm(r.Context(), conn, locale, company)
slug := params[0].Value
if slug == "new" {
form.MustRender(w, r)
return
}
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
if !form.MustFillFromDatabase(r.Context(), conn, slug) {
http.NotFound(w, r)
return
}
form.MustRender(w, r)
}
type PaymentAccountForm struct {
locale *Locale
company *Company
Slug string
Type *RadioField
Name *InputField
IBAN *InputField
LastFourDigits *InputField
ExpirationMonthYear *InputField
}
func newPaymentAccountForm(ctx context.Context, conn *Conn, locale *Locale, company *Company) *PaymentAccountForm {
return &PaymentAccountForm{
locale: locale,
company: company,
Type: &RadioField{
Name: "payment_account_type",
Label: pgettext("input", "Type", locale),
Required: true,
Options: MustGetRadioOptions(ctx, conn, "select payment_account_type, i18n.name from payment_account_type join payment_account_type_i18n as i18n using (payment_account_type) where i18n.lang_tag = $1 order by payment_account_type", locale.Language.String()),
Attributes: []template.HTMLAttr{
`x-model="type"`,
},
},
Name: &InputField{
Name: "name",
Label: pgettext("input", "Name", locale),
Required: true,
Type: "text",
},
IBAN: &InputField{
Name: "iban",
Label: pgettext("input", "IBAN", locale),
Required: true,
Type: "text",
},
LastFourDigits: &InputField{
Name: "last_four_digits",
Label: pgettext("input", "Cards last four digits", locale),
Required: true,
Type: "text",
Attributes: []template.HTMLAttr{
`maxlength="4"`,
`minlength="4"`,
`pattern="[0-9]{4}"`,
},
},
ExpirationMonthYear: &InputField{
Name: "expiration_date",
Label: pgettext("input", "Expiration date", locale),
Required: true,
Type: "text",
Attributes: []template.HTMLAttr{
`maxlength="5"`,
`minlength="5"`,
`pattern="[0-9]{2}/[0-9]{2}"`,
},
},
}
}
func (f *PaymentAccountForm) MustRender(w http.ResponseWriter, r *http.Request) {
if f.Slug == "" {
mustRenderMainTemplate(w, r, "payments/accounts/new.gohtml", f)
} else {
mustRenderMainTemplate(w, r, "payments/accounts/edit.gohtml", f)
}
}
func (f *PaymentAccountForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool {
selectedType := f.Type.Selected
var expirationDate pgtype.Date
if notFoundErrorOrPanic(conn.QueryRow(ctx, `
select payment_account_type
, name
, coalesce(iban::text, '') as iban
, coalesce(last_four_digits, '') as last_four_digits
, expiration_date
from payment_account
left join payment_account_bank using (payment_account_id, payment_account_type)
left join payment_account_card using (payment_account_id, payment_account_type)
where slug = $1
`, slug).Scan(
f.Type,
f.Name,
f.IBAN,
f.LastFourDigits,
&expirationDate)) {
f.Type.Selected = selectedType
return false
}
f.Slug = slug
if expirationDate.Status == pgtype.Present {
f.ExpirationMonthYear.Val = expirationDate.Time.Format(ExpirationDateFormat)
} else {
f.ExpirationMonthYear.Val = ""
}
return true
}
func (f *PaymentAccountForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
f.Type.FillValue(r)
f.Name.FillValue(r)
f.IBAN.FillValue(r)
f.LastFourDigits.FillValue(r)
f.ExpirationMonthYear.FillValue(r)
return nil
}
func (f *PaymentAccountForm) Validate(ctx context.Context, conn *Conn) bool {
validator := newFormValidator()
if validator.CheckValidRadioOption(f.Type, gettext("Selected payment account type is not valid.", f.locale)) {
switch f.Type.Selected {
case AccountTypeBank:
if validator.CheckRequiredInput(f.IBAN, gettext("IBAN can not be empty.", f.locale)) {
validator.CheckValidIBANInput(ctx, conn, f.IBAN, gettext("This value is not a valid IBAN.", f.locale))
}
case AccountTypeCard:
if validator.CheckRequiredInput(f.LastFourDigits, gettext("Last four digits can not be empty.", f.locale)) {
if validator.CheckInputLength(f.LastFourDigits, 4, gettext("You must enter the cards last four digits", f.locale)) {
validator.CheckValidInteger(f.LastFourDigits, 0, 9999, gettext("Last four digits must be a number.", f.locale))
}
}
if validator.CheckRequiredInput(f.ExpirationMonthYear, gettext("Expiration date can not be empty.", f.locale)) {
_, err := time.Parse(ExpirationDateFormat, f.ExpirationMonthYear.Val)
validator.checkInput(f.ExpirationMonthYear, err == nil, gettext("Expiration date should be a valid date in format MM/YY (e.g., 08/24).", f.locale))
}
}
}
validator.CheckRequiredInput(f.Name, gettext("Payment account name can not be empty.", f.locale))
return validator.AllOK()
}
func (f *PaymentAccountForm) ExpirationDate() (time.Time, error) {
date, err := time.Parse(ExpirationDateFormat, f.ExpirationMonthYear.Val)
if err != nil {
return date, err
}
return date.AddDate(0, 1, -1), nil
}
func handleAddPaymentAccount(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
locale := getLocale(r)
conn := getConn(r)
company := mustGetCompany(r)
form := newPaymentAccountForm(r.Context(), conn, locale, company)
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if !form.Validate(r.Context(), conn) {
w.WriteHeader(http.StatusUnprocessableEntity)
form.MustRender(w, r)
return
}
switch form.Type.Selected {
case AccountTypeBank:
conn.MustExec(r.Context(), "select add_payment_account_bank($1, $2, $3)", company.Id, form.Name, form.IBAN)
case AccountTypeCard:
date, err := form.ExpirationDate()
if err != nil {
panic(err)
}
conn.MustExec(r.Context(), "select add_payment_account_card($1, $2, $3, $4)", company.Id, form.Name, form.LastFourDigits, date)
case AccountTypeCash:
conn.MustExec(r.Context(), "select add_payment_account_cash($1, $2)", company.Id, form.Name)
case AccountTypeOther:
conn.MustExec(r.Context(), "select add_payment_account_other($1, $2)", company.Id, form.Name)
}
htmxRedirect(w, r, companyURI(company, "/payment-accounts"))
}
func handleEditPaymentAccount(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
conn := getConn(r)
locale := getLocale(r)
company := mustGetCompany(r)
form := newPaymentAccountForm(r.Context(), conn, locale, company)
form.Slug = params[0].Value
if !ValidUuid(form.Slug) {
http.NotFound(w, r)
return
}
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if !form.Validate(r.Context(), conn) {
w.WriteHeader(http.StatusUnprocessableEntity)
form.MustRender(w, r)
return
}
var found string
switch form.Type.Selected {
case AccountTypeBank:
found = conn.MustGetText(r.Context(), "", "select edit_payment_account_bank($1, $2, $3)", form.Slug, form.Name, form.IBAN)
case AccountTypeCard:
date, err := form.ExpirationDate()
if err != nil {
panic(err)
}
found = conn.MustGetText(r.Context(), "", "select edit_payment_account_card($1, $2, $3, $4)", form.Slug, form.Name, form.LastFourDigits, date)
case AccountTypeCash:
found = conn.MustGetText(r.Context(), "", "select edit_payment_account_cash($1, $2)", form.Slug, form.Name)
case AccountTypeOther:
found = conn.MustGetText(r.Context(), "", "select edit_payment_account_other($1, $2)", form.Slug, form.Name)
}
if found == "" {
http.NotFound(w, r)
return
}
htmxRedirect(w, r, companyURI(company, "/payment-accounts"))
}

View File

@ -1,25 +0,0 @@
package pkg
import (
"github.com/julienschmidt/httprouter"
"net/http"
"strconv"
)
func serveAttachment(w http.ResponseWriter, r *http.Request, params httprouter.Params, sql string) {
slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
conn := getConn(r)
var contentType string
var content []byte
if notFoundErrorOrPanic(conn.QueryRow(r.Context(), sql, slug).Scan(&contentType, &content)) {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(content)), 10))
_, _ = w.Write(content)
}

View File

@ -1,3 +0,0 @@
package pkg
const Version = "1.1~git"

View File

@ -3,7 +3,6 @@ package pkg
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"html/template" "html/template"
"math" "math"
@ -83,7 +82,6 @@ type PaymentMethod struct {
Id int Id int
Name string Name string
Instructions string Instructions string
IsDefault bool
} }
type taxDetailsForm struct { type taxDetailsForm struct {
@ -100,6 +98,11 @@ type taxDetailsForm struct {
PostalCode *InputField PostalCode *InputField
Country *SelectField Country *SelectField
Currency *SelectField Currency *SelectField
InvoiceNumberFormat *InputField
NextInvoiceNumber *InputField
QuoteNumberFormat *InputField
NextQuoteNumber *InputField
LegalDisclaimer *InputField
} }
func newTaxDetailsForm(ctx context.Context, conn *Conn, locale *Locale) *taxDetailsForm { func newTaxDetailsForm(ctx context.Context, conn *Conn, locale *Locale) *taxDetailsForm {
@ -199,204 +202,6 @@ func newTaxDetailsForm(ctx context.Context, conn *Conn, locale *Locale) *taxDeta
Required: true, Required: true,
Selected: []string{"EUR"}, Selected: []string{"EUR"},
}, },
}
}
func (form *taxDetailsForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
form.TradeName.FillValue(r)
form.BusinessName.FillValue(r)
form.VATIN.FillValue(r)
form.Phone.FillValue(r)
form.Email.FillValue(r)
form.Web.FillValue(r)
form.Address.FillValue(r)
form.City.FillValue(r)
form.Province.FillValue(r)
form.PostalCode.FillValue(r)
form.Country.FillValue(r)
form.Currency.FillValue(r)
return nil
}
func (form *taxDetailsForm) Validate(ctx context.Context, conn *Conn) bool {
validator := newFormValidator()
country := ""
if validator.CheckValidSelectOption(form.Country, gettext("Selected country is not valid.", form.locale)) {
country = form.Country.Selected[0]
}
validator.CheckRequiredInput(form.BusinessName, gettext("Business name can not be empty.", form.locale))
validator.CheckInputMinLength(form.BusinessName, 2, gettext("Business name must have at least two letters.", form.locale))
if validator.CheckRequiredInput(form.VATIN, gettext("VAT number can not be empty.", form.locale)) {
validator.CheckValidVATINInput(ctx, conn, form.VATIN, country, gettext("This value is not a valid VAT number.", form.locale))
}
if validator.CheckRequiredInput(form.Phone, gettext("Phone can not be empty.", form.locale)) {
validator.CheckValidPhoneInput(ctx, conn, form.Phone, country, gettext("This value is not a valid phone number.", form.locale))
}
if validator.CheckRequiredInput(form.Email, gettext("Email can not be empty.", form.locale)) {
validator.CheckValidEmailInput(form.Email, gettext("This value is not a valid email. It should be like name@domain.com.", form.locale))
}
if form.Web.Val != "" {
validator.CheckValidURL(form.Web, gettext("This value is not a valid web address. It should be like https://domain.com/.", form.locale))
}
validator.CheckRequiredInput(form.Address, gettext("Address can not be empty.", form.locale))
validator.CheckRequiredInput(form.City, gettext("City can not be empty.", form.locale))
validator.CheckRequiredInput(form.Province, gettext("Province can not be empty.", form.locale))
if validator.CheckRequiredInput(form.PostalCode, gettext("Postal code can not be empty.", form.locale)) {
validator.CheckValidPostalCode(ctx, conn, form.PostalCode, country, gettext("This value is not a valid postal code.", form.locale))
}
validator.CheckValidSelectOption(form.Currency, gettext("Selected currency is not valid.", form.locale))
return validator.AllOK()
}
func (form *taxDetailsForm) mustFillFromDatabase(ctx context.Context, conn *Conn, company *Company) *taxDetailsForm {
err := conn.QueryRow(ctx, `
select business_name
, substr(vatin::text, 3)
, trade_name
, phone
, email
, web
, address
, city
, province
, postal_code
, country_code
, currency_code
from company
where company.company_id = $1`, company.Id).Scan(
form.BusinessName,
form.VATIN,
form.TradeName,
form.Phone,
form.Email,
form.Web,
form.Address,
form.City,
form.Province,
form.PostalCode,
form.Country,
form.Currency,
)
if err != nil {
panic(err)
}
return form
}
type TaxDetailsPage struct {
DetailsForm *taxDetailsForm
}
func (page *TaxDetailsPage) MustRender(w http.ResponseWriter, r *http.Request) {
mustRenderMainTemplate(w, r, "company/tax-details.gohtml", page)
}
func GetCompanyTaxDetailsForm(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
mustRenderTaxDetailsForm(w, r, newTaxDetailsFormFromDatabase(r))
}
func newTaxDetailsFormFromDatabase(r *http.Request) *taxDetailsForm {
locale := getLocale(r)
conn := getConn(r)
form := newTaxDetailsForm(r.Context(), conn, locale)
company := mustGetCompany(r)
form.mustFillFromDatabase(r.Context(), conn, company)
return form
}
func HandleCompanyTaxDetailsForm(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
locale := getLocale(r)
conn := getConn(r)
form := newTaxDetailsForm(r.Context(), conn, locale)
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if ok := form.Validate(r.Context(), conn); !ok {
w.WriteHeader(http.StatusUnprocessableEntity)
mustRenderTaxDetailsForm(w, r, form)
return
}
company := mustGetCompany(r)
conn.MustExec(r.Context(), `
update company
set business_name = $1
, vatin = ($11 || $2)::vatin
, trade_name = $3
, phone = parse_packed_phone_number($4, $11)
, email = $5
, web = $6
, address = $7
, city = $8
, province = $9
, postal_code = $10
, country_code = $11
, currency_code = $12
where company_id = $13
`,
form.BusinessName,
form.VATIN,
form.TradeName,
form.Phone,
form.Email,
form.Web,
form.Address,
form.City,
form.Province,
form.PostalCode,
form.Country,
form.Currency,
company.Id)
htmxRedirect(w, r, companyURI(company, "/tax-details"))
}
func mustRenderTaxDetailsForm(w http.ResponseWriter, r *http.Request, form *taxDetailsForm) {
page := &TaxDetailsPage{
DetailsForm: form,
}
page.MustRender(w, r)
}
func mustGetCompany(r *http.Request) *Company {
company := getCompany(r)
if company == nil {
panic(errors.New("company: required but not found"))
}
return company
}
func serveCompanyInvoicingForm(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
conn := getConn(r)
company := mustGetCompany(r)
locale := getLocale(r)
form := newInvoicingFormFromDatabase(r.Context(), conn, company, locale)
form.MustRender(w, r)
}
type InvoicingForm struct {
locale *Locale
InvoiceNumberFormat *InputField
NextInvoiceNumber *InputField
QuoteNumberFormat *InputField
NextQuoteNumber *InputField
LegalDisclaimer *InputField
}
func newInvoicingForm(locale *Locale) *InvoicingForm {
return &InvoicingForm{
locale: locale,
InvoiceNumberFormat: &InputField{ InvoiceNumberFormat: &InputField{
Name: "invoice_number_format", Name: "invoice_number_format",
Label: pgettext("input", "Invoice number format", locale), Label: pgettext("input", "Invoice number format", locale),
@ -435,15 +240,82 @@ func newInvoicingForm(locale *Locale) *InvoicingForm {
} }
} }
func newInvoicingFormFromDatabase(ctx context.Context, conn *Conn, company *Company, locale *Locale) *InvoicingForm { func (form *taxDetailsForm) Parse(r *http.Request) error {
form := newInvoicingForm(locale) if err := r.ParseForm(); err != nil {
form.mustFillFromDatabase(ctx, conn, company) return err
return form }
form.TradeName.FillValue(r)
form.BusinessName.FillValue(r)
form.VATIN.FillValue(r)
form.Phone.FillValue(r)
form.Email.FillValue(r)
form.Web.FillValue(r)
form.Address.FillValue(r)
form.City.FillValue(r)
form.Province.FillValue(r)
form.PostalCode.FillValue(r)
form.Country.FillValue(r)
form.Currency.FillValue(r)
form.InvoiceNumberFormat.FillValue(r)
form.NextInvoiceNumber.FillValue(r)
form.QuoteNumberFormat.FillValue(r)
form.NextQuoteNumber.FillValue(r)
form.LegalDisclaimer.FillValue(r)
return nil
} }
func (form *InvoicingForm) mustFillFromDatabase(ctx context.Context, conn *Conn, company *Company) { func (form *taxDetailsForm) Validate(ctx context.Context, conn *Conn) bool {
validator := newFormValidator()
country := ""
if validator.CheckValidSelectOption(form.Country, gettext("Selected country is not valid.", form.locale)) {
country = form.Country.Selected[0]
}
validator.CheckRequiredInput(form.BusinessName, gettext("Business name can not be empty.", form.locale))
validator.CheckInputMinLength(form.BusinessName, 2, gettext("Business name must have at least two letters.", form.locale))
if validator.CheckRequiredInput(form.VATIN, gettext("VAT number can not be empty.", form.locale)) {
validator.CheckValidVATINInput(ctx, conn, form.VATIN, country, gettext("This value is not a valid VAT number.", form.locale))
}
if validator.CheckRequiredInput(form.Phone, gettext("Phone can not be empty.", form.locale)) {
validator.CheckValidPhoneInput(ctx, conn, form.Phone, country, gettext("This value is not a valid phone number.", form.locale))
}
if validator.CheckRequiredInput(form.Email, gettext("Email can not be empty.", form.locale)) {
validator.CheckValidEmailInput(form.Email, gettext("This value is not a valid email. It should be like name@domain.com.", form.locale))
}
if form.Web.Val != "" {
validator.CheckValidURL(form.Web, gettext("This value is not a valid web address. It should be like https://domain.com/.", form.locale))
}
validator.CheckRequiredInput(form.Address, gettext("Address can not be empty.", form.locale))
validator.CheckRequiredInput(form.City, gettext("City can not be empty.", form.locale))
validator.CheckRequiredInput(form.Province, gettext("Province can not be empty.", form.locale))
if validator.CheckRequiredInput(form.PostalCode, gettext("Postal code can not be empty.", form.locale)) {
validator.CheckValidPostalCode(ctx, conn, form.PostalCode, country, gettext("This value is not a valid postal code.", form.locale))
}
validator.CheckValidSelectOption(form.Currency, gettext("Selected currency is not valid.", form.locale))
validator.CheckRequiredInput(form.InvoiceNumberFormat, gettext("Invoice number format can not be empty.", form.locale))
validator.CheckValidInteger(form.NextInvoiceNumber, 1, math.MaxInt32, gettext("Next invoice number must be a number greater than zero.", form.locale))
validator.CheckRequiredInput(form.QuoteNumberFormat, gettext("Quotation number format can not be empty.", form.locale))
validator.CheckValidInteger(form.NextQuoteNumber, 1, math.MaxInt32, gettext("Next quotation number must be a number greater than zero.", form.locale))
return validator.AllOK()
}
func (form *taxDetailsForm) mustFillFromDatabase(ctx context.Context, conn *Conn, company *Company) *taxDetailsForm {
err := conn.QueryRow(ctx, ` err := conn.QueryRow(ctx, `
select invoice_number_format select business_name
, substr(vatin::text, 3)
, trade_name
, phone
, email
, web
, address
, city
, province
, postal_code
, country_code
, currency_code
, invoice_number_format
, quote_number_format , quote_number_format
, legal_disclaimer , legal_disclaimer
, coalesce(invoice_number_counter.currval, 0) + 1 , coalesce(invoice_number_counter.currval, 0) + 1
@ -456,6 +328,18 @@ func (form *InvoicingForm) mustFillFromDatabase(ctx context.Context, conn *Conn,
on quote_number_counter.company_id = company.company_id on quote_number_counter.company_id = company.company_id
and quote_number_counter.year = date_part('year', current_date) and quote_number_counter.year = date_part('year', current_date)
where company.company_id = $1`, company.Id).Scan( where company.company_id = $1`, company.Id).Scan(
form.BusinessName,
form.VATIN,
form.TradeName,
form.Phone,
form.Email,
form.Web,
form.Address,
form.City,
form.Province,
form.PostalCode,
form.Country,
form.Currency,
form.InvoiceNumberFormat, form.InvoiceNumberFormat,
form.QuoteNumberFormat, form.QuoteNumberFormat,
form.LegalDisclaimer, form.LegalDisclaimer,
@ -465,39 +349,36 @@ func (form *InvoicingForm) mustFillFromDatabase(ctx context.Context, conn *Conn,
if err != nil { if err != nil {
panic(err) panic(err)
} }
return form
} }
func (form *InvoicingForm) MustRender(w http.ResponseWriter, r *http.Request) { type TaxDetailsPage struct {
mustRenderMainTemplate(w, r, "company/invoicing.gohtml", form) DetailsForm *taxDetailsForm
NewTaxForm *taxForm
Taxes []*Tax
NewPaymentMethodForm *paymentMethodForm
PaymentMethods []*PaymentMethod
} }
func (form *InvoicingForm) Parse(r *http.Request) error { func GetCompanyTaxDetailsForm(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
if err := r.ParseForm(); err != nil { mustRenderTaxDetailsForm(w, r, newTaxDetailsFormFromDatabase(r))
return err
}
form.InvoiceNumberFormat.FillValue(r)
form.NextInvoiceNumber.FillValue(r)
form.QuoteNumberFormat.FillValue(r)
form.NextQuoteNumber.FillValue(r)
form.LegalDisclaimer.FillValue(r)
return nil
} }
func (form *InvoicingForm) Validate() bool { func newTaxDetailsFormFromDatabase(r *http.Request) *taxDetailsForm {
validator := newFormValidator()
validator.CheckRequiredInput(form.InvoiceNumberFormat, gettext("Invoice number format can not be empty.", form.locale))
validator.CheckValidInteger(form.NextInvoiceNumber, 1, math.MaxInt32, gettext("Next invoice number must be a number greater than zero.", form.locale))
validator.CheckRequiredInput(form.QuoteNumberFormat, gettext("Quotation number format can not be empty.", form.locale))
validator.CheckValidInteger(form.NextQuoteNumber, 1, math.MaxInt32, gettext("Next quotation number must be a number greater than zero.", form.locale))
return validator.AllOK()
}
func handleCompanyInvoicingForm(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
locale := getLocale(r) locale := getLocale(r)
conn := getConn(r) conn := getConn(r)
form := newInvoicingForm(locale) form := newTaxDetailsForm(r.Context(), conn, locale)
company := mustGetCompany(r)
form.mustFillFromDatabase(r.Context(), conn, company)
return form
}
func HandleCompanyTaxDetailsForm(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
locale := getLocale(r)
conn := getConn(r)
form := newTaxDetailsForm(r.Context(), conn, locale)
if err := form.Parse(r); err != nil { if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@ -506,9 +387,11 @@ func handleCompanyInvoicingForm(w http.ResponseWriter, r *http.Request, _ httpro
http.Error(w, err.Error(), http.StatusForbidden) http.Error(w, err.Error(), http.StatusForbidden)
return return
} }
if ok := form.Validate(); !ok { if ok := form.Validate(r.Context(), conn); !ok {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
form.MustRender(w, r) }
mustRenderTaxDetailsForm(w, r, form)
return return
} }
company := mustGetCompany(r) company := mustGetCompany(r)
@ -516,11 +399,35 @@ func handleCompanyInvoicingForm(w http.ResponseWriter, r *http.Request, _ httpro
defer tx.MustRollback(r.Context()) defer tx.MustRollback(r.Context())
tx.MustExec(r.Context(), ` tx.MustExec(r.Context(), `
update company update company
set invoice_number_format = $1 set business_name = $1
, quote_number_format = $2 , vatin = ($11 || $2)::vatin
, legal_disclaimer = $3 , trade_name = $3
where company_id = $4 , phone = parse_packed_phone_number($4, $11)
, email = $5
, web = $6
, address = $7
, city = $8
, province = $9
, postal_code = $10
, country_code = $11
, currency_code = $12
, invoice_number_format = $13
, quote_number_format = $14
, legal_disclaimer = $15
where company_id = $16
`, `,
form.BusinessName,
form.VATIN,
form.TradeName,
form.Phone,
form.Email,
form.Web,
form.Address,
form.City,
form.Province,
form.PostalCode,
form.Country,
form.Currency,
form.InvoiceNumberFormat, form.InvoiceNumberFormat,
form.QuoteNumberFormat, form.QuoteNumberFormat,
form.LegalDisclaimer, form.LegalDisclaimer,
@ -542,39 +449,60 @@ func handleCompanyInvoicingForm(w http.ResponseWriter, r *http.Request, _ httpro
company.Id, company.Id,
form.NextQuoteNumber.Integer()-1) form.NextQuoteNumber.Integer()-1)
tx.MustCommit(r.Context()) tx.MustCommit(r.Context())
htmxRedirect(w, r, companyURI(company, "/invoicing")) if IsHTMxRequest(r) {
} w.Header().Set(HxTrigger, "closeModal")
w.WriteHeader(http.StatusNoContent)
func serveCompanyTaxes(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { } else {
conn := getConn(r) http.Redirect(w, r, companyURI(company, "/tax-details"), http.StatusSeeOther)
company := mustGetCompany(r)
locale := getLocale(r)
page := newTaxesPage(r.Context(), conn, company, locale)
page.MustRender(w, r)
}
type TaxesPage struct {
Taxes []*Tax
Form *taxForm
}
func newTaxesPage(ctx context.Context, conn *Conn, company *Company, locale *Locale) *TaxesPage {
form := newTaxForm(ctx, conn, company, locale)
return newTaxesPageWithForm(ctx, conn, company, form)
}
func newTaxesPageWithForm(ctx context.Context, conn *Conn, company *Company, form *taxForm) *TaxesPage {
return &TaxesPage{
Taxes: mustCollectTaxes(ctx, conn, company),
Form: form,
} }
} }
func (page *TaxesPage) MustRender(w http.ResponseWriter, r *http.Request) { func mustRenderTaxDetailsForm(w http.ResponseWriter, r *http.Request, form *taxDetailsForm) {
mustRenderMainTemplate(w, r, "company/taxes.gohtml", page) conn := getConn(r)
locale := getLocale(r)
page := &TaxDetailsPage{
DetailsForm: form,
NewTaxForm: newTaxForm(r.Context(), conn, mustGetCompany(r), locale),
NewPaymentMethodForm: newPaymentMethodForm(locale),
}
mustRenderTexDetailsPage(w, r, page)
} }
func mustCollectTaxes(ctx context.Context, conn *Conn, company *Company) []*Tax { func mustRenderTaxForm(w http.ResponseWriter, r *http.Request, form *taxForm) {
page := &TaxDetailsPage{
DetailsForm: newTaxDetailsFormFromDatabase(r),
NewTaxForm: form,
NewPaymentMethodForm: newPaymentMethodForm(getLocale(r)),
}
mustRenderTexDetailsPage(w, r, page)
}
func mustRenderPaymentMethodForm(w http.ResponseWriter, r *http.Request, form *paymentMethodForm) {
page := &TaxDetailsPage{
DetailsForm: newTaxDetailsFormFromDatabase(r),
NewTaxForm: newTaxForm(r.Context(), getConn(r), mustGetCompany(r), getLocale(r)),
NewPaymentMethodForm: form,
}
mustRenderTexDetailsPage(w, r, page)
}
func mustRenderTexDetailsPage(w http.ResponseWriter, r *http.Request, page *TaxDetailsPage) {
conn := getConn(r)
company := mustGetCompany(r)
page.Taxes = mustGetTaxes(r.Context(), conn, company)
page.PaymentMethods = mustCollectPaymentMethods(r.Context(), conn, company)
mustRenderModalTemplate(w, r, "tax-details.gohtml", page)
}
func mustGetCompany(r *http.Request) *Company {
company := getCompany(r)
if company == nil {
panic(errors.New("company: required but not found"))
}
return company
}
func mustGetTaxes(ctx context.Context, conn *Conn, company *Company) []*Tax {
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) 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 { if err != nil {
panic(err) panic(err)
@ -597,6 +525,29 @@ func mustCollectTaxes(ctx context.Context, conn *Conn, company *Company) []*Tax
return taxes return taxes
} }
func mustCollectPaymentMethods(ctx context.Context, conn *Conn, company *Company) []*PaymentMethod {
rows, err := conn.Query(ctx, "select payment_method_id, name, instructions from payment_method where company_id = $1 order by name", company.Id)
if err != nil {
panic(err)
}
defer rows.Close()
var methods []*PaymentMethod
for rows.Next() {
method := &PaymentMethod{}
err = rows.Scan(&method.Id, &method.Name, &method.Instructions)
if err != nil {
panic(err)
}
methods = append(methods, method)
}
if rows.Err() != nil {
panic(rows.Err())
}
return methods
}
type taxForm struct { type taxForm struct {
locale *Locale locale *Locale
Name *InputField Name *InputField
@ -653,6 +604,25 @@ func (form *taxForm) Validate() bool {
return validator.AllOK() return validator.AllOK()
} }
func HandleDeleteCompanyTax(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
taxId, err := strconv.Atoi(params[0].Value)
if err != nil {
http.NotFound(w, r)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
conn := getConn(r)
conn.MustExec(r.Context(), "delete from tax where tax_id = $1", taxId)
if IsHTMxRequest(r) {
w.WriteHeader(http.StatusOK)
} else {
http.Redirect(w, r, companyURI(mustGetCompany(r), "/tax-details"), http.StatusSeeOther)
}
}
func HandleAddCompanyTax(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { func HandleAddCompanyTax(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
locale := getLocale(r) locale := getLocale(r)
conn := getConn(r) conn := getConn(r)
@ -667,108 +637,22 @@ func HandleAddCompanyTax(w http.ResponseWriter, r *http.Request, _ httprouter.Pa
return return
} }
if !form.Validate() { if !form.Validate() {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
page := newTaxesPageWithForm(r.Context(), conn, company, form) }
page.MustRender(w, r) mustRenderTaxForm(w, r, form)
return return
} }
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()) 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())
htmxRedirect(w, r, companyURI(company, "/taxes")) if IsHTMxRequest(r) {
} mustRenderTaxForm(w, r, newTaxForm(r.Context(), conn, company, locale))
} else {
func HandleDeleteCompanyTax(w http.ResponseWriter, r *http.Request, params httprouter.Params) { http.Redirect(w, r, companyURI(company, "/tax-details"), http.StatusSeeOther)
taxId, err := strconv.Atoi(params[0].Value)
if err != nil {
http.NotFound(w, r)
return
} }
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
conn := getConn(r)
conn.MustExec(r.Context(), "delete from tax where tax_id = $1", taxId)
company := mustGetCompany(r)
htmxRedirect(w, r, companyURI(company, "/taxes"))
}
func servePaymentMethods(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
conn := getConn(r)
company := mustGetCompany(r)
locale := getLocale(r)
page := newPaymentMethodsPage(r.Context(), conn, company, locale)
page.MustRender(w, r)
}
type PaymentMethodsPage struct {
PaymentMethods []*PaymentMethod
UpdateDefaultError error
Form *paymentMethodForm
}
func newPaymentMethodsPage(ctx context.Context, conn *Conn, company *Company, locale *Locale) *PaymentMethodsPage {
form := newPaymentMethodForm(locale)
return newPaymentMethodsPageWithForm(ctx, conn, company, form)
}
func newPaymentMethodsPageWithForm(ctx context.Context, conn *Conn, company *Company, form *paymentMethodForm) *PaymentMethodsPage {
return &PaymentMethodsPage{
PaymentMethods: mustCollectPaymentMethods(ctx, conn, company),
Form: form,
}
}
func (page *PaymentMethodsPage) MustRender(w http.ResponseWriter, r *http.Request) {
mustRenderMainTemplate(w, r, "payments/methods/index.gohtml", page)
}
func mustCollectPaymentMethods(ctx context.Context, conn *Conn, company *Company) []*PaymentMethod {
return mustCollectPaymentMethodsWhere(ctx, conn, "company_id = $1", company.Id)
}
func mustCollectPaymentMethod(ctx context.Context, conn *Conn, paymentMethodId int) *PaymentMethod {
methods := mustCollectPaymentMethodsWhere(ctx, conn, "payment_method_id = $1", paymentMethodId)
if len(methods) == 0 {
return nil
}
return methods[0]
}
func mustCollectPaymentMethodsWhere(ctx context.Context, conn *Conn, where string, value any) []*PaymentMethod {
rows, err := conn.Query(ctx, fmt.Sprintf(`
select payment_method_id
, name
, instructions
, payment_method_id = default_payment_method_id
from payment_method
join company using (company_id)
where %s
order by name
`, where), value)
if err != nil {
panic(err)
}
defer rows.Close()
var methods []*PaymentMethod
for rows.Next() {
method := &PaymentMethod{}
err = rows.Scan(&method.Id, &method.Name, &method.Instructions, &method.IsDefault)
if err != nil {
panic(err)
}
methods = append(methods, method)
}
if rows.Err() != nil {
panic(rows.Err())
}
return methods
} }
type paymentMethodForm struct { type paymentMethodForm struct {
locale *Locale locale *Locale
PaymentMethodId int
Name *InputField Name *InputField
Instructions *InputField Instructions *InputField
} }
@ -778,7 +662,7 @@ func newPaymentMethodForm(locale *Locale) *paymentMethodForm {
locale: locale, locale: locale,
Name: &InputField{ Name: &InputField{
Name: "method_name", Name: "method_name",
Label: pgettext("input", "Invoicing method name", locale), Label: pgettext("input", "Payment method name", locale),
Type: "text", Type: "text",
Required: true, Required: true,
}, },
@ -791,25 +675,6 @@ func newPaymentMethodForm(locale *Locale) *paymentMethodForm {
} }
} }
func (form *paymentMethodForm) MustRender(w http.ResponseWriter, r *http.Request) {
mustRenderStandaloneTemplate(w, r, "payments/methods/edit.gohtml", form)
}
func (form *paymentMethodForm) MustFillFromDatabase(ctx context.Context, conn *Conn, paymentMethodId int) bool {
if notFoundErrorOrPanic(conn.QueryRow(ctx, `
select name
, instructions
from payment_method
where payment_method_id = $1
`, paymentMethodId).Scan(
form.Name,
form.Instructions)) {
return false
}
form.PaymentMethodId = paymentMethodId
return true
}
func (form *paymentMethodForm) Parse(r *http.Request) error { func (form *paymentMethodForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
return err return err
@ -821,11 +686,30 @@ func (form *paymentMethodForm) Parse(r *http.Request) error {
func (form *paymentMethodForm) Validate() bool { func (form *paymentMethodForm) Validate() bool {
validator := newFormValidator() validator := newFormValidator()
validator.CheckRequiredInput(form.Name, gettext("Invoicing method name can not be empty.", form.locale)) validator.CheckRequiredInput(form.Name, gettext("Payment method name can not be empty.", form.locale))
validator.CheckRequiredInput(form.Instructions, gettext("Payment instructions can not be empty.", form.locale)) validator.CheckRequiredInput(form.Instructions, gettext("Payment instructions can not be empty.", form.locale))
return validator.AllOK() return validator.AllOK()
} }
func HandleDeletePaymentMethod(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
paymentMethodId, err := strconv.Atoi(params[0].Value)
if err != nil {
http.NotFound(w, r)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
conn := getConn(r)
conn.MustExec(r.Context(), "delete from payment_method where payment_method_id = $1", paymentMethodId)
if IsHTMxRequest(r) {
w.WriteHeader(http.StatusOK)
} else {
http.Redirect(w, r, companyURI(mustGetCompany(r), "/tax-details"), http.StatusSeeOther)
}
}
func HandleAddPaymentMethod(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { func HandleAddPaymentMethod(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
locale := getLocale(r) locale := getLocale(r)
conn := getConn(r) conn := getConn(r)
@ -840,157 +724,16 @@ func HandleAddPaymentMethod(w http.ResponseWriter, r *http.Request, _ httprouter
return return
} }
if !form.Validate() { if !form.Validate() {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
page := newPaymentMethodsPageWithForm(r.Context(), conn, company, form) }
page.MustRender(w, r) mustRenderPaymentMethodForm(w, r, form)
return return
} }
conn.MustExec(r.Context(), "insert into payment_method (company_id, name, instructions) values ($1, $2, $3)", company.Id, form.Name, form.Instructions) conn.MustExec(r.Context(), "insert into payment_method (company_id, name, instructions) values ($1, $2, $3)", company.Id, form.Name, form.Instructions)
htmxRedirect(w, r, companyURI(company, "/payment-methods")) if IsHTMxRequest(r) {
} mustRenderPaymentMethodForm(w, r, newPaymentMethodForm(locale))
} else {
func HandleUpdatePaymentMethod(w http.ResponseWriter, r *http.Request, params httprouter.Params) { http.Redirect(w, r, companyURI(company, "/tax-details"), http.StatusSeeOther)
if params[0].Value == "default" { }
HandleUpdateDefaultPaymentMethod(w, r)
return
}
paymentMethodId, err := strconv.Atoi(params[0].Value)
if err != nil {
http.NotFound(w, r)
return
}
locale := getLocale(r)
conn := getConn(r)
form := newPaymentMethodForm(locale)
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if len(r.Form["cancel"]) == 0 {
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if !form.Validate() {
w.WriteHeader(http.StatusUnprocessableEntity)
form.MustRender(w, r)
return
}
newName := conn.MustGetText(r.Context(), "", "update payment_method set name = $1, instructions = $2 where payment_method_id = $3 returning name", form.Name, form.Instructions, paymentMethodId)
if newName == "" {
http.NotFound(w, r)
return
}
}
paymentMethod := mustCollectPaymentMethod(r.Context(), conn, paymentMethodId)
if paymentMethod == nil {
http.NotFound(w, r)
return
}
mustRenderStandaloneTemplateFragment(w, r, "payments/methods/index.gohtml", "row", paymentMethod)
}
func HandleUpdateDefaultPaymentMethod(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
locale := getLocale(r)
conn := getConn(r)
company := mustGetCompany(r)
defaultPaymentId, err := strconv.Atoi(r.FormValue("default_payment_id"))
if err != nil {
w.WriteHeader(http.StatusUnprocessableEntity)
page := newPaymentMethodsPage(r.Context(), conn, company, locale)
page.UpdateDefaultError = fmt.Errorf(gettext("Selected payment method is not valid.", locale))
page.MustRender(w, r)
return
}
conn.MustExec(r.Context(), "update company set default_payment_method_id = $1 where company_id = $2", defaultPaymentId, company.Id)
htmxRedirect(w, r, companyURI(company, "/payment-methods"))
}
func servePaymentMethodEditForm(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
paymentMethodId, err := strconv.Atoi(params[0].Value)
if err != nil {
http.NotFound(w, r)
return
}
locale := getLocale(r)
form := newPaymentMethodForm(locale)
conn := getConn(r)
if !form.MustFillFromDatabase(r.Context(), conn, paymentMethodId) {
http.NotFound(w, r)
return
}
form.MustRender(w, r)
}
func HandleDeletePaymentMethod(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
paymentMethodId, err := strconv.Atoi(params[0].Value)
if err != nil {
http.NotFound(w, r)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
conn := getConn(r)
conn.MustExec(r.Context(), "delete from payment_method where payment_method_id = $1", paymentMethodId)
company := mustGetCompany(r)
htmxRedirect(w, r, companyURI(company, "/payment-methods"))
}
func GetCompanySwitcher(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
page := &CompanySwitchPage{
Companies: mustCollectUserCompanies(r.Context(), getConn(r)),
}
page.MustRender(w, r)
}
type CompanySwitchPage struct {
Companies []*UserCompany
}
type UserCompany struct {
Name string
Slug string
}
func (page *CompanySwitchPage) MustRender(w http.ResponseWriter, r *http.Request) {
mustRenderModalTemplate(w, r, "company/switch.gohtml", page)
}
func mustCollectUserCompanies(ctx context.Context, conn *Conn) []*UserCompany {
rows, err := conn.Query(ctx, "select business_name::text, slug::text from company order by business_name")
if err != nil {
panic(err)
}
defer rows.Close()
var companies []*UserCompany
for rows.Next() {
company := &UserCompany{}
err = rows.Scan(&company.Name, &company.Slug)
if err != nil {
panic(err)
}
companies = append(companies, company)
}
if rows.Err() != nil {
panic(rows.Err())
}
return companies
} }

View File

@ -53,10 +53,6 @@ func GetContactForm(w http.ResponseWriter, r *http.Request, params httprouter.Pa
mustRenderNewContactForm(w, r, form) mustRenderNewContactForm(w, r, form)
return return
} }
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
if !form.MustFillFromDatabase(r.Context(), conn, slug) { if !form.MustFillFromDatabase(r.Context(), conn, slug) {
http.NotFound(w, r) http.NotFound(w, r)
return return
@ -97,7 +93,9 @@ func HandleAddContact(w http.ResponseWriter, r *http.Request, _ httprouter.Param
return return
} }
if !form.Validate(r.Context(), conn) { if !form.Validate(r.Context(), conn) {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
}
mustRenderNewContactForm(w, r, form) mustRenderNewContactForm(w, r, form)
return return
} }
@ -122,12 +120,7 @@ func HandleUpdateContact(w http.ResponseWriter, r *http.Request, params httprout
mustRenderEditContactForm(w, r, params[0].Value, form) mustRenderEditContactForm(w, r, params[0].Value, form)
return return
} }
slug := params[0].Value slug := conn.MustGetText(r.Context(), "", "select edit_contact($1, $2, $3, $4, $5, $6, $7, $8, $9)", params[0].Value, form.Name, form.Phone, form.Email, form.Web, form.TaxDetails(), form.IBAN, form.BIC, form.Tags)
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
slug = conn.MustGetText(r.Context(), "", "select edit_contact($1, $2, $3, $4, $5, $6, $7, $8, $9)", slug, form.Name, form.Phone, form.Email, form.Web, form.TaxDetails(), form.IBAN, form.BIC, form.Tags)
if slug == "" { if slug == "" {
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -179,11 +172,6 @@ func (form *contactFilterForm) Parse(r *http.Request) error {
return nil return nil
} }
func (form *contactFilterForm) HasValue() bool {
return form.Name.HasValue() ||
form.Tags.HasValue()
}
func mustCollectContactEntries(ctx context.Context, conn *Conn, company *Company, filters *contactFilterForm) []*ContactEntry { func mustCollectContactEntries(ctx context.Context, conn *Conn, company *Company, filters *contactFilterForm) []*ContactEntry {
args := []interface{}{company.Id} args := []interface{}{company.Id}
where := []string{"contact.company_id = $1"} where := []string{"contact.company_id = $1"}
@ -489,11 +477,36 @@ func (form *contactForm) TaxDetails() *CustomerTaxDetails {
} }
func ServeEditContactTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) { func ServeEditContactTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
serveTagsEditForm(w, r, params, "/contacts/", "select tags from contact where slug = $1") conn := getConn(r)
locale := getLocale(r)
company := getCompany(r)
slug := params[0].Value
form := newTagsForm(companyURI(company, "/contacts/"+slug+"/tags"), slug, locale)
if notFoundErrorOrPanic(conn.QueryRow(r.Context(), `select tags from contact where slug = $1`, form.Slug).Scan(form.Tags)) {
http.NotFound(w, r)
return
}
mustRenderStandaloneTemplate(w, r, "tags/edit.gohtml", form)
} }
func HandleUpdateContactTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) { func HandleUpdateContactTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
handleUpdateTags(w, r, params, "/contacts/", "update contact set tags = $1 where slug = $2 returning slug") locale := getLocale(r)
conn := getConn(r)
company := getCompany(r)
slug := params[0].Value
form := newTagsForm(companyURI(company, "/contacts/"+slug+"/tags/edit"), slug, locale)
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if conn.MustGetText(r.Context(), "", "update contact set tags = $1 where slug = $2 returning slug", form.Tags, form.Slug) == "" {
http.NotFound(w, r)
}
mustRenderStandaloneTemplate(w, r, "tags/view.gohtml", form)
} }
func ServeImportPage(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { func ServeImportPage(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {

View File

@ -66,7 +66,7 @@ func ServeDashboard(w http.ResponseWriter, r *http.Request, _ httprouter.Params)
rows := conn.MustQuery(r.Context(), fmt.Sprintf(` rows := conn.MustQuery(r.Context(), fmt.Sprintf(`
select to_price(0, decimal_digits) as sales select to_price(0, decimal_digits) as sales
, to_price(coalesce(invoice.total, 0), decimal_digits) as income , to_price(coalesce(invoice.total, 0), decimal_digits) as income
, to_price(coalesce(expense.total, 0) + coalesce(expense_tax.vat, 0) + coalesce(expense_tax.irpf, 0), decimal_digits) as expenses , to_price(coalesce(expense.total, 0), decimal_digits) as expenses
, to_price(coalesce(invoice_tax.vat, 0) - coalesce(expense_tax.vat, 0), decimal_digits) as vat , to_price(coalesce(invoice_tax.vat, 0) - coalesce(expense_tax.vat, 0), decimal_digits) as vat
, to_price(coalesce(invoice_tax.irpf, 0) + coalesce(expense_tax.irpf, 0), decimal_digits) as irpf , to_price(coalesce(invoice_tax.irpf, 0) + coalesce(expense_tax.irpf, 0), decimal_digits) as irpf
, to_price(coalesce(invoice.total, 0) - coalesce(expense.total, 0) - (coalesce(invoice_tax.vat, 0) - coalesce(expense_tax.vat, 0)) + coalesce(expense_tax.irpf, 0), decimal_digits) as net_income , to_price(coalesce(invoice.total, 0) - coalesce(expense.total, 0) - (coalesce(invoice_tax.vat, 0) - coalesce(expense_tax.vat, 0)) + coalesce(expense_tax.irpf, 0), decimal_digits) as net_income
@ -201,21 +201,9 @@ func buildDashboardChart(ctx context.Context, conn *Conn, locale *Locale, compan
) as invoice ) as invoice
left join ( left join (
select to_char(date.invoice_date, '%[3]s')::integer as date select to_char(date.invoice_date, '%[3]s')::integer as date
, sum(subtotal + taxes)::integer as total , sum(amount)::integer as total
from generate_series(%[1]s, %[2]s, interval '1 day') as date(invoice_date) from generate_series(%[1]s, %[2]s, interval '1 day') as date(invoice_date)
left join ( left join expense on expense.invoice_date = date.invoice_date and company_id = $1
select expense_id
, invoice_date
, expense.amount as subtotal
, coalesce(sum(tax.amount)::integer, 0) as taxes
from expense
left join expense_tax_amount as tax using (expense_id)
where company_id = $1
group by expense_id
, invoice_date
, expense.amount
) as expense
on expense.invoice_date = date.invoice_date
group by date group by date
) as expense using (date) ) as expense using (date)
order by date order by date

View File

@ -13,28 +13,19 @@ import (
) )
type ExpenseEntry struct { type ExpenseEntry struct {
ID int
Slug string Slug string
InvoiceDate time.Time InvoiceDate time.Time
InvoiceNumber string InvoiceNumber string
Amount string Amount string
Taxes map[string]string
Total string
InvoicerName string InvoicerName string
OriginalFileName string OriginalFileName string
Tags []string Tags []string
Status string
StatusLabel string
} }
type expensesIndexPage struct { type expensesIndexPage struct {
Expenses []*ExpenseEntry Expenses []*ExpenseEntry
SumAmount string TotalAmount string
SumTaxes map[string]string
SumTotal string
Filters *expenseFilterForm Filters *expenseFilterForm
TaxClasses []string
ExpenseStatuses map[string]string
} }
func IndexExpenses(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { func IndexExpenses(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
@ -47,66 +38,38 @@ func IndexExpenses(w http.ResponseWriter, r *http.Request, _ httprouter.Params)
return return
} }
page := &expensesIndexPage{ page := &expensesIndexPage{
Expenses: mustCollectExpenseEntries(r.Context(), conn, locale, filters), Expenses: mustCollectExpenseEntries(r.Context(), conn, filters),
ExpenseStatuses: mustCollectExpenseStatuses(r.Context(), conn, locale), TotalAmount: mustComputeExpensesTotalAmount(r.Context(), conn, filters),
TaxClasses: mustCollectTaxClasses(r.Context(), conn, company),
Filters: filters, Filters: filters,
} }
page.mustComputeExpensesTotalAmount(r.Context(), conn, filters)
mustRenderMainTemplate(w, r, "expenses/index.gohtml", page) mustRenderMainTemplate(w, r, "expenses/index.gohtml", page)
} }
func mustCollectExpenseEntries(ctx context.Context, conn *Conn, locale *Locale, filters *expenseFilterForm) []*ExpenseEntry { func mustCollectExpenseEntries(ctx context.Context, conn *Conn, filters *expenseFilterForm) []*ExpenseEntry {
where, args := filters.BuildQuery([]interface{}{locale.Language.String()}) where, args := filters.BuildQuery(nil)
rows := conn.MustQuery(ctx, fmt.Sprintf(` rows := conn.MustQuery(ctx, fmt.Sprintf(`
select expense_id select expense.slug
, expense.slug
, invoice_date , invoice_date
, invoice_number , invoice_number
, to_price(expense.amount, decimal_digits) as amount , to_price(amount, decimal_digits)
, array_agg(array[tax_class.name, to_price(coalesce(expense_tax.amount, 0), decimal_digits)]) filter (where tax_class.name is not null)
, to_price(expense.amount + coalesce(sum(expense_tax.amount)::integer, 0), decimal_digits) as total
, contact.name , contact.name
, coalesce(attachment.original_filename, '') , coalesce(attachment.original_filename, '')
, expense.tags , expense.tags
, expense.expense_status
, esi18n.name
from expense from expense
left join expense_attachment as attachment using (expense_id) left join expense_attachment as attachment using (expense_id)
left join expense_tax_amount as expense_tax using (expense_id)
left join tax using (tax_id)
left join tax_class using (tax_class_id)
join contact using (contact_id) join contact using (contact_id)
join expense_status_i18n esi18n on expense.expense_status = esi18n.expense_status and esi18n.lang_tag = $1
join currency using (currency_code) join currency using (currency_code)
where (%s) where (%s)
group by expense_id order by invoice_date
, expense.slug
, invoice_date
, invoice_number
, expense.amount
, decimal_digits
, contact.name
, attachment.original_filename
, expense.tags
, expense.expense_status
, esi18n.name
order by invoice_date desc, contact.name, total desc
`, where), args...) `, where), args...)
defer rows.Close() defer rows.Close()
var entries []*ExpenseEntry var entries []*ExpenseEntry
for rows.Next() { for rows.Next() {
entry := &ExpenseEntry{ entry := &ExpenseEntry{}
Taxes: make(map[string]string), if err := rows.Scan(&entry.Slug, &entry.InvoiceDate, &entry.InvoiceNumber, &entry.Amount, &entry.InvoicerName, &entry.OriginalFileName, &entry.Tags); err != nil {
}
var taxes [][]string
if err := rows.Scan(&entry.ID, &entry.Slug, &entry.InvoiceDate, &entry.InvoiceNumber, &entry.Amount, &taxes, &entry.Total, &entry.InvoicerName, &entry.OriginalFileName, &entry.Tags, &entry.Status, &entry.StatusLabel); err != nil {
panic(err) panic(err)
} }
for _, tax := range taxes {
entry.Taxes[tax[0]] = tax[1]
}
entries = append(entries, entry) entries = append(entries, entry)
} }
if rows.Err() != nil { if rows.Err() != nil {
@ -116,96 +79,15 @@ func mustCollectExpenseEntries(ctx context.Context, conn *Conn, locale *Locale,
return entries return entries
} }
func mustCollectExpenseStatuses(ctx context.Context, conn *Conn, locale *Locale) map[string]string { func mustComputeExpensesTotalAmount(ctx context.Context, conn *Conn, filters *expenseFilterForm) string {
rows := conn.MustQuery(ctx, `
select expense_status.expense_status
, esi18n.name
from expense_status
join expense_status_i18n esi18n using(expense_status)
where esi18n.lang_tag = $1
order by expense_status`, locale.Language.String())
defer rows.Close()
statuses := map[string]string{}
for rows.Next() {
var key, name string
if err := rows.Scan(&key, &name); err != nil {
panic(err)
}
statuses[key] = name
}
if rows.Err() != nil {
panic(rows.Err())
}
return statuses
}
func (page *expensesIndexPage) mustComputeExpensesTotalAmount(ctx context.Context, conn *Conn, filters *expenseFilterForm) {
where, args := filters.BuildQuery(nil) where, args := filters.BuildQuery(nil)
row := conn.QueryRow(ctx, fmt.Sprintf(` return conn.MustGetText(ctx, "0", fmt.Sprintf(`
select to_price(sum(subtotal)::integer, decimal_digits) select to_price(sum(amount)::integer, decimal_digits)
, to_price(sum(subtotal + taxes)::integer, decimal_digits)
from (
select expense_id
, expense.amount as subtotal
, coalesce(sum(tax.amount)::integer, 0) as taxes
, currency_code
from expense from expense
left join expense_tax_amount as tax using (expense_id)
where (%s)
group by expense_id
, expense.amount
, currency_code
) as expense
join currency using (currency_code) join currency using (currency_code)
where (%s)
group by decimal_digits group by decimal_digits
`, where), args...) `, where), args...)
if notFoundErrorOrPanic(row.Scan(&page.SumAmount, &page.SumTotal)) {
page.SumAmount = "0.0"
page.SumTotal = "0.0"
}
row = conn.QueryRow(ctx, fmt.Sprintf(`
select array_agg(array[tax_class_name, to_price(coalesce(tax_amount, 0), decimal_digits)]) filter (where tax_class_name is not null)
from (
select tax_class.name as tax_class_name
, coalesce(sum(expense_tax.amount)::integer, 0) as tax_amount
, currency_code
from expense
left join expense_tax_amount as expense_tax using (expense_id)
left join tax using (tax_id)
left join tax_class using (tax_class_id)
where (%s)
group by tax_class.name
, currency_code
) as tax
join currency using (currency_code)
group by decimal_digits
`, where), args...)
var taxes [][]string
if notFoundErrorOrPanic(row.Scan(&taxes)) {
// well, nothing to do
}
page.SumTaxes = make(map[string]string)
for _, tax := range taxes {
page.SumTaxes[tax[0]] = tax[1]
}
}
func mustCollectTaxClasses(ctx context.Context, conn *Conn, company *Company) []string {
rows := conn.MustQuery(ctx, "select name from tax_class where company_id = $1", company.Id)
defer rows.Close()
var taxClasses []string
for rows.Next() {
var taxClass string
if err := rows.Scan(&taxClass); err != nil {
panic(err)
}
taxClasses = append(taxClasses, taxClass)
}
return taxClasses
} }
func ServeExpenseForm(w http.ResponseWriter, r *http.Request, params httprouter.Params) { func ServeExpenseForm(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
@ -220,10 +102,6 @@ func ServeExpenseForm(w http.ResponseWriter, r *http.Request, params httprouter.
mustRenderNewExpenseForm(w, r, form) mustRenderNewExpenseForm(w, r, form)
return return
} }
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
if !form.MustFillFromDatabase(r.Context(), conn, slug) { if !form.MustFillFromDatabase(r.Context(), conn, slug) {
http.NotFound(w, r) http.NotFound(w, r)
return return
@ -235,40 +113,20 @@ func ServeExpenseForm(w http.ResponseWriter, r *http.Request, params httprouter.
func mustRenderNewExpenseForm(w http.ResponseWriter, r *http.Request, form *expenseForm) { func mustRenderNewExpenseForm(w http.ResponseWriter, r *http.Request, form *expenseForm) {
locale := getLocale(r) locale := getLocale(r)
form.Invoicer.EmptyLabel = gettext("Select a contact.", locale) form.Invoicer.EmptyLabel = gettext("Select a contact.", locale)
page := newNewExpensePage(form, r) mustRenderMainTemplate(w, r, "expenses/new.gohtml", form)
mustRenderMainTemplate(w, r, "expenses/new.gohtml", page)
}
type newExpensePage struct {
Form *expenseForm
Taxes [][]string
Total string
}
func newNewExpensePage(form *expenseForm, r *http.Request) *newExpensePage {
page := &newExpensePage{
Form: form,
}
conn := getConn(r)
company := mustGetCompany(r)
err := conn.QueryRow(r.Context(), "select taxes, total from compute_new_expense_amount($1, $2, $3)", company.Id, form.Amount, form.Tax.Selected).Scan(&page.Taxes, &page.Total)
if err != nil {
panic(err)
}
return page
} }
func mustRenderEditExpenseForm(w http.ResponseWriter, r *http.Request, slug string, form *expenseForm) { func mustRenderEditExpenseForm(w http.ResponseWriter, r *http.Request, slug string, form *expenseForm) {
page := &editExpensePage{ page := &editExpensePage{
newNewExpensePage(form, r), Slug: slug,
slug, Form: form,
} }
mustRenderMainTemplate(w, r, "expenses/edit.gohtml", page) mustRenderMainTemplate(w, r, "expenses/edit.gohtml", page)
} }
type editExpensePage struct { type editExpensePage struct {
*newExpensePage
Slug string Slug string
Form *expenseForm
} }
type expenseForm struct { type expenseForm struct {
@ -284,7 +142,6 @@ type expenseForm struct {
} }
func newExpenseForm(ctx context.Context, conn *Conn, locale *Locale, company *Company) *expenseForm { func newExpenseForm(ctx context.Context, conn *Conn, locale *Locale, company *Company) *expenseForm {
triggerRecompute := template.HTMLAttr(`data-hx-on="change: this.dispatchEvent(new CustomEvent('recompute', {bubbles: true}))"`)
return &expenseForm{ return &expenseForm{
locale: locale, locale: locale,
company: company, company: company,
@ -310,9 +167,6 @@ func newExpenseForm(ctx context.Context, conn *Conn, locale *Locale, company *Co
Label: pgettext("input", "Taxes", locale), Label: pgettext("input", "Taxes", locale),
Multiple: true, Multiple: true,
Options: mustGetTaxOptions(ctx, conn, company), Options: mustGetTaxOptions(ctx, conn, company),
Attributes: []template.HTMLAttr{
triggerRecompute,
},
}, },
Amount: &InputField{ Amount: &InputField{
Name: "amount", Name: "amount",
@ -320,7 +174,6 @@ func newExpenseForm(ctx context.Context, conn *Conn, locale *Locale, company *Co
Type: "number", Type: "number",
Required: true, Required: true,
Attributes: []template.HTMLAttr{ Attributes: []template.HTMLAttr{
triggerRecompute,
`min="0"`, `min="0"`,
template.HTMLAttr(fmt.Sprintf(`step="%v"`, company.MinCents())), template.HTMLAttr(fmt.Sprintf(`step="%v"`, company.MinCents())),
}, },
@ -337,16 +190,6 @@ func newExpenseForm(ctx context.Context, conn *Conn, locale *Locale, company *Co
} }
} }
func mustGetExpenseStatusOptions(ctx context.Context, conn *Conn, locale *Locale) []*SelectOption {
return MustGetOptions(ctx, conn, `
select expense_status.expense_status
, esi18n.name
from expense_status
join expense_status_i18n esi18n using(expense_status)
where esi18n.lang_tag = $1
order by expense_status`, locale.Language.String())
}
func (form *expenseForm) Parse(r *http.Request) error { func (form *expenseForm) Parse(r *http.Request) error {
if err := r.ParseMultipartForm(form.File.MaxSize); err != nil { if err := r.ParseMultipartForm(form.File.MaxSize); err != nil {
return err return err
@ -372,16 +215,18 @@ func (form *expenseForm) Validate() bool {
if validator.CheckRequiredInput(form.Amount, gettext("Amount can not be empty.", form.locale)) { if validator.CheckRequiredInput(form.Amount, gettext("Amount can not be empty.", form.locale)) {
validator.CheckValidDecimal(form.Amount, form.company.MinCents(), math.MaxFloat64, gettext("Amount must be a number greater than zero.", form.locale)) validator.CheckValidDecimal(form.Amount, form.company.MinCents(), math.MaxFloat64, gettext("Amount must be a number greater than zero.", form.locale))
} }
validator.CheckValidSelectOption(form.Tax, gettext("Selected tax is not valid.", form.locale))
validator.CheckAtMostOneOfEachGroup(form.Tax, gettext("You can only select a tax of each class.", form.locale))
return validator.AllOK() return validator.AllOK()
} }
func (form *expenseForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool { func (form *expenseForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool {
if notFoundErrorOrPanic(conn.QueryRow(ctx, ` return !notFoundErrorOrPanic(conn.QueryRow(ctx, `
select contact_id select contact_id
, invoice_number , invoice_number
, invoice_date , invoice_date
, to_price(amount, decimal_digits) , to_price(amount, decimal_digits)
, array_agg(tax_id) filter ( where tax_id is not null ) , array_agg(tax_id)
, tags , tags
from expense from expense
left join expense_tax using (expense_id) left join expense_tax using (expense_id)
@ -399,14 +244,36 @@ func (form *expenseForm) MustFillFromDatabase(ctx context.Context, conn *Conn, s
form.InvoiceDate, form.InvoiceDate,
form.Amount, form.Amount,
form.Tax, form.Tax,
form.Tags)) { form.Tags))
return false
}
if len(form.Tax.Selected) == 1 && form.Tax.Selected[0] == "" {
form.Tax.Selected = nil
}
return true
} }
func HandleAddExpense(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
conn := getConn(r)
locale := getLocale(r)
company := mustGetCompany(r)
form := newExpenseForm(r.Context(), conn, locale, company)
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if !form.Validate() {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
mustRenderNewExpenseForm(w, r, form)
return
}
taxes := mustSliceAtoi(form.Tax.Selected)
slug := conn.MustGetText(r.Context(), "", "select add_expense($1, $2, $3, $4, $5, $6, $7)", company.Id, form.InvoiceDate, form.Invoicer, form.InvoiceNumber, form.Amount, taxes, form.Tags)
if len(form.File.Content) > 0 {
conn.MustQuery(r.Context(), "select attach_to_expense($1, $2, $3, $4)", slug, form.File.OriginalFileName, form.File.ContentType, form.File.Content)
}
htmxRedirect(w, r, companyURI(company, "/expenses"))
}
func HandleUpdateExpense(w http.ResponseWriter, r *http.Request, params httprouter.Params) { func HandleUpdateExpense(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
conn := getConn(r) conn := getConn(r)
locale := getLocale(r) locale := getLocale(r)
@ -421,12 +288,10 @@ func HandleUpdateExpense(w http.ResponseWriter, r *http.Request, params httprout
return return
} }
slug := params[0].Value slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
if !form.Validate() { if !form.Validate() {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
}
mustRenderEditExpenseForm(w, r, slug, form) mustRenderEditExpenseForm(w, r, slug, form)
return return
} }
@ -448,7 +313,6 @@ type expenseFilterForm struct {
InvoiceNumber *InputField InvoiceNumber *InputField
FromDate *InputField FromDate *InputField
ToDate *InputField ToDate *InputField
ExpenseStatus *SelectField
Tags *TagsField Tags *TagsField
TagsCondition *ToggleField TagsCondition *ToggleField
} }
@ -482,12 +346,6 @@ func newExpenseFilterForm(ctx context.Context, conn *Conn, locale *Locale, compa
Name: "tags", Name: "tags",
Label: pgettext("input", "Tags", locale), Label: pgettext("input", "Tags", locale),
}, },
ExpenseStatus: &SelectField{
Name: "expense_status",
Label: pgettext("input", "Expense Status", locale),
EmptyLabel: gettext("All status", locale),
Options: mustGetExpenseStatusOptions(ctx, conn, locale),
},
TagsCondition: &ToggleField{ TagsCondition: &ToggleField{
Name: "tags_condition", Name: "tags_condition",
Label: pgettext("input", "Tags Condition", locale), Label: pgettext("input", "Tags Condition", locale),
@ -514,21 +372,11 @@ func (form *expenseFilterForm) Parse(r *http.Request) error {
form.InvoiceNumber.FillValue(r) form.InvoiceNumber.FillValue(r)
form.FromDate.FillValue(r) form.FromDate.FillValue(r)
form.ToDate.FillValue(r) form.ToDate.FillValue(r)
form.ExpenseStatus.FillValue(r)
form.Tags.FillValue(r) form.Tags.FillValue(r)
form.TagsCondition.FillValue(r) form.TagsCondition.FillValue(r)
return nil return nil
} }
func (form *expenseFilterForm) HasValue() bool {
return form.Contact.HasValue() ||
form.InvoiceNumber.HasValue() ||
form.FromDate.HasValue() ||
form.ToDate.HasValue() ||
form.ExpenseStatus.HasValue() ||
form.Tags.HasValue()
}
func (form *expenseFilterForm) BuildQuery(args []interface{}) (string, []interface{}) { func (form *expenseFilterForm) BuildQuery(args []interface{}) (string, []interface{}) {
var where []string var where []string
appendWhere := func(expression string, value interface{}) { appendWhere := func(expression string, value interface{}) {
@ -550,7 +398,6 @@ func (form *expenseFilterForm) BuildQuery(args []interface{}) (string, []interfa
customerId, _ := strconv.Atoi(form.Contact.Selected[0]) customerId, _ := strconv.Atoi(form.Contact.Selected[0])
return customerId return customerId
}) })
maybeAppendWhere("expense.expense_status = $%d", form.ExpenseStatus.String(), nil)
maybeAppendWhere("invoice_number = $%d", form.InvoiceNumber.String(), nil) maybeAppendWhere("invoice_number = $%d", form.InvoiceNumber.String(), nil)
maybeAppendWhere("invoice_date >= $%d", form.FromDate.String(), nil) maybeAppendWhere("invoice_date >= $%d", form.FromDate.String(), nil)
maybeAppendWhere("invoice_date <= $%d", form.ToDate.String(), nil) maybeAppendWhere("invoice_date <= $%d", form.ToDate.String(), nil)
@ -566,51 +413,24 @@ func (form *expenseFilterForm) BuildQuery(args []interface{}) (string, []interfa
} }
func ServeEditExpenseTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) { func ServeEditExpenseTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
serveTagsEditForm(w, r, params, "/expenses/", "select tags from expense where slug = $1") conn := getConn(r)
} locale := getLocale(r)
company := getCompany(r)
func HandleUpdateExpenseTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
handleUpdateTags(w, r, params, "/expenses/", "update expense set tags = $1 where slug = $2 returning slug")
}
func ServeExpenseAttachment(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
serveAttachment(w, r, params, `
select mime_type
, content
from expense
join expense_attachment using (expense_id)
where slug = $1
`)
}
func HandleEditExpenseAction(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
slug := params[0].Value slug := params[0].Value
switch slug { form := newTagsForm(companyURI(company, "/expenses/"+slug+"/tags"), slug, locale)
case "batch": if notFoundErrorOrPanic(conn.QueryRow(r.Context(), `select tags from expense where slug = $1`, form.Slug).Scan(form.Tags)) {
HandleBatchExpenseAction(w, r, params)
default:
if !ValidUuid(slug) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
actionUri := fmt.Sprintf("/invoices/%s/edit", slug) mustRenderStandaloneTemplate(w, r, "tags/edit.gohtml", form)
handleExpenseAction(w, r, actionUri, func(w http.ResponseWriter, r *http.Request, form *expenseForm) {
mustRenderEditExpenseForm(w, r, slug, form)
})
}
} }
func HandleNewExpenseAction(w http.ResponseWriter, r *http.Request, params httprouter.Params) { func HandleUpdateExpenseTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
handleExpenseAction(w, r, "/expenses", mustRenderNewExpenseForm)
}
type renderExpenseFormFunc func(w http.ResponseWriter, r *http.Request, form *expenseForm)
func handleExpenseAction(w http.ResponseWriter, r *http.Request, action string, renderForm renderExpenseFormFunc) {
locale := getLocale(r) locale := getLocale(r)
conn := getConn(r) conn := getConn(r)
company := mustGetCompany(r) company := getCompany(r)
form := newExpenseForm(r.Context(), conn, locale, company) slug := params[0].Value
form := newTagsForm(companyURI(company, "/expenses/"+slug+"/tags/edit"), slug, locale)
if err := form.Parse(r); err != nil { if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@ -619,117 +439,29 @@ func handleExpenseAction(w http.ResponseWriter, r *http.Request, action string,
http.Error(w, err.Error(), http.StatusForbidden) http.Error(w, err.Error(), http.StatusForbidden)
return return
} }
actionField := r.Form.Get("action") if conn.MustGetText(r.Context(), "", "update expense set tags = $1 where slug = $2 returning slug", form.Tags, form.Slug) == "" {
switch actionField { http.NotFound(w, r)
case "update":
// Nothing else to do
w.WriteHeader(http.StatusOK)
renderForm(w, r, form)
case "add":
if !form.Validate() {
w.WriteHeader(http.StatusUnprocessableEntity)
renderForm(w, r, form)
return
}
taxes := mustSliceAtoi(form.Tax.Selected)
slug := conn.MustGetText(r.Context(), "", "select add_expense($1, $2, $3, $4, $5, $6, $7)", company.Id, form.InvoiceDate, form.Invoicer, form.InvoiceNumber, form.Amount, taxes, form.Tags)
if len(form.File.Content) > 0 {
conn.MustQuery(r.Context(), "select attach_to_expense($1, $2, $3, $4)", slug, form.File.OriginalFileName, form.File.ContentType, form.File.Content)
}
htmxRedirect(w, r, companyURI(company, action))
default:
http.Error(w, gettext("Invalid action", locale), http.StatusBadRequest)
} }
mustRenderStandaloneTemplate(w, r, "tags/view.gohtml", form)
} }
func HandleBatchExpenseAction(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { func ServeExpenseAttachment(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
locale := getLocale(r)
switch r.Form.Get("action") {
case "export":
conn := getConn(r)
company := getCompany(r)
filters := newExpenseFilterForm(r.Context(), conn, locale, company)
if err := filters.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
entries := mustCollectExpenseEntries(r.Context(), conn, locale, filters)
vatin := mustCollectExpenseEntriesVATIN(r.Context(), conn, entries)
lastPaymentDate := mustCollectExpenseEntriesLastPaymentDate(r.Context(), conn, entries)
taxes := mustCollectExpenseEntriesTaxes(r.Context(), conn, entries)
taxColumns := mustCollectTaxColumns(r.Context(), conn, company)
ods := mustWriteExpensesOds(entries, vatin, lastPaymentDate, taxes, taxColumns, locale, company)
writeOdsResponse(w, ods, gettext("expenses.ods", locale))
default:
http.Error(w, gettext("Invalid action", locale), http.StatusBadRequest)
}
}
func mustCollectExpenseEntriesTaxes(ctx context.Context, conn *Conn, entries []*ExpenseEntry) map[int]taxMap {
ids := mustMakeIDArray(entries, func(entry *ExpenseEntry) int {
return entry.ID
})
return mustMakeTaxMap(ctx, conn, ids, `
select expense_id
, tax_id
, to_price(tax.amount, decimal_digits)
from expense_tax_amount as tax
join expense using (expense_id)
join currency using (currency_code)
where expense_id = any ($1)
`)
}
func mustCollectExpenseEntriesVATIN(ctx context.Context, conn *Conn, entries []*ExpenseEntry) map[int]string {
ids := mustMakeIDArray(entries, func(entry *ExpenseEntry) int {
return entry.ID
})
return mustMakeVATINMap(ctx, conn, ids, `
select expense_id
, vatin::text
from contact_tax_details as tax
join expense using (contact_id)
where expense_id = any ($1)
`)
}
func mustCollectExpenseEntriesLastPaymentDate(ctx context.Context, conn *Conn, entries []*ExpenseEntry) map[int]time.Time {
ids := mustMakeIDArray(entries, func(entry *ExpenseEntry) int {
return entry.ID
})
return mustMakeDateMap(ctx, conn, ids, `
select expense_id
, max(payment_date)
from expense_payment
join payment using (payment_id)
where expense_id = any ($1)
group by expense_id
`)
}
func handleRemoveExpense(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
slug := params[0].Value slug := params[0].Value
if !ValidUuid(slug) { conn := getConn(r)
var contentType string
var content []byte
if notFoundErrorOrPanic(conn.QueryRow(r.Context(), `
select mime_type
, content
from expense
join expense_attachment using (expense_id)
where slug = $1
`, slug).Scan(&contentType, &content)) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
w.Header().Set("Content-Type", contentType)
if err := verifyCsrfTokenValid(r); err != nil { w.Header().Set("Content-Length", strconv.FormatInt(int64(len(content)), 10))
http.Error(w, err.Error(), http.StatusForbidden) w.WriteHeader(http.StatusOK)
return w.Write(content)
}
conn := getConn(r)
conn.MustExec(r.Context(), "select remove_expense($1)", slug)
company := mustGetCompany(r)
htmxRedirect(w, r, companyURI(company, "/expenses"))
} }

View File

@ -59,10 +59,6 @@ func (field *InputField) Value() (driver.Value, error) {
return field.Val, nil return field.Val, nil
} }
func (field *InputField) HasValue() bool {
return field.Val != ""
}
func (field *InputField) FillValue(r *http.Request) { func (field *InputField) FillValue(r *http.Request) {
field.Val = strings.TrimSpace(r.FormValue(field.Name)) field.Val = strings.TrimSpace(r.FormValue(field.Name))
} }
@ -188,10 +184,6 @@ func (field *SelectField) Clear() {
field.Selected = []string{} field.Selected = []string{}
} }
func (field *SelectField) HasValue() bool {
return len(field.Selected) > 0 && field.Selected[0] != ""
}
func MustGetOptions(ctx context.Context, conn *Conn, sql string, args ...interface{}) []*SelectOption { func MustGetOptions(ctx context.Context, conn *Conn, sql string, args ...interface{}) []*SelectOption {
rows, err := conn.Query(ctx, sql, args...) rows, err := conn.Query(ctx, sql, args...)
if err != nil { if err != nil {
@ -295,33 +287,6 @@ func (field *RadioField) isValidOption(selected string) bool {
return field.FindOption(selected) != nil return field.FindOption(selected) != nil
} }
func (field *RadioField) HasValidOption() bool {
return field.isValidOption(field.Selected)
}
func MustGetRadioOptions(ctx context.Context, conn *Conn, sql string, args ...interface{}) []*RadioOption {
rows, err := conn.Query(ctx, sql, args...)
if err != nil {
panic(err)
}
defer rows.Close()
var options []*RadioOption
for rows.Next() {
option := &RadioOption{}
err = rows.Scan(&option.Value, &option.Label)
if err != nil {
panic(err)
}
options = append(options, option)
}
if rows.Err() != nil {
panic(rows.Err())
}
return options
}
type CheckField struct { type CheckField struct {
Name string Name string
Label string Label string
@ -401,10 +366,6 @@ func (field *TagsField) Value() (driver.Value, error) {
return field.Tags, nil return field.Tags, nil
} }
func (field *TagsField) HasValue() bool {
return len(field.Tags) > 0 && field.Tags[0] != ""
}
func (field *TagsField) Scan(value interface{}) error { func (field *TagsField) Scan(value interface{}) error {
if value == nil { if value == nil {
return nil return nil
@ -455,10 +416,6 @@ func (field *ToggleField) FillValue(r *http.Request) {
} }
} }
func (field *ToggleField) String() string {
return field.Selected
}
type FormValidator struct { type FormValidator struct {
Valid bool Valid bool
} }
@ -479,10 +436,6 @@ func (v *FormValidator) CheckInputMinLength(field *InputField, min int, message
return v.checkInput(field, len(field.Val) >= min, message) return v.checkInput(field, len(field.Val) >= min, message)
} }
func (v *FormValidator) CheckInputLength(field *InputField, length int, message string) bool {
return v.checkInput(field, len(field.Val) == length, message)
}
func (v *FormValidator) CheckValidEmailInput(field *InputField, message string) bool { func (v *FormValidator) CheckValidEmailInput(field *InputField, message string) bool {
_, err := mail.ParseAddress(field.Val) _, err := mail.ParseAddress(field.Val)
return v.checkInput(field, err == nil, message) return v.checkInput(field, err == nil, message)
@ -512,10 +465,6 @@ func (v *FormValidator) CheckValidSelectOption(field *SelectField, message strin
return v.checkSelect(field, field.HasValidOptions(), message) return v.checkSelect(field, field.HasValidOptions(), message)
} }
func (v *FormValidator) CheckValidRadioOption(field *RadioField, message string) bool {
return v.checkRadio(field, field.HasValidOption(), message)
}
func (v *FormValidator) CheckAtMostOneOfEachGroup(field *SelectField, message string) bool { func (v *FormValidator) CheckAtMostOneOfEachGroup(field *SelectField, message string) bool {
repeated := false repeated := false
groups := map[string]bool{} groups := map[string]bool{}
@ -574,11 +523,3 @@ func (v *FormValidator) checkSelect(field *SelectField, ok bool, message string)
} }
return ok return ok
} }
func (v *FormValidator) checkRadio(field *RadioField, ok bool, message string) bool {
if !ok {
field.Errors = append(field.Errors, errors.New(message))
v.Valid = false
}
return ok
}

View File

@ -6,7 +6,6 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"github.com/jackc/pgtype"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"html/template" "html/template"
"io" "io"
@ -24,11 +23,9 @@ import (
const removedProductSuffix = ".removed" const removedProductSuffix = ".removed"
type InvoiceEntry struct { type InvoiceEntry struct {
ID int
Slug string Slug string
Date time.Time Date time.Time
Number string Number string
Subtotal string
Total string Total string
CustomerName string CustomerName string
Tags []string Tags []string
@ -64,15 +61,13 @@ func IndexInvoices(w http.ResponseWriter, r *http.Request, _ httprouter.Params)
func mustCollectInvoiceEntries(ctx context.Context, conn *Conn, locale *Locale, filters *invoiceFilterForm) []*InvoiceEntry { func mustCollectInvoiceEntries(ctx context.Context, conn *Conn, locale *Locale, filters *invoiceFilterForm) []*InvoiceEntry {
where, args := filters.BuildQuery([]interface{}{locale.Language.String()}) where, args := filters.BuildQuery([]interface{}{locale.Language.String()})
rows := conn.MustQuery(ctx, fmt.Sprintf(` rows := conn.MustQuery(ctx, fmt.Sprintf(`
select invoice_id select invoice.slug
, invoice.slug
, invoice_date , invoice_date
, invoice_number , invoice_number
, contact.name , contact.name
, invoice.tags , invoice.tags
, invoice.invoice_status , invoice.invoice_status
, isi18n.name , isi18n.name
, to_price(subtotal, decimal_digits)
, to_price(total, decimal_digits) , to_price(total, decimal_digits)
from invoice from invoice
join contact using (contact_id) join contact using (contact_id)
@ -88,7 +83,7 @@ func mustCollectInvoiceEntries(ctx context.Context, conn *Conn, locale *Locale,
var entries []*InvoiceEntry var entries []*InvoiceEntry
for rows.Next() { for rows.Next() {
entry := &InvoiceEntry{} entry := &InvoiceEntry{}
if err := rows.Scan(&entry.ID, &entry.Slug, &entry.Date, &entry.Number, &entry.CustomerName, &entry.Tags, &entry.Status, &entry.StatusLabel, &entry.Subtotal, &entry.Total); err != nil { if err := rows.Scan(&entry.Slug, &entry.Date, &entry.Number, &entry.CustomerName, &entry.Tags, &entry.Status, &entry.StatusLabel, &entry.Total); err != nil {
panic(err) panic(err)
} }
entries = append(entries, entry) entries = append(entries, entry)
@ -157,7 +152,7 @@ func newInvoiceFilterForm(ctx context.Context, conn *Conn, locale *Locale, compa
Name: "invoice_status", Name: "invoice_status",
Label: pgettext("input", "Invoice Status", locale), Label: pgettext("input", "Invoice Status", locale),
EmptyLabel: gettext("All status", locale), EmptyLabel: gettext("All status", locale),
Options: mustGetInvoiceStatusOptions(ctx, conn, locale), Options: MustGetOptions(ctx, conn, "select invoice_status.invoice_status, isi18n.name from invoice_status join invoice_status_i18n isi18n using(invoice_status) where isi18n.lang_tag = $1 order by invoice_status", locale.Language.String()),
}, },
InvoiceNumber: &InputField{ InvoiceNumber: &InputField{
Name: "number", Name: "number",
@ -210,15 +205,6 @@ func (form *invoiceFilterForm) Parse(r *http.Request) error {
return nil return nil
} }
func (form *invoiceFilterForm) HasValue() bool {
return form.Customer.HasValue() ||
form.InvoiceStatus.HasValue() ||
form.InvoiceNumber.HasValue() ||
form.FromDate.HasValue() ||
form.ToDate.HasValue() ||
form.Tags.HasValue()
}
func (form *invoiceFilterForm) BuildQuery(args []interface{}) (string, []interface{}) { func (form *invoiceFilterForm) BuildQuery(args []interface{}) (string, []interface{}) {
var where []string var where []string
appendWhere := func(expression string, value interface{}) { appendWhere := func(expression string, value interface{}) {
@ -262,9 +248,10 @@ func ServeInvoice(w http.ResponseWriter, r *http.Request, params httprouter.Para
case "new": case "new":
locale := getLocale(r) locale := getLocale(r)
form := newInvoiceForm(r.Context(), conn, locale, company) form := newInvoiceForm(r.Context(), conn, locale, company)
if invoiceToDuplicate := r.URL.Query().Get("duplicate"); ValidUuid(invoiceToDuplicate) { if invoiceToDuplicate := r.URL.Query().Get("duplicate"); invoiceToDuplicate != "" {
form.MustFillFromDatabase(r.Context(), conn, invoiceToDuplicate) form.MustFillFromDatabase(r.Context(), conn, invoiceToDuplicate)
} else if quoteToInvoice := r.URL.Query().Get("quote"); ValidUuid(quoteToInvoice) { form.InvoiceStatus.Selected = []string{"created"}
} else if quoteToInvoice := r.URL.Query().Get("quote"); quoteToInvoice != "" {
form.MustFillFromQuote(r.Context(), conn, quoteToInvoice) form.MustFillFromQuote(r.Context(), conn, quoteToInvoice)
} }
form.Date.Val = time.Now().Format("2006-01-02") form.Date.Val = time.Now().Format("2006-01-02")
@ -293,10 +280,6 @@ func ServeInvoice(w http.ResponseWriter, r *http.Request, params httprouter.Para
pdf = true pdf = true
slug = slug[:len(slug)-len(".pdf")] slug = slug[:len(slug)-len(".pdf")]
} }
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
inv := mustGetInvoice(r.Context(), conn, company, slug) inv := mustGetInvoice(r.Context(), conn, company, slug)
if inv == nil { if inv == nil {
http.NotFound(w, r) http.NotFound(w, r)
@ -366,7 +349,6 @@ type invoice struct {
HasDiscounts bool HasDiscounts bool
Total string Total string
LegalDisclaimer string LegalDisclaimer string
OriginalFileName string
} }
type taxDetails struct { type taxDetails struct {
@ -412,13 +394,11 @@ func mustGetInvoice(ctx context.Context, conn *Conn, company *Company, slug stri
, postal_code , postal_code
, to_price(subtotal, decimal_digits) , to_price(subtotal, decimal_digits)
, to_price(total, decimal_digits) , to_price(total, decimal_digits)
, coalesce(attachment.original_filename, '')
from invoice from invoice
join payment_method using (payment_method_id) join payment_method using (payment_method_id)
join contact_tax_details using (contact_id) join contact_tax_details using (contact_id)
join invoice_amount using (invoice_id) join invoice_amount using (invoice_id)
join currency using (currency_code) join currency using (currency_code)
left join invoice_attachment as attachment using (invoice_id)
where invoice.slug = $1`, slug).Scan( where invoice.slug = $1`, slug).Scan(
&invoiceId, &invoiceId,
&decimalDigits, &decimalDigits,
@ -433,67 +413,16 @@ func mustGetInvoice(ctx context.Context, conn *Conn, company *Company, slug stri
&inv.Invoicee.Province, &inv.Invoicee.Province,
&inv.Invoicee.PostalCode, &inv.Invoicee.PostalCode,
&inv.Subtotal, &inv.Subtotal,
&inv.Total, &inv.Total)) {
&inv.OriginalFileName)) {
return nil return nil
} }
if err := conn.QueryRow(ctx, ` if err := conn.QueryRow(ctx, "select business_name, vatin, phone, email, address, city, province, postal_code, legal_disclaimer from company where company_id = $1", company.Id).Scan(&inv.Invoicer.Name, &inv.Invoicer.VATIN, &inv.Invoicer.Phone, &inv.Invoicer.Email, &inv.Invoicer.Address, &inv.Invoicer.City, &inv.Invoicer.Province, &inv.Invoicer.PostalCode, &inv.LegalDisclaimer); err != nil {
select business_name
, vatin
, phone
, email
, address
, city
, province
, postal_code
, legal_disclaimer
from company
where company_id = $1
`, company.Id).Scan(
&inv.Invoicer.Name,
&inv.Invoicer.VATIN,
&inv.Invoicer.Phone,
&inv.Invoicer.Email,
&inv.Invoicer.Address,
&inv.Invoicer.City,
&inv.Invoicer.Province,
&inv.Invoicer.PostalCode,
&inv.LegalDisclaimer); err != nil {
panic(err) panic(err)
} }
if err := conn.QueryRow(ctx, ` if err := conn.QueryRow(ctx, "select array_agg(array[name, to_price(amount, $2)]) from invoice_tax_amount join tax using (tax_id) where invoice_id = $1", invoiceId, decimalDigits).Scan(&inv.Taxes); err != nil {
select array_agg(array[name, to_price(amount, $2)])
from invoice_tax_amount
join tax using (tax_id)
where invoice_id = $1
`, invoiceId, decimalDigits).Scan(&inv.Taxes); err != nil {
panic(err) panic(err)
} }
rows := conn.MustQuery(ctx, ` rows := conn.MustQuery(ctx, "select invoice_product.name, description, to_price(price, $2), (discount_rate * 100)::integer, quantity, to_price(subtotal, $2), to_price(total, $2), array_agg(array[tax_class.name, (tax_rate * 100)::integer::text]) filter (where tax_rate is not null) from invoice_product join invoice_product_amount using (invoice_product_id) left join invoice_product_tax using (invoice_product_id) left join tax using (tax_id) left join tax_class using (tax_class_id) where invoice_id = $1 group by invoice_product.name, description, discount_rate, price, quantity, subtotal, total", invoiceId, decimalDigits)
select invoice_product.name
, description
, to_price(price, $2)
, (discount_rate * 100)::integer
, quantity
, to_price(subtotal, $2)
, to_price(total, $2)
, array_agg(array[tax_class.name, (tax_rate * 100)::integer::text]) filter (where tax_rate is not null)
from invoice_product
join invoice_product_amount using (invoice_product_id)
left join invoice_product_tax using (invoice_product_id)
left join tax using (tax_id)
left join tax_class using (tax_class_id)
where invoice_id = $1
group by invoice_product_id
, invoice_product.name
, description
, discount_rate
, price
, quantity
, subtotal
, total
order by invoice_product_id
`, invoiceId, decimalDigits)
defer rows.Close() defer rows.Close()
taxClasses := map[string]bool{} taxClasses := map[string]bool{}
for rows.Next() { for rows.Next() {
@ -501,15 +430,7 @@ func mustGetInvoice(ctx context.Context, conn *Conn, company *Company, slug stri
Taxes: make(map[string]int), Taxes: make(map[string]int),
} }
var taxes [][]string var taxes [][]string
if err := rows.Scan( if err := rows.Scan(&product.Name, &product.Description, &product.Price, &product.Discount, &product.Quantity, &product.Subtotal, &product.Total, &taxes); err != nil {
&product.Name,
&product.Description,
&product.Price,
&product.Discount,
&product.Quantity,
&product.Subtotal,
&product.Total,
&taxes); err != nil {
panic(err) panic(err)
} }
for _, tax := range taxes { for _, tax := range taxes {
@ -619,14 +540,13 @@ func HandleAddInvoice(w http.ResponseWriter, r *http.Request, _ httprouter.Param
return return
} }
if !form.Validate() { if !form.Validate() {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
}
mustRenderNewInvoiceForm(w, r, form) mustRenderNewInvoiceForm(w, r, form)
return return
} }
slug := conn.MustGetText(r.Context(), "", "select add_invoice($1, $2, $3, $4, $5, $6, $7)", company.Id, form.Date, form.Customer, form.Notes, form.PaymentMethod, form.Tags, NewInvoiceProductArray(form.Products)) slug := conn.MustGetText(r.Context(), "", "select add_invoice($1, $2, $3, $4, $5, $6, $7)", company.Id, form.Date, form.Customer, form.Notes, form.PaymentMethod, form.Tags, NewInvoiceProductArray(form.Products))
if len(form.File.Content) > 0 {
conn.MustQuery(r.Context(), "select attach_to_invoice($1, $2, $3, $4)", slug, form.File.OriginalFileName, form.File.ContentType, form.File.Content)
}
htmxRedirect(w, r, companyURI(company, "/invoices/"+slug)) htmxRedirect(w, r, companyURI(company, "/invoices/"+slug))
} }
@ -650,14 +570,14 @@ func HandleBatchInvoiceAction(w http.ResponseWriter, r *http.Request, _ httprout
http.Error(w, err.Error(), http.StatusForbidden) http.Error(w, err.Error(), http.StatusForbidden)
return return
} }
locale := getLocale(r)
switch r.Form.Get("action") {
case "download":
slugs := r.Form["invoice"] slugs := r.Form["invoice"]
if len(slugs) == 0 { if len(slugs) == 0 {
http.Redirect(w, r, companyURI(mustGetCompany(r), "/invoices"), http.StatusSeeOther) http.Redirect(w, r, companyURI(mustGetCompany(r), "/invoices"), http.StatusSeeOther)
return return
} }
locale := getLocale(r)
switch r.Form.Get("action") {
case "download":
invoices := mustWriteInvoicesPdf(r, slugs) invoices := mustWriteInvoicesPdf(r, slugs)
w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", gettext("invoices.zip", locale))) w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", gettext("invoices.zip", locale)))
@ -665,187 +585,11 @@ func HandleBatchInvoiceAction(w http.ResponseWriter, r *http.Request, _ httprout
if _, err := w.Write(invoices); err != nil { if _, err := w.Write(invoices); err != nil {
panic(err) panic(err)
} }
case "export":
conn := getConn(r)
company := getCompany(r)
filters := newInvoiceFilterForm(r.Context(), conn, locale, company)
if err := filters.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
entries := mustCollectInvoiceEntries(r.Context(), conn, locale, filters)
vatin := mustCollectInvoiceEntriesVATIN(r.Context(), conn, entries)
lastCollectionDate := mustCollectInvoiceEntriesLastCollectionDate(r.Context(), conn, entries)
taxes := mustCollectInvoiceEntriesTaxes(r.Context(), conn, entries)
taxColumns := mustCollectTaxColumns(r.Context(), conn, company)
ods := mustWriteInvoicesOds(entries, vatin, lastCollectionDate, taxes, taxColumns, locale, company)
writeOdsResponse(w, ods, gettext("invoices.ods", locale))
default: default:
http.Error(w, gettext("Invalid action", locale), http.StatusBadRequest) http.Error(w, gettext("Invalid action", locale), http.StatusBadRequest)
} }
} }
func mustCollectTaxColumns(ctx context.Context, conn *Conn, company *Company) map[int]string {
rows, err := conn.Query(ctx, `
select tax_id
, name
from tax
where company_id = $1
`, company.Id)
if err != nil {
panic(err)
}
defer rows.Close()
columns := make(map[int]string)
for rows.Next() {
var taxID int
var name string
err = rows.Scan(&taxID, &name)
if err != nil {
panic(err)
}
columns[taxID] = name
}
return columns
}
type taxMap map[int]string
func mustCollectInvoiceEntriesTaxes(ctx context.Context, conn *Conn, entries []*InvoiceEntry) map[int]taxMap {
ids := mustMakeIDArray(entries, func(entry *InvoiceEntry) int {
return entry.ID
})
return mustMakeTaxMap(ctx, conn, ids, `
select invoice_id
, tax_id
, to_price(amount, decimal_digits)
from invoice_tax_amount
join invoice using (invoice_id)
join currency using (currency_code)
where invoice_id = any ($1)
`)
}
func mustMakeIDArray[T any](entries []*T, id func(entry *T) int) *pgtype.Int4Array {
ids := make([]int, len(entries))
i := 0
for _, entry := range entries {
ids[i] = id(entry)
i++
}
idArray := &pgtype.Int4Array{}
if err := idArray.Set(ids); err != nil {
panic(err)
}
return idArray
}
func mustMakeTaxMap(ctx context.Context, conn *Conn, ids *pgtype.Int4Array, sql string) map[int]taxMap {
rows, err := conn.Query(ctx, sql, ids)
if err != nil {
panic(err)
}
defer rows.Close()
taxes := make(map[int]taxMap)
for rows.Next() {
var entryID int
var taxID int
var amount string
err := rows.Scan(&entryID, &taxID, &amount)
if err != nil {
panic(err)
}
entryTaxes := taxes[entryID]
if entryTaxes == nil {
entryTaxes = make(taxMap)
taxes[entryID] = entryTaxes
}
entryTaxes[taxID] = amount
}
if rows.Err() != nil {
panic(rows.Err())
}
return taxes
}
func mustCollectInvoiceEntriesVATIN(ctx context.Context, conn *Conn, entries []*InvoiceEntry) map[int]string {
ids := mustMakeIDArray(entries, func(entry *InvoiceEntry) int {
return entry.ID
})
return mustMakeVATINMap(ctx, conn, ids, `
select invoice_id
, vatin::text
from contact_tax_details
join invoice using (contact_id)
where invoice_id = any ($1)
`)
}
func mustMakeVATINMap(ctx context.Context, conn *Conn, ids *pgtype.Int4Array, sql string) map[int]string {
rows, err := conn.Query(ctx, sql, ids)
if err != nil {
panic(err)
}
defer rows.Close()
vatin := make(map[int]string)
for rows.Next() {
var entryID int
var number string
err := rows.Scan(&entryID, &number)
if err != nil {
panic(err)
}
vatin[entryID] = number
}
if rows.Err() != nil {
panic(rows.Err())
}
return vatin
}
func mustCollectInvoiceEntriesLastCollectionDate(ctx context.Context, conn *Conn, entries []*InvoiceEntry) map[int]time.Time {
ids := mustMakeIDArray(entries, func(entry *InvoiceEntry) int {
return entry.ID
})
return mustMakeDateMap(ctx, conn, ids, `
select invoice_id
, max(collection_date)
from invoice_collection
join collection using (collection_id)
where invoice_id = any ($1)
group by invoice_id
`)
}
func mustMakeDateMap(ctx context.Context, conn *Conn, ids *pgtype.Int4Array, sql string) map[int]time.Time {
rows, err := conn.Query(ctx, sql, ids)
if err != nil {
panic(err)
}
defer rows.Close()
dates := make(map[int]time.Time)
for rows.Next() {
var entryID int
var date time.Time
if err := rows.Scan(&entryID, &date); err != nil {
panic(err)
}
dates[entryID] = date
}
if rows.Err() != nil {
panic(rows.Err())
}
return dates
}
func mustWriteInvoicesPdf(r *http.Request, slugs []string) []byte { func mustWriteInvoicesPdf(r *http.Request, slugs []string) []byte {
conn := getConn(r) conn := getConn(r)
company := mustGetCompany(r) company := mustGetCompany(r)
@ -856,7 +600,7 @@ func mustWriteInvoicesPdf(r *http.Request, slugs []string) []byte {
if inv == nil { if inv == nil {
continue continue
} }
f, err := w.Create(fmt.Sprintf("%s-%s.pdf", inv.Number, slugify(inv.Invoicee.Name))) f, err := w.Create(inv.Number + ".pdf")
if err != nil { if err != nil {
panic(err) panic(err)
} }
@ -870,6 +614,7 @@ type invoiceForm struct {
locale *Locale locale *Locale
company *Company company *Company
Number string Number string
InvoiceStatus *SelectField
Customer *SelectField Customer *SelectField
Date *InputField Date *InputField
Notes *InputField Notes *InputField
@ -877,13 +622,19 @@ type invoiceForm struct {
Tags *TagsField Tags *TagsField
Products []*invoiceProductForm Products []*invoiceProductForm
RemovedProduct *invoiceProductForm RemovedProduct *invoiceProductForm
File *FileField
} }
func newInvoiceForm(ctx context.Context, conn *Conn, locale *Locale, company *Company) *invoiceForm { func newInvoiceForm(ctx context.Context, conn *Conn, locale *Locale, company *Company) *invoiceForm {
return &invoiceForm{ return &invoiceForm{
locale: locale, locale: locale,
company: company, company: company,
InvoiceStatus: &SelectField{
Name: "invoice_status",
Required: true,
Label: pgettext("input", "Invoice Status", locale),
Selected: []string{"created"},
Options: MustGetOptions(ctx, conn, "select invoice_status.invoice_status, isi18n.name from invoice_status join invoice_status_i18n isi18n using(invoice_status) where isi18n.lang_tag = $1 order by invoice_status", locale.Language.String()),
},
Customer: &SelectField{ Customer: &SelectField{
Name: "customer", Name: "customer",
Label: pgettext("input", "Customer", locale), Label: pgettext("input", "Customer", locale),
@ -908,40 +659,23 @@ func newInvoiceForm(ctx context.Context, conn *Conn, locale *Locale, company *Co
PaymentMethod: &SelectField{ PaymentMethod: &SelectField{
Name: "payment_method", Name: "payment_method",
Required: true, Required: true,
Label: pgettext("input", "Invoicing Method", locale), Label: pgettext("input", "Payment Method", locale),
Selected: []string{mustGetDefaultPaymentMethod(ctx, conn, company)}, Selected: []string{mustGetDefaultPaymentMethod(ctx, conn, company)},
Options: mustGetPaymentMethodOptions(ctx, conn, company), Options: mustGetPaymentMethodOptions(ctx, conn, company),
}, },
File: &FileField{
Name: "file",
Label: pgettext("input", "File", locale),
MaxSize: 1 << 20,
},
} }
} }
func mustGetInvoiceStatusOptions(ctx context.Context, conn *Conn, locale *Locale) []*SelectOption {
return MustGetOptions(ctx, conn, `
select invoice_status.invoice_status
, isi18n.name
from invoice_status
join invoice_status_i18n isi18n using(invoice_status)
where isi18n.lang_tag = $1
order by invoice_status`, locale.Language.String())
}
func (form *invoiceForm) Parse(r *http.Request) error { func (form *invoiceForm) Parse(r *http.Request) error {
if err := r.ParseMultipartForm(form.File.MaxSize); err != nil { if err := r.ParseForm(); err != nil {
return err return err
} }
form.InvoiceStatus.FillValue(r)
form.Customer.FillValue(r) form.Customer.FillValue(r)
form.Date.FillValue(r) form.Date.FillValue(r)
form.Notes.FillValue(r) form.Notes.FillValue(r)
form.Tags.FillValue(r) form.Tags.FillValue(r)
form.PaymentMethod.FillValue(r) form.PaymentMethod.FillValue(r)
if err := form.File.FillValue(r); err != nil {
return err
}
if _, ok := r.Form["product.id.0"]; ok { if _, ok := r.Form["product.id.0"]; ok {
taxOptions := mustGetTaxOptions(r.Context(), getConn(r), form.company) taxOptions := mustGetTaxOptions(r.Context(), getConn(r), form.company)
for index := 0; true; index++ { for index := 0; true; index++ {
@ -961,11 +695,12 @@ func (form *invoiceForm) Parse(r *http.Request) error {
func (form *invoiceForm) Validate() bool { func (form *invoiceForm) Validate() bool {
validator := newFormValidator() validator := newFormValidator()
validator.CheckValidSelectOption(form.InvoiceStatus, gettext("Selected invoice status is not valid.", form.locale))
validator.CheckValidSelectOption(form.Customer, gettext("Selected customer is not valid.", form.locale)) validator.CheckValidSelectOption(form.Customer, gettext("Selected customer is not valid.", form.locale))
if validator.CheckRequiredInput(form.Date, gettext("Invoice date can not be empty.", form.locale)) { if validator.CheckRequiredInput(form.Date, gettext("Invoice date can not be empty.", form.locale)) {
validator.CheckValidDate(form.Date, gettext("Invoice date must be a valid date.", form.locale)) validator.CheckValidDate(form.Date, gettext("Invoice date must be a valid date.", form.locale))
} }
validator.CheckValidSelectOption(form.PaymentMethod, gettext("Selected invoicing method is not valid.", form.locale)) validator.CheckValidSelectOption(form.PaymentMethod, gettext("Selected payment method is not valid.", form.locale))
allOK := validator.AllOK() allOK := validator.AllOK()
for _, product := range form.Products { for _, product := range form.Products {
@ -1070,10 +805,13 @@ func (form *invoiceForm) InsertProduct(product *invoiceProductForm) {
func (form *invoiceForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool { func (form *invoiceForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool {
var invoiceId int var invoiceId int
selectedInvoiceStatus := form.InvoiceStatus.Selected
form.InvoiceStatus.Clear()
selectedPaymentMethod := form.PaymentMethod.Selected selectedPaymentMethod := form.PaymentMethod.Selected
form.PaymentMethod.Clear() form.PaymentMethod.Clear()
if notFoundErrorOrPanic(conn.QueryRow(ctx, ` if notFoundErrorOrPanic(conn.QueryRow(ctx, `
select invoice_id select invoice_id
, invoice_status
, contact_id , contact_id
, invoice_number , invoice_number
, invoice_date , invoice_date
@ -1082,8 +820,9 @@ func (form *invoiceForm) MustFillFromDatabase(ctx context.Context, conn *Conn, s
, tags , tags
from invoice from invoice
where slug = $1 where slug = $1
`, slug).Scan(&invoiceId, form.Customer, &form.Number, form.Date, form.Notes, form.PaymentMethod, form.Tags)) { `, slug).Scan(&invoiceId, form.InvoiceStatus, form.Customer, &form.Number, form.Date, form.Notes, form.PaymentMethod, form.Tags)) {
form.PaymentMethod.Selected = selectedPaymentMethod form.PaymentMethod.Selected = selectedPaymentMethod
form.InvoiceStatus.Selected = selectedInvoiceStatus
return false return false
} }
form.Products = []*invoiceProductForm{} form.Products = []*invoiceProductForm{}
@ -1112,7 +851,7 @@ func (form *invoiceForm) MustFillFromQuote(ctx context.Context, conn *Conn, slug
return false return false
} }
form.Products = []*invoiceProductForm{} form.Products = []*invoiceProductForm{}
form.mustAddProductsFromQuery(ctx, conn, "select '', coalesce(product_id::text, ''), name, description, to_price(price, $2), quantity, (discount_rate * 100)::integer, array_remove(array_agg(tax_id), null) from quote_product left join quote_product_product using (quote_product_id) left join quote_product_tax using (quote_product_id) where quote_id = $1 group by quote_product_id, coalesce(product_id::text, ''), name, description, discount_rate, price, quantity", quoteId, form.company.DecimalDigits) form.mustAddProductsFromQuery(ctx, conn, "select '', coalesce(product_id, 0), name, description, to_price(price, $2), quantity, (discount_rate * 100)::integer, array_remove(array_agg(tax_id), null) from quote_product left join quote_product_product using (quote_product_id) left join quote_product_tax using (quote_product_id) where quote_id = $1 group by quote_product_id, coalesce(product_id, 0), name, description, discount_rate, price, quantity", quoteId, form.company.DecimalDigits)
return true return true
} }
@ -1319,25 +1058,28 @@ func HandleUpdateInvoice(w http.ResponseWriter, r *http.Request, params httprout
http.Error(w, err.Error(), http.StatusForbidden) http.Error(w, err.Error(), http.StatusForbidden)
return return
} }
slug := params[0].Value if r.FormValue("quick") == "status" {
if !ValidUuid(slug) { slug := conn.MustGetText(r.Context(), "", "update invoice set invoice_status = $1 where slug = $2 returning slug", form.InvoiceStatus, params[0].Value)
if slug == "" {
http.NotFound(w, r) http.NotFound(w, r)
return
} }
htmxRedirect(w, r, companyURI(mustGetCompany(r), "/invoices"))
} else {
slug := params[0].Value
if !form.Validate() { if !form.Validate() {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
}
mustRenderEditInvoiceForm(w, r, slug, form) mustRenderEditInvoiceForm(w, r, slug, form)
return return
} }
slug = conn.MustGetText(r.Context(), "", "select edit_invoice($1, $2, $3, $4, $5, $6)", slug, form.Customer, form.Notes, form.PaymentMethod, form.Tags, EditedInvoiceProductArray(form.Products)) slug = conn.MustGetText(r.Context(), "", "select edit_invoice($1, $2, $3, $4, $5, $6, $7)", slug, form.InvoiceStatus, form.Customer, form.Notes, form.PaymentMethod, form.Tags, EditedInvoiceProductArray(form.Products))
if slug == "" { if slug == "" {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
if len(form.File.Content) > 0 {
conn.MustQuery(r.Context(), "select attach_to_invoice($1, $2, $3, $4)", slug, form.File.OriginalFileName, form.File.ContentType, form.File.Content)
}
htmxRedirect(w, r, companyURI(company, "/invoices/"+slug)) htmxRedirect(w, r, companyURI(company, "/invoices/"+slug))
}
} }
func htmxRedirect(w http.ResponseWriter, r *http.Request, uri string) { func htmxRedirect(w http.ResponseWriter, r *http.Request, uri string) {
@ -1356,10 +1098,6 @@ func ServeEditInvoice(w http.ResponseWriter, r *http.Request, params httprouter.
conn := getConn(r) conn := getConn(r)
company := mustGetCompany(r) company := mustGetCompany(r)
slug := params[0].Value slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
locale := getLocale(r) locale := getLocale(r)
form := newInvoiceForm(r.Context(), conn, locale, company) form := newInvoiceForm(r.Context(), conn, locale, company)
if !form.MustFillFromDatabase(r.Context(), conn, slug) { if !form.MustFillFromDatabase(r.Context(), conn, slug) {
@ -1391,10 +1129,6 @@ func mustRenderEditInvoiceForm(w http.ResponseWriter, r *http.Request, slug stri
func HandleEditInvoiceAction(w http.ResponseWriter, r *http.Request, params httprouter.Params) { func HandleEditInvoiceAction(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
slug := params[0].Value slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
actionUri := fmt.Sprintf("/invoices/%s/edit", slug) actionUri := fmt.Sprintf("/invoices/%s/edit", slug)
handleInvoiceAction(w, r, actionUri, func(w http.ResponseWriter, r *http.Request, form *invoiceForm) { handleInvoiceAction(w, r, actionUri, func(w http.ResponseWriter, r *http.Request, form *invoiceForm) {
conn := getConn(r) conn := getConn(r)
@ -1459,20 +1193,60 @@ func handleInvoiceAction(w http.ResponseWriter, r *http.Request, action string,
} }
} }
type tagsForm struct {
Action string
Slug string
Tags *TagsField
}
func newTagsForm(uri string, slug string, locale *Locale) *tagsForm {
return &tagsForm{
Action: uri,
Slug: slug,
Tags: &TagsField{
Name: "tags-" + slug,
Label: pgettext("input", "Tags", locale),
},
}
}
func (form *tagsForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
form.Tags.FillValue(r)
return nil
}
func ServeEditInvoiceTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) { func ServeEditInvoiceTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
serveTagsEditForm(w, r, params, "/invoices/", "select tags from invoice where slug = $1") conn := getConn(r)
locale := getLocale(r)
company := getCompany(r)
slug := params[0].Value
form := newTagsForm(companyURI(company, "/invoices/"+slug+"/tags"), slug, locale)
if notFoundErrorOrPanic(conn.QueryRow(r.Context(), `select tags from invoice where slug = $1`, form.Slug).Scan(form.Tags)) {
http.NotFound(w, r)
return
}
mustRenderStandaloneTemplate(w, r, "tags/edit.gohtml", form)
} }
func HandleUpdateInvoiceTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) { func HandleUpdateInvoiceTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
handleUpdateTags(w, r, params, "/invoices/", "update invoice set tags = $1 where slug = $2 returning slug") locale := getLocale(r)
} conn := getConn(r)
company := getCompany(r)
func ServeInvoiceAttachment(w http.ResponseWriter, r *http.Request, params httprouter.Params) { slug := params[0].Value
serveAttachment(w, r, params, ` form := newTagsForm(companyURI(company, "/invoices/"+slug+"/tags/edit"), slug, locale)
select mime_type if err := form.Parse(r); err != nil {
, content http.Error(w, err.Error(), http.StatusBadRequest)
from invoice return
join invoice_attachment using (invoice_id) }
where slug = $1 if err := verifyCsrfTokenValid(r); err != nil {
`) http.Error(w, err.Error(), http.StatusForbidden)
return
}
if conn.MustGetText(r.Context(), "", "update invoice set tags = $1 where slug = $2 returning slug", form.Tags, form.Slug) == "" {
http.NotFound(w, r)
}
mustRenderStandaloneTemplate(w, r, "tags/view.gohtml", form)
} }

View File

@ -20,7 +20,6 @@ const (
sessionCookie = "numerus-session" sessionCookie = "numerus-session"
defaultRole = "guest" defaultRole = "guest"
csrfTokenField = "csfrToken" csrfTokenField = "csfrToken"
csrfTokenHeader = "X-CSRFToken"
) )
type loginForm struct { type loginForm struct {
@ -30,8 +29,8 @@ type loginForm struct {
Password *InputField Password *InputField
} }
func newLoginForm(demo bool, locale *Locale) *loginForm { func newLoginForm(locale *Locale) *loginForm {
form := &loginForm{ return &loginForm{
locale: locale, locale: locale,
Email: &InputField{ Email: &InputField{
Name: "email", Name: "email",
@ -54,11 +53,6 @@ func newLoginForm(demo bool, locale *Locale) *loginForm {
}, },
}, },
} }
if demo {
form.Email.Val = "admin@numerus"
form.Password.Val = "admin"
}
return form
} }
func (form *loginForm) Parse(r *http.Request) error { func (form *loginForm) Parse(r *http.Request) error {
@ -80,26 +74,26 @@ func (form *loginForm) Validate() bool {
return validator.AllOK() return validator.AllOK()
} }
func GetLoginForm(w http.ResponseWriter, r *http.Request, demo bool) { func GetLoginForm(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
user := getUser(r) user := getUser(r)
if user.LoggedIn { if user.LoggedIn {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
return return
} }
locale := getLocale(r) locale := getLocale(r)
form := newLoginForm(demo, locale) form := newLoginForm(locale)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
mustRenderLoginForm(w, r, form) mustRenderLoginForm(w, r, form)
} }
func HandleLoginForm(w http.ResponseWriter, r *http.Request, demo bool) { func HandleLoginForm(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
user := getUser(r) user := getUser(r)
if user.LoggedIn { if user.LoggedIn {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
return return
} }
locale := getLocale(r) locale := getLocale(r)
form := newLoginForm(demo, locale) form := newLoginForm(locale)
if err := form.Parse(r); err != nil { if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@ -206,10 +200,7 @@ func LoginChecker(db *Db, next http.Handler) http.Handler {
func verifyCsrfTokenValid(r *http.Request) error { func verifyCsrfTokenValid(r *http.Request) error {
user := getUser(r) user := getUser(r)
token := r.Header.Get(csrfTokenHeader) token := r.FormValue(csrfTokenField)
if token == "" {
token = r.FormValue(csrfTokenField)
}
if user.CsrfToken == token { if user.CsrfToken == token {
return nil return nil
} }

View File

@ -1,267 +0,0 @@
package pkg
import (
"archive/zip"
"bytes"
"encoding/xml"
"fmt"
"net/http"
"sort"
"strings"
"time"
)
const (
mimetype = "application/vnd.oasis.opendocument.spreadsheet"
metaDashInfManifestXml = `<?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest
xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"
manifest:version="1.3">
<manifest:file-entry manifest:full-path="/" manifest:version="1.3" manifest:media-type="application/vnd.oasis.opendocument.spreadsheet"/>
<manifest:file-entry manifest:full-path="meta.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
</manifest:manifest>
`
metaXml = `<?xml version="1.0" encoding="UTF-8"?>
<office:document-meta
xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0"
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
office:version="1.3">
<office:meta>
<meta:creation-date></meta:creation-date>
<meta:generator>Numerus</meta:generator>
</office:meta>
</office:document-meta>
`
stylesXml = `<?xml version="1.0" encoding="UTF-8"?>
<office:document-styles
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
office:version="1.3">
</office:document-styles>
`
)
func extractTaxIDs(taxColumns map[int]string) []int {
taxIDs := make([]int, len(taxColumns))
i := 0
for k := range taxColumns {
taxIDs[i] = k
i++
}
sort.Ints(taxIDs[:])
return taxIDs
}
func mustWriteInvoicesOds(invoices []*InvoiceEntry, vatin map[int]string, lastCollectionDate map[int]time.Time, taxes map[int]taxMap, taxColumns map[int]string, locale *Locale, company *Company) []byte {
taxIDs := extractTaxIDs(taxColumns)
columns := make([]string, 9+len(taxIDs))
columns[0] = "Date"
columns[1] = "Invoice Num."
columns[2] = "Customer"
columns[3] = pgettext("title", "VAT number", locale)
columns[4] = "Payment Date"
columns[5] = "Status"
columns[6] = "Tax Base"
i := 7
for _, taxID := range taxIDs {
columns[i] = taxColumns[taxID]
i++
}
columns[i] = "Amount"
columns[i+1] = "Tags"
return mustWriteTableOds(invoices, columns, locale, func(sb *strings.Builder, invoice *InvoiceEntry) {
writeCellDate(sb, invoice.Date)
writeCellString(sb, invoice.Number)
writeCellString(sb, invoice.CustomerName)
writeCellString(sb, vatin[invoice.ID])
if date, ok := lastCollectionDate[invoice.ID]; ok {
writeCellDate(sb, date)
} else {
writeCellString(sb, "")
}
writeCellString(sb, invoice.StatusLabel)
writeCellFloat(sb, invoice.Subtotal, locale, company)
writeTaxes(sb, taxes[invoice.ID], taxIDs, locale, company)
writeCellFloat(sb, invoice.Total, locale, company)
writeCellString(sb, strings.Join(invoice.Tags, ","))
})
}
func mustWriteQuotesOds(quotes []*QuoteEntry, locale *Locale, company *Company) []byte {
columns := []string{
"Date",
"Quotation Num.",
"Customer",
"Status",
"Tags",
"Amount",
}
return mustWriteTableOds(quotes, columns, locale, func(sb *strings.Builder, quote *QuoteEntry) {
writeCellDate(sb, quote.Date)
writeCellString(sb, quote.Number)
writeCellString(sb, quote.CustomerName)
writeCellString(sb, quote.StatusLabel)
writeCellString(sb, strings.Join(quote.Tags, ","))
writeCellFloat(sb, quote.Total, locale, company)
})
}
func mustWriteExpensesOds(expenses []*ExpenseEntry, vatin map[int]string, lastPaymentDate map[int]time.Time, taxes map[int]taxMap, taxColumns map[int]string, locale *Locale, company *Company) []byte {
taxIDs := extractTaxIDs(taxColumns)
columns := make([]string, 9+len(taxIDs))
columns[0] = "Contact"
columns[1] = pgettext("title", "VAT number", locale)
columns[2] = "Invoice Date"
columns[3] = "Invoice Number"
columns[4] = "Payment Date"
columns[5] = "Status"
columns[6] = "Amount"
i := 7
for _, taxID := range taxIDs {
columns[i] = taxColumns[taxID]
i++
}
columns[i] = "Total"
columns[i+1] = "Tags"
return mustWriteTableOds(expenses, columns, locale, func(sb *strings.Builder, expense *ExpenseEntry) {
writeCellString(sb, expense.InvoicerName)
writeCellString(sb, vatin[expense.ID])
writeCellDate(sb, expense.InvoiceDate)
writeCellString(sb, expense.InvoiceNumber)
if date, ok := lastPaymentDate[expense.ID]; ok {
writeCellDate(sb, date)
} else {
writeCellString(sb, "")
}
writeCellString(sb, expense.StatusLabel)
writeCellFloat(sb, expense.Amount, locale, company)
writeTaxes(sb, taxes[expense.ID], taxIDs, locale, company)
writeCellFloat(sb, expense.Total, locale, company)
writeCellString(sb, strings.Join(expense.Tags, ","))
})
}
func mustWriteTableOds[K interface{}](rows []*K, columns []string, locale *Locale, writeRow func(*strings.Builder, *K)) []byte {
var sb strings.Builder
sb.WriteString(`<?xml version="1.0" encoding="UTF-8"?>
<office:document-content
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
xmlns:calcext="urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0"
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0"
office:version="1.3">
<office:scripts/>
<office:font-face-decls>
<style:font-face style:name="Liberation Sans" svg:font-family="'Liberation Sans'" style:font-family-generic="swiss" style:font-pitch="variable"/>
</office:font-face-decls>
<office:automatic-styles>
<style:style style:name="co1" style:family="table-column">
<style:table-column-properties fo:break-before="auto" style:column-width="0.889in"/>
</style:style>
<style:style style:name="ro1" style:family="table-row">
<style:table-row-properties style:row-height="0.178in" fo:break-before="auto" style:use-optimal-row-height="true"/>
</style:style>
<style:style style:name="ta1" style:family="table" style:master-page-name="Default">
<style:table-properties table:display="true" style:writing-mode="lr-tb"/>
</style:style>
<number:date-style style:name="N37" number:automatic-order="true">
<number:day number:style="long"/>
<number:text>/</number:text>
<number:month number:style="long"/>
<number:text>/</number:text>
<number:year/>
</number:date-style>
<style:style style:name="ce1" style:family="table-cell" style:parent-style-name="Default" style:data-style-name="N37"/>
</office:automatic-styles>
<office:body>
<office:spreadsheet>
<table:calculation-settings table:automatic-find-labels="false" table:use-regular-expressions="false" table:use-wildcards="true"/>
<table:table table:name="Sheet1" table:style-name="ta1">
`)
sb.WriteString(fmt.Sprintf(" <table:table-column table:style-name=\"co1\" table:number-columns-repeated=\"%d\" table:default-cell-style-name=\"Default\"/>\n", len(columns)))
sb.WriteString(` <table:table-row table:style-name="ro1">
`)
for _, t := range columns {
writeCellString(&sb, locale.GetC(t, "title"))
}
sb.WriteString(" </table:table-row>\n")
for _, row := range rows {
sb.WriteString(" <table:table-row table:style-name=\"ro1\">\n")
writeRow(&sb, row)
sb.WriteString(" </table:table-row>\n")
}
sb.WriteString(` </table:table>
<table:named-expressions/>
</office:spreadsheet>
</office:body>
</office:document-content>
`)
return mustWriteOds(sb.String())
}
func mustWriteOds(content string) []byte {
buf := new(bytes.Buffer)
ods := zip.NewWriter(buf)
mustWriteOdsFile(ods, "mimetype", mimetype, zip.Store)
mustWriteOdsFile(ods, "META-INF/manifest.xml", metaDashInfManifestXml, zip.Deflate)
mustWriteOdsFile(ods, "meta.xml", metaXml, zip.Deflate)
mustWriteOdsFile(ods, "styles.xml", stylesXml, zip.Deflate)
mustWriteOdsFile(ods, "content.xml", content, zip.Deflate)
mustClose(ods)
return buf.Bytes()
}
func mustWriteOdsFile(ods *zip.Writer, name string, content string, method uint16) {
f, err := ods.CreateHeader(&zip.FileHeader{
Name: name,
Method: method,
Modified: time.Now(),
})
if err != nil {
panic(err)
}
if _, err = f.Write([]byte(content)); err != nil {
panic(err)
}
}
func writeCellString(sb *strings.Builder, s string) {
sb.WriteString(` <table:table-cell office:value-type="string" calcext:value-type="string"><text:p>`)
if err := xml.EscapeText(sb, []byte(s)); err != nil {
panic(err)
}
sb.WriteString("</text:p></table:table-cell>\n")
}
func writeCellDate(sb *strings.Builder, t time.Time) {
sb.WriteString(fmt.Sprintf(" <table:table-cell table:style-name=\"ce1\" office:value-type=\"date\" office:date-value=\"%s\" calcext:value-type=\"date\"><text:p>%s</text:p></table:table-cell>\n", t.Format("2006-01-02"), t.Format("02/01/06")))
}
func writeCellFloat(sb *strings.Builder, s string, locale *Locale, company *Company) {
sb.WriteString(fmt.Sprintf(" <table:table-cell office:value-type=\"float\" office:value=\"%s\" calcext:value-type=\"float\"><text:p>%s</text:p></table:table-cell>\n", s, formatPrice(s, locale.Language, "%.[1]*[2]f", company.DecimalDigits, "")))
}
func writeOdsResponse(w http.ResponseWriter, ods []byte, filename string) {
w.Header().Set("Content-Type", "application/vnd.oasis.opendocument.spreadsheet")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
w.WriteHeader(http.StatusOK)
if _, err := w.Write(ods); err != nil {
panic(err)
}
}
func writeTaxes(sb *strings.Builder, taxes taxMap, taxIDs []int, locale *Locale, company *Company) {
for _, taxID := range taxIDs {
var amount string
if taxes != nil {
amount = taxes[taxID]
}
writeCellFloat(sb, amount, locale, company)
}
}

View File

@ -1,739 +0,0 @@
package pkg
import (
"context"
"fmt"
"github.com/julienschmidt/httprouter"
"html/template"
"math"
"net/http"
"time"
)
const (
PaymentTypePayment = "P"
PaymentTypeCollection = "C"
)
func servePaymentIndex(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
conn := getConn(r)
company := mustGetCompany(r)
locale := getLocale(r)
page := NewPaymentIndexPage(r.Context(), conn, company, locale)
page.MustRender(w, r)
}
func serveExpensePaymentIndex(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
expenseSlug := params[0].Value
conn := getConn(r)
company := mustGetCompany(r)
locale := getLocale(r)
expense := mustGetPaymentExpense(r.Context(), conn, expenseSlug)
if expense == nil {
http.NotFound(w, r)
return
}
page := NewPaymentIndexPageForExpense(r.Context(), conn, company, locale, expense)
page.MustRender(w, r)
}
func serveInvoiceCollectionIndex(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
invoiceSlug := params[0].Value
conn := getConn(r)
company := mustGetCompany(r)
locale := getLocale(r)
invoice := mustGetCollectionInvoice(r.Context(), conn, invoiceSlug)
if invoice == nil {
http.NotFound(w, r)
return
}
page := NewPaymentIndexPageForInvoice(r.Context(), conn, company, locale, invoice)
page.MustRender(w, r)
}
type PaymentIndexPage struct {
Payments []*PaymentEntry
BaseURI string
Expense *PaymentExpense
Invoice *CollectionInvoice
}
func NewPaymentIndexPage(ctx context.Context, conn *Conn, company *Company, locale *Locale) *PaymentIndexPage {
return &PaymentIndexPage{
Payments: mustCollectPaymentEntries(ctx, conn, company, locale, "", 0),
BaseURI: companyURI(company, "/payments"),
}
}
func NewPaymentIndexPageForExpense(ctx context.Context, conn *Conn, company *Company, locale *Locale, expense *PaymentExpense) *PaymentIndexPage {
return &PaymentIndexPage{
Payments: mustCollectPaymentEntries(ctx, conn, company, locale, PaymentTypePayment, expense.Id),
BaseURI: expense.BaseURI(company),
Expense: expense,
}
}
func NewPaymentIndexPageForInvoice(ctx context.Context, conn *Conn, company *Company, locale *Locale, invoice *CollectionInvoice) *PaymentIndexPage {
return &PaymentIndexPage{
Payments: mustCollectPaymentEntries(ctx, conn, company, locale, PaymentTypeCollection, invoice.Id),
BaseURI: invoice.BaseURI(company),
Invoice: invoice,
}
}
func (page *PaymentIndexPage) MustRender(w http.ResponseWriter, r *http.Request) {
mustRenderMainTemplate(w, r, "payments/index.gohtml", page)
}
type PaymentExpense struct {
Id int
Slug string
InvoiceNumber string
}
func mustGetPaymentExpense(ctx context.Context, conn *Conn, expenseSlug string) *PaymentExpense {
if !ValidUuid(expenseSlug) {
return nil
}
expense := &PaymentExpense{}
if notFoundErrorOrPanic(conn.QueryRow(ctx, `
select expense_id
, slug
, coalesce(nullif(invoice_number, ''), slug::text)
from expense
where expense.slug = $1
`, expenseSlug).Scan(
&expense.Id,
&expense.Slug,
&expense.InvoiceNumber)) {
return nil
}
return expense
}
func (expense *PaymentExpense) BaseURI(company *Company) string {
return companyURI(company, "/expenses/"+expense.Slug+"/payments")
}
func (expense *PaymentExpense) calcRemainingPaymentAmount(ctx context.Context, conn *Conn) string {
return conn.MustGetText(ctx, "", `
select to_price(greatest(0, expense.amount + tax_amount - paid_amount)::int, decimal_digits)
from (
select coalesce (sum(payment.amount), 0) as paid_amount
from expense_payment
join payment using (payment_id)
where expense_payment.expense_id = $1
) as payment
cross join (
select coalesce (sum(amount), 0) as tax_amount
from expense_tax_amount
where expense_id = $1
) as tax
cross join (
select amount, decimal_digits
from expense
join currency using (currency_code)
where expense_id = $1
) as expense
`, expense.Id)
}
type CollectionInvoice struct {
Id int
Slug string
InvoiceNumber string
}
func mustGetCollectionInvoice(ctx context.Context, conn *Conn, invoiceSlug string) *CollectionInvoice {
if !ValidUuid(invoiceSlug) {
return nil
}
invoice := &CollectionInvoice{}
if notFoundErrorOrPanic(conn.QueryRow(ctx, `
select invoice_id
, slug
, invoice_number
from invoice
where invoice.slug = $1
`, invoiceSlug).Scan(
&invoice.Id,
&invoice.Slug,
&invoice.InvoiceNumber)) {
return nil
}
return invoice
}
func (invoice *CollectionInvoice) BaseURI(company *Company) string {
return companyURI(company, "/invoices/"+invoice.Slug+"/collections")
}
func (invoice *CollectionInvoice) calcRemainingPaymentAmount(ctx context.Context, conn *Conn) string {
return conn.MustGetText(ctx, "", `
select to_price(greatest(0, invoice_amount.total - collected_amount)::int, decimal_digits)
from (
select coalesce (sum(collection.amount), 0) as collected_amount
from invoice_collection
join collection using (collection_id)
where invoice_collection.invoice_id = $1
) as collection
cross join (
select total
, decimal_digits
from invoice_amount
join invoice using (invoice_id)
join currency using (currency_code)
where invoice_id = $1
) as invoice_amount
`, invoice.Id)
}
type PaymentEntry struct {
ID int
Type string
Slug string
PaymentDate time.Time
Description string
DocumentSlug string
InvoiceNumber string
Total string
OriginalFileName string
Tags []string
Status string
StatusLabel string
}
func mustCollectPaymentEntries(ctx context.Context, conn *Conn, company *Company, locale *Locale, paymentType string, documentId int) []*PaymentEntry {
rows := conn.MustQuery(ctx, `
select $5 as type
, payment_id
, payment.slug
, payment_date
, description
, to_price(payment.amount, decimal_digits) as total
, payment.tags
, payment.payment_status
, psi18n.name
, coalesce(attachment.original_filename, '')
, coalesce(expense.slug::text, '')
, coalesce(expense.invoice_number, '')
from payment
join payment_status_i18n psi18n on payment.payment_status = psi18n.payment_status and psi18n.lang_tag = $1
join currency using (currency_code)
left join payment_attachment as attachment using (payment_id)
left join expense_payment using (payment_id)
left join expense using (expense_id)
where payment.company_id = $2
and ($3 = '' or ($3 = $5 and expense_id = $4))
union all
select $6 as type
, collection_id
, collection.slug
, collection_date as payment_date
, description
, to_price(collection.amount, decimal_digits) as total
, collection.tags
, collection.payment_status
, psi18n.name
, coalesce(attachment.original_filename, '')
, coalesce(invoice.slug::text, '')
, coalesce(invoice.invoice_number, '')
from collection
join payment_status_i18n psi18n on collection.payment_status = psi18n.payment_status and psi18n.lang_tag = $1
join currency using (currency_code)
left join collection_attachment as attachment using (collection_id)
left join invoice_collection using (collection_id)
left join invoice using (invoice_id)
where collection.company_id = $2
and ($3 = '' or ($3 = $6 and invoice_id = $4))
order by payment_date desc, total desc
`, locale.Language, company.Id, paymentType, documentId, PaymentTypePayment, PaymentTypeCollection)
defer rows.Close()
var entries []*PaymentEntry
for rows.Next() {
entry := &PaymentEntry{}
if err := rows.Scan(&entry.Type, &entry.ID, &entry.Slug, &entry.PaymentDate, &entry.Description, &entry.Total, &entry.Tags, &entry.Status, &entry.StatusLabel, &entry.OriginalFileName, &entry.DocumentSlug, &entry.InvoiceNumber); err != nil {
panic(err)
}
entries = append(entries, entry)
}
if rows.Err() != nil {
panic(rows.Err())
}
return entries
}
func servePaymentForm(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
locale := getLocale(r)
conn := getConn(r)
company := mustGetCompany(r)
form := newPaymentForm(r.Context(), conn, locale, company)
slug := params[0].Value
if slug == "new" {
form.PaymentDate.Val = time.Now().Format("2006-01-02")
form.MustRender(w, r)
return
}
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
if !form.MustFillFromDatabase(r.Context(), conn, slug) {
http.NotFound(w, r)
return
}
form.MustRender(w, r)
}
func serveExpensePaymentForm(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
locale := getLocale(r)
conn := getConn(r)
company := mustGetCompany(r)
expenseSlug := params[0].Value
expense := mustGetPaymentExpense(r.Context(), conn, expenseSlug)
if expense == nil {
http.NotFound(w, r)
return
}
form := newPaymentFormForExpense(r.Context(), conn, locale, company, expense)
paymentSlug := params[1].Value
if paymentSlug == "new" {
form.PaymentDate.Val = time.Now().Format("2006-01-02")
form.Description.Val = fmt.Sprintf(gettext("Payment of %s", locale), form.Expense.InvoiceNumber)
form.Amount.Val = form.Expense.calcRemainingPaymentAmount(r.Context(), conn)
form.MustRender(w, r)
return
}
if !ValidUuid(paymentSlug) {
http.NotFound(w, r)
return
}
if !form.MustFillFromDatabase(r.Context(), conn, paymentSlug) {
http.NotFound(w, r)
return
}
form.MustRender(w, r)
}
func serveInvoiceCollectionForm(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
locale := getLocale(r)
conn := getConn(r)
company := mustGetCompany(r)
invoiceSlug := params[0].Value
invoice := mustGetCollectionInvoice(r.Context(), conn, invoiceSlug)
if invoice == nil {
http.NotFound(w, r)
return
}
form := newPaymentFormForInvoice(r.Context(), conn, locale, company, invoice)
paymentSlug := params[1].Value
if paymentSlug == "new" {
form.PaymentDate.Val = time.Now().Format("2006-01-02")
form.Description.Val = fmt.Sprintf(gettext("Collection of %s", locale), form.Invoice.InvoiceNumber)
form.Amount.Val = form.Invoice.calcRemainingPaymentAmount(r.Context(), conn)
form.MustRender(w, r)
return
}
if !ValidUuid(paymentSlug) {
http.NotFound(w, r)
return
}
if !form.MustFillFromDatabase(r.Context(), conn, paymentSlug) {
http.NotFound(w, r)
return
}
form.MustRender(w, r)
}
type PaymentForm struct {
locale *Locale
company *Company
Slug string
BaseURI string
Expense *PaymentExpense
Invoice *CollectionInvoice
Type *SelectField
Description *InputField
PaymentDate *InputField
PaymentAccount *SelectField
Amount *InputField
File *FileField
Tags *TagsField
}
func newPaymentForm(ctx context.Context, conn *Conn, locale *Locale, company *Company) *PaymentForm {
return &PaymentForm{
locale: locale,
company: company,
BaseURI: companyURI(company, "/payments"),
Type: &SelectField{
Name: "type",
Label: pgettext("input", "Type", locale),
Required: true,
Options: []*SelectOption{
{Value: PaymentTypePayment, Label: pgettext("payment type", "Payment", locale)},
{Value: PaymentTypeCollection, Label: pgettext("payment type", "Collection", locale)},
},
},
Description: &InputField{
Name: "description",
Label: pgettext("input", "Description", locale),
Required: true,
Type: "text",
},
PaymentDate: &InputField{
Name: "payment_date",
Label: pgettext("input", "Payment Date", locale),
Required: true,
Type: "date",
},
PaymentAccount: &SelectField{
Name: "payment_account",
Label: pgettext("input", "Account", locale),
Required: true,
Options: MustGetOptions(ctx, conn, "select payment_account_id::text, name from payment_account where company_id = $1 order by name", company.Id),
},
Amount: &InputField{
Name: "amount",
Label: pgettext("input", "Amount", locale),
Type: "number",
Required: true,
Attributes: []template.HTMLAttr{
`min="0"`,
template.HTMLAttr(fmt.Sprintf(`step="%v"`, company.MinCents())),
},
},
File: &FileField{
Name: "file",
Label: pgettext("input", "File", locale),
MaxSize: 1 << 20,
},
Tags: &TagsField{
Name: "tags",
Label: pgettext("input", "Tags", locale),
},
}
}
func newPaymentFormForExpense(ctx context.Context, conn *Conn, locale *Locale, company *Company, expense *PaymentExpense) *PaymentForm {
form := newPaymentForm(ctx, conn, locale, company)
form.Type.Selected = []string{PaymentTypePayment}
form.BaseURI = expense.BaseURI(company)
form.Expense = expense
return form
}
func newPaymentFormForInvoice(ctx context.Context, conn *Conn, locale *Locale, company *Company, invoice *CollectionInvoice) *PaymentForm {
form := newPaymentForm(ctx, conn, locale, company)
form.Type.Selected = []string{PaymentTypeCollection}
form.BaseURI = invoice.BaseURI(company)
form.Invoice = invoice
return form
}
func (f *PaymentForm) MustRender(w http.ResponseWriter, r *http.Request) {
if f.Slug == "" {
f.Type.EmptyLabel = gettext("Select a type.", f.locale)
f.PaymentAccount.EmptyLabel = gettext("Select an account.", f.locale)
mustRenderMainTemplate(w, r, "payments/new.gohtml", f)
} else {
mustRenderMainTemplate(w, r, "payments/edit.gohtml", f)
}
}
func (f *PaymentForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool {
selectedType := f.Type.Selected
selectedPaymentAccount := f.PaymentAccount.Selected
f.PaymentAccount.Clear()
if notFoundErrorOrPanic(conn.QueryRow(ctx, `
select $2 as type
, description
, payment_date
, payment_account_id::text
, to_price(amount, decimal_digits)
, tags
from payment
join currency using (currency_code)
where payment.slug = $1
union all
select $3 as type
, description
, collection_date
, payment_account_id::text
, to_price(amount, decimal_digits)
, tags
from collection
join currency using (currency_code)
where collection.slug = $1
`, slug, PaymentTypePayment, PaymentTypeCollection).Scan(
f.Type,
f.Description,
f.PaymentDate,
f.PaymentAccount,
f.Amount,
f.Tags)) {
f.Type.Selected = selectedType
f.PaymentAccount.Selected = selectedPaymentAccount
return false
}
f.Slug = slug
return true
}
func (f *PaymentForm) Parse(r *http.Request) error {
if err := r.ParseMultipartForm(f.File.MaxSize); err != nil {
return err
}
f.Type.FillValue(r)
f.Description.FillValue(r)
f.PaymentDate.FillValue(r)
f.PaymentAccount.FillValue(r)
f.Amount.FillValue(r)
if err := f.File.FillValue(r); err != nil {
return err
}
f.Tags.FillValue(r)
return nil
}
func (f *PaymentForm) Validate() bool {
validator := newFormValidator()
validator.CheckValidSelectOption(f.Type, gettext("Selected payment type is not valid.", f.locale))
validator.CheckRequiredInput(f.Description, gettext("Description can not be empty.", f.locale))
validator.CheckValidSelectOption(f.PaymentAccount, gettext("Selected payment account is not valid.", f.locale))
validator.CheckValidDate(f.PaymentDate, gettext("Payment date must be a valid date.", f.locale))
if validator.CheckRequiredInput(f.Amount, gettext("Amount can not be empty.", f.locale)) {
validator.CheckValidDecimal(f.Amount, f.company.MinCents(), math.MaxFloat64, gettext("Amount must be a number greater than zero.", f.locale))
}
return validator.AllOK()
}
func handleAddPayment(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
locale := getLocale(r)
conn := getConn(r)
company := mustGetCompany(r)
form := newPaymentForm(r.Context(), conn, locale, company)
handleAddPaymentForm(w, r, conn, company, form)
}
func handleAddPaymentForm(w http.ResponseWriter, r *http.Request, conn *Conn, company *Company, form *PaymentForm) {
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if !form.Validate() {
w.WriteHeader(http.StatusUnprocessableEntity)
form.MustRender(w, r)
return
}
var documentId any
if form.Type.String() == PaymentTypePayment {
if form.Expense != nil {
documentId = form.Expense.Id
}
slug := conn.MustGetText(r.Context(), "", "select add_payment($1, $2, $3, $4, $5, $6, $7)", company.Id, documentId, form.PaymentDate, form.PaymentAccount, form.Description, form.Amount, form.Tags)
if len(form.File.Content) > 0 {
conn.MustQuery(r.Context(), "select attach_to_payment($1, $2, $3, $4)", slug, form.File.OriginalFileName, form.File.ContentType, form.File.Content)
}
} else {
if form.Invoice != nil {
documentId = form.Invoice.Id
}
slug := conn.MustGetText(r.Context(), "", "select add_collection($1, $2, $3, $4, $5, $6, $7)", company.Id, documentId, form.PaymentDate, form.PaymentAccount, form.Description, form.Amount, form.Tags)
if len(form.File.Content) > 0 {
conn.MustQuery(r.Context(), "select attach_to_collection($1, $2, $3, $4)", slug, form.File.OriginalFileName, form.File.ContentType, form.File.Content)
}
}
htmxRedirect(w, r, form.BaseURI)
}
func handleAddExpensePayment(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
expenseSlug := params[0].Value
locale := getLocale(r)
conn := getConn(r)
company := mustGetCompany(r)
expense := mustGetPaymentExpense(r.Context(), conn, expenseSlug)
if expense == nil {
http.NotFound(w, r)
return
}
form := newPaymentFormForExpense(r.Context(), conn, locale, company, expense)
handleAddPaymentForm(w, r, conn, company, form)
}
func handleAddInvoiceCollection(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
invoiceSlug := params[0].Value
locale := getLocale(r)
conn := getConn(r)
company := mustGetCompany(r)
invoice := mustGetCollectionInvoice(r.Context(), conn, invoiceSlug)
if invoice == nil {
http.NotFound(w, r)
return
}
form := newPaymentFormForInvoice(r.Context(), conn, locale, company, invoice)
handleAddPaymentForm(w, r, conn, company, form)
}
func handleEditPayment(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
conn := getConn(r)
locale := getLocale(r)
company := mustGetCompany(r)
form := newPaymentForm(r.Context(), conn, locale, company)
form.Slug = params[0].Value
handleEditPaymentForm(w, r, conn, form)
}
func handleEditPaymentForm(w http.ResponseWriter, r *http.Request, conn *Conn, form *PaymentForm) {
if !ValidUuid(form.Slug) {
http.NotFound(w, r)
return
}
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if !form.Validate() {
w.WriteHeader(http.StatusUnprocessableEntity)
form.MustRender(w, r)
return
}
if form.Type.String() == PaymentTypePayment {
if found := conn.MustGetText(r.Context(), "", "select edit_payment($1, $2, $3, $4, $5, $6)", form.Slug, form.PaymentDate, form.PaymentAccount, form.Description, form.Amount, form.Tags); found == "" {
http.NotFound(w, r)
return
}
if len(form.File.Content) > 0 {
conn.MustQuery(r.Context(), "select attach_to_payment($1, $2, $3, $4)", form.Slug, form.File.OriginalFileName, form.File.ContentType, form.File.Content)
}
} else {
if found := conn.MustGetText(r.Context(), "", "select edit_collection($1, $2, $3, $4, $5, $6)", form.Slug, form.PaymentDate, form.PaymentAccount, form.Description, form.Amount, form.Tags); found == "" {
http.NotFound(w, r)
return
}
if len(form.File.Content) > 0 {
conn.MustQuery(r.Context(), "select attach_to_collection($1, $2, $3, $4)", form.Slug, form.File.OriginalFileName, form.File.ContentType, form.File.Content)
}
}
htmxRedirect(w, r, form.BaseURI)
}
func handleEditExpensePayment(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
conn := getConn(r)
locale := getLocale(r)
company := mustGetCompany(r)
expenseSlug := params[0].Value
expense := mustGetPaymentExpense(r.Context(), conn, expenseSlug)
if expense == nil {
http.NotFound(w, r)
return
}
form := newPaymentFormForExpense(r.Context(), conn, locale, company, expense)
form.Slug = params[1].Value
handleEditPaymentForm(w, r, conn, form)
}
func handleEditInvoiceCollection(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
conn := getConn(r)
locale := getLocale(r)
company := mustGetCompany(r)
invoiceSlug := params[0].Value
invoice := mustGetCollectionInvoice(r.Context(), conn, invoiceSlug)
if invoice == nil {
http.NotFound(w, r)
return
}
form := newPaymentFormForInvoice(r.Context(), conn, locale, company, invoice)
form.Slug = params[1].Value
handleEditPaymentForm(w, r, conn, form)
}
func handleRemovePayment(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
company := mustGetCompany(r)
removePayment(w, r, params[0].Value, companyURI(company, "/payments"))
}
func removePayment(w http.ResponseWriter, r *http.Request, slug string, backURI string) {
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
conn := getConn(r)
conn.MustExec(r.Context(), "select remove_payment($1), remove_collection($1)", slug)
htmxRedirect(w, r, backURI)
}
func handleRemoveExpensePayment(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
conn := getConn(r)
expenseSlug := params[0].Value
expense := mustGetPaymentExpense(r.Context(), conn, expenseSlug)
if expense == nil {
http.NotFound(w, r)
return
}
company := mustGetCompany(r)
removePayment(w, r, params[1].Value, expense.BaseURI(company))
}
func handleRemoveInvoiceCollection(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
conn := getConn(r)
invoiceSlug := params[0].Value
invoice := mustGetCollectionInvoice(r.Context(), conn, invoiceSlug)
if invoice == nil {
http.NotFound(w, r)
return
}
company := mustGetCompany(r)
removePayment(w, r, params[1].Value, invoice.BaseURI(company))
}
func servePaymentAttachment(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
serveAttachment(w, r, params, `
select mime_type
, content
from payment
join payment_attachment using (payment_id)
where slug = $1
union all
select mime_type
, content
from collection
join collection_attachment using (collection_id)
where slug = $1
`)
}
func servePaymentTagsEditForm(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
serveTagsEditForm(w, r, params, "/payments/", "select tags from payment where slug = $1 union all select tags from collection where slug = $1")
}
func handleUpdatePaymentTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
handleUpdateTags(w, r, params, "/payments/", "with p as (update payment set tags = $1 where slug = $2 returning slug), c as (update collection set tags = $1 where slug = $2 returning slug) select p.slug from p union all select c.slug from c")
}

View File

@ -7,10 +7,6 @@ import (
"github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4"
) )
var (
oidCache = make(map[string]uint32)
)
type CustomerTaxDetails struct { type CustomerTaxDetails struct {
BusinessName string BusinessName string
VATIN string VATIN string
@ -329,13 +325,9 @@ func registerPgTypes(ctx context.Context, conn *pgx.Conn) error {
} }
func registerPgType(ctx context.Context, conn *pgx.Conn, value pgtype.Value, name string) (oid uint32, err error) { func registerPgType(ctx context.Context, conn *pgx.Conn, value pgtype.Value, name string) (oid uint32, err error) {
var found bool
if oid, found = oidCache[name]; !found {
if err = conn.QueryRow(ctx, "select $1::regtype::oid", name).Scan(&oid); err != nil { if err = conn.QueryRow(ctx, "select $1::regtype::oid", name).Scan(&oid); err != nil {
return return
} }
oidCache[name] = oid
}
conn.ConnInfo().RegisterDataType(pgtype.DataType{Value: value, Name: name, OID: oid}) conn.ConnInfo().RegisterDataType(pgtype.DataType{Value: value, Name: name, OID: oid})
return return
} }

View File

@ -50,10 +50,6 @@ func GetProductForm(w http.ResponseWriter, r *http.Request, params httprouter.Pa
mustRenderNewProductForm(w, r, form) mustRenderNewProductForm(w, r, form)
return return
} }
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
if !form.MustFillFromDatabase(r.Context(), conn, slug) { if !form.MustFillFromDatabase(r.Context(), conn, slug) {
http.NotFound(w, r) http.NotFound(w, r)
return return
@ -95,7 +91,9 @@ func HandleAddProduct(w http.ResponseWriter, r *http.Request, _ httprouter.Param
return return
} }
if !form.Validate() { if !form.Validate() {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
}
mustRenderNewProductForm(w, r, form) mustRenderNewProductForm(w, r, form)
return return
} }
@ -138,12 +136,10 @@ func HandleUpdateProduct(w http.ResponseWriter, r *http.Request, params httprout
return return
} }
slug := params[0].Value slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
if !form.Validate() { if !form.Validate() {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
}
mustRenderEditProductForm(w, r, slug, form) mustRenderEditProductForm(w, r, slug, form)
return return
} }
@ -200,11 +196,6 @@ func (form *productFilterForm) Parse(r *http.Request) error {
return nil return nil
} }
func (form *productFilterForm) HasValue() bool {
return form.Name.HasValue() ||
form.Tags.HasValue()
}
func mustCollectProductEntries(ctx context.Context, conn *Conn, company *Company, filters *productFilterForm) []*ProductEntry { func mustCollectProductEntries(ctx context.Context, conn *Conn, company *Company, filters *productFilterForm) []*ProductEntry {
args := []interface{}{company.Id} args := []interface{}{company.Id}
where := []string{"product.company_id = $1"} where := []string{"product.company_id = $1"}
@ -363,9 +354,34 @@ func HandleProductSearch(w http.ResponseWriter, r *http.Request, _ httprouter.Pa
} }
func ServeEditProductTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) { func ServeEditProductTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
serveTagsEditForm(w, r, params, "/products/", "select tags from product where slug = $1") conn := getConn(r)
locale := getLocale(r)
company := getCompany(r)
slug := params[0].Value
form := newTagsForm(companyURI(company, "/products/"+slug+"/tags"), slug, locale)
if notFoundErrorOrPanic(conn.QueryRow(r.Context(), `select tags from product where slug = $1`, form.Slug).Scan(form.Tags)) {
http.NotFound(w, r)
return
}
mustRenderStandaloneTemplate(w, r, "tags/edit.gohtml", form)
} }
func HandleUpdateProductTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) { func HandleUpdateProductTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
handleUpdateTags(w, r, params, "/products/", "update product set tags = $1 where slug = $2 returning slug") locale := getLocale(r)
conn := getConn(r)
company := getCompany(r)
slug := params[0].Value
form := newTagsForm(companyURI(company, "/products/"+slug+"/tags/edit"), slug, locale)
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if conn.MustGetText(r.Context(), "", "update product set tags = $1 where slug = $2 returning slug", form.Tags, form.Slug) == "" {
http.NotFound(w, r)
}
mustRenderStandaloneTemplate(w, r, "tags/view.gohtml", form)
} }

View File

@ -121,7 +121,9 @@ func HandleProfileForm(w http.ResponseWriter, r *http.Request, _ httprouter.Para
return return
} }
if ok := form.Validate(); !ok { if ok := form.Validate(); !ok {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
}
mustRenderProfileForm(w, r, form) mustRenderProfileForm(w, r, form)
return return
} }

View File

@ -205,15 +205,6 @@ func (form *quoteFilterForm) Parse(r *http.Request) error {
return nil return nil
} }
func (form *quoteFilterForm) HasValue() bool {
return form.Customer.HasValue() ||
form.QuoteStatus.HasValue() ||
form.QuoteNumber.HasValue() ||
form.FromDate.HasValue() ||
form.ToDate.HasValue() ||
form.Tags.HasValue()
}
func (form *quoteFilterForm) BuildQuery(args []interface{}) (string, []interface{}) { func (form *quoteFilterForm) BuildQuery(args []interface{}) (string, []interface{}) {
var where []string var where []string
appendWhere := func(expression string, value interface{}) { appendWhere := func(expression string, value interface{}) {
@ -258,7 +249,7 @@ func ServeQuote(w http.ResponseWriter, r *http.Request, params httprouter.Params
case "new": case "new":
locale := getLocale(r) locale := getLocale(r)
form := newQuoteForm(r.Context(), conn, locale, company) form := newQuoteForm(r.Context(), conn, locale, company)
if quoteToDuplicate := r.URL.Query().Get("duplicate"); ValidUuid(quoteToDuplicate) { if quoteToDuplicate := r.URL.Query().Get("duplicate"); quoteToDuplicate != "" {
form.MustFillFromDatabase(r.Context(), conn, quoteToDuplicate) form.MustFillFromDatabase(r.Context(), conn, quoteToDuplicate)
form.QuoteStatus.Selected = []string{"created"} form.QuoteStatus.Selected = []string{"created"}
} }
@ -288,10 +279,6 @@ func ServeQuote(w http.ResponseWriter, r *http.Request, params httprouter.Params
pdf = true pdf = true
slug = slug[:len(slug)-len(".pdf")] slug = slug[:len(slug)-len(".pdf")]
} }
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
quo := mustGetQuote(r.Context(), conn, company, slug) quo := mustGetQuote(r.Context(), conn, company, slug)
if quo == nil { if quo == nil {
http.NotFound(w, r) http.NotFound(w, r)
@ -423,60 +410,13 @@ func mustGetQuote(ctx context.Context, conn *Conn, company *Company, slug string
&quo.Total)) { &quo.Total)) {
return nil return nil
} }
if err := conn.QueryRow(ctx, ` if err := conn.QueryRow(ctx, "select business_name, vatin, phone, email, address, city, province, postal_code, legal_disclaimer from company where company_id = $1", company.Id).Scan(&quo.Quoter.Name, &quo.Quoter.VATIN, &quo.Quoter.Phone, &quo.Quoter.Email, &quo.Quoter.Address, &quo.Quoter.City, &quo.Quoter.Province, &quo.Quoter.PostalCode, &quo.LegalDisclaimer); err != nil {
select business_name
, vatin, phone
, email
, address
, city
, province
, postal_code
, legal_disclaimer
from company
where company_id = $1
`, company.Id).Scan(
&quo.Quoter.Name,
&quo.Quoter.VATIN,
&quo.Quoter.Phone,
&quo.Quoter.Email,
&quo.Quoter.Address,
&quo.Quoter.City,
&quo.Quoter.Province,
&quo.Quoter.PostalCode,
&quo.LegalDisclaimer); err != nil {
panic(err) panic(err)
} }
if err := conn.QueryRow(ctx, ` if err := conn.QueryRow(ctx, "select array_agg(array[name, to_price(amount, $2)]) from quote_tax_amount join tax using (tax_id) where quote_id = $1", quoteId, decimalDigits).Scan(&quo.Taxes); err != nil {
select array_agg(array[name, to_price(amount, $2)]) from quote_tax_amount
join tax using (tax_id)
where quote_id = $1
`, quoteId, decimalDigits).Scan(&quo.Taxes); err != nil {
panic(err) panic(err)
} }
rows := conn.MustQuery(ctx, ` rows := conn.MustQuery(ctx, "select quote_product.name, description, to_price(price, $2), (discount_rate * 100)::integer, quantity, to_price(subtotal, $2), to_price(total, $2), array_agg(array[tax_class.name, (tax_rate * 100)::integer::text]) filter (where tax_rate is not null) from quote_product join quote_product_amount using (quote_product_id) left join quote_product_tax using (quote_product_id) left join tax using (tax_id) left join tax_class using (tax_class_id) where quote_id = $1 group by quote_product.name, description, discount_rate, price, quantity, subtotal, total", quoteId, decimalDigits)
select quote_product.name
, description
, to_price(price, $2)
, (discount_rate * 100)::integer
, quantity, to_price(subtotal, $2)
, to_price(total, $2)
, array_agg(array[tax_class.name, (tax_rate * 100)::integer::text]) filter (where tax_rate is not null)
from quote_product
join quote_product_amount using (quote_product_id)
left join quote_product_tax using (quote_product_id)
left join tax using (tax_id)
left join tax_class using (tax_class_id)
where quote_id = $1
group by quote_product_id
, quote_product.name
, description
, discount_rate
, price
, quantity
, subtotal
, total
order by quote_product_id
`, quoteId, decimalDigits)
defer rows.Close() defer rows.Close()
taxClasses := map[string]bool{} taxClasses := map[string]bool{}
for rows.Next() { for rows.Next() {
@ -484,15 +424,7 @@ func mustGetQuote(ctx context.Context, conn *Conn, company *Company, slug string
Taxes: make(map[string]int), Taxes: make(map[string]int),
} }
var taxes [][]string var taxes [][]string
if err := rows.Scan( if err := rows.Scan(&product.Name, &product.Description, &product.Price, &product.Discount, &product.Quantity, &product.Subtotal, &product.Total, &taxes); err != nil {
&product.Name,
&product.Description,
&product.Price,
&product.Discount,
&product.Quantity,
&product.Subtotal,
&product.Total,
&taxes); err != nil {
panic(err) panic(err)
} }
for _, tax := range taxes { for _, tax := range taxes {
@ -575,7 +507,9 @@ func HandleAddQuote(w http.ResponseWriter, r *http.Request, _ httprouter.Params)
return return
} }
if !form.Validate() { if !form.Validate() {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
}
mustRenderNewQuoteForm(w, r, form) mustRenderNewQuoteForm(w, r, form)
return return
} }
@ -603,14 +537,14 @@ func HandleBatchQuoteAction(w http.ResponseWriter, r *http.Request, _ httprouter
http.Error(w, err.Error(), http.StatusForbidden) http.Error(w, err.Error(), http.StatusForbidden)
return return
} }
locale := getLocale(r)
switch r.Form.Get("action") {
case "download":
slugs := r.Form["quote"] slugs := r.Form["quote"]
if len(slugs) == 0 { if len(slugs) == 0 {
http.Redirect(w, r, companyURI(mustGetCompany(r), "/quotes"), http.StatusSeeOther) http.Redirect(w, r, companyURI(mustGetCompany(r), "/quotes"), http.StatusSeeOther)
return return
} }
locale := getLocale(r)
switch r.Form.Get("action") {
case "download":
quotes := mustWriteQuotesPdf(r, slugs) quotes := mustWriteQuotesPdf(r, slugs)
w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", gettext("quotations.zip", locale))) w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", gettext("quotations.zip", locale)))
@ -618,16 +552,6 @@ func HandleBatchQuoteAction(w http.ResponseWriter, r *http.Request, _ httprouter
if _, err := w.Write(quotes); err != nil { if _, err := w.Write(quotes); err != nil {
panic(err) panic(err)
} }
case "export":
conn := getConn(r)
company := getCompany(r)
filters := newQuoteFilterForm(r.Context(), conn, locale, company)
if err := filters.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ods := mustWriteQuotesOds(mustCollectQuoteEntries(r.Context(), conn, locale, filters), locale, company)
writeOdsResponse(w, ods, gettext("quotations.ods", locale))
default: default:
http.Error(w, gettext("Invalid action", locale), http.StatusBadRequest) http.Error(w, gettext("Invalid action", locale), http.StatusBadRequest)
} }
@ -707,8 +631,8 @@ func newQuoteForm(ctx context.Context, conn *Conn, locale *Locale, company *Comp
}, },
PaymentMethod: &SelectField{ PaymentMethod: &SelectField{
Name: "payment_method", Name: "payment_method",
Label: pgettext("input", "Invoicing Method", locale), Label: pgettext("input", "Payment Method", locale),
EmptyLabel: gettext("Select a invoicing method.", locale), EmptyLabel: gettext("Select a payment method.", locale),
Options: mustGetPaymentMethodOptions(ctx, conn, company), Options: mustGetPaymentMethodOptions(ctx, conn, company),
}, },
} }
@ -752,7 +676,7 @@ func (form *quoteForm) Validate() bool {
validator.CheckValidDate(form.Date, gettext("Quotation date must be a valid date.", form.locale)) validator.CheckValidDate(form.Date, gettext("Quotation date must be a valid date.", form.locale))
} }
if form.PaymentMethod.String() != "" { if form.PaymentMethod.String() != "" {
validator.CheckValidSelectOption(form.PaymentMethod, gettext("Selected invoicing method is not valid.", form.locale)) validator.CheckValidSelectOption(form.PaymentMethod, gettext("Selected payment method is not valid.", form.locale))
} }
allOK := validator.AllOK() allOK := validator.AllOK()
@ -1048,21 +972,18 @@ func HandleUpdateQuote(w http.ResponseWriter, r *http.Request, params httprouter
http.Error(w, err.Error(), http.StatusForbidden) http.Error(w, err.Error(), http.StatusForbidden)
return return
} }
slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
if r.FormValue("quick") == "status" { if r.FormValue("quick") == "status" {
slug = conn.MustGetText(r.Context(), "", "update quote set quote_status = $1 where slug = $2 returning slug", form.QuoteStatus, slug) slug := conn.MustGetText(r.Context(), "", "update quote set quote_status = $1 where slug = $2 returning slug", form.QuoteStatus, params[0].Value)
if slug == "" { if slug == "" {
http.NotFound(w, r) http.NotFound(w, r)
return
} }
htmxRedirect(w, r, companyURI(mustGetCompany(r), "/quotes")) htmxRedirect(w, r, companyURI(mustGetCompany(r), "/quotes"))
} else { } else {
slug := params[0].Value
if !form.Validate() { if !form.Validate() {
if !IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
}
mustRenderEditQuoteForm(w, r, slug, form) mustRenderEditQuoteForm(w, r, slug, form)
return return
} }
@ -1079,10 +1000,6 @@ func ServeEditQuote(w http.ResponseWriter, r *http.Request, params httprouter.Pa
conn := getConn(r) conn := getConn(r)
company := mustGetCompany(r) company := mustGetCompany(r)
slug := params[0].Value slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
locale := getLocale(r) locale := getLocale(r)
form := newQuoteForm(r.Context(), conn, locale, company) form := newQuoteForm(r.Context(), conn, locale, company)
if !form.MustFillFromDatabase(r.Context(), conn, slug) { if !form.MustFillFromDatabase(r.Context(), conn, slug) {
@ -1114,10 +1031,6 @@ func mustRenderEditQuoteForm(w http.ResponseWriter, r *http.Request, slug string
func HandleEditQuoteAction(w http.ResponseWriter, r *http.Request, params httprouter.Params) { func HandleEditQuoteAction(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
slug := params[0].Value slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
actionUri := fmt.Sprintf("/quotes/%s/edit", slug) actionUri := fmt.Sprintf("/quotes/%s/edit", slug)
handleQuoteAction(w, r, actionUri, func(w http.ResponseWriter, r *http.Request, form *quoteForm) { handleQuoteAction(w, r, actionUri, func(w http.ResponseWriter, r *http.Request, form *quoteForm) {
conn := getConn(r) conn := getConn(r)
@ -1183,9 +1096,34 @@ func handleQuoteAction(w http.ResponseWriter, r *http.Request, action string, re
} }
func ServeEditQuoteTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) { func ServeEditQuoteTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
serveTagsEditForm(w, r, params, "/quotes/", "select tags from quote where slug = $1") conn := getConn(r)
locale := getLocale(r)
company := getCompany(r)
slug := params[0].Value
form := newTagsForm(companyURI(company, "/quotes/"+slug+"/tags"), slug, locale)
if notFoundErrorOrPanic(conn.QueryRow(r.Context(), `select tags from quote where slug = $1`, form.Slug).Scan(form.Tags)) {
http.NotFound(w, r)
return
}
mustRenderStandaloneTemplate(w, r, "tags/edit.gohtml", form)
} }
func HandleUpdateQuoteTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) { func HandleUpdateQuoteTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
handleUpdateTags(w, r, params, "/quotes/", "update quote set tags = $1 where slug = $2 returning slug") locale := getLocale(r)
conn := getConn(r)
company := getCompany(r)
slug := params[0].Value
form := newTagsForm(companyURI(company, "/quotes/"+slug+"/tags/edit"), slug, locale)
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if conn.MustGetText(r.Context(), "", "update quote set tags = $1 where slug = $2 returning slug", form.Tags, form.Slug) == "" {
http.NotFound(w, r)
}
mustRenderStandaloneTemplate(w, r, "tags/view.gohtml", form)
} }

View File

@ -6,23 +6,16 @@ import (
"net/http" "net/http"
) )
func NewRouter(db *Db, demo bool) http.Handler { func NewRouter(db *Db) http.Handler {
companyRouter := httprouter.New() companyRouter := httprouter.New()
companyRouter.GET("/profile", GetProfileForm) companyRouter.GET("/profile", GetProfileForm)
companyRouter.POST("/profile", HandleProfileForm) companyRouter.POST("/profile", HandleProfileForm)
companyRouter.GET("/tax-details", GetCompanyTaxDetailsForm) companyRouter.GET("/tax-details", GetCompanyTaxDetailsForm)
companyRouter.POST("/tax-details", HandleCompanyTaxDetailsForm) companyRouter.POST("/tax-details", HandleCompanyTaxDetailsForm)
companyRouter.GET("/invoicing", serveCompanyInvoicingForm) companyRouter.POST("/tax", HandleAddCompanyTax)
companyRouter.POST("/invoicing", handleCompanyInvoicingForm) companyRouter.DELETE("/tax/:taxId", HandleDeleteCompanyTax)
companyRouter.GET("/switch-company", GetCompanySwitcher) companyRouter.POST("/payment-method", HandleAddPaymentMethod)
companyRouter.GET("/taxes", serveCompanyTaxes) companyRouter.DELETE("/payment-method/:paymentMethodId", HandleDeletePaymentMethod)
companyRouter.POST("/taxes", HandleAddCompanyTax)
companyRouter.DELETE("/taxes/:taxId", HandleDeleteCompanyTax)
companyRouter.GET("/payment-methods", servePaymentMethods)
companyRouter.POST("/payment-methods", HandleAddPaymentMethod)
companyRouter.PUT("/payment-methods/:paymentMethodId", HandleUpdatePaymentMethod)
companyRouter.DELETE("/payment-methods/:paymentMethodId", HandleDeletePaymentMethod)
companyRouter.GET("/payment-methods/:paymentMethodId/edit", servePaymentMethodEditForm)
companyRouter.GET("/contacts", IndexContacts) companyRouter.GET("/contacts", IndexContacts)
companyRouter.POST("/contacts", HandleAddContact) companyRouter.POST("/contacts", HandleAddContact)
companyRouter.POST("/contacts/import", HandleImportContacts) companyRouter.POST("/contacts/import", HandleImportContacts)
@ -45,12 +38,6 @@ func NewRouter(db *Db, demo bool) http.Handler {
companyRouter.POST("/invoices/:slug/edit", HandleEditInvoiceAction) companyRouter.POST("/invoices/:slug/edit", HandleEditInvoiceAction)
companyRouter.PUT("/invoices/:slug/tags", HandleUpdateInvoiceTags) companyRouter.PUT("/invoices/:slug/tags", HandleUpdateInvoiceTags)
companyRouter.GET("/invoices/:slug/tags/edit", ServeEditInvoiceTags) companyRouter.GET("/invoices/:slug/tags/edit", ServeEditInvoiceTags)
companyRouter.GET("/invoices/:slug/download/:filename", ServeInvoiceAttachment)
companyRouter.GET("/invoices/:slug/collections", serveInvoiceCollectionIndex)
companyRouter.POST("/invoices/:slug/collections", handleAddInvoiceCollection)
companyRouter.GET("/invoices/:slug/collections/:slug", serveInvoiceCollectionForm)
companyRouter.PUT("/invoices/:slug/collections/:slug", handleEditInvoiceCollection)
companyRouter.DELETE("/invoices/:slug/collections/:slug", handleRemoveInvoiceCollection)
companyRouter.GET("/quotes", IndexQuotes) companyRouter.GET("/quotes", IndexQuotes)
companyRouter.POST("/quotes", HandleAddQuote) companyRouter.POST("/quotes", HandleAddQuote)
companyRouter.GET("/quotes/:slug", ServeQuote) companyRouter.GET("/quotes/:slug", ServeQuote)
@ -62,41 +49,18 @@ func NewRouter(db *Db, demo bool) http.Handler {
companyRouter.GET("/quotes/:slug/tags/edit", ServeEditQuoteTags) companyRouter.GET("/quotes/:slug/tags/edit", ServeEditQuoteTags)
companyRouter.GET("/search/products", HandleProductSearch) companyRouter.GET("/search/products", HandleProductSearch)
companyRouter.GET("/expenses", IndexExpenses) companyRouter.GET("/expenses", IndexExpenses)
companyRouter.POST("/expenses", HandleNewExpenseAction) companyRouter.POST("/expenses", HandleAddExpense)
companyRouter.GET("/expenses/:slug", ServeExpenseForm) companyRouter.GET("/expenses/:slug", ServeExpenseForm)
companyRouter.POST("/expenses/:slug", HandleEditExpenseAction)
companyRouter.PUT("/expenses/:slug", HandleUpdateExpense) companyRouter.PUT("/expenses/:slug", HandleUpdateExpense)
companyRouter.DELETE("/expenses/:slug", handleRemoveExpense)
companyRouter.PUT("/expenses/:slug/tags", HandleUpdateExpenseTags) companyRouter.PUT("/expenses/:slug/tags", HandleUpdateExpenseTags)
companyRouter.GET("/expenses/:slug/tags/edit", ServeEditExpenseTags) companyRouter.GET("/expenses/:slug/tags/edit", ServeEditExpenseTags)
companyRouter.GET("/expenses/:slug/download/:filename", ServeExpenseAttachment) companyRouter.GET("/expenses/:slug/download/:filename", ServeExpenseAttachment)
companyRouter.GET("/expenses/:slug/payments", serveExpensePaymentIndex)
companyRouter.POST("/expenses/:slug/payments", handleAddExpensePayment)
companyRouter.GET("/expenses/:slug/payments/:slug", serveExpensePaymentForm)
companyRouter.PUT("/expenses/:slug/payments/:slug", handleEditExpensePayment)
companyRouter.DELETE("/expenses/:slug/payments/:slug", handleRemoveExpensePayment)
companyRouter.GET("/payments", servePaymentIndex)
companyRouter.POST("/payments", handleAddPayment)
companyRouter.GET("/payments/:slug", servePaymentForm)
companyRouter.PUT("/payments/:slug", handleEditPayment)
companyRouter.DELETE("/payments/:slug", handleRemovePayment)
companyRouter.PUT("/payments/:slug/tags", handleUpdatePaymentTags)
companyRouter.GET("/payments/:slug/tags/edit", servePaymentTagsEditForm)
companyRouter.GET("/payments/:slug/download/:filename", servePaymentAttachment)
companyRouter.GET("/payment-accounts", servePaymentAccountIndex)
companyRouter.POST("/payment-accounts", handleAddPaymentAccount)
companyRouter.GET("/payment-accounts/:slug", servePaymentAccountForm)
companyRouter.PUT("/payment-accounts/:slug", handleEditPaymentAccount)
companyRouter.GET("/", ServeDashboard) companyRouter.GET("/", ServeDashboard)
router := httprouter.New() router := httprouter.New()
router.ServeFiles("/static/*filepath", http.Dir("web/static")) router.ServeFiles("/static/*filepath", http.Dir("web/static"))
router.GET("/login", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { router.GET("/login", GetLoginForm)
GetLoginForm(w, r, demo) router.POST("/login", HandleLoginForm)
})
router.POST("/login", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
HandleLoginForm(w, r, demo)
})
router.POST("/logout", Authenticated(HandleLogout)) router.POST("/logout", Authenticated(HandleLogout))
companyHandler := Authenticated(CompanyHandler(companyRouter)) companyHandler := Authenticated(CompanyHandler(companyRouter))
@ -105,18 +69,6 @@ func NewRouter(db *Db, demo bool) http.Handler {
router.PUT("/company/:slug/*rest", companyHandler) router.PUT("/company/:slug/*rest", companyHandler)
router.DELETE("/company/:slug/*rest", companyHandler) router.DELETE("/company/:slug/*rest", companyHandler)
router.GET("/legal", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
mustRenderWebTemplate(w, r, "legal.gohtml", nil)
})
router.GET("/privacy", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
mustRenderWebTemplate(w, r, "privacy.gohtml", nil)
})
router.GET("/cookies", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
mustRenderWebTemplate(w, r, "cookies.gohtml", nil)
})
router.GET("/", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { router.GET("/", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
user := getUser(r) user := getUser(r)
if user.LoggedIn { if user.LoggedIn {

View File

@ -1,22 +0,0 @@
package pkg
import (
"github.com/rainycape/unidecode"
"regexp"
"strings"
)
var (
nonValidChars = regexp.MustCompile("[^a-z0-9-_]")
multipleDashes = regexp.MustCompile("-+")
)
func slugify(s string) (slug string) {
slug = strings.TrimSpace(s)
slug = unidecode.Unidecode(slug)
slug = strings.ToLower(slug)
slug = nonValidChars.ReplaceAllString(slug, "-")
slug = multipleDashes.ReplaceAllString(slug, "-")
slug = strings.Trim(slug, "-_")
return slug
}

View File

@ -1,73 +0,0 @@
package pkg
import (
"github.com/julienschmidt/httprouter"
"net/http"
)
func serveTagsEditForm(w http.ResponseWriter, r *http.Request, params httprouter.Params, prefix string, sql string) {
conn := getConn(r)
locale := getLocale(r)
company := getCompany(r)
slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
form := newTagsForm(companyURI(company, prefix+slug+"/tags"), slug, locale)
if notFoundErrorOrPanic(conn.QueryRow(r.Context(), sql, form.Slug).Scan(form.Tags)) {
http.NotFound(w, r)
return
}
mustRenderStandaloneTemplate(w, r, "tags/edit.gohtml", form)
}
type tagsForm struct {
Action string
Slug string
Tags *TagsField
}
func newTagsForm(uri string, slug string, locale *Locale) *tagsForm {
return &tagsForm{
Action: uri,
Slug: slug,
Tags: &TagsField{
Name: "tags-" + slug,
Label: pgettext("input", "Tags", locale),
},
}
}
func (form *tagsForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
form.Tags.FillValue(r)
return nil
}
func handleUpdateTags(w http.ResponseWriter, r *http.Request, params httprouter.Params, prefix string, sql string) {
locale := getLocale(r)
conn := getConn(r)
company := getCompany(r)
slug := params[0].Value
if !ValidUuid(slug) {
http.NotFound(w, r)
return
}
form := newTagsForm(companyURI(company, prefix+slug+"/tags/edit"), slug, locale)
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := verifyCsrfTokenValid(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if conn.MustGetText(r.Context(), "", sql, form.Tags, form.Slug) == "" {
http.NotFound(w, r)
return
}
mustRenderStandaloneTemplate(w, r, "tags/view.gohtml", form)
}

View File

@ -10,7 +10,6 @@ import (
"math" "math"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
) )
@ -21,10 +20,6 @@ func templateFile(name string) string {
} }
func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename string, data interface{}) { func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename string, data interface{}) {
mustRenderTemplateFragment(wr, r, layout, filename, layout, data)
}
func mustRenderTemplateFragment(wr io.Writer, r *http.Request, layout string, filename string, fragment string, data interface{}) {
locale := getLocale(r) locale := getLocale(r)
company := getCompany(r) company := getCompany(r)
user := getUser(r) user := getUser(r)
@ -35,12 +30,6 @@ func mustRenderTemplateFragment(wr io.Writer, r *http.Request, layout string, fi
"currentLocale": func() string { "currentLocale": func() string {
return locale.Language.String() return locale.Language.String()
}, },
"requestURIMatches": func(uri string) bool {
return r.RequestURI == uri
},
"requestURIHasPrefix": func(uri string) bool {
return strings.HasPrefix(r.RequestURI, uri)
},
"companyURI": func(uri string) string { "companyURI": func(uri string) string {
return companyURI(company, uri) return companyURI(company, uri)
}, },
@ -62,9 +51,6 @@ func mustRenderTemplateFragment(wr io.Writer, r *http.Request, layout string, fi
"csrfToken": func() template.HTML { "csrfToken": func() template.HTML {
return template.HTML(fmt.Sprintf(`<input type="hidden" name="%s" value="%s">`, csrfTokenField, user.CsrfToken)) return template.HTML(fmt.Sprintf(`<input type="hidden" name="%s" value="%s">`, csrfTokenField, user.CsrfToken))
}, },
"csrfHeader": func() string {
return fmt.Sprintf(`"%s": "%s"`, csrfTokenHeader, user.CsrfToken)
},
"addInputAttr": func(attr string, field *InputField) *InputField { "addInputAttr": func(attr string, field *InputField) *InputField {
field.Attributes = append(field.Attributes, template.HTMLAttr(attr)) field.Attributes = append(field.Attributes, template.HTMLAttr(attr))
return field return field
@ -100,12 +86,6 @@ func mustRenderTemplateFragment(wr io.Writer, r *http.Request, layout string, fi
sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"} sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}
return humanizeBytes(bytes, 1024, sizes) return humanizeBytes(bytes, 1024, sizes)
}, },
"slugify": func(s string) string {
return slugify(s)
},
"numerusVersion": func() string {
return Version
},
}) })
if _, err := t.ParseFiles(templateFile(filename), templateFile(layout), templateFile("form.gohtml")); err != nil { if _, err := t.ParseFiles(templateFile(filename), templateFile(layout), templateFile("form.gohtml")); err != nil {
panic(err) panic(err)
@ -113,7 +93,7 @@ func mustRenderTemplateFragment(wr io.Writer, r *http.Request, layout string, fi
if w, ok := wr.(http.ResponseWriter); ok { if w, ok := wr.(http.ResponseWriter); ok {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
} }
if err := t.ExecuteTemplate(wr, fragment, data); err != nil { if err := t.ExecuteTemplate(wr, layout, data); err != nil {
panic(err) panic(err)
} }
} }
@ -149,11 +129,7 @@ func mustRenderMainTemplate(w io.Writer, r *http.Request, filename string, data
} }
func mustRenderStandaloneTemplate(w io.Writer, r *http.Request, filename string, data interface{}) { func mustRenderStandaloneTemplate(w io.Writer, r *http.Request, filename string, data interface{}) {
mustRenderStandaloneTemplateFragment(w, r, filename, "standalone.gohtml", data) mustRenderTemplate(w, r, "standalone.gohtml", filename, data)
}
func mustRenderStandaloneTemplateFragment(w io.Writer, r *http.Request, filename string, fragment string, data interface{}) {
mustRenderTemplateFragment(w, r, "standalone.gohtml", filename, fragment, data)
} }
func mustRenderWebTemplate(w io.Writer, r *http.Request, filename string, data interface{}) { func mustRenderWebTemplate(w io.Writer, r *http.Request, filename string, data interface{}) {

View File

@ -1,46 +0,0 @@
package pkg
func ValidUuid(s string) bool {
if len(s) != 36 {
return false
}
// it must be of the form xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
if s[8] != '-' || s[13] != '-' || s[18] != '-' || s[23] != '-' {
return false
}
for _, x := range [16]int{
0, 2, 4, 6,
9, 11,
14, 16,
19, 21,
24, 26, 28, 30, 32, 34} {
if !validHex(s[x], s[x+1]) {
return false
}
}
return true
}
// xvalues returns the value of a byte as a hexadecimal digit or 255.
var xvalues = [256]byte{
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 255, 255, 255, 255, 255, 255,
255, 10, 11, 12, 13, 14, 15, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 10, 11, 12, 13, 14, 15, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
}
func validHex(x1, x2 byte) bool {
return xvalues[x1] != 255 && xvalues[x2] != 255
}

View File

@ -1,33 +0,0 @@
package pkg
import (
"strings"
"testing"
)
type test struct {
in string
isUuid bool
}
var tests = []test{
{"f47ac10b-58cc-0372-8567-0e02b2c3d479", true},
{"2bc1be74-169d-4300-a239-49a1196a045d", true},
{"12bc1be74-169d-4300-a239-49a1196a045d", false},
{"2bc1be74-169d-4300-a239-49a1196a045", false},
{"2bc1be74-1x9d-4300-a239-49a1196a045d", false},
{"2bc1be74-169d-4300-a239-49a1196ag45d", false},
}
func testValidUuid(t *testing.T, in string, isUuid bool) {
if ok := ValidUuid(in); ok != isUuid {
t.Errorf("ValidUuid(%s) got %v expected %v", in, ok, isUuid)
}
}
func TestUUID(t *testing.T) {
for _, tt := range tests {
testValidUuid(t, tt.in, tt.isUuid)
testValidUuid(t, strings.ToUpper(tt.in), tt.isUuid)
}
}

1255
po/ca.po

File diff suppressed because it is too large Load Diff

1251
po/es.po

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +0,0 @@
-- Revert numerus:add_collection from pg
begin;
drop function if exists numerus.add_collection(integer, integer, date, integer, text, text, numerus.tag_name[]);
commit;

View File

@ -1,55 +1,7 @@
-- Deploy numerus:add_expense to pg -- Revert numerus:add_expense from pg
-- requires: schema_numerus
-- requires: expense
-- requires: expense_tax
-- requires: tax
-- requires: company
-- requires: currency
-- requires: parse_price
-- requires: tax
-- requires: tag_name
-- requires: expense_status
-- requires: expense_expense_status
begin; begin;
set search_path to numerus, public; drop function if exists numerus.add_expense(integer, date, integer, text, text, integer[], numerus.tag_name[]);
drop function if exists add_expense(integer, date, integer, text, text, integer[], tag_name[]);
create or replace function add_expense(company integer, status text, invoice_date date, contact_id integer, invoice_number text, amount text, taxes integer[], tags tag_name[]) returns uuid as
$$
declare
eid integer;
eslug uuid;
begin
insert into expense (company_id, contact_id, invoice_number, invoice_date, amount, currency_code, expense_status, tags)
select company_id
, contact_id
, invoice_number
, invoice_date
, parse_price(amount, currency.decimal_digits)
, currency_code
, status
, tags
from company
join currency using (currency_code)
where company.company_id = add_expense.company
returning expense_id, slug
into eid, eslug;
insert into expense_tax (expense_id, tax_id, tax_rate)
select eid, tax_id, tax.rate
from tax
join unnest(taxes) as etax(tax_id) using (tax_id);
return eslug;
end;
$$
language plpgsql;
revoke execute on function add_expense(integer, text, date, integer, text, text, integer[], tag_name[]) from public;
grant execute on function add_expense(integer, text, date, integer, text, text, integer[], tag_name[]) to invoicer;
grant execute on function add_expense(integer, text, date, integer, text, text, integer[], tag_name[]) to admin;
commit; commit;

View File

@ -1,7 +0,0 @@
-- Revert numerus:add_expense from pg
begin;
drop function if exists numerus.add_expense(integer, date, integer, text, text, integer[], numerus.tag_name[]);
commit;

View File

@ -1,52 +0,0 @@
-- Deploy numerus:add_expense to pg
-- requires: schema_numerus
-- requires: expense
-- requires: expense_tax
-- requires: tax
-- requires: company
-- requires: currency
-- requires: parse_price
-- requires: tax
-- requires: tag_name
begin;
set search_path to numerus, public;
drop function if exists add_expense(integer, text, date, integer, text, text, integer[], tag_name[]);
create or replace function add_expense(company integer, invoice_date date, contact_id integer, invoice_number text, amount text, taxes integer[], tags tag_name[]) returns uuid as
$$
declare
eid integer;
eslug uuid;
begin
insert into expense (company_id, contact_id, invoice_number, invoice_date, amount, currency_code, tags)
select company_id
, contact_id
, invoice_number
, invoice_date
, parse_price(amount, currency.decimal_digits)
, currency_code
, tags
from company
join currency using (currency_code)
where company.company_id = add_expense.company
returning expense_id, slug
into eid, eslug;
insert into expense_tax (expense_id, tax_id, tax_rate)
select eid, tax_id, tax.rate
from tax
join unnest(taxes) as etax(tax_id) using (tax_id);
return eslug;
end;
$$
language plpgsql;
revoke execute on function add_expense(integer, date, integer, text, text, integer[], tag_name[]) from public;
grant execute on function add_expense(integer, date, integer, text, text, integer[], tag_name[]) to invoicer;
grant execute on function add_expense(integer, date, integer, text, text, integer[], tag_name[]) to admin;
commit;

View File

@ -1,7 +0,0 @@
-- Revert numerus:add_payment from pg
begin;
drop function if exists numerus.add_payment(integer, integer, date, integer, text, text, numerus.tag_name[]);
commit;

View File

@ -1,7 +0,0 @@
-- Revert numerus:add_payment_account_bank from pg
begin;
drop function if exists numerus.add_payment_account_bank(integer, text, iban);
commit;

View File

@ -1,7 +0,0 @@
-- Revert numerus:add_payment_account_card from pg
begin;
drop function if exists numerus.add_payment_account_card(integer, text, text, date);
commit;

View File

@ -1,7 +0,0 @@
-- Revert numerus:add_payment_account_cash from pg
begin;
drop function if exists numerus.add_payment_account_cash(integer, text);
commit;

View File

@ -1,7 +0,0 @@
-- Revert numerus:add_payment_account_other from pg
begin;
drop function if exists numerus.add_payment_account_other(integer, text);
commit;

View File

@ -1,7 +0,0 @@
-- Revert numerus:attach_to_collection from pg
begin;
drop function if exists numerus.attach_to_collection(uuid, text, text, bytea);
commit;

View File

@ -1,7 +0,0 @@
-- Revert numerus:attach_to_invoice from pg
begin;
drop function if exists numerus.attach_to_invoice(uuid, text, text, bytea);
commit;

View File

@ -1,7 +0,0 @@
-- Revert numerus:attach_to_payment from pg
begin;
drop function if exists numerus.attach_to_payment(uuid, text, text, bytea);
commit;

View File

@ -1,14 +0,0 @@
-- Deploy numerus:available_expense_status to pg
-- requires: schema_numerus
-- requires: expense_status
-- requires: expense_status_i18n
begin;
set search_path to numerus;
update expense set expense_status = 'pending' where expense_status = 'partial';
delete from expense_status_i18n where expense_status = 'partial';
delete from expense_status where expense_status = 'partial';
commit;

View File

@ -1,10 +0,0 @@
-- Revert numerus:available_expense_status from pg
begin;
set search_path to numerus;
delete from expense_status_i18n;
delete from expense_status;
commit;

Some files were not shown because too many files have changed in this diff Show More