Allow removal of payments

I am using an htmx-infused button to remove the payment, but that
button can not have the CSRF token as value, thus i have to send it in a
header.

The removal of payments warrants a functions, instead of just DELETE
(and CASCADE) as i do for payment methods, because i have to adjust the
status of expenses too.  Since i already have functions for everything,
it is not worth using triggers just for that.
This commit is contained in:
jordi fita mas 2024-08-11 03:22:37 +02:00
parent ad5bc271b6
commit 778f9c1555
13 changed files with 281 additions and 23 deletions

39
deploy/remove_payment.sql Normal file
View File

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

View File

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

View File

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

View File

@ -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"))
}

View File

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

View File

@ -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(`<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

View File

@ -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 <jordi@tandem.blog>\n"
"Language-Team: Catalan <ca@dodds.net>\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 dusuari o contrasenya incorrectes."

View File

@ -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 <jordi@tandem.blog>\n"
"Language-Team: Spanish <es@tp.org.es>\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."

View File

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

View File

@ -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 <jordi@tandem.blog> # 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 <jordi@tandem.blog> # 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 <jordi@tandem.blog> # 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 <jordi@tandem.blog> # Add function to remove payments

119
test/remove_payment.sql Normal file
View File

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

View File

@ -0,0 +1,7 @@
-- Verify numerus:remove_payment on pg
begin;
select has_function_privilege('numerus.remove_payment(uuid)', 'execute');
rollback;

View File

@ -27,10 +27,12 @@
<th>{{( pgettext "Status" "title" )}}</th>
<th>{{( pgettext "Tags" "title" )}}</th>
<th class="numeric">{{( pgettext "Total" "title" )}}</th>
<th>{{( pgettext "Actions" "title" )}}</th>
</tr>
</thead>
<tbody>
{{ with .Payments }}
{{ $confirm := (gettext "Are you sure you wish to delete this payment?")}}
{{- range $payment := . }}
<tr>
<td>{{ .PaymentDate|formatDate }}</td>
@ -47,6 +49,33 @@
{{- end }}
</td>
<td class="numeric">{{ .Total | formatPrice }}</td>
<td class="actions">
<details class="menu">
{{- $label := .Description | printf (gettext "Actions for payment %s") -}}
<summary aria-label="{{ $label }}"><i class="ri-more-line"></i></summary>
<ul role="menu" class="action-menu">
<li role="presentation">
<a role="menuitem" href="{{ companyURI "/payments"}}/{{ .Slug }}"
data-hx-target="main" data-hx-boost="true"
>
<i class="ri-edit-line"></i>
{{( pgettext "Edit" "action" )}}
</a>
</li>
<li role="presentation">
<button role="menuitem"
data-hx-delete="{{ companyURI "/payments"}}/{{ .Slug }}"
data-hx-confirm="{{ $confirm }}"
data-hx-headers='{ {{ csrfHeader }} }'
data-hx-target="main"
>
<i class="ri-delete-back-2-line"></i>
{{( pgettext "Remove" "action" )}}
</button>
</li>
</ul>
</details>
</td>
</tr>
{{- end }}
{{ else }}