diff --git a/deploy/remove_payment.sql b/deploy/remove_payment.sql new file mode 100644 index 0000000..b9d7c6f --- /dev/null +++ b/deploy/remove_payment.sql @@ -0,0 +1,39 @@ +-- Deploy numerus:remove_payment to pg +-- requires: roles +-- requires: schema_numerus +-- requires: expense_payment +-- requires: payment +-- requires: extension_pgcrypto +-- 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 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; diff --git a/deploy/update_expense_payment_status.sql b/deploy/update_expense_payment_status.sql index ecaee80..b1fb9bf 100644 --- a/deploy/update_expense_payment_status.sql +++ b/deploy/update_expense_payment_status.sql @@ -21,16 +21,17 @@ $$ ; update expense - set expense_status = case when paid_amount >= expense.amount then 'paid' else 'partial' end + set expense_status = case + when paid_amount >= expense.amount then 'paid' + when paid_amount = 0 then 'pending' + else 'partial' end from ( - select expense_payment.expense_id - , sum(payment.amount) as paid_amount + select coalesce (sum(payment.amount), 0) as paid_amount from expense_payment join payment using (payment_id) - group by expense_payment.expense_id + where expense_payment.expense_id = eid ) as payment - where payment.expense_id = expense.expense_id - and expense.expense_id = eid + where expense.expense_id = eid ; $$ language sql diff --git a/pkg/login.go b/pkg/login.go index 95ed5dc..00ec17e 100644 --- a/pkg/login.go +++ b/pkg/login.go @@ -20,6 +20,7 @@ const ( sessionCookie = "numerus-session" defaultRole = "guest" csrfTokenField = "csfrToken" + csrfTokenHeader = "X-CSRFToken" ) type loginForm struct { @@ -205,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 } diff --git a/pkg/payments.go b/pkg/payments.go index 7fb47b7..162e9a4 100644 --- a/pkg/payments.go +++ b/pkg/payments.go @@ -259,3 +259,22 @@ func handleEditPayment(w http.ResponseWriter, r *http.Request, params httprouter } htmxRedirect(w, r, companyURI(company, "/payments")) } + +func handleRemovePayment(w http.ResponseWriter, r *http.Request, params httprouter.Params) { + slug := params[0].Value + 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)", slug) + + company := mustGetCompany(r) + htmxRedirect(w, r, companyURI(company, "/payments")) +} diff --git a/pkg/router.go b/pkg/router.go index c57d181..5cf644b 100644 --- a/pkg/router.go +++ b/pkg/router.go @@ -62,6 +62,7 @@ func NewRouter(db *Db, demo bool) http.Handler { companyRouter.POST("/payments", handleAddPayment) companyRouter.GET("/payments/:slug", servePaymentForm) companyRouter.PUT("/payments/:slug", handleEditPayment) + companyRouter.DELETE("/payments/:slug", handleRemovePayment) companyRouter.GET("/payment-accounts", servePaymentAccountIndex) companyRouter.POST("/payment-accounts", handleAddPaymentAccount) companyRouter.GET("/payment-accounts/:slug", servePaymentAccountForm) diff --git a/pkg/template.go b/pkg/template.go index 33d0adc..6aeef59 100644 --- a/pkg/template.go +++ b/pkg/template.go @@ -58,6 +58,9 @@ func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename s "csrfToken": func() template.HTML { return template.HTML(fmt.Sprintf(``, 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 diff --git a/po/ca.po b/po/ca.po index c6f2ba9..88132d7 100644 --- a/po/ca.po +++ b/po/ca.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: numerus\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n" -"POT-Creation-Date: 2024-08-10 04:08+0200\n" +"POT-Creation-Date: 2024-08-11 03:17+0200\n" "PO-Revision-Date: 2023-01-18 17:08+0100\n" "Last-Translator: jordi fita mas \n" "Language-Team: Catalan \n" @@ -217,6 +217,7 @@ msgstr "Descàrrega" #: web/template/invoices/index.gohtml:74 web/template/switch-company.gohtml:23 #: web/template/quotes/index.gohtml:74 web/template/contacts/index.gohtml:51 #: web/template/expenses/index.gohtml:76 web/template/products/index.gohtml:48 +#: web/template/payments/index.gohtml:30 msgctxt "title" msgid "Actions" msgstr "Accions" @@ -238,7 +239,7 @@ msgstr "Accions per la factura %s" #: web/template/invoices/index.gohtml:139 web/template/invoices/view.gohtml:19 #: web/template/quotes/index.gohtml:137 web/template/quotes/view.gohtml:22 #: web/template/contacts/index.gohtml:82 web/template/expenses/index.gohtml:143 -#: web/template/products/index.gohtml:78 +#: web/template/products/index.gohtml:78 web/template/payments/index.gohtml:62 msgctxt "action" msgid "Edit" msgstr "Edita" @@ -772,7 +773,20 @@ msgctxt "title" msgid "Description" msgstr "Descripció" +#: web/template/payments/index.gohtml:35 +msgid "Are you sure you wish to delete this payment?" +msgstr "Esteu segur de voler esborrar aquest pagament?" + #: web/template/payments/index.gohtml:54 +msgid "Actions for payment %s" +msgstr "Accions pel pagament %s" + +#: web/template/payments/index.gohtml:73 +msgctxt "action" +msgid "Remove" +msgstr "Esborra" + +#: web/template/payments/index.gohtml:83 msgid "No payments added yet." msgstr "No hi ha cap pagament." @@ -829,29 +843,29 @@ msgctxt "title" msgid "VAT number" msgstr "DNI / NIF" -#: pkg/login.go:37 pkg/company.go:127 pkg/profile.go:40 pkg/contacts.go:276 +#: pkg/login.go:38 pkg/company.go:127 pkg/profile.go:40 pkg/contacts.go:276 msgctxt "input" msgid "Email" msgstr "Correu-e" -#: pkg/login.go:48 pkg/profile.go:49 +#: pkg/login.go:49 pkg/profile.go:49 msgctxt "input" msgid "Password" msgstr "Contrasenya" -#: pkg/login.go:75 pkg/company.go:283 pkg/profile.go:89 +#: pkg/login.go:76 pkg/company.go:283 pkg/profile.go:89 msgid "Email can not be empty." msgstr "No podeu deixar el correu-e en blanc." -#: pkg/login.go:76 pkg/company.go:284 pkg/profile.go:90 pkg/contacts.go:420 +#: pkg/login.go:77 pkg/company.go:284 pkg/profile.go:90 pkg/contacts.go:420 msgid "This value is not a valid email. It should be like name@domain.com." msgstr "Aquest valor no és un correu-e vàlid. Hauria de ser similar a nom@domini.cat." -#: pkg/login.go:78 +#: pkg/login.go:79 msgid "Password can not be empty." msgstr "No podeu deixar la contrasenya en blanc." -#: pkg/login.go:114 +#: pkg/login.go:115 msgid "Invalid user or password." msgstr "Nom d’usuari o contrasenya incorrectes." diff --git a/po/es.po b/po/es.po index eccd060..6a43ec1 100644 --- a/po/es.po +++ b/po/es.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: numerus\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n" -"POT-Creation-Date: 2024-08-10 04:08+0200\n" +"POT-Creation-Date: 2024-08-11 03:17+0200\n" "PO-Revision-Date: 2023-01-18 17:45+0100\n" "Last-Translator: jordi fita mas \n" "Language-Team: Spanish \n" @@ -217,6 +217,7 @@ msgstr "Descargar" #: web/template/invoices/index.gohtml:74 web/template/switch-company.gohtml:23 #: web/template/quotes/index.gohtml:74 web/template/contacts/index.gohtml:51 #: web/template/expenses/index.gohtml:76 web/template/products/index.gohtml:48 +#: web/template/payments/index.gohtml:30 msgctxt "title" msgid "Actions" msgstr "Acciones" @@ -238,7 +239,7 @@ msgstr "Acciones para la factura %s" #: web/template/invoices/index.gohtml:139 web/template/invoices/view.gohtml:19 #: web/template/quotes/index.gohtml:137 web/template/quotes/view.gohtml:22 #: web/template/contacts/index.gohtml:82 web/template/expenses/index.gohtml:143 -#: web/template/products/index.gohtml:78 +#: web/template/products/index.gohtml:78 web/template/payments/index.gohtml:62 msgctxt "action" msgid "Edit" msgstr "Editar" @@ -772,7 +773,20 @@ msgctxt "title" msgid "Description" msgstr "Descripción" +#: web/template/payments/index.gohtml:35 +msgid "Are you sure you wish to delete this payment?" +msgstr "¿Estáis seguro de querer borrar este pago?" + #: web/template/payments/index.gohtml:54 +msgid "Actions for payment %s" +msgstr "Acciones para el pago %s" + +#: web/template/payments/index.gohtml:73 +msgctxt "action" +msgid "Remove" +msgstr "Borrar" + +#: web/template/payments/index.gohtml:83 msgid "No payments added yet." msgstr "No hay pagos." @@ -829,29 +843,29 @@ msgctxt "title" msgid "VAT number" msgstr "DNI / NIF" -#: pkg/login.go:37 pkg/company.go:127 pkg/profile.go:40 pkg/contacts.go:276 +#: pkg/login.go:38 pkg/company.go:127 pkg/profile.go:40 pkg/contacts.go:276 msgctxt "input" msgid "Email" msgstr "Correo-e" -#: pkg/login.go:48 pkg/profile.go:49 +#: pkg/login.go:49 pkg/profile.go:49 msgctxt "input" msgid "Password" msgstr "Contraseña" -#: pkg/login.go:75 pkg/company.go:283 pkg/profile.go:89 +#: pkg/login.go:76 pkg/company.go:283 pkg/profile.go:89 msgid "Email can not be empty." msgstr "No podéis dejar el correo-e en blanco." -#: pkg/login.go:76 pkg/company.go:284 pkg/profile.go:90 pkg/contacts.go:420 +#: pkg/login.go:77 pkg/company.go:284 pkg/profile.go:90 pkg/contacts.go:420 msgid "This value is not a valid email. It should be like name@domain.com." msgstr "Este valor no es un correo-e válido. Tiene que ser parecido a nombre@dominio.es." -#: pkg/login.go:78 +#: pkg/login.go:79 msgid "Password can not be empty." msgstr "No podéis dejar la contraseña en blanco." -#: pkg/login.go:114 +#: pkg/login.go:115 msgid "Invalid user or password." msgstr "Nombre de usuario o contraseña inválido." diff --git a/revert/remove_payment.sql b/revert/remove_payment.sql new file mode 100644 index 0000000..b48f482 --- /dev/null +++ b/revert/remove_payment.sql @@ -0,0 +1,7 @@ +-- Revert numerus:remove_payment from pg + +begin; + +drop function if exists numerus.remove_payment(uuid); + +commit; diff --git a/sqitch.plan b/sqitch.plan index 3010bed..b014e6e 100644 --- a/sqitch.plan +++ b/sqitch.plan @@ -152,3 +152,4 @@ available_expense_status [available_expense_status@v2] 2024-08-04T05:24:08Z jord update_expense_payment_status [roles schema_numerus expense payment expense_payment available_expense_status available_payment_status] 2024-08-04T06:36:00Z jordi fita mas # Add function to update payment and expense status add_payment [roles schema_numerus payment expense_payment company currency parse_price tag_name update_expense_payment_status] 2024-08-04T03:16:55Z jordi fita mas # Add function to insert new payments edit_payment [roles schema_numerus payment expense_payment currency parse_price tag_name update_expense_payment_status] 2024-08-04T03:31:45Z jordi fita mas # Add function to update payments +remove_payment [roles schema_numerus expense_payment payment extension_pgcrypto update_expense_payment_status] 2024-08-11T00:30:58Z jordi fita mas # Add function to remove payments diff --git a/test/remove_payment.sql b/test/remove_payment.sql new file mode 100644 index 0000000..86a17f3 --- /dev/null +++ b/test/remove_payment.sql @@ -0,0 +1,119 @@ +-- Test remove_payment +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(15); + +set search_path to numerus, public; + +select has_function('numerus', 'remove_payment', array['uuid']); +select function_lang_is('numerus', 'remove_payment', array['uuid'], 'plpgsql'); +select function_returns('numerus', 'remove_payment', array['uuid'], 'void'); +select isnt_definer('numerus', 'remove_payment', array['uuid']); +select volatility_is('numerus', 'remove_payment', array['uuid'], 'volatile'); + +select function_privs_are('numerus', 'remove_payment', array ['uuid'], 'guest', array []::text[]); +select function_privs_are('numerus', 'remove_payment', array ['uuid'], 'invoicer', array ['EXECUTE']); +select function_privs_are('numerus', 'remove_payment', array ['uuid'], 'admin', array ['EXECUTE']); +select function_privs_are('numerus', 'remove_payment', array ['uuid'], 'authenticator', array []::text[]); + + +set client_min_messages to warning; +truncate expense_payment cascade; +truncate payment cascade; +truncate expense cascade; +truncate contact cascade; +truncate payment_account cascade; +truncate payment_method cascade; +truncate company cascade; +reset client_min_messages; + + +set constraints "company_default_payment_method_id_fkey" deferred; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_payment_method_id) +values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 111) +; + +insert into payment_method (payment_method_id, company_id, name, instructions) +values (111, 1, 'cash', 'cash') +; + +set constraints "company_default_payment_method_id_fkey" immediate; + +insert into contact (contact_id, company_id, name) +values ( 9, 1, 'Customer 1') +; + +insert into expense (expense_id, company_id, invoice_number, contact_id, invoice_date, amount, currency_code, expense_status) +values (13, 1, 'INV001', 9, '2011-01-11', 111, 'EUR', 'paid') + , (14, 1, 'INV002', 9, '2022-02-22', 222, 'EUR', 'paid') + , (15, 1, 'INV003', 9, '2022-02-22', 333, 'EUR', 'partial') +; + +insert into payment_account (payment_account_id, company_id, payment_account_type, name) +values (11, 1, 'cash', 'Cash 1') + , (12, 1, 'cash', 'Cash 2') + , (13, 1, 'other', 'Other') +; + +insert into payment (payment_id, company_id, slug, description, payment_date, payment_account_id, amount, currency_code, payment_status, tags) +values (16, 1, '7ac3ae0e-b0c1-4206-a19b-0be20835edd4', 'Payment INV001', '2023-05-04', 12, 111, 'EUR', 'complete', '{tag1}') + , (17, 1, 'b57b980b-247b-4be4-a0b7-03a7819c53ae', 'First INV002', '2023-05-05', 13, 100, 'EUR', 'partial', '{tag2}') + , (18, 1, '3bdad7a8-4a1e-4ae0-b5c6-015e51ee0502', 'Second INV002', '2023-05-06', 13, 122, 'EUR', 'partial', '{tag1,tag3}') + , (19, 1, '5a524bee-8311-4d13-9adf-ef6310b26990', 'Partial INV003', '2023-05-07', 11, 123, 'EUR', 'partial', '{}') +; + +insert into expense_payment (expense_id, payment_id) +values (13, 16) + , (14, 17) + , (14, 18) + , (15, 19) +; + +select lives_ok( + $$ select remove_payment('7ac3ae0e-b0c1-4206-a19b-0be20835edd4') $$, + 'Should be able to remove a complete payment' +); + +select lives_ok( + $$ select remove_payment('5a524bee-8311-4d13-9adf-ef6310b26990') $$, + 'Should be able to remove a partial payment, ' +); + +select lives_ok( + $$ select remove_payment('b57b980b-247b-4be4-a0b7-03a7819c53ae') $$, + 'Should be able to remove a partial payment, leaving the expense’s other partial payment' +); + +select bag_eq( + $$ select description, payment_date::text, payment_account_id, amount, payment_status, tags::text from payment $$, + $$ values ('Second INV002', '2023-05-06', 13, 122, 'partial', '{tag1,tag3}') + $$, + 'Should have deleted all given payments' +); + +select bag_eq( + $$ select expense_id, payment_id from expense_payment$$, + $$ values (14, 18) + $$, + 'Should have deleted all related expenses’ payments' +); + +select bag_eq( + $$ select expense_id, expense_status from expense $$, + $$ values (13, 'pending') + , (14, 'partial') + , (15, 'pending') + $$, + 'Should have updated expenses too' +); + + +select * +from finish(); + +rollback; diff --git a/verify/remove_payment.sql b/verify/remove_payment.sql new file mode 100644 index 0000000..3c4ada6 --- /dev/null +++ b/verify/remove_payment.sql @@ -0,0 +1,7 @@ +-- Verify numerus:remove_payment on pg + +begin; + +select has_function_privilege('numerus.remove_payment(uuid)', 'execute'); + +rollback; diff --git a/web/template/payments/index.gohtml b/web/template/payments/index.gohtml index 42cbd8e..93fd092 100644 --- a/web/template/payments/index.gohtml +++ b/web/template/payments/index.gohtml @@ -27,10 +27,12 @@ {{( pgettext "Status" "title" )}} {{( pgettext "Tags" "title" )}} {{( pgettext "Total" "title" )}} + {{( pgettext "Actions" "title" )}} {{ with .Payments }} + {{ $confirm := (gettext "Are you sure you wish to delete this payment?")}} {{- range $payment := . }} {{ .PaymentDate|formatDate }} @@ -47,6 +49,33 @@ {{- end }} {{ .Total | formatPrice }} + + + {{- $label := .Description | printf (gettext "Actions for payment %s") -}} + + + + + + {{( pgettext "Edit" "action" )}} + + + + + + {{( pgettext "Remove" "action" )}} + + + + + {{- end }} {{ else }}