Compare commits
107 Commits
Author | SHA1 | Date |
---|---|---|
jordi fita mas | 01807de5ca | |
jordi fita mas | f1534e6cd2 | |
jordi fita mas | cfd7a0c701 | |
jordi fita mas | 4ae9fc5cfa | |
jordi fita mas | ce4d29b83b | |
jordi fita mas | 193409eed8 | |
jordi fita mas | f5a9e819eb | |
jordi fita mas | 82da7f4697 | |
jordi fita mas | 2eecdcfc3e | |
jordi fita mas | 16e80b5ae0 | |
jordi fita mas | 36423c8636 | |
jordi fita mas | 6ef551a846 | |
jordi fita mas | 292720de28 | |
jordi fita mas | e8a44e480e | |
jordi fita mas | 0b74c7a91c | |
jordi fita mas | 790417e12c | |
jordi fita mas | b815a18967 | |
jordi fita mas | 7b1220c9f6 | |
jordi fita mas | e3d1e1fd1d | |
jordi fita mas | 93a95d77d0 | |
jordi fita mas | d4ef6c3254 | |
jordi fita mas | 7f31b10cce | |
jordi fita mas | c6c550a036 | |
jordi fita mas | 4f646e35d6 | |
jordi fita mas | eb880fed36 | |
jordi fita mas | 268ab9989a | |
jordi fita mas | dda32db683 | |
jordi fita mas | a30e015639 | |
jordi fita mas | fa57c4b191 | |
jordi fita mas | dca8b3a719 | |
jordi fita mas | 9ab08deaa1 | |
jordi fita mas | 7f21a2131e | |
jordi fita mas | f95936c523 | |
jordi fita mas | e626c7b4bd | |
jordi fita mas | ac0143b2b0 | |
jordi fita mas | 71a0a82a3f | |
jordi fita mas | c95f172499 | |
jordi fita mas | 58cef8c00b | |
jordi fita mas | 4deb698265 | |
jordi fita mas | 778f9c1555 | |
jordi fita mas | ad5bc271b6 | |
jordi fita mas | f546632a89 | |
jordi fita mas | c3fa23727f | |
jordi fita mas | 505fa0f154 | |
oriol carbonell pujolàs | 64be350677 | |
oriol carbonell pujolàs | 4363073682 | |
oriol carbonell pujolàs | 3e6f44f778 | |
jordi fita mas | faf7ee8ed5 | |
jordi fita mas | a689e2f734 | |
jordi fita mas | 405c833490 | |
jordi fita mas | 65413637ac | |
oriol carbonell pujolàs | 6fcc19bebf | |
jordi fita mas | 5b0ca28b97 | |
jordi fita mas | 662ba59be3 | |
jordi fita mas | 24a4bf2583 | |
jordi fita mas | 2ec88eddae | |
jordi fita mas | 5f7b798eb4 | |
jordi fita mas | 2bd7b2e952 | |
jordi fita mas | 843379a908 | |
jordi fita mas | 61fc8ee255 | |
jordi fita mas | b28f29eb24 | |
jordi fita mas | 4d2af368d2 | |
jordi fita mas | b4b049aab9 | |
jordi fita mas | e0bdb89472 | |
jordi fita mas | f15294c042 | |
jordi fita mas | 0937cfcf33 | |
jordi fita mas | 18b38f593c | |
jordi fita mas | 22ee6343e2 | |
oriol carbonell pujolàs | 7e377f550c | |
oriol carbonell pujolàs | d3afde9e21 | |
oriol carbonell pujolàs | a5fdeb9ab4 | |
oriol carbonell pujolàs | 45a45d7cc9 | |
jordi fita mas | b62f86950e | |
jordi fita mas | 2d0572e1d6 | |
jordi fita mas | 6de4135fa6 | |
oriol carbonell pujolàs | f3fdc0d743 | |
jordi fita mas | e34ef4f458 | |
jordi fita mas | 31a655ae7f | |
oriol carbonell pujolàs | c3e1597972 | |
jordi fita mas | e322ddd168 | |
jordi fita mas | 998159d1d7 | |
jordi fita mas | 4e831d94db | |
jordi fita mas | ef215f1e6e | |
jordi fita mas | 2501b7d226 | |
jordi fita mas | 0fd0cf5a38 | |
jordi fita mas | 80a6a802a2 | |
jordi fita mas | 831becf6fd | |
jordi fita mas | 60ec335769 | |
jordi fita mas | 52256c3cb9 | |
jordi fita mas | 1c6375b51d | |
jordi fita mas | 0c4ef97dff | |
jordi fita mas | 835e52dbcb | |
jordi fita mas | 5e8bed8452 | |
jordi fita mas | 51c789ca13 | |
jordi fita mas | ae1e294144 | |
jordi fita mas | a7c1df20f0 | |
jordi fita mas | 7d55e949fc | |
jordi fita mas | bb7af20a17 | |
jordi fita mas | 66ab3b4bf7 | |
jordi fita mas | b48a974086 | |
jordi fita mas | b7578a56df | |
jordi fita mas | fa97f53dd7 | |
jordi fita mas | 1164210d84 | |
jordi fita mas | c174fb447c | |
jordi fita mas | 1bb6870f26 | |
jordi fita mas | 58dd69773a | |
jordi fita mas | d697b340e9 |
|
@ -2,6 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
@ -19,9 +20,12 @@ func main() {
|
|||
}
|
||||
defer db.Close()
|
||||
|
||||
var demo bool
|
||||
_ = db.QueryRow(context.Background(), "select database_is_numerus_demo()").Scan(&demo)
|
||||
|
||||
srv := http.Server{
|
||||
Addr: ":8080",
|
||||
Handler: numerus.NewRouter(db),
|
||||
Handler: numerus.NewRouter(db, demo),
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
IdleTimeout: 2 * time.Minute,
|
||||
|
@ -29,7 +33,7 @@ func main() {
|
|||
|
||||
go func() {
|
||||
log.Printf("INFO - listening on %s\n", srv.Addr)
|
||||
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
|
||||
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("http server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
|
|
@ -10,8 +10,9 @@ Build-Depends:
|
|||
golang-github-jackc-pgx-v4-dev,
|
||||
golang-github-julienschmidt-httprouter-dev,
|
||||
golang-github-leonelquinteros-gotext-dev,
|
||||
golang-golang-x-text-dev,
|
||||
golang-github-rainycape-unidecode-dev,
|
||||
golang-github-tealeg-xlsx-dev,
|
||||
golang-golang-x-text-dev,
|
||||
postgresql-all (>= 217~),
|
||||
sqitch,
|
||||
pgtap,
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
#!/usr/bin/make -f
|
||||
|
||||
include /usr/share/dpkg/pkg-info.mk
|
||||
|
||||
%:
|
||||
dh $@ --builddirectory=_build --buildsystem=golang --with=golang
|
||||
|
||||
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
|
||||
|
||||
execute_after_dh_auto_test:
|
||||
|
|
145
demo/demo.sql
145
demo/demo.sql
|
@ -2,6 +2,8 @@ begin;
|
|||
|
||||
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;
|
||||
insert into auth."user" (email, name, password, role)
|
||||
values ('demo@numerus', 'Demo User', 'demo', 'invoicer')
|
||||
|
@ -12,12 +14,16 @@ set constraints "company_default_payment_method_id_fkey" deferred;
|
|||
|
||||
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)
|
||||
values ('Juli Verd', 'ES40404040D', 'Pesebre', parse_packed_phone_number('972 50 60 70', 'ES'), 'info@numerus.cat', 'https://numerus.cat/', 'C/ de l’Hort', 'Castelló d’Empúries', 'Girona', '17486', 'ES', 'EUR', 124, 'Juli Verd és responsable del tractament de les seves dades d’acord 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 d’accés, rectificació, portabilitat, supressió, limitació i oposició a Juli Verd, amb domicili Carrer de l’Hort 71, 17486 Castelló d’Empú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', 'Pessebre', parse_packed_phone_number('972 50 60 70', 'ES'), 'info@numerus.cat', 'https://numerus.cat/', 'C/ de l’Hort', 'Castelló d’Empúries', 'Girona', '17486', 'ES', 'EUR', 124, 'Juli Verd és responsable del tractament de les seves dades d’acord 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 d’accés, rectificació, portabilitat, supressió, limitació i oposició a Juli Verd, amb domicili Carrer de l’Hort 71, 17486 Castelló d’Empú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 l’Hort', 'Castelló d’Empúries', 'Girona', '17486', 'ES', 'EUR', 126, 'Pere Gil és responsable del tractament de les seves dades d’acord 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 d’accés, rectificació, portabilitat, supressió, limitació i oposició a Pere Gil, amb domicili Carrer de l’Hort 71, 17486 Castelló d’Empú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;
|
||||
insert into payment_method (company_id, name, instructions)
|
||||
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')
|
||||
, (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;
|
||||
|
@ -25,12 +31,16 @@ set constraints "company_default_payment_method_id_fkey" immediate;
|
|||
insert into company_user (company_id, user_id)
|
||||
values (123, 123)
|
||||
, (123, 124)
|
||||
, (124, 123)
|
||||
, (124, 124)
|
||||
;
|
||||
|
||||
alter sequence tax_class_tax_class_id_seq restart with 123;
|
||||
insert into tax_class (company_id, name)
|
||||
values (123, 'IRPF')
|
||||
, (123, 'IVA')
|
||||
, (124, 'IRPF')
|
||||
, (124, 'IVA')
|
||||
;
|
||||
|
||||
alter sequence tax_tax_id_seq restart with 123;
|
||||
|
@ -39,15 +49,25 @@ values (123, 123, 'Retenció 15 %', -0.15)
|
|||
, (123, 124, 'IVA 21 %', 0.21)
|
||||
, (123, 124, 'IVA 10 %', 0.10)
|
||||
, (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;
|
||||
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, 'Gaspar', '111', 'gaspar@reismags.cat', '', '(Rei Ros,2,"C/ Principal, 2",Nova Delhi,Delhi,2,IN)', '', '', 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']);
|
||||
select add_contact (123, 'Caganera', '222 222 222', 'caganera@pesebre.cat', '', '(Caganera,41414141L,"C/ De l’Hort, 4",Olot,Girona,17800,ES)', '', '', array['pesebre', 'persona']);
|
||||
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, 'Rabadà', '444 444 444', 'rabada@pesebre.cat', '', '(Rabadà,41414143K,"C/ De les Ovelles, 6",Fornells de la Selva,Girona,17458,ES)', '', '', array['pesebre', 'persona']);
|
||||
-- customers
|
||||
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', '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', 'client']);
|
||||
select add_contact (123, 'Caganera', '222 222 222', 'caganera@pesebre.cat', '', '(Caganera,41414141L,"C/ De l’Hort, 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', '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', '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;
|
||||
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 l’aigua règia.', '55.92', array[124], array['metall']);
|
||||
|
@ -58,17 +78,122 @@ 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, 'Teia', 'Fusta resinosa de pi i d’altres arbres, provinent sobretot del cor de l’arbre, 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_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 l’aigua 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 d’altres arbres, provinent sobretot del cor de l’arbre, 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 l’aigua 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 d’altres arbres, provinent sobretot del cor de l’arbre, 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 s’usa 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 s’usa 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 d’altres arbres, provinent sobretot del cor de l’arbre, 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 l’aigua 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 s’usa 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 s’usa 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 l’aigua 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 d’altres arbres, provinent sobretot del cor de l’arbre, 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 s’usa 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 d’altres arbres, provinent sobretot del cor de l’arbre, 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 l’aigua 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 d’altres arbres, provinent sobretot del cor de l’arbre, 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 - '17 days'::interval)::date, 126, '', 123, '{producte,higiene}','{"(126,\"Paper higiènic (pack de 32 U)\",Paper que s’usa 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 - '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 l’aigua 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})"}');
|
||||
|
||||
update invoice set invoice_status = 'paid' where invoice_id in (123, 127);
|
||||
update invoice set invoice_status = 'unpaid' where invoice_id = 125;
|
||||
update invoice set invoice_status = 'sent' where invoice_id = 126;
|
||||
alter sequence expense_expense_id_seq restart with 123;
|
||||
select add_expense(123, (date_trunc('month', current_date) - '11 months + 14 day'::interval)::date, 130, 'ABC123', '256.12', '{124}', '{}');
|
||||
select add_payment(123, 123, (date_trunc('month', current_date) - '11 months + 04 day'::interval)::date, 123, 'Pagament d’ABC123', '256.12', '{}');
|
||||
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 d’123ABC', '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 d’N 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 d’123XYZ', '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 d’N 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 d’XYZ123', '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 d’N 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 d’N 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 d’N 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 d’N 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 d’N ', '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 d’N 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 d’N 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 d’N 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 d’N 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 d’N 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 d’N 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;
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
-- 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
|
||||
-- collection’s 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;
|
|
@ -8,11 +8,15 @@
|
|||
-- requires: parse_price
|
||||
-- requires: tax
|
||||
-- requires: tag_name
|
||||
-- requires: expense_status
|
||||
-- requires: expense_expense_status
|
||||
|
||||
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
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
-- 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;
|
|
@ -0,0 +1,55 @@
|
|||
-- 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;
|
|
@ -0,0 +1,67 @@
|
|||
-- 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
|
||||
-- payment’s 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;
|
|
@ -0,0 +1,35 @@
|
|||
-- 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;
|
|
@ -0,0 +1,34 @@
|
|||
-- 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;
|
|
@ -0,0 +1,23 @@
|
|||
-- 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;
|
|
@ -0,0 +1,23 @@
|
|||
-- 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;
|
|
@ -0,0 +1,30 @@
|
|||
-- 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;
|
|
@ -0,0 +1,30 @@
|
|||
-- 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;
|
|
@ -0,0 +1,30 @@
|
|||
-- 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;
|
|
@ -0,0 +1,27 @@
|
|||
-- 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;
|
|
@ -0,0 +1,22 @@
|
|||
-- 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;
|
|
@ -10,19 +10,28 @@ set search_path to numerus;
|
|||
insert into invoice_status (invoice_status, name)
|
||||
values ('created', 'Created')
|
||||
, ('sent', 'Sent')
|
||||
, ('partial', 'Partial')
|
||||
, ('paid', 'Paid')
|
||||
, ('unpaid', 'Unpaid')
|
||||
on conflict (invoice_status) do nothing
|
||||
;
|
||||
|
||||
insert into invoice_status_i18n (invoice_status, lang_tag, name)
|
||||
values ('created', 'ca', 'Creada')
|
||||
, ('sent', 'ca', 'Enviada')
|
||||
, ('partial', 'ca', 'Parcial')
|
||||
, ('paid', 'ca', 'Cobrada')
|
||||
, ('unpaid', 'ca', 'No cobrada')
|
||||
, ('created', 'es', 'Creada')
|
||||
, ('sent', 'es', 'Enviada')
|
||||
, ('partial', 'es', 'Parcial')
|
||||
, ('paid', 'es', '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;
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
-- 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;
|
|
@ -0,0 +1,28 @@
|
|||
-- 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;
|
|
@ -0,0 +1,22 @@
|
|||
-- 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;
|
|
@ -0,0 +1,45 @@
|
|||
-- 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;
|
|
@ -0,0 +1,32 @@
|
|||
-- 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;
|
|
@ -0,0 +1,68 @@
|
|||
-- 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;
|
|
@ -0,0 +1,53 @@
|
|||
-- 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;
|
|
@ -5,11 +5,15 @@
|
|||
-- requires: parse_price
|
||||
-- requires: tax
|
||||
-- requires: tag_name
|
||||
-- requires: expense_status
|
||||
-- requires: expense_expense_status
|
||||
|
||||
begin;
|
||||
|
||||
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
|
||||
$$
|
||||
declare
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
-- 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;
|
|
@ -0,0 +1,53 @@
|
|||
-- 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;
|
|
@ -14,7 +14,9 @@ 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
|
||||
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, contact_id integer, notes text, payment_method_id integer, tags tag_name[], products edited_invoice_product[]) returns uuid as
|
||||
$$
|
||||
declare
|
||||
iid integer;
|
||||
|
@ -27,7 +29,6 @@ declare
|
|||
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
|
||||
|
@ -103,9 +104,9 @@ 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;
|
||||
revoke execute on function edit_invoice(uuid, 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, integer, text, integer, tag_name[], edited_invoice_product[]) to admin;
|
||||
|
||||
|
||||
commit;
|
||||
|
|
|
@ -0,0 +1,111 @@
|
|||
-- 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;
|
|
@ -0,0 +1,53 @@
|
|||
-- 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;
|
|
@ -0,0 +1,43 @@
|
|||
-- 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;
|
|
@ -0,0 +1,43 @@
|
|||
-- 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;
|
|
@ -0,0 +1,26 @@
|
|||
-- 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;
|
|
@ -0,0 +1,26 @@
|
|||
-- 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;
|
|
@ -0,0 +1,12 @@
|
|||
-- 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;
|
|
@ -0,0 +1,32 @@
|
|||
-- 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;
|
|
@ -0,0 +1,17 @@
|
|||
-- 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;
|
|
@ -0,0 +1,21 @@
|
|||
-- 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;
|
|
@ -0,0 +1,32 @@
|
|||
-- 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;
|
|
@ -0,0 +1,32 @@
|
|||
-- 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;
|
|
@ -0,0 +1,13 @@
|
|||
-- 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;
|
|
@ -24,6 +24,9 @@ begin
|
|||
end if;
|
||||
|
||||
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
|
||||
result := result * 10;
|
||||
end loop;
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
-- 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;
|
|
@ -0,0 +1,47 @@
|
|||
-- 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;
|
|
@ -0,0 +1,38 @@
|
|||
-- 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;
|
|
@ -0,0 +1,33 @@
|
|||
-- 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;
|
|
@ -0,0 +1,33 @@
|
|||
-- 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;
|
|
@ -0,0 +1,17 @@
|
|||
-- 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;
|
|
@ -0,0 +1,21 @@
|
|||
-- 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;
|
|
@ -0,0 +1,33 @@
|
|||
-- 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;
|
|
@ -0,0 +1,17 @@
|
|||
-- 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;
|
|
@ -0,0 +1,21 @@
|
|||
-- 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;
|
|
@ -0,0 +1,40 @@
|
|||
-- 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;
|
|
@ -0,0 +1,34 @@
|
|||
-- 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;
|
|
@ -0,0 +1,40 @@
|
|||
-- 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;
|
|
@ -0,0 +1,51 @@
|
|||
-- 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;
|
|
@ -0,0 +1,49 @@
|
|||
-- 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
1
go.mod
|
@ -7,6 +7,7 @@ require (
|
|||
github.com/jackc/pgx/v4 v4.15.0
|
||||
github.com/julienschmidt/httprouter v1.3.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
|
||||
golang.org/x/text v0.7.0
|
||||
)
|
||||
|
|
2
go.sum
2
go.sum
|
@ -92,6 +92,8 @@ 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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
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/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||
|
|
|
@ -0,0 +1,257 @@
|
|||
(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 Google’s 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+))
|
|
@ -0,0 +1,338 @@
|
|||
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", "Card’s 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 card’s 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"))
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
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)
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
package pkg
|
||||
|
||||
const Version = "1.1~git"
|
829
pkg/company.go
829
pkg/company.go
|
@ -3,6 +3,7 @@ package pkg
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"html/template"
|
||||
"math"
|
||||
|
@ -82,27 +83,23 @@ type PaymentMethod struct {
|
|||
Id int
|
||||
Name string
|
||||
Instructions string
|
||||
IsDefault bool
|
||||
}
|
||||
|
||||
type taxDetailsForm struct {
|
||||
locale *Locale
|
||||
TradeName *InputField
|
||||
BusinessName *InputField
|
||||
VATIN *InputField
|
||||
Phone *InputField
|
||||
Email *InputField
|
||||
Web *InputField
|
||||
Address *InputField
|
||||
City *InputField
|
||||
Province *InputField
|
||||
PostalCode *InputField
|
||||
Country *SelectField
|
||||
Currency *SelectField
|
||||
InvoiceNumberFormat *InputField
|
||||
NextInvoiceNumber *InputField
|
||||
QuoteNumberFormat *InputField
|
||||
NextQuoteNumber *InputField
|
||||
LegalDisclaimer *InputField
|
||||
locale *Locale
|
||||
TradeName *InputField
|
||||
BusinessName *InputField
|
||||
VATIN *InputField
|
||||
Phone *InputField
|
||||
Email *InputField
|
||||
Web *InputField
|
||||
Address *InputField
|
||||
City *InputField
|
||||
Province *InputField
|
||||
PostalCode *InputField
|
||||
Country *SelectField
|
||||
Currency *SelectField
|
||||
}
|
||||
|
||||
func newTaxDetailsForm(ctx context.Context, conn *Conn, locale *Locale) *taxDetailsForm {
|
||||
|
@ -202,6 +199,204 @@ func newTaxDetailsForm(ctx context.Context, conn *Conn, locale *Locale) *taxDeta
|
|||
Required: true,
|
||||
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{
|
||||
Name: "invoice_number_format",
|
||||
Label: pgettext("input", "Invoice number format", locale),
|
||||
|
@ -240,82 +435,15 @@ func newTaxDetailsForm(ctx context.Context, conn *Conn, locale *Locale) *taxDeta
|
|||
}
|
||||
}
|
||||
|
||||
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)
|
||||
form.InvoiceNumberFormat.FillValue(r)
|
||||
form.NextInvoiceNumber.FillValue(r)
|
||||
form.QuoteNumberFormat.FillValue(r)
|
||||
form.NextQuoteNumber.FillValue(r)
|
||||
form.LegalDisclaimer.FillValue(r)
|
||||
return nil
|
||||
func newInvoicingFormFromDatabase(ctx context.Context, conn *Conn, company *Company, locale *Locale) *InvoicingForm {
|
||||
form := newInvoicingForm(locale)
|
||||
form.mustFillFromDatabase(ctx, conn, company)
|
||||
return form
|
||||
}
|
||||
|
||||
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 {
|
||||
func (form *InvoicingForm) mustFillFromDatabase(ctx context.Context, conn *Conn, company *Company) {
|
||||
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
|
||||
, invoice_number_format
|
||||
select invoice_number_format
|
||||
, quote_number_format
|
||||
, legal_disclaimer
|
||||
, coalesce(invoice_number_counter.currval, 0) + 1
|
||||
|
@ -328,18 +456,6 @@ func (form *taxDetailsForm) mustFillFromDatabase(ctx context.Context, conn *Conn
|
|||
on quote_number_counter.company_id = company.company_id
|
||||
and quote_number_counter.year = date_part('year', current_date)
|
||||
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.QuoteNumberFormat,
|
||||
form.LegalDisclaimer,
|
||||
|
@ -349,36 +465,39 @@ func (form *taxDetailsForm) mustFillFromDatabase(ctx context.Context, conn *Conn
|
|||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return form
|
||||
}
|
||||
|
||||
type TaxDetailsPage struct {
|
||||
DetailsForm *taxDetailsForm
|
||||
NewTaxForm *taxForm
|
||||
Taxes []*Tax
|
||||
NewPaymentMethodForm *paymentMethodForm
|
||||
PaymentMethods []*PaymentMethod
|
||||
func (form *InvoicingForm) MustRender(w http.ResponseWriter, r *http.Request) {
|
||||
mustRenderMainTemplate(w, r, "company/invoicing.gohtml", form)
|
||||
}
|
||||
|
||||
func GetCompanyTaxDetailsForm(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
mustRenderTaxDetailsForm(w, r, newTaxDetailsFormFromDatabase(r))
|
||||
func (form *InvoicingForm) Parse(r *http.Request) error {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
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 newTaxDetailsFormFromDatabase(r *http.Request) *taxDetailsForm {
|
||||
func (form *InvoicingForm) Validate() bool {
|
||||
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)
|
||||
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)
|
||||
form := newInvoicingForm(locale)
|
||||
if err := form.Parse(r); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
|
@ -387,11 +506,9 @@ func HandleCompanyTaxDetailsForm(w http.ResponseWriter, r *http.Request, _ httpr
|
|||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if ok := form.Validate(r.Context(), conn); !ok {
|
||||
if !IsHTMxRequest(r) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
}
|
||||
mustRenderTaxDetailsForm(w, r, form)
|
||||
if ok := form.Validate(); !ok {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
form.MustRender(w, r)
|
||||
return
|
||||
}
|
||||
company := mustGetCompany(r)
|
||||
|
@ -399,35 +516,11 @@ func HandleCompanyTaxDetailsForm(w http.ResponseWriter, r *http.Request, _ httpr
|
|||
defer tx.MustRollback(r.Context())
|
||||
tx.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
|
||||
, invoice_number_format = $13
|
||||
, quote_number_format = $14
|
||||
, legal_disclaimer = $15
|
||||
where company_id = $16
|
||||
set invoice_number_format = $1
|
||||
, quote_number_format = $2
|
||||
, legal_disclaimer = $3
|
||||
where company_id = $4
|
||||
`,
|
||||
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.QuoteNumberFormat,
|
||||
form.LegalDisclaimer,
|
||||
|
@ -449,60 +542,39 @@ func HandleCompanyTaxDetailsForm(w http.ResponseWriter, r *http.Request, _ httpr
|
|||
company.Id,
|
||||
form.NextQuoteNumber.Integer()-1)
|
||||
tx.MustCommit(r.Context())
|
||||
if IsHTMxRequest(r) {
|
||||
w.Header().Set(HxTrigger, "closeModal")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
} else {
|
||||
http.Redirect(w, r, companyURI(company, "/tax-details"), http.StatusSeeOther)
|
||||
}
|
||||
htmxRedirect(w, r, companyURI(company, "/invoicing"))
|
||||
}
|
||||
|
||||
func mustRenderTaxDetailsForm(w http.ResponseWriter, r *http.Request, form *taxDetailsForm) {
|
||||
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 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) {
|
||||
func serveCompanyTaxes(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
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)
|
||||
locale := getLocale(r)
|
||||
page := newTaxesPage(r.Context(), conn, company, locale)
|
||||
page.MustRender(w, r)
|
||||
}
|
||||
|
||||
func mustGetCompany(r *http.Request) *Company {
|
||||
company := getCompany(r)
|
||||
if company == nil {
|
||||
panic(errors.New("company: required but not found"))
|
||||
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,
|
||||
}
|
||||
return company
|
||||
}
|
||||
|
||||
func mustGetTaxes(ctx context.Context, conn *Conn, company *Company) []*Tax {
|
||||
func (page *TaxesPage) MustRender(w http.ResponseWriter, r *http.Request) {
|
||||
mustRenderMainTemplate(w, r, "company/taxes.gohtml", page)
|
||||
}
|
||||
|
||||
func mustCollectTaxes(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)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
@ -525,29 +597,6 @@ func mustGetTaxes(ctx context.Context, conn *Conn, company *Company) []*Tax {
|
|||
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 {
|
||||
locale *Locale
|
||||
Name *InputField
|
||||
|
@ -604,25 +653,6 @@ func (form *taxForm) Validate() bool {
|
|||
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) {
|
||||
locale := getLocale(r)
|
||||
conn := getConn(r)
|
||||
|
@ -637,24 +667,110 @@ func HandleAddCompanyTax(w http.ResponseWriter, r *http.Request, _ httprouter.Pa
|
|||
return
|
||||
}
|
||||
if !form.Validate() {
|
||||
if !IsHTMxRequest(r) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
}
|
||||
mustRenderTaxForm(w, r, form)
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
page := newTaxesPageWithForm(r.Context(), conn, company, form)
|
||||
page.MustRender(w, r)
|
||||
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())
|
||||
if IsHTMxRequest(r) {
|
||||
mustRenderTaxForm(w, r, newTaxForm(r.Context(), conn, company, locale))
|
||||
} else {
|
||||
http.Redirect(w, r, companyURI(company, "/tax-details"), http.StatusSeeOther)
|
||||
htmxRedirect(w, r, companyURI(company, "/taxes"))
|
||||
}
|
||||
|
||||
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)
|
||||
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 {
|
||||
locale *Locale
|
||||
Name *InputField
|
||||
Instructions *InputField
|
||||
locale *Locale
|
||||
PaymentMethodId int
|
||||
Name *InputField
|
||||
Instructions *InputField
|
||||
}
|
||||
|
||||
func newPaymentMethodForm(locale *Locale) *paymentMethodForm {
|
||||
|
@ -662,7 +778,7 @@ func newPaymentMethodForm(locale *Locale) *paymentMethodForm {
|
|||
locale: locale,
|
||||
Name: &InputField{
|
||||
Name: "method_name",
|
||||
Label: pgettext("input", "Payment method name", locale),
|
||||
Label: pgettext("input", "Invoicing method name", locale),
|
||||
Type: "text",
|
||||
Required: true,
|
||||
},
|
||||
|
@ -675,6 +791,25 @@ 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 {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return err
|
||||
|
@ -686,30 +821,11 @@ func (form *paymentMethodForm) Parse(r *http.Request) error {
|
|||
|
||||
func (form *paymentMethodForm) Validate() bool {
|
||||
validator := newFormValidator()
|
||||
validator.CheckRequiredInput(form.Name, gettext("Payment method name can not be empty.", form.locale))
|
||||
validator.CheckRequiredInput(form.Name, gettext("Invoicing method name can not be empty.", form.locale))
|
||||
validator.CheckRequiredInput(form.Instructions, gettext("Payment instructions can not be empty.", form.locale))
|
||||
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) {
|
||||
locale := getLocale(r)
|
||||
conn := getConn(r)
|
||||
|
@ -724,16 +840,157 @@ func HandleAddPaymentMethod(w http.ResponseWriter, r *http.Request, _ httprouter
|
|||
return
|
||||
}
|
||||
if !form.Validate() {
|
||||
if !IsHTMxRequest(r) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
}
|
||||
mustRenderPaymentMethodForm(w, r, form)
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
page := newPaymentMethodsPageWithForm(r.Context(), conn, company, form)
|
||||
page.MustRender(w, r)
|
||||
return
|
||||
}
|
||||
conn.MustExec(r.Context(), "insert into payment_method (company_id, name, instructions) values ($1, $2, $3)", company.Id, form.Name, form.Instructions)
|
||||
if IsHTMxRequest(r) {
|
||||
mustRenderPaymentMethodForm(w, r, newPaymentMethodForm(locale))
|
||||
} else {
|
||||
http.Redirect(w, r, companyURI(company, "/tax-details"), http.StatusSeeOther)
|
||||
}
|
||||
htmxRedirect(w, r, companyURI(company, "/payment-methods"))
|
||||
}
|
||||
|
||||
func HandleUpdatePaymentMethod(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
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
|
||||
}
|
||||
|
|
|
@ -53,6 +53,10 @@ func GetContactForm(w http.ResponseWriter, r *http.Request, params httprouter.Pa
|
|||
mustRenderNewContactForm(w, r, form)
|
||||
return
|
||||
}
|
||||
if !ValidUuid(slug) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !form.MustFillFromDatabase(r.Context(), conn, slug) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
|
@ -93,9 +97,7 @@ func HandleAddContact(w http.ResponseWriter, r *http.Request, _ httprouter.Param
|
|||
return
|
||||
}
|
||||
if !form.Validate(r.Context(), conn) {
|
||||
if !IsHTMxRequest(r) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
}
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
mustRenderNewContactForm(w, r, form)
|
||||
return
|
||||
}
|
||||
|
@ -120,7 +122,12 @@ func HandleUpdateContact(w http.ResponseWriter, r *http.Request, params httprout
|
|||
mustRenderEditContactForm(w, r, params[0].Value, form)
|
||||
return
|
||||
}
|
||||
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)
|
||||
slug := params[0].Value
|
||||
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 == "" {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
@ -172,6 +179,11 @@ func (form *contactFilterForm) Parse(r *http.Request) error {
|
|||
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 {
|
||||
args := []interface{}{company.Id}
|
||||
where := []string{"contact.company_id = $1"}
|
||||
|
@ -477,36 +489,11 @@ func (form *contactForm) TaxDetails() *CustomerTaxDetails {
|
|||
}
|
||||
|
||||
func ServeEditContactTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
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)
|
||||
serveTagsEditForm(w, r, params, "/contacts/", "select tags from contact where slug = $1")
|
||||
}
|
||||
|
||||
func HandleUpdateContactTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
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)
|
||||
handleUpdateTags(w, r, params, "/contacts/", "update contact set tags = $1 where slug = $2 returning slug")
|
||||
}
|
||||
|
||||
func ServeImportPage(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
|
|
|
@ -66,7 +66,7 @@ func ServeDashboard(w http.ResponseWriter, r *http.Request, _ httprouter.Params)
|
|||
rows := conn.MustQuery(r.Context(), fmt.Sprintf(`
|
||||
select to_price(0, decimal_digits) as sales
|
||||
, to_price(coalesce(invoice.total, 0), decimal_digits) as income
|
||||
, to_price(coalesce(expense.total, 0), decimal_digits) as expenses
|
||||
, to_price(coalesce(expense.total, 0) + coalesce(expense_tax.vat, 0) + coalesce(expense_tax.irpf, 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.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
|
||||
|
@ -201,9 +201,21 @@ func buildDashboardChart(ctx context.Context, conn *Conn, locale *Locale, compan
|
|||
) as invoice
|
||||
left join (
|
||||
select to_char(date.invoice_date, '%[3]s')::integer as date
|
||||
, sum(amount)::integer as total
|
||||
, sum(subtotal + taxes)::integer as total
|
||||
from generate_series(%[1]s, %[2]s, interval '1 day') as date(invoice_date)
|
||||
left join expense on expense.invoice_date = date.invoice_date and company_id = $1
|
||||
left join (
|
||||
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
|
||||
) as expense using (date)
|
||||
order by date
|
||||
|
|
446
pkg/expenses.go
446
pkg/expenses.go
|
@ -13,19 +13,28 @@ import (
|
|||
)
|
||||
|
||||
type ExpenseEntry struct {
|
||||
ID int
|
||||
Slug string
|
||||
InvoiceDate time.Time
|
||||
InvoiceNumber string
|
||||
Amount string
|
||||
Taxes map[string]string
|
||||
Total string
|
||||
InvoicerName string
|
||||
OriginalFileName string
|
||||
Tags []string
|
||||
Status string
|
||||
StatusLabel string
|
||||
}
|
||||
|
||||
type expensesIndexPage struct {
|
||||
Expenses []*ExpenseEntry
|
||||
TotalAmount string
|
||||
Filters *expenseFilterForm
|
||||
Expenses []*ExpenseEntry
|
||||
SumAmount string
|
||||
SumTaxes map[string]string
|
||||
SumTotal string
|
||||
Filters *expenseFilterForm
|
||||
TaxClasses []string
|
||||
ExpenseStatuses map[string]string
|
||||
}
|
||||
|
||||
func IndexExpenses(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
|
@ -38,38 +47,66 @@ func IndexExpenses(w http.ResponseWriter, r *http.Request, _ httprouter.Params)
|
|||
return
|
||||
}
|
||||
page := &expensesIndexPage{
|
||||
Expenses: mustCollectExpenseEntries(r.Context(), conn, filters),
|
||||
TotalAmount: mustComputeExpensesTotalAmount(r.Context(), conn, filters),
|
||||
Filters: filters,
|
||||
Expenses: mustCollectExpenseEntries(r.Context(), conn, locale, filters),
|
||||
ExpenseStatuses: mustCollectExpenseStatuses(r.Context(), conn, locale),
|
||||
TaxClasses: mustCollectTaxClasses(r.Context(), conn, company),
|
||||
Filters: filters,
|
||||
}
|
||||
page.mustComputeExpensesTotalAmount(r.Context(), conn, filters)
|
||||
mustRenderMainTemplate(w, r, "expenses/index.gohtml", page)
|
||||
}
|
||||
|
||||
func mustCollectExpenseEntries(ctx context.Context, conn *Conn, filters *expenseFilterForm) []*ExpenseEntry {
|
||||
where, args := filters.BuildQuery(nil)
|
||||
func mustCollectExpenseEntries(ctx context.Context, conn *Conn, locale *Locale, filters *expenseFilterForm) []*ExpenseEntry {
|
||||
where, args := filters.BuildQuery([]interface{}{locale.Language.String()})
|
||||
rows := conn.MustQuery(ctx, fmt.Sprintf(`
|
||||
select expense.slug
|
||||
select expense_id
|
||||
, expense.slug
|
||||
, invoice_date
|
||||
, invoice_number
|
||||
, to_price(amount, decimal_digits)
|
||||
, to_price(expense.amount, decimal_digits) as amount
|
||||
, 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
|
||||
, coalesce(attachment.original_filename, '')
|
||||
, expense.tags
|
||||
, expense.expense_status
|
||||
, esi18n.name
|
||||
from expense
|
||||
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 expense_status_i18n esi18n on expense.expense_status = esi18n.expense_status and esi18n.lang_tag = $1
|
||||
join currency using (currency_code)
|
||||
where (%s)
|
||||
order by invoice_date
|
||||
group by expense_id
|
||||
, 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...)
|
||||
defer rows.Close()
|
||||
|
||||
var entries []*ExpenseEntry
|
||||
for rows.Next() {
|
||||
entry := &ExpenseEntry{}
|
||||
if err := rows.Scan(&entry.Slug, &entry.InvoiceDate, &entry.InvoiceNumber, &entry.Amount, &entry.InvoicerName, &entry.OriginalFileName, &entry.Tags); err != nil {
|
||||
entry := &ExpenseEntry{
|
||||
Taxes: make(map[string]string),
|
||||
}
|
||||
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)
|
||||
}
|
||||
for _, tax := range taxes {
|
||||
entry.Taxes[tax[0]] = tax[1]
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
if rows.Err() != nil {
|
||||
|
@ -79,15 +116,96 @@ func mustCollectExpenseEntries(ctx context.Context, conn *Conn, filters *expense
|
|||
return entries
|
||||
}
|
||||
|
||||
func mustComputeExpensesTotalAmount(ctx context.Context, conn *Conn, filters *expenseFilterForm) string {
|
||||
func mustCollectExpenseStatuses(ctx context.Context, conn *Conn, locale *Locale) map[string]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)
|
||||
return conn.MustGetText(ctx, "0", fmt.Sprintf(`
|
||||
select to_price(sum(amount)::integer, decimal_digits)
|
||||
from expense
|
||||
row := conn.QueryRow(ctx, fmt.Sprintf(`
|
||||
select to_price(sum(subtotal)::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
|
||||
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)
|
||||
where (%s)
|
||||
group by decimal_digits
|
||||
`, 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) {
|
||||
|
@ -102,6 +220,10 @@ func ServeExpenseForm(w http.ResponseWriter, r *http.Request, params httprouter.
|
|||
mustRenderNewExpenseForm(w, r, form)
|
||||
return
|
||||
}
|
||||
if !ValidUuid(slug) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !form.MustFillFromDatabase(r.Context(), conn, slug) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
|
@ -113,20 +235,40 @@ func ServeExpenseForm(w http.ResponseWriter, r *http.Request, params httprouter.
|
|||
func mustRenderNewExpenseForm(w http.ResponseWriter, r *http.Request, form *expenseForm) {
|
||||
locale := getLocale(r)
|
||||
form.Invoicer.EmptyLabel = gettext("Select a contact.", locale)
|
||||
mustRenderMainTemplate(w, r, "expenses/new.gohtml", form)
|
||||
page := newNewExpensePage(form, r)
|
||||
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) {
|
||||
page := &editExpensePage{
|
||||
Slug: slug,
|
||||
Form: form,
|
||||
newNewExpensePage(form, r),
|
||||
slug,
|
||||
}
|
||||
mustRenderMainTemplate(w, r, "expenses/edit.gohtml", page)
|
||||
}
|
||||
|
||||
type editExpensePage struct {
|
||||
*newExpensePage
|
||||
Slug string
|
||||
Form *expenseForm
|
||||
}
|
||||
|
||||
type expenseForm struct {
|
||||
|
@ -142,6 +284,7 @@ type expenseForm struct {
|
|||
}
|
||||
|
||||
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{
|
||||
locale: locale,
|
||||
company: company,
|
||||
|
@ -167,6 +310,9 @@ func newExpenseForm(ctx context.Context, conn *Conn, locale *Locale, company *Co
|
|||
Label: pgettext("input", "Taxes", locale),
|
||||
Multiple: true,
|
||||
Options: mustGetTaxOptions(ctx, conn, company),
|
||||
Attributes: []template.HTMLAttr{
|
||||
triggerRecompute,
|
||||
},
|
||||
},
|
||||
Amount: &InputField{
|
||||
Name: "amount",
|
||||
|
@ -174,6 +320,7 @@ func newExpenseForm(ctx context.Context, conn *Conn, locale *Locale, company *Co
|
|||
Type: "number",
|
||||
Required: true,
|
||||
Attributes: []template.HTMLAttr{
|
||||
triggerRecompute,
|
||||
`min="0"`,
|
||||
template.HTMLAttr(fmt.Sprintf(`step="%v"`, company.MinCents())),
|
||||
},
|
||||
|
@ -190,6 +337,16 @@ 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 {
|
||||
if err := r.ParseMultipartForm(form.File.MaxSize); err != nil {
|
||||
return err
|
||||
|
@ -215,18 +372,16 @@ func (form *expenseForm) Validate() bool {
|
|||
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.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()
|
||||
}
|
||||
|
||||
func (form *expenseForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool {
|
||||
return !notFoundErrorOrPanic(conn.QueryRow(ctx, `
|
||||
if notFoundErrorOrPanic(conn.QueryRow(ctx, `
|
||||
select contact_id
|
||||
, invoice_number
|
||||
, invoice_date
|
||||
, to_price(amount, decimal_digits)
|
||||
, array_agg(tax_id)
|
||||
, array_agg(tax_id) filter ( where tax_id is not null )
|
||||
, tags
|
||||
from expense
|
||||
left join expense_tax using (expense_id)
|
||||
|
@ -244,36 +399,14 @@ func (form *expenseForm) MustFillFromDatabase(ctx context.Context, conn *Conn, s
|
|||
form.InvoiceDate,
|
||||
form.Amount,
|
||||
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) {
|
||||
conn := getConn(r)
|
||||
locale := getLocale(r)
|
||||
|
@ -288,10 +421,12 @@ func HandleUpdateExpense(w http.ResponseWriter, r *http.Request, params httprout
|
|||
return
|
||||
}
|
||||
slug := params[0].Value
|
||||
if !ValidUuid(slug) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !form.Validate() {
|
||||
if !IsHTMxRequest(r) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
}
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
mustRenderEditExpenseForm(w, r, slug, form)
|
||||
return
|
||||
}
|
||||
|
@ -313,6 +448,7 @@ type expenseFilterForm struct {
|
|||
InvoiceNumber *InputField
|
||||
FromDate *InputField
|
||||
ToDate *InputField
|
||||
ExpenseStatus *SelectField
|
||||
Tags *TagsField
|
||||
TagsCondition *ToggleField
|
||||
}
|
||||
|
@ -346,6 +482,12 @@ func newExpenseFilterForm(ctx context.Context, conn *Conn, locale *Locale, compa
|
|||
Name: "tags",
|
||||
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{
|
||||
Name: "tags_condition",
|
||||
Label: pgettext("input", "Tags Condition", locale),
|
||||
|
@ -372,11 +514,21 @@ func (form *expenseFilterForm) Parse(r *http.Request) error {
|
|||
form.InvoiceNumber.FillValue(r)
|
||||
form.FromDate.FillValue(r)
|
||||
form.ToDate.FillValue(r)
|
||||
form.ExpenseStatus.FillValue(r)
|
||||
form.Tags.FillValue(r)
|
||||
form.TagsCondition.FillValue(r)
|
||||
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{}) {
|
||||
var where []string
|
||||
appendWhere := func(expression string, value interface{}) {
|
||||
|
@ -398,6 +550,7 @@ func (form *expenseFilterForm) BuildQuery(args []interface{}) (string, []interfa
|
|||
customerId, _ := strconv.Atoi(form.Contact.Selected[0])
|
||||
return customerId
|
||||
})
|
||||
maybeAppendWhere("expense.expense_status = $%d", form.ExpenseStatus.String(), nil)
|
||||
maybeAppendWhere("invoice_number = $%d", form.InvoiceNumber.String(), nil)
|
||||
maybeAppendWhere("invoice_date >= $%d", form.FromDate.String(), nil)
|
||||
maybeAppendWhere("invoice_date <= $%d", form.ToDate.String(), nil)
|
||||
|
@ -413,24 +566,51 @@ func (form *expenseFilterForm) BuildQuery(args []interface{}) (string, []interfa
|
|||
}
|
||||
|
||||
func ServeEditExpenseTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
conn := getConn(r)
|
||||
locale := getLocale(r)
|
||||
company := getCompany(r)
|
||||
slug := params[0].Value
|
||||
form := newTagsForm(companyURI(company, "/expenses/"+slug+"/tags"), slug, locale)
|
||||
if notFoundErrorOrPanic(conn.QueryRow(r.Context(), `select tags from expense where slug = $1`, form.Slug).Scan(form.Tags)) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
mustRenderStandaloneTemplate(w, r, "tags/edit.gohtml", form)
|
||||
serveTagsEditForm(w, r, params, "/expenses/", "select tags from expense where slug = $1")
|
||||
}
|
||||
|
||||
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
|
||||
switch slug {
|
||||
case "batch":
|
||||
HandleBatchExpenseAction(w, r, params)
|
||||
default:
|
||||
if !ValidUuid(slug) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
actionUri := fmt.Sprintf("/invoices/%s/edit", slug)
|
||||
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) {
|
||||
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)
|
||||
conn := getConn(r)
|
||||
company := getCompany(r)
|
||||
slug := params[0].Value
|
||||
form := newTagsForm(companyURI(company, "/expenses/"+slug+"/tags/edit"), slug, locale)
|
||||
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
|
||||
|
@ -439,29 +619,117 @@ func HandleUpdateExpenseTags(w http.ResponseWriter, r *http.Request, params http
|
|||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if conn.MustGetText(r.Context(), "", "update expense set tags = $1 where slug = $2 returning slug", form.Tags, form.Slug) == "" {
|
||||
http.NotFound(w, r)
|
||||
actionField := r.Form.Get("action")
|
||||
switch actionField {
|
||||
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 ServeExpenseAttachment(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
func HandleBatchExpenseAction(w http.ResponseWriter, r *http.Request, _ 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
|
||||
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)) {
|
||||
if !ValidUuid(slug) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(content)), 10))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(content)
|
||||
|
||||
if err := verifyCsrfTokenValid(r); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
conn := getConn(r)
|
||||
conn.MustExec(r.Context(), "select remove_expense($1)", slug)
|
||||
|
||||
company := mustGetCompany(r)
|
||||
htmxRedirect(w, r, companyURI(company, "/expenses"))
|
||||
}
|
||||
|
|
59
pkg/form.go
59
pkg/form.go
|
@ -59,6 +59,10 @@ func (field *InputField) Value() (driver.Value, error) {
|
|||
return field.Val, nil
|
||||
}
|
||||
|
||||
func (field *InputField) HasValue() bool {
|
||||
return field.Val != ""
|
||||
}
|
||||
|
||||
func (field *InputField) FillValue(r *http.Request) {
|
||||
field.Val = strings.TrimSpace(r.FormValue(field.Name))
|
||||
}
|
||||
|
@ -184,6 +188,10 @@ func (field *SelectField) Clear() {
|
|||
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 {
|
||||
rows, err := conn.Query(ctx, sql, args...)
|
||||
if err != nil {
|
||||
|
@ -287,6 +295,33 @@ func (field *RadioField) isValidOption(selected string) bool {
|
|||
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 {
|
||||
Name string
|
||||
Label string
|
||||
|
@ -366,6 +401,10 @@ func (field *TagsField) Value() (driver.Value, error) {
|
|||
return field.Tags, nil
|
||||
}
|
||||
|
||||
func (field *TagsField) HasValue() bool {
|
||||
return len(field.Tags) > 0 && field.Tags[0] != ""
|
||||
}
|
||||
|
||||
func (field *TagsField) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
return nil
|
||||
|
@ -416,6 +455,10 @@ func (field *ToggleField) FillValue(r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
func (field *ToggleField) String() string {
|
||||
return field.Selected
|
||||
}
|
||||
|
||||
type FormValidator struct {
|
||||
Valid bool
|
||||
}
|
||||
|
@ -436,6 +479,10 @@ func (v *FormValidator) CheckInputMinLength(field *InputField, min int, 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 {
|
||||
_, err := mail.ParseAddress(field.Val)
|
||||
return v.checkInput(field, err == nil, message)
|
||||
|
@ -465,6 +512,10 @@ func (v *FormValidator) CheckValidSelectOption(field *SelectField, message strin
|
|||
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 {
|
||||
repeated := false
|
||||
groups := map[string]bool{}
|
||||
|
@ -523,3 +574,11 @@ func (v *FormValidator) checkSelect(field *SelectField, ok bool, message string)
|
|||
}
|
||||
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
|
||||
}
|
||||
|
|
450
pkg/invoices.go
450
pkg/invoices.go
|
@ -6,6 +6,7 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/jackc/pgtype"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"html/template"
|
||||
"io"
|
||||
|
@ -23,9 +24,11 @@ import (
|
|||
const removedProductSuffix = ".removed"
|
||||
|
||||
type InvoiceEntry struct {
|
||||
ID int
|
||||
Slug string
|
||||
Date time.Time
|
||||
Number string
|
||||
Subtotal string
|
||||
Total string
|
||||
CustomerName string
|
||||
Tags []string
|
||||
|
@ -61,13 +64,15 @@ func IndexInvoices(w http.ResponseWriter, r *http.Request, _ httprouter.Params)
|
|||
func mustCollectInvoiceEntries(ctx context.Context, conn *Conn, locale *Locale, filters *invoiceFilterForm) []*InvoiceEntry {
|
||||
where, args := filters.BuildQuery([]interface{}{locale.Language.String()})
|
||||
rows := conn.MustQuery(ctx, fmt.Sprintf(`
|
||||
select invoice.slug
|
||||
select invoice_id
|
||||
, invoice.slug
|
||||
, invoice_date
|
||||
, invoice_number
|
||||
, contact.name
|
||||
, invoice.tags
|
||||
, invoice.invoice_status
|
||||
, isi18n.name
|
||||
, to_price(subtotal, decimal_digits)
|
||||
, to_price(total, decimal_digits)
|
||||
from invoice
|
||||
join contact using (contact_id)
|
||||
|
@ -83,7 +88,7 @@ func mustCollectInvoiceEntries(ctx context.Context, conn *Conn, locale *Locale,
|
|||
var entries []*InvoiceEntry
|
||||
for rows.Next() {
|
||||
entry := &InvoiceEntry{}
|
||||
if err := rows.Scan(&entry.Slug, &entry.Date, &entry.Number, &entry.CustomerName, &entry.Tags, &entry.Status, &entry.StatusLabel, &entry.Total); err != nil {
|
||||
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 {
|
||||
panic(err)
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
|
@ -152,7 +157,7 @@ func newInvoiceFilterForm(ctx context.Context, conn *Conn, locale *Locale, compa
|
|||
Name: "invoice_status",
|
||||
Label: pgettext("input", "Invoice Status", locale),
|
||||
EmptyLabel: gettext("All status", 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()),
|
||||
Options: mustGetInvoiceStatusOptions(ctx, conn, locale),
|
||||
},
|
||||
InvoiceNumber: &InputField{
|
||||
Name: "number",
|
||||
|
@ -205,6 +210,15 @@ func (form *invoiceFilterForm) Parse(r *http.Request) error {
|
|||
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{}) {
|
||||
var where []string
|
||||
appendWhere := func(expression string, value interface{}) {
|
||||
|
@ -248,10 +262,9 @@ func ServeInvoice(w http.ResponseWriter, r *http.Request, params httprouter.Para
|
|||
case "new":
|
||||
locale := getLocale(r)
|
||||
form := newInvoiceForm(r.Context(), conn, locale, company)
|
||||
if invoiceToDuplicate := r.URL.Query().Get("duplicate"); invoiceToDuplicate != "" {
|
||||
if invoiceToDuplicate := r.URL.Query().Get("duplicate"); ValidUuid(invoiceToDuplicate) {
|
||||
form.MustFillFromDatabase(r.Context(), conn, invoiceToDuplicate)
|
||||
form.InvoiceStatus.Selected = []string{"created"}
|
||||
} else if quoteToInvoice := r.URL.Query().Get("quote"); quoteToInvoice != "" {
|
||||
} else if quoteToInvoice := r.URL.Query().Get("quote"); ValidUuid(quoteToInvoice) {
|
||||
form.MustFillFromQuote(r.Context(), conn, quoteToInvoice)
|
||||
}
|
||||
form.Date.Val = time.Now().Format("2006-01-02")
|
||||
|
@ -280,6 +293,10 @@ func ServeInvoice(w http.ResponseWriter, r *http.Request, params httprouter.Para
|
|||
pdf = true
|
||||
slug = slug[:len(slug)-len(".pdf")]
|
||||
}
|
||||
if !ValidUuid(slug) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
inv := mustGetInvoice(r.Context(), conn, company, slug)
|
||||
if inv == nil {
|
||||
http.NotFound(w, r)
|
||||
|
@ -349,6 +366,7 @@ type invoice struct {
|
|||
HasDiscounts bool
|
||||
Total string
|
||||
LegalDisclaimer string
|
||||
OriginalFileName string
|
||||
}
|
||||
|
||||
type taxDetails struct {
|
||||
|
@ -394,11 +412,13 @@ func mustGetInvoice(ctx context.Context, conn *Conn, company *Company, slug stri
|
|||
, postal_code
|
||||
, to_price(subtotal, decimal_digits)
|
||||
, to_price(total, decimal_digits)
|
||||
, coalesce(attachment.original_filename, '')
|
||||
from invoice
|
||||
join payment_method using (payment_method_id)
|
||||
join contact_tax_details using (contact_id)
|
||||
join invoice_amount using (invoice_id)
|
||||
join currency using (currency_code)
|
||||
left join invoice_attachment as attachment using (invoice_id)
|
||||
where invoice.slug = $1`, slug).Scan(
|
||||
&invoiceId,
|
||||
&decimalDigits,
|
||||
|
@ -413,16 +433,67 @@ func mustGetInvoice(ctx context.Context, conn *Conn, company *Company, slug stri
|
|||
&inv.Invoicee.Province,
|
||||
&inv.Invoicee.PostalCode,
|
||||
&inv.Subtotal,
|
||||
&inv.Total)) {
|
||||
&inv.Total,
|
||||
&inv.OriginalFileName)) {
|
||||
return nil
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
panic(err)
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
panic(err)
|
||||
}
|
||||
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)
|
||||
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_id
|
||||
, invoice_product.name
|
||||
, description
|
||||
, discount_rate
|
||||
, price
|
||||
, quantity
|
||||
, subtotal
|
||||
, total
|
||||
order by invoice_product_id
|
||||
`, invoiceId, decimalDigits)
|
||||
defer rows.Close()
|
||||
taxClasses := map[string]bool{}
|
||||
for rows.Next() {
|
||||
|
@ -430,7 +501,15 @@ func mustGetInvoice(ctx context.Context, conn *Conn, company *Company, slug stri
|
|||
Taxes: make(map[string]int),
|
||||
}
|
||||
var taxes [][]string
|
||||
if err := rows.Scan(&product.Name, &product.Description, &product.Price, &product.Discount, &product.Quantity, &product.Subtotal, &product.Total, &taxes); err != nil {
|
||||
if err := rows.Scan(
|
||||
&product.Name,
|
||||
&product.Description,
|
||||
&product.Price,
|
||||
&product.Discount,
|
||||
&product.Quantity,
|
||||
&product.Subtotal,
|
||||
&product.Total,
|
||||
&taxes); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for _, tax := range taxes {
|
||||
|
@ -540,13 +619,14 @@ func HandleAddInvoice(w http.ResponseWriter, r *http.Request, _ httprouter.Param
|
|||
return
|
||||
}
|
||||
if !form.Validate() {
|
||||
if !IsHTMxRequest(r) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
}
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
mustRenderNewInvoiceForm(w, r, form)
|
||||
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))
|
||||
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))
|
||||
}
|
||||
|
||||
|
@ -570,14 +650,14 @@ func HandleBatchInvoiceAction(w http.ResponseWriter, r *http.Request, _ httprout
|
|||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
slugs := r.Form["invoice"]
|
||||
if len(slugs) == 0 {
|
||||
http.Redirect(w, r, companyURI(mustGetCompany(r), "/invoices"), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
locale := getLocale(r)
|
||||
switch r.Form.Get("action") {
|
||||
case "download":
|
||||
slugs := r.Form["invoice"]
|
||||
if len(slugs) == 0 {
|
||||
http.Redirect(w, r, companyURI(mustGetCompany(r), "/invoices"), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
invoices := mustWriteInvoicesPdf(r, slugs)
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", gettext("invoices.zip", locale)))
|
||||
|
@ -585,11 +665,187 @@ func HandleBatchInvoiceAction(w http.ResponseWriter, r *http.Request, _ httprout
|
|||
if _, err := w.Write(invoices); err != nil {
|
||||
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:
|
||||
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 {
|
||||
conn := getConn(r)
|
||||
company := mustGetCompany(r)
|
||||
|
@ -600,7 +856,7 @@ func mustWriteInvoicesPdf(r *http.Request, slugs []string) []byte {
|
|||
if inv == nil {
|
||||
continue
|
||||
}
|
||||
f, err := w.Create(inv.Number + ".pdf")
|
||||
f, err := w.Create(fmt.Sprintf("%s-%s.pdf", inv.Number, slugify(inv.Invoicee.Name)))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
@ -614,7 +870,6 @@ type invoiceForm struct {
|
|||
locale *Locale
|
||||
company *Company
|
||||
Number string
|
||||
InvoiceStatus *SelectField
|
||||
Customer *SelectField
|
||||
Date *InputField
|
||||
Notes *InputField
|
||||
|
@ -622,19 +877,13 @@ type invoiceForm struct {
|
|||
Tags *TagsField
|
||||
Products []*invoiceProductForm
|
||||
RemovedProduct *invoiceProductForm
|
||||
File *FileField
|
||||
}
|
||||
|
||||
func newInvoiceForm(ctx context.Context, conn *Conn, locale *Locale, company *Company) *invoiceForm {
|
||||
return &invoiceForm{
|
||||
locale: locale,
|
||||
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{
|
||||
Name: "customer",
|
||||
Label: pgettext("input", "Customer", locale),
|
||||
|
@ -659,23 +908,40 @@ func newInvoiceForm(ctx context.Context, conn *Conn, locale *Locale, company *Co
|
|||
PaymentMethod: &SelectField{
|
||||
Name: "payment_method",
|
||||
Required: true,
|
||||
Label: pgettext("input", "Payment Method", locale),
|
||||
Label: pgettext("input", "Invoicing Method", locale),
|
||||
Selected: []string{mustGetDefaultPaymentMethod(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 {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
if err := r.ParseMultipartForm(form.File.MaxSize); err != nil {
|
||||
return err
|
||||
}
|
||||
form.InvoiceStatus.FillValue(r)
|
||||
form.Customer.FillValue(r)
|
||||
form.Date.FillValue(r)
|
||||
form.Notes.FillValue(r)
|
||||
form.Tags.FillValue(r)
|
||||
form.PaymentMethod.FillValue(r)
|
||||
if err := form.File.FillValue(r); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := r.Form["product.id.0"]; ok {
|
||||
taxOptions := mustGetTaxOptions(r.Context(), getConn(r), form.company)
|
||||
for index := 0; true; index++ {
|
||||
|
@ -695,12 +961,11 @@ func (form *invoiceForm) Parse(r *http.Request) error {
|
|||
func (form *invoiceForm) Validate() bool {
|
||||
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))
|
||||
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.CheckValidSelectOption(form.PaymentMethod, gettext("Selected payment method is not valid.", form.locale))
|
||||
validator.CheckValidSelectOption(form.PaymentMethod, gettext("Selected invoicing method is not valid.", form.locale))
|
||||
|
||||
allOK := validator.AllOK()
|
||||
for _, product := range form.Products {
|
||||
|
@ -805,13 +1070,10 @@ func (form *invoiceForm) InsertProduct(product *invoiceProductForm) {
|
|||
|
||||
func (form *invoiceForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool {
|
||||
var invoiceId int
|
||||
selectedInvoiceStatus := form.InvoiceStatus.Selected
|
||||
form.InvoiceStatus.Clear()
|
||||
selectedPaymentMethod := form.PaymentMethod.Selected
|
||||
form.PaymentMethod.Clear()
|
||||
if notFoundErrorOrPanic(conn.QueryRow(ctx, `
|
||||
select invoice_id
|
||||
, invoice_status
|
||||
, contact_id
|
||||
, invoice_number
|
||||
, invoice_date
|
||||
|
@ -820,9 +1082,8 @@ func (form *invoiceForm) MustFillFromDatabase(ctx context.Context, conn *Conn, s
|
|||
, tags
|
||||
from invoice
|
||||
where slug = $1
|
||||
`, slug).Scan(&invoiceId, form.InvoiceStatus, form.Customer, &form.Number, form.Date, form.Notes, form.PaymentMethod, form.Tags)) {
|
||||
`, slug).Scan(&invoiceId, form.Customer, &form.Number, form.Date, form.Notes, form.PaymentMethod, form.Tags)) {
|
||||
form.PaymentMethod.Selected = selectedPaymentMethod
|
||||
form.InvoiceStatus.Selected = selectedInvoiceStatus
|
||||
return false
|
||||
}
|
||||
form.Products = []*invoiceProductForm{}
|
||||
|
@ -851,7 +1112,7 @@ func (form *invoiceForm) MustFillFromQuote(ctx context.Context, conn *Conn, slug
|
|||
return false
|
||||
}
|
||||
form.Products = []*invoiceProductForm{}
|
||||
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)
|
||||
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)
|
||||
return true
|
||||
}
|
||||
|
||||
|
@ -1058,28 +1319,25 @@ func HandleUpdateInvoice(w http.ResponseWriter, r *http.Request, params httprout
|
|||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if r.FormValue("quick") == "status" {
|
||||
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)
|
||||
}
|
||||
htmxRedirect(w, r, companyURI(mustGetCompany(r), "/invoices"))
|
||||
} else {
|
||||
slug := params[0].Value
|
||||
if !form.Validate() {
|
||||
if !IsHTMxRequest(r) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
}
|
||||
mustRenderEditInvoiceForm(w, r, slug, form)
|
||||
return
|
||||
}
|
||||
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 == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
htmxRedirect(w, r, companyURI(company, "/invoices/"+slug))
|
||||
slug := params[0].Value
|
||||
if !ValidUuid(slug) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !form.Validate() {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
mustRenderEditInvoiceForm(w, r, slug, form)
|
||||
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))
|
||||
if slug == "" {
|
||||
http.NotFound(w, r)
|
||||
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))
|
||||
}
|
||||
|
||||
func htmxRedirect(w http.ResponseWriter, r *http.Request, uri string) {
|
||||
|
@ -1098,6 +1356,10 @@ func ServeEditInvoice(w http.ResponseWriter, r *http.Request, params httprouter.
|
|||
conn := getConn(r)
|
||||
company := mustGetCompany(r)
|
||||
slug := params[0].Value
|
||||
if !ValidUuid(slug) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
locale := getLocale(r)
|
||||
form := newInvoiceForm(r.Context(), conn, locale, company)
|
||||
if !form.MustFillFromDatabase(r.Context(), conn, slug) {
|
||||
|
@ -1129,6 +1391,10 @@ func mustRenderEditInvoiceForm(w http.ResponseWriter, r *http.Request, slug stri
|
|||
|
||||
func HandleEditInvoiceAction(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
slug := params[0].Value
|
||||
if !ValidUuid(slug) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
actionUri := fmt.Sprintf("/invoices/%s/edit", slug)
|
||||
handleInvoiceAction(w, r, actionUri, func(w http.ResponseWriter, r *http.Request, form *invoiceForm) {
|
||||
conn := getConn(r)
|
||||
|
@ -1193,60 +1459,20 @@ 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) {
|
||||
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)
|
||||
serveTagsEditForm(w, r, params, "/invoices/", "select tags from invoice where slug = $1")
|
||||
}
|
||||
|
||||
func HandleUpdateInvoiceTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
locale := getLocale(r)
|
||||
conn := getConn(r)
|
||||
company := getCompany(r)
|
||||
slug := params[0].Value
|
||||
form := newTagsForm(companyURI(company, "/invoices/"+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 invoice set tags = $1 where slug = $2 returning slug", form.Tags, form.Slug) == "" {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
mustRenderStandaloneTemplate(w, r, "tags/view.gohtml", form)
|
||||
handleUpdateTags(w, r, params, "/invoices/", "update invoice set tags = $1 where slug = $2 returning slug")
|
||||
}
|
||||
|
||||
func ServeInvoiceAttachment(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
serveAttachment(w, r, params, `
|
||||
select mime_type
|
||||
, content
|
||||
from invoice
|
||||
join invoice_attachment using (invoice_id)
|
||||
where slug = $1
|
||||
`)
|
||||
}
|
||||
|
|
23
pkg/login.go
23
pkg/login.go
|
@ -20,6 +20,7 @@ const (
|
|||
sessionCookie = "numerus-session"
|
||||
defaultRole = "guest"
|
||||
csrfTokenField = "csfrToken"
|
||||
csrfTokenHeader = "X-CSRFToken"
|
||||
)
|
||||
|
||||
type loginForm struct {
|
||||
|
@ -29,8 +30,8 @@ type loginForm struct {
|
|||
Password *InputField
|
||||
}
|
||||
|
||||
func newLoginForm(locale *Locale) *loginForm {
|
||||
return &loginForm{
|
||||
func newLoginForm(demo bool, locale *Locale) *loginForm {
|
||||
form := &loginForm{
|
||||
locale: locale,
|
||||
Email: &InputField{
|
||||
Name: "email",
|
||||
|
@ -53,6 +54,11 @@ func newLoginForm(locale *Locale) *loginForm {
|
|||
},
|
||||
},
|
||||
}
|
||||
if demo {
|
||||
form.Email.Val = "admin@numerus"
|
||||
form.Password.Val = "admin"
|
||||
}
|
||||
return form
|
||||
}
|
||||
|
||||
func (form *loginForm) Parse(r *http.Request) error {
|
||||
|
@ -74,26 +80,26 @@ func (form *loginForm) Validate() bool {
|
|||
return validator.AllOK()
|
||||
}
|
||||
|
||||
func GetLoginForm(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
func GetLoginForm(w http.ResponseWriter, r *http.Request, demo bool) {
|
||||
user := getUser(r)
|
||||
if user.LoggedIn {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
locale := getLocale(r)
|
||||
form := newLoginForm(locale)
|
||||
form := newLoginForm(demo, locale)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
mustRenderLoginForm(w, r, form)
|
||||
}
|
||||
|
||||
func HandleLoginForm(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
func HandleLoginForm(w http.ResponseWriter, r *http.Request, demo bool) {
|
||||
user := getUser(r)
|
||||
if user.LoggedIn {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
locale := getLocale(r)
|
||||
form := newLoginForm(locale)
|
||||
form := newLoginForm(demo, locale)
|
||||
if err := form.Parse(r); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
|
@ -200,7 +206,10 @@ func LoginChecker(db *Db, next http.Handler) http.Handler {
|
|||
|
||||
func verifyCsrfTokenValid(r *http.Request) error {
|
||||
user := getUser(r)
|
||||
token := r.FormValue(csrfTokenField)
|
||||
token := r.Header.Get(csrfTokenHeader)
|
||||
if token == "" {
|
||||
token = r.FormValue(csrfTokenField)
|
||||
}
|
||||
if user.CsrfToken == token {
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -0,0 +1,267 @@
|
|||
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)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,739 @@
|
|||
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")
|
||||
}
|
|
@ -7,6 +7,10 @@ import (
|
|||
"github.com/jackc/pgx/v4"
|
||||
)
|
||||
|
||||
var (
|
||||
oidCache = make(map[string]uint32)
|
||||
)
|
||||
|
||||
type CustomerTaxDetails struct {
|
||||
BusinessName string
|
||||
VATIN string
|
||||
|
@ -325,8 +329,12 @@ 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) {
|
||||
if err = conn.QueryRow(ctx, "select $1::regtype::oid", name).Scan(&oid); err != nil {
|
||||
return
|
||||
var found bool
|
||||
if oid, found = oidCache[name]; !found {
|
||||
if err = conn.QueryRow(ctx, "select $1::regtype::oid", name).Scan(&oid); err != nil {
|
||||
return
|
||||
}
|
||||
oidCache[name] = oid
|
||||
}
|
||||
conn.ConnInfo().RegisterDataType(pgtype.DataType{Value: value, Name: name, OID: oid})
|
||||
return
|
||||
|
|
|
@ -50,6 +50,10 @@ func GetProductForm(w http.ResponseWriter, r *http.Request, params httprouter.Pa
|
|||
mustRenderNewProductForm(w, r, form)
|
||||
return
|
||||
}
|
||||
if !ValidUuid(slug) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !form.MustFillFromDatabase(r.Context(), conn, slug) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
|
@ -91,9 +95,7 @@ func HandleAddProduct(w http.ResponseWriter, r *http.Request, _ httprouter.Param
|
|||
return
|
||||
}
|
||||
if !form.Validate() {
|
||||
if !IsHTMxRequest(r) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
}
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
mustRenderNewProductForm(w, r, form)
|
||||
return
|
||||
}
|
||||
|
@ -136,10 +138,12 @@ func HandleUpdateProduct(w http.ResponseWriter, r *http.Request, params httprout
|
|||
return
|
||||
}
|
||||
slug := params[0].Value
|
||||
if !ValidUuid(slug) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !form.Validate() {
|
||||
if !IsHTMxRequest(r) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
}
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
mustRenderEditProductForm(w, r, slug, form)
|
||||
return
|
||||
}
|
||||
|
@ -196,6 +200,11 @@ func (form *productFilterForm) Parse(r *http.Request) error {
|
|||
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 {
|
||||
args := []interface{}{company.Id}
|
||||
where := []string{"product.company_id = $1"}
|
||||
|
@ -354,34 +363,9 @@ func HandleProductSearch(w http.ResponseWriter, r *http.Request, _ httprouter.Pa
|
|||
}
|
||||
|
||||
func ServeEditProductTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
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)
|
||||
serveTagsEditForm(w, r, params, "/products/", "select tags from product where slug = $1")
|
||||
}
|
||||
|
||||
func HandleUpdateProductTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
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)
|
||||
handleUpdateTags(w, r, params, "/products/", "update product set tags = $1 where slug = $2 returning slug")
|
||||
}
|
||||
|
|
|
@ -121,9 +121,7 @@ func HandleProfileForm(w http.ResponseWriter, r *http.Request, _ httprouter.Para
|
|||
return
|
||||
}
|
||||
if ok := form.Validate(); !ok {
|
||||
if !IsHTMxRequest(r) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
}
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
mustRenderProfileForm(w, r, form)
|
||||
return
|
||||
}
|
||||
|
|
158
pkg/quote.go
158
pkg/quote.go
|
@ -205,6 +205,15 @@ func (form *quoteFilterForm) Parse(r *http.Request) error {
|
|||
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{}) {
|
||||
var where []string
|
||||
appendWhere := func(expression string, value interface{}) {
|
||||
|
@ -249,7 +258,7 @@ func ServeQuote(w http.ResponseWriter, r *http.Request, params httprouter.Params
|
|||
case "new":
|
||||
locale := getLocale(r)
|
||||
form := newQuoteForm(r.Context(), conn, locale, company)
|
||||
if quoteToDuplicate := r.URL.Query().Get("duplicate"); quoteToDuplicate != "" {
|
||||
if quoteToDuplicate := r.URL.Query().Get("duplicate"); ValidUuid(quoteToDuplicate) {
|
||||
form.MustFillFromDatabase(r.Context(), conn, quoteToDuplicate)
|
||||
form.QuoteStatus.Selected = []string{"created"}
|
||||
}
|
||||
|
@ -279,6 +288,10 @@ func ServeQuote(w http.ResponseWriter, r *http.Request, params httprouter.Params
|
|||
pdf = true
|
||||
slug = slug[:len(slug)-len(".pdf")]
|
||||
}
|
||||
if !ValidUuid(slug) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
quo := mustGetQuote(r.Context(), conn, company, slug)
|
||||
if quo == nil {
|
||||
http.NotFound(w, r)
|
||||
|
@ -410,13 +423,60 @@ func mustGetQuote(ctx context.Context, conn *Conn, company *Company, slug string
|
|||
&quo.Total)) {
|
||||
return nil
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
panic(err)
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
panic(err)
|
||||
}
|
||||
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)
|
||||
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_id
|
||||
, quote_product.name
|
||||
, description
|
||||
, discount_rate
|
||||
, price
|
||||
, quantity
|
||||
, subtotal
|
||||
, total
|
||||
order by quote_product_id
|
||||
`, quoteId, decimalDigits)
|
||||
defer rows.Close()
|
||||
taxClasses := map[string]bool{}
|
||||
for rows.Next() {
|
||||
|
@ -424,7 +484,15 @@ func mustGetQuote(ctx context.Context, conn *Conn, company *Company, slug string
|
|||
Taxes: make(map[string]int),
|
||||
}
|
||||
var taxes [][]string
|
||||
if err := rows.Scan(&product.Name, &product.Description, &product.Price, &product.Discount, &product.Quantity, &product.Subtotal, &product.Total, &taxes); err != nil {
|
||||
if err := rows.Scan(
|
||||
&product.Name,
|
||||
&product.Description,
|
||||
&product.Price,
|
||||
&product.Discount,
|
||||
&product.Quantity,
|
||||
&product.Subtotal,
|
||||
&product.Total,
|
||||
&taxes); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for _, tax := range taxes {
|
||||
|
@ -507,9 +575,7 @@ func HandleAddQuote(w http.ResponseWriter, r *http.Request, _ httprouter.Params)
|
|||
return
|
||||
}
|
||||
if !form.Validate() {
|
||||
if !IsHTMxRequest(r) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
}
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
mustRenderNewQuoteForm(w, r, form)
|
||||
return
|
||||
}
|
||||
|
@ -537,14 +603,14 @@ func HandleBatchQuoteAction(w http.ResponseWriter, r *http.Request, _ httprouter
|
|||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
slugs := r.Form["quote"]
|
||||
if len(slugs) == 0 {
|
||||
http.Redirect(w, r, companyURI(mustGetCompany(r), "/quotes"), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
locale := getLocale(r)
|
||||
switch r.Form.Get("action") {
|
||||
case "download":
|
||||
slugs := r.Form["quote"]
|
||||
if len(slugs) == 0 {
|
||||
http.Redirect(w, r, companyURI(mustGetCompany(r), "/quotes"), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
quotes := mustWriteQuotesPdf(r, slugs)
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", gettext("quotations.zip", locale)))
|
||||
|
@ -552,6 +618,16 @@ func HandleBatchQuoteAction(w http.ResponseWriter, r *http.Request, _ httprouter
|
|||
if _, err := w.Write(quotes); err != nil {
|
||||
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:
|
||||
http.Error(w, gettext("Invalid action", locale), http.StatusBadRequest)
|
||||
}
|
||||
|
@ -631,8 +707,8 @@ func newQuoteForm(ctx context.Context, conn *Conn, locale *Locale, company *Comp
|
|||
},
|
||||
PaymentMethod: &SelectField{
|
||||
Name: "payment_method",
|
||||
Label: pgettext("input", "Payment Method", locale),
|
||||
EmptyLabel: gettext("Select a payment method.", locale),
|
||||
Label: pgettext("input", "Invoicing Method", locale),
|
||||
EmptyLabel: gettext("Select a invoicing method.", locale),
|
||||
Options: mustGetPaymentMethodOptions(ctx, conn, company),
|
||||
},
|
||||
}
|
||||
|
@ -676,7 +752,7 @@ func (form *quoteForm) Validate() bool {
|
|||
validator.CheckValidDate(form.Date, gettext("Quotation date must be a valid date.", form.locale))
|
||||
}
|
||||
if form.PaymentMethod.String() != "" {
|
||||
validator.CheckValidSelectOption(form.PaymentMethod, gettext("Selected payment method is not valid.", form.locale))
|
||||
validator.CheckValidSelectOption(form.PaymentMethod, gettext("Selected invoicing method is not valid.", form.locale))
|
||||
}
|
||||
|
||||
allOK := validator.AllOK()
|
||||
|
@ -972,18 +1048,21 @@ func HandleUpdateQuote(w http.ResponseWriter, r *http.Request, params httprouter
|
|||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
slug := params[0].Value
|
||||
if !ValidUuid(slug) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.FormValue("quick") == "status" {
|
||||
slug := conn.MustGetText(r.Context(), "", "update quote set quote_status = $1 where slug = $2 returning slug", form.QuoteStatus, params[0].Value)
|
||||
slug = conn.MustGetText(r.Context(), "", "update quote set quote_status = $1 where slug = $2 returning slug", form.QuoteStatus, slug)
|
||||
if slug == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
htmxRedirect(w, r, companyURI(mustGetCompany(r), "/quotes"))
|
||||
} else {
|
||||
slug := params[0].Value
|
||||
if !form.Validate() {
|
||||
if !IsHTMxRequest(r) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
}
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
mustRenderEditQuoteForm(w, r, slug, form)
|
||||
return
|
||||
}
|
||||
|
@ -1000,6 +1079,10 @@ func ServeEditQuote(w http.ResponseWriter, r *http.Request, params httprouter.Pa
|
|||
conn := getConn(r)
|
||||
company := mustGetCompany(r)
|
||||
slug := params[0].Value
|
||||
if !ValidUuid(slug) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
locale := getLocale(r)
|
||||
form := newQuoteForm(r.Context(), conn, locale, company)
|
||||
if !form.MustFillFromDatabase(r.Context(), conn, slug) {
|
||||
|
@ -1031,6 +1114,10 @@ func mustRenderEditQuoteForm(w http.ResponseWriter, r *http.Request, slug string
|
|||
|
||||
func HandleEditQuoteAction(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
slug := params[0].Value
|
||||
if !ValidUuid(slug) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
actionUri := fmt.Sprintf("/quotes/%s/edit", slug)
|
||||
handleQuoteAction(w, r, actionUri, func(w http.ResponseWriter, r *http.Request, form *quoteForm) {
|
||||
conn := getConn(r)
|
||||
|
@ -1096,34 +1183,9 @@ func handleQuoteAction(w http.ResponseWriter, r *http.Request, action string, re
|
|||
}
|
||||
|
||||
func ServeEditQuoteTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
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)
|
||||
serveTagsEditForm(w, r, params, "/quotes/", "select tags from quote where slug = $1")
|
||||
}
|
||||
|
||||
func HandleUpdateQuoteTags(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
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)
|
||||
handleUpdateTags(w, r, params, "/quotes/", "update quote set tags = $1 where slug = $2 returning slug")
|
||||
}
|
||||
|
|
|
@ -6,16 +6,23 @@ import (
|
|||
"net/http"
|
||||
)
|
||||
|
||||
func NewRouter(db *Db) http.Handler {
|
||||
func NewRouter(db *Db, demo bool) http.Handler {
|
||||
companyRouter := httprouter.New()
|
||||
companyRouter.GET("/profile", GetProfileForm)
|
||||
companyRouter.POST("/profile", HandleProfileForm)
|
||||
companyRouter.GET("/tax-details", GetCompanyTaxDetailsForm)
|
||||
companyRouter.POST("/tax-details", HandleCompanyTaxDetailsForm)
|
||||
companyRouter.POST("/tax", HandleAddCompanyTax)
|
||||
companyRouter.DELETE("/tax/:taxId", HandleDeleteCompanyTax)
|
||||
companyRouter.POST("/payment-method", HandleAddPaymentMethod)
|
||||
companyRouter.DELETE("/payment-method/:paymentMethodId", HandleDeletePaymentMethod)
|
||||
companyRouter.GET("/invoicing", serveCompanyInvoicingForm)
|
||||
companyRouter.POST("/invoicing", handleCompanyInvoicingForm)
|
||||
companyRouter.GET("/switch-company", GetCompanySwitcher)
|
||||
companyRouter.GET("/taxes", serveCompanyTaxes)
|
||||
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.POST("/contacts", HandleAddContact)
|
||||
companyRouter.POST("/contacts/import", HandleImportContacts)
|
||||
|
@ -38,6 +45,12 @@ func NewRouter(db *Db) http.Handler {
|
|||
companyRouter.POST("/invoices/:slug/edit", HandleEditInvoiceAction)
|
||||
companyRouter.PUT("/invoices/:slug/tags", HandleUpdateInvoiceTags)
|
||||
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.POST("/quotes", HandleAddQuote)
|
||||
companyRouter.GET("/quotes/:slug", ServeQuote)
|
||||
|
@ -49,18 +62,41 @@ func NewRouter(db *Db) http.Handler {
|
|||
companyRouter.GET("/quotes/:slug/tags/edit", ServeEditQuoteTags)
|
||||
companyRouter.GET("/search/products", HandleProductSearch)
|
||||
companyRouter.GET("/expenses", IndexExpenses)
|
||||
companyRouter.POST("/expenses", HandleAddExpense)
|
||||
companyRouter.POST("/expenses", HandleNewExpenseAction)
|
||||
companyRouter.GET("/expenses/:slug", ServeExpenseForm)
|
||||
companyRouter.POST("/expenses/:slug", HandleEditExpenseAction)
|
||||
companyRouter.PUT("/expenses/:slug", HandleUpdateExpense)
|
||||
companyRouter.DELETE("/expenses/:slug", handleRemoveExpense)
|
||||
companyRouter.PUT("/expenses/:slug/tags", HandleUpdateExpenseTags)
|
||||
companyRouter.GET("/expenses/:slug/tags/edit", ServeEditExpenseTags)
|
||||
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)
|
||||
|
||||
router := httprouter.New()
|
||||
router.ServeFiles("/static/*filepath", http.Dir("web/static"))
|
||||
router.GET("/login", GetLoginForm)
|
||||
router.POST("/login", HandleLoginForm)
|
||||
router.GET("/login", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
GetLoginForm(w, r, demo)
|
||||
})
|
||||
router.POST("/login", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
HandleLoginForm(w, r, demo)
|
||||
})
|
||||
router.POST("/logout", Authenticated(HandleLogout))
|
||||
|
||||
companyHandler := Authenticated(CompanyHandler(companyRouter))
|
||||
|
@ -69,6 +105,18 @@ func NewRouter(db *Db) http.Handler {
|
|||
router.PUT("/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) {
|
||||
user := getUser(r)
|
||||
if user.LoggedIn {
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
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
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
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)
|
||||
}
|
|
@ -10,6 +10,7 @@ import (
|
|||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
@ -20,6 +21,10 @@ func templateFile(name string) string {
|
|||
}
|
||||
|
||||
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)
|
||||
company := getCompany(r)
|
||||
user := getUser(r)
|
||||
|
@ -30,6 +35,12 @@ func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename s
|
|||
"currentLocale": func() 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 {
|
||||
return companyURI(company, uri)
|
||||
},
|
||||
|
@ -51,6 +62,9 @@ func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename s
|
|||
"csrfToken": func() template.HTML {
|
||||
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 {
|
||||
field.Attributes = append(field.Attributes, template.HTMLAttr(attr))
|
||||
return field
|
||||
|
@ -86,6 +100,12 @@ func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename s
|
|||
sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}
|
||||
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 {
|
||||
panic(err)
|
||||
|
@ -93,7 +113,7 @@ func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename s
|
|||
if w, ok := wr.(http.ResponseWriter); ok {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
}
|
||||
if err := t.ExecuteTemplate(wr, layout, data); err != nil {
|
||||
if err := t.ExecuteTemplate(wr, fragment, data); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
@ -129,7 +149,11 @@ func mustRenderMainTemplate(w io.Writer, r *http.Request, filename string, data
|
|||
}
|
||||
|
||||
func mustRenderStandaloneTemplate(w io.Writer, r *http.Request, filename string, data interface{}) {
|
||||
mustRenderTemplate(w, r, "standalone.gohtml", filename, data)
|
||||
mustRenderStandaloneTemplateFragment(w, r, filename, "standalone.gohtml", 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{}) {
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
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
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
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)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
-- Revert numerus:add_collection from pg
|
||||
|
||||
begin;
|
||||
|
||||
drop function if exists numerus.add_collection(integer, integer, date, integer, text, text, numerus.tag_name[]);
|
||||
|
||||
commit;
|
|
@ -1,7 +1,55 @@
|
|||
-- Revert numerus:add_expense from pg
|
||||
-- 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;
|
||||
|
||||
drop function if exists numerus.add_expense(integer, date, integer, text, text, integer[], numerus.tag_name[]);
|
||||
set search_path to numerus, public;
|
||||
|
||||
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;
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
-- Revert numerus:add_expense from pg
|
||||
|
||||
begin;
|
||||
|
||||
drop function if exists numerus.add_expense(integer, date, integer, text, text, integer[], numerus.tag_name[]);
|
||||
|
||||
commit;
|
|
@ -0,0 +1,52 @@
|
|||
-- 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;
|
|
@ -0,0 +1,7 @@
|
|||
-- Revert numerus:add_payment from pg
|
||||
|
||||
begin;
|
||||
|
||||
drop function if exists numerus.add_payment(integer, integer, date, integer, text, text, numerus.tag_name[]);
|
||||
|
||||
commit;
|
|
@ -0,0 +1,7 @@
|
|||
-- Revert numerus:add_payment_account_bank from pg
|
||||
|
||||
begin;
|
||||
|
||||
drop function if exists numerus.add_payment_account_bank(integer, text, iban);
|
||||
|
||||
commit;
|
|
@ -0,0 +1,7 @@
|
|||
-- Revert numerus:add_payment_account_card from pg
|
||||
|
||||
begin;
|
||||
|
||||
drop function if exists numerus.add_payment_account_card(integer, text, text, date);
|
||||
|
||||
commit;
|
|
@ -0,0 +1,7 @@
|
|||
-- Revert numerus:add_payment_account_cash from pg
|
||||
|
||||
begin;
|
||||
|
||||
drop function if exists numerus.add_payment_account_cash(integer, text);
|
||||
|
||||
commit;
|
|
@ -0,0 +1,7 @@
|
|||
-- Revert numerus:add_payment_account_other from pg
|
||||
|
||||
begin;
|
||||
|
||||
drop function if exists numerus.add_payment_account_other(integer, text);
|
||||
|
||||
commit;
|
|
@ -0,0 +1,7 @@
|
|||
-- Revert numerus:attach_to_collection from pg
|
||||
|
||||
begin;
|
||||
|
||||
drop function if exists numerus.attach_to_collection(uuid, text, text, bytea);
|
||||
|
||||
commit;
|
|
@ -0,0 +1,7 @@
|
|||
-- Revert numerus:attach_to_invoice from pg
|
||||
|
||||
begin;
|
||||
|
||||
drop function if exists numerus.attach_to_invoice(uuid, text, text, bytea);
|
||||
|
||||
commit;
|
|
@ -0,0 +1,7 @@
|
|||
-- Revert numerus:attach_to_payment from pg
|
||||
|
||||
begin;
|
||||
|
||||
drop function if exists numerus.attach_to_payment(uuid, text, text, bytea);
|
||||
|
||||
commit;
|
|
@ -0,0 +1,14 @@
|
|||
-- 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;
|
|
@ -0,0 +1,10 @@
|
|||
-- 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
Loading…
Reference in New Issue