facturation: pouvoir annuler un règlement (#88592) #176

Merged
lguerin merged 2 commits from wip/88592-invoicing-payment-cancel into main 2024-03-29 08:28:56 +01:00
13 changed files with 751 additions and 60 deletions

View File

@ -17,7 +17,9 @@
import django_filters
from django import forms
from django.db import transaction
from django.db.models import Q
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from gadjo.forms.widgets import MultiSelectWidget
@ -36,6 +38,7 @@ from lingo.invoicing.models import (
JournalLine,
Payer,
Payment,
PaymentCancellationReason,
PaymentType,
Refund,
Regie,
@ -769,6 +772,16 @@ class RegiePaymentFilterSet(AgendaFieldsFilterSetMixin, django_filters.FilterSet
empty_label=_('all'),
method='filter_event',
)
cancelled = django_filters.ChoiceFilter(
label=_('Cancelled'),
widget=forms.RadioSelect,
empty_label=_('all'),
choices=[
('yes', _('Yes')),
('no', _('No')),
],
method='filter_cancelled',
)
class Meta:
model = Payment
@ -804,6 +817,33 @@ class RegiePaymentFilterSet(AgendaFieldsFilterSetMixin, django_filters.FilterSet
lines = InvoiceLine.objects.filter(event_slug=value)
return queryset.filter(pk__in=InvoiceLinePayment.objects.filter(line__in=lines).values('payment'))
def filter_cancelled(self, queryset, name, value):
if not value:
return queryset
if value == 'yes':
return queryset.filter(cancelled_at__isnull=False)
return queryset.filter(cancelled_at__isnull=True)
class RegiePaymentCancelForm(forms.ModelForm):
class Meta:
model = Payment
fields = ['cancellation_reason', 'cancellation_description']
def __init__(self, *args, **kwargs):
self.request = kwargs.pop('request')
super().__init__(*args, **kwargs)
self.fields['cancellation_reason'].queryset = PaymentCancellationReason.objects.filter(disabled=False)
def save(self):
super().save(commit=False)
self.instance.cancelled_at = now()
self.instance.cancelled_by = self.request.user
with transaction.atomic():
self.instance.save()
self.instance.invoicelinepayment_set.all().delete()
return self.instance
class RegieCreditFilterSet(AgendaFieldsFilterSetMixin, django_filters.FilterSet):
number = django_filters.CharFilter(
@ -1062,3 +1102,19 @@ class PayerMappingForm(forms.ModelForm):
self.instance.user_fields_mapping = {k: self.cleaned_data[k] for k, v in self.instance.user_variables}
self.instance.save()
return self.instance
class PaymentCancellationReasonForm(forms.ModelForm):
class Meta:
model = PaymentCancellationReason
fields = ['label', 'slug', 'disabled']
def clean_slug(self):
slug = self.cleaned_data['slug']
if PaymentCancellationReason.objects.filter(slug=slug).exclude(pk=self.instance.pk).exists():
raise forms.ValidationError(
_('Another payment cancellation reason exists with the same identifier.')
)
return slug

View File

@ -0,0 +1,57 @@
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('invoicing', '0083_credit_activity_fields'),
]
operations = [
migrations.AddField(
model_name='payment',
name='cancellation_description',
field=models.TextField(blank=True, verbose_name='Description'),
),
migrations.AddField(
model_name='payment',
name='cancelled_at',
field=models.DateTimeField(null=True),
),
migrations.AddField(
model_name='payment',
name='cancelled_by',
field=models.ForeignKey(
null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL
),
),
migrations.CreateModel(
name='PaymentCancellationReason',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('label', models.CharField(max_length=150, verbose_name='Label')),
('slug', models.SlugField(max_length=160, unique=True, verbose_name='Identifier')),
('disabled', models.BooleanField(default=False, verbose_name='Disabled')),
],
options={
'ordering': ['label'],
},
),
migrations.AddField(
model_name='payment',
name='cancellation_reason',
field=models.ForeignKey(
default='',
verbose_name='Cancellation reason',
on_delete=django.db.models.deletion.PROTECT,
to='invoicing.paymentcancellationreason',
null=True,
),
preserve_default=False,
),
]

View File

@ -30,7 +30,7 @@ from django.core import validators
from django.core.serializers.json import DjangoJSONEncoder
from django.db import connection, models, transaction
from django.template import RequestContext, Template, TemplateSyntaxError, VariableDoesNotExist
from django.template.defaultfilters import floatformat
from django.template.defaultfilters import floatformat, linebreaksbr
from django.template.loader import get_template
from django.utils.encoding import force_str
from django.utils.formats import date_format
@ -1314,6 +1314,27 @@ class PaymentType(models.Model):
return created, payment_type
class PaymentCancellationReason(models.Model):
label = models.CharField(_('Label'), max_length=150)
slug = models.SlugField(_('Identifier'), max_length=160, unique=True)
disabled = models.BooleanField(_('Disabled'), default=False)
class Meta:
ordering = ['label']
pmarillonnet marked this conversation as resolved Outdated

J’imagine qu’il y a une bonne raison pour ne pas avoir posé cette contrainte d’unicité dans le champ directement (genre avec un unique=True à la définition du champ) mais je ne saisis pas laquelle, je viens bien la savoir ne serait-ce que pour ma propre édification personnelle stp :)

J’imagine qu’il y a une bonne raison pour ne pas avoir posé cette contrainte d’unicité dans le champ directement (genre avec un `unique=True` à la définition du champ) mais je ne saisis pas laquelle, je viens bien la savoir ne serait-ce que pour ma propre édification personnelle stp :)

non c'est une erreur, merci :) (fixup)

non c'est une erreur, merci :) (fixup)
def __str__(self):
return self.label
def save(self, *args, **kwargs):
if not self.slug:
self.slug = generate_slug(self)
super().save(*args, **kwargs)
@property
def base_slug(self):
return slugify(self.label)
PAYMENT_INFO = [
('check_issuer', _('Issuer')),
('check_bank', _('Bank/Organism')),
@ -1338,6 +1359,13 @@ class Payment(models.Model):
payer_last_name = models.CharField(max_length=250)
payer_address = models.TextField()
cancelled_at = models.DateTimeField(null=True)
cancelled_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
cancellation_reason = models.ForeignKey(
PaymentCancellationReason, verbose_name=_('Cancellation reason'), on_delete=models.PROTECT, null=True
)
cancellation_description = models.TextField(_('Description'), blank=True)
order_date = models.DateTimeField(null=True)
created_at = models.DateTimeField(auto_now_add=True)
@ -1430,6 +1458,18 @@ class Payment(models.Model):
result.append((label, self.payment_info[key]))
return result
def get_cancellation_info(self):
result = []
if not self.cancelled_at:
return result
result.append((_('Cancelled on'), self.cancelled_at.strftime('%d/%m/%Y %H:%M')))
if self.cancelled_by:
pmarillonnet marked this conversation as resolved Outdated

Pareil ici, plutôt 'Cancelled on'.

Pareil ici, plutôt `'Cancelled on'`.

fixup pour ça

fixup pour ça
result.append((_('Cancelled by'), self.cancelled_by))
result.append((_('Reason'), self.cancellation_reason))
if self.cancellation_description:
result.append((_('Description'), linebreaksbr(self.cancellation_description)))
return result
def get_invoice_payments(self):
if hasattr(self, 'prefetched_invoicelinepayments'):
invoice_line_payments = self.prefetched_invoicelinepayments

View File

@ -0,0 +1,46 @@
{% extends "lingo/invoicing/manager_regie_list.html" %}
{% load i18n %}
{% block page-title-extra-label %}{% trans "Cancellation reasons" %}{% endblock %}
{% block breadcrumb %}
<a href="{% url 'lingo-manager-homepage' %}">{% trans "Payments" context 'lingo title' %}</a>
<a href="{% url 'lingo-manager-invoicing-regie-list' %}">{% trans "Regies" %}</a>
<a href="{% url 'lingo-manager-invoicing-cancellation-reason-list' %}">{% trans "Cancellation reasons" %}</a>
{% endblock %}
{% block appbar %}
<h2>{% trans "Cancellation reasons" %}</h2>
{% endblock %}
{% block content %}
<div class="section">
<div class="pk-tabs">
<div class="pk-tabs--tab-list" role="tablist">
<button aria-controls="panel-payment" aria-selected="true" id="tab-payment" role="tab" tabindex="0">{% trans "Payments" %}</button>

dans l'idée d'avoir aussi des motifs d'annulation pour les factures, j'ai fait un onglet "payments" pour avoir un onglet "invoices" dans un futur ticket

dans l'idée d'avoir aussi des motifs d'annulation pour les factures, j'ai fait un onglet "payments" pour avoir un onglet "invoices" dans un futur ticket
</div>
<div class="pk-tabs--container">
<div aria-labelledby="tab-payment" id="panel-payment" role="tabpanel" tabindex="0">
<ul class="objects-list single-links">
{% for reason in payment_reason_list %}
<li>
<a href="{% url 'lingo-manager-invoicing-payment-cancellation-reason-edit' reason.pk %}">{{ reason }}{% if reason.disabled %}<span class="extra-info"> ({% trans "disabled" %})</span>{% endif %}</a>
{% if not reason.payment_set.exists %}
<a class="delete" rel="popup" href="{% url 'lingo-manager-invoicing-payment-cancellation-reason-delete' reason.pk %}">{% trans "delete"%}</a>
{% endif %}
</li>
{% endfor %}
</ul>
<div class="panel--buttons">
<a class="pk-button" href="{% url 'lingo-manager-invoicing-payment-cancellation-reason-add' %}" rel="popup">{% trans "New payment cancellation reason" %}</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

@ -0,0 +1,25 @@
{% extends "lingo/invoicing/manager_payment_list.html" %}
{% load i18n gadjo %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'lingo-manager-invoicing-regie-payment-cancel' regie.pk object.pk %}">{% trans "Cancel payment" %}</a>
{% endblock %}
{% block appbar %}
<h2>{% trans "Cancel payment" %} - {{ object.formatted_number }}</h2>
{% endblock %}
{% block content %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form|with_template }}
<div class="buttons">
<button>{% trans "Submit" %}</button>
<a class="cancel" href="{% url 'lingo-manager-invoicing-regie-payment-list' regie.pk %}">{% trans 'Cancel' %}</a>
</div>
</form>
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

@ -0,0 +1,33 @@
{% extends "lingo/invoicing/manager_cancellation_reason_list.html" %}
{% load i18n gadjo %}
{% block breadcrumb %}
{{ block.super }}
{% if object.pk %}
<a href="{% url 'lingo-manager-invoicing-payment-cancellation-reason-edit' object.pk %}">{% trans "Edit" %}</a>
{% else %}
<a href="{% url 'lingo-manager-invoicing-payment-cancellation-reason-add' %}">{% trans "New payment cancellation reason" %}</a>
{% endif %}
{% endblock %}
{% block appbar %}
{% if object.pk %}
<h2>{% trans "Edit payment cancellation reason" %} - {{ reason }}</h2>
{% else %}
<h2>{% trans "New paymen cancellation reason" %}</h2>
{% endif %}
{% endblock %}
{% block content %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form|with_template }}
<div class="buttons">
<button>{% trans "Submit" %}</button>
<a class="cancel" href="{% url 'lingo-manager-invoicing-cancellation-reason-list' %}#open:payment">{% trans 'Cancel' %}</a>
</div>
</form>
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

@ -34,39 +34,63 @@
<table class="main pk-compact-table invoicing-element-list">
{% url 'lingo-manager-invoicing-regie-invoice-list' regie_pk=regie.pk as regie_invoice_list_url %}
{% for payment in object_list %}
{% with invoice_payments=payment.get_invoice_payments %}
<tr class="payment untoggled" data-invoicing-element-id="{{ payment.pk }}">
<td colspan="4">
{% blocktrans with payment_number=payment.formatted_number cdate=payment.created_at|date:'d/m/Y' payer_id=invoice_payments.0.invoice.payer_external_id payer_name=invoice_payments.0.invoice.payer_name amount=payment.amount payment_type=payment.payment_type %}Payment {{ payment_number }} dated {{ cdate }} from <a href="?payer_external_id={{ payer_id }}">{{ payer_name }}</a>, amount {{ amount }}€ ({{ payment_type }}){% endblocktrans %}
- <a href="{% url 'lingo-manager-invoicing-regie-payment-pdf' regie_pk=regie.pk payment_pk=payment.pk %}">{% trans "download" %}</a>
</td>
<td class="with-togglable">
<span class="togglable"></span>
</td>
</tr>
<tr class="line" data-related-invoicing-element-id="{{ payment.pk }}" style="display: none;">
<td>{% trans "Invoice" %}</td>
<td class="amount" colspan="2">{% trans "Amount charged" %}</td>
<td class="amount" colspan="2">{% trans "Amount assigned" %}</td>
</tr>
{% for invoice_payment in invoice_payments %}
<tr class="line {% if forloop.last %}last-line{% endif %}" data-related-invoicing-element-id="{{ payment.pk }}" style="display: none;">
<td class="invoice">
<a href="{{ regie_invoice_list_url }}?number={{ invoice_payment.invoice.formatted_number }}">{{ invoice_payment.invoice.formatted_number }}</a>
</td>
<td class="amount" colspan="2">{% blocktrans with amount=invoice_payment.invoice.total_amount|floatformat:"2" %}{{ amount }}€{% endblocktrans %}</td>
<td class="amount" colspan="2">{% blocktrans with amount=invoice_payment.amount|floatformat:"2" %}{{ amount }}€{% endblocktrans %}</td>
</tr>
{% endfor %}
{% endwith %}
{% for label, value in payment.get_payment_info %}
<tr class="payment untoggled" data-invoicing-element-id="{{ payment.pk }}">
<td colspan="4">
{% if payment.cancelled_at %}
<span class="meta meta-success">{% trans "Cancelled" %}</span>
{% endif %}
{% blocktrans with payment_number=payment.formatted_number cdate=payment.created_at|date:'d/m/Y' payer_id=payment.payer_external_id payer_name=payment.payer_name amount=payment.amount payment_type=payment.payment_type %}Payment {{ payment_number }} dated {{ cdate }} from <a href="?payer_external_id={{ payer_id }}">{{ payer_name }}</a>, amount {{ amount }}€ ({{ payment_type }}){% endblocktrans %}
- <a href="{% url 'lingo-manager-invoicing-regie-payment-pdf' regie_pk=regie.pk payment_pk=payment.pk %}">{% trans "download" %}</a>
</td>
<td class="with-togglable">
<span class="togglable"></span>
</td>
</tr>
<tr class="line" data-related-invoicing-element-id="{{ payment.pk }}" style="display: none;">
<td>{% trans "Invoice" %}</td>
<td class="amount" colspan="2">{% trans "Amount charged" %}</td>
<td class="amount" colspan="2">{% trans "Amount assigned" %}</td>
</tr>
{% for invoice_payment in payment.get_invoice_payments %}
<tr class="line {% if forloop.last %}last-line{% endif %}" data-related-invoicing-element-id="{{ payment.pk }}" style="display: none;">
<td class="invoice">
<a href="{{ regie_invoice_list_url }}?number={{ invoice_payment.invoice.formatted_number }}">{{ invoice_payment.invoice.formatted_number }}</a>
</td>
<td class="amount" colspan="2">{% blocktrans with amount=invoice_payment.invoice.total_amount|floatformat:"2" %}{{ amount }}€{% endblocktrans %}</td>
<td class="amount" colspan="2">{% blocktrans with amount=invoice_payment.amount|floatformat:"2" %}{{ amount }}€{% endblocktrans %}</td>
</tr>
{% empty %}
<tr class="line" data-related-invoicing-element-id="{{ payment.pk }}" style="display: none;">
<td colspan="5" class="no-assignments">
{% trans "No assignments for this payment" %}
</td>
</tr>
{% endfor %}
{% for label, value in payment.get_payment_info %}
<tr class="line" data-related-invoicing-element-id="{{ payment.pk }}" style="display: none;">
<td colspan="2"></td>
<td class="payment-details" colspan="3">
<i>{% blocktrans %}{{ label }}:{% endblocktrans %} {{ value }}</i>
</td>
</tr>
{% endfor %}
{% if not payment.cancelled_at %}
<tr class="line last-line" data-related-invoicing-element-id="{{ payment.pk }}" style="display: none;">
<td colspan="2"></td>
<td class="payment-details" colspan="3">
<a href="{% url 'lingo-manager-invoicing-regie-payment-cancel' regie_pk=regie.pk payment_pk=payment.pk %}">{% trans "Cancel payment" %}</a>
</td>
</tr>
{% else %}
{% for label, value in payment.get_cancellation_info %}
<tr class="line {% if forloop.last %}last-line{% endif %}" data-related-invoicing-element-id="{{ payment.pk }}" style="display: none;">
<td colspan="2"></td>
<td class="payment-details" colspan="3">
<i>{% blocktrans %}{{ label }}:{% endblocktrans %} {{ value }}</i>
pmarillonnet marked this conversation as resolved Outdated

Du détail i18n, mais je mettrais tout le {{ label }}: {{ value }} dans le bloc internationalisé (genre il y a des langues comme le grec ou bien certaines langues slaves où les deux points n’ont pas cette signification descriptive, et où il faudrait traduire le bloc entier d’une façon différente).

Du détail i18n, mais je mettrais tout le `{{ label }}: {{ value }}` dans le bloc internationalisé (genre il y a des langues comme le grec ou bien certaines langues slaves où les deux points n’ont pas cette signification descriptive, et où il faudrait traduire le bloc entier d’une façon différente).

Alors il faudrait reprendre les trads de toutes les briques, on a souvent des chaînes qui terminent par ":".

Alors il faudrait reprendre les trads de toutes les briques, on a souvent des chaînes qui terminent par ":".

Oui je sais bien, je lutte contre ça mais tu as raison, ce bateau a déjà pris les voiles :)
(pour tout te dire il y a même des endroits dans les briques où c’est “internationalisé” en {{ label }}{% trans ": " %}{{ value }} 🙃).

Oui je sais bien, je lutte contre ça mais tu as raison, ce bateau a déjà pris les voiles :) (pour tout te dire il y a même des endroits dans les briques où c’est “internationalisé” en `{{ label }}{% trans ": " %}{{ value }}` 🙃).
</td>
</tr>
{% endfor %}
{% endif %}
{% endfor %}
</table>
{% include "gadjo/pagination.html" %}

View File

@ -51,6 +51,7 @@
<h3>{% trans "Navigation" %}</h3>
<a class="button button-paragraph" href="{% url 'lingo-manager-invoicing-appearance-settings' %}">{% trans "Appearance Settings" %}</a>
<a class="button button-paragraph" href="{% url 'lingo-manager-invoicing-payer-list' %}">{% trans "Payers" %}</a>
<a class="button button-paragraph" href="{% url 'lingo-manager-invoicing-cancellation-reason-list' %}">{% trans "Cancellation reasons" %}</a>
{% include 'lingo/includes/application_list_fragment.html' with title_no_application=_('Regies outside applications') %}

View File

@ -18,6 +18,7 @@ from django.urls import path
from .views import appearance as appearance_views
from .views import campaign as campaign_views
from .views import cancellation_reason as cancellation_reason_views
from .views import home as home_views
from .views import payer as payer_views
from .views import pool as pool_views
@ -202,6 +203,11 @@ urlpatterns = [
regie_views.regie_payment_pdf,
name='lingo-manager-invoicing-regie-payment-pdf',
),
path(
'regie/<int:regie_pk>/payment/<int:payment_pk>/cancel/',
regie_views.regie_payment_cancel,
name='lingo-manager-invoicing-regie-payment-cancel',
),
path(
'regie/<int:regie_pk>/credits/',
regie_views.regie_credit_list,
@ -253,4 +259,24 @@ urlpatterns = [
payer_views.payer_export,
name='lingo-manager-invoicing-payer-export',
),
path(
'cancellation-reasons/',
cancellation_reason_views.reason_list,
name='lingo-manager-invoicing-cancellation-reason-list',
),
path(
'cancellation-reason/payment/add/',
cancellation_reason_views.payment_reason_add,
name='lingo-manager-invoicing-payment-cancellation-reason-add',
),
path(
'cancellation-reason/payment/<int:pk>/edit/',
cancellation_reason_views.payment_reason_edit,
name='lingo-manager-invoicing-payment-cancellation-reason-edit',
),
path(
'cancellation-reason/payment/<int:pk>/delete/',
cancellation_reason_views.payment_reason_delete,
name='lingo-manager-invoicing-payment-cancellation-reason-delete',
),
]

View File

@ -0,0 +1,74 @@
# lingo - payment and billing system
# Copyright (C) 2024 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.urls import reverse
from django.views.generic import CreateView, DeleteView, TemplateView, UpdateView
from lingo.invoicing.forms import PaymentCancellationReasonForm
from lingo.invoicing.models import PaymentCancellationReason
class ReasonListView(TemplateView):
template_name = 'lingo/invoicing/manager_cancellation_reason_list.html'
def get_context_data(self, **kwargs):
kwargs.update(
{
'payment_reason_list': PaymentCancellationReason.objects.all().order_by('disabled', 'label'),
pmarillonnet marked this conversation as resolved Outdated

Question d’interface utilisateur, peut-être un order_by('-disabled') ici, histoire, dans le rendu du gabarit, d’avoir les raisons actives apparaissant en premier, puis à la fin celles qui sont désactivées ?

Question d’interface utilisateur, peut-être un `order_by('-disabled')` ici, histoire, dans le rendu du gabarit, d’avoir les raisons actives apparaissant en premier, puis à la fin celles qui sont désactivées ?

ok, je fais ça (fixup)

ok, je fais ça (fixup)
}
)
return super().get_context_data(**kwargs)
reason_list = ReasonListView.as_view()
class PaymentReasonAddView(CreateView):
pmarillonnet marked this conversation as resolved Outdated

Pour quelle raison cette vue de création n’a-t-elle pas recours, comme la vue d’édition, à un formulaire, similaire ou identique au PaymentCancellationReasonForm, qui gérerait la prévention de doublons sur le slug ?

Pour quelle raison cette vue de création n’a-t-elle pas recours, comme la vue d’édition, à un formulaire, similaire ou identique au `PaymentCancellationReasonForm`, qui gérerait la prévention de doublons sur le slug ?

le slug est automatiquement setté par la méthode save s'il n'est pas fourni

le slug est automatiquement setté par la méthode save s'il n'est pas fourni

Ah oui ok j’avais loupé ça, merci.

Ah oui ok j’avais loupé ça, merci.
template_name = 'lingo/invoicing/manager_payment_cancellation_reason_form.html'
model = PaymentCancellationReason
fields = ['label']
def get_success_url(self):
return '%s#open:payment' % reverse('lingo-manager-invoicing-cancellation-reason-list')
payment_reason_add = PaymentReasonAddView.as_view()
class PaymentReasonEditView(UpdateView):
template_name = 'lingo/invoicing/manager_payment_cancellation_reason_form.html'
model = PaymentCancellationReason
form_class = PaymentCancellationReasonForm
def get_success_url(self):
return '%s#open:payment' % reverse('lingo-manager-invoicing-cancellation-reason-list')
payment_reason_edit = PaymentReasonEditView.as_view()
class PaymentReasonDeleteView(DeleteView):
template_name = 'lingo/manager_confirm_delete.html'
model = PaymentCancellationReason
def get_queryset(self):
return PaymentCancellationReason.objects.filter(payment__isnull=True)
pmarillonnet marked this conversation as resolved Outdated

J’ai peur qu’on perde des agents en route ici, qu’illes croient à un bug à ne pas voir apparaître ici la raison d’annulation qu’illes souhaitent supprimer, alors qu’en fait cette raison n’y apparaît pas car encore liée à un paiement.

Est-ce qu’on aurait pas intérêt, dans un autre ticket, à faire apparaître dans cet écran la liste des raisons encore actives et qui ne peuvent pas être supprimées ?

J’ai peur qu’on perde des agents en route ici, qu’illes croient à un bug à ne pas voir apparaître ici la raison d’annulation qu’illes souhaitent supprimer, alors qu’en fait cette raison n’y apparaît pas car encore liée à un paiement. Est-ce qu’on aurait pas intérêt, dans un autre ticket, à faire apparaître dans cet écran la liste des raisons encore actives et qui ne peuvent pas être supprimées ?

en fait on n'affiche pas le lien de suppression s'il existe des paiements liés à ce motif, c'est juste une protection

en fait on n'affiche pas le lien de suppression s'il existe des paiements liés à ce motif, c'est juste une protection

Ok, encore mieux, nickel, merci.

Ok, encore mieux, nickel, merci.
def get_success_url(self):
return '%s#open:payment' % reverse('lingo-manager-invoicing-cancellation-reason-list')
payment_reason_delete = PaymentReasonDeleteView.as_view()

View File

@ -35,6 +35,7 @@ from lingo.invoicing.forms import (
RegieCreditFilterSet,
RegieForm,
RegieInvoiceFilterSet,
RegiePaymentCancelForm,
RegiePaymentFilterSet,
RegiePublishingForm,
RegieRefundFilterSet,
@ -620,33 +621,64 @@ class RegiePaymentListView(ListView):
response['Content-Disposition'] = 'attachment; filename="payments.csv"'
writer = csv.writer(response)
# headers
headers = [
_('Number'),
_('Invoice number'),
_('Date'),
_('Payer ID'),
_('Payer first name'),
_('Payer last name'),
_('Payment type'),
_('Amount assigned'),
_('Total amount'),
] + [v for k, v in PAYMENT_INFO]
headers = (
[
_('Number'),
_('Invoice number'),
_('Date'),
_('Payer ID'),
_('Payer first name'),
_('Payer last name'),
_('Payment type'),
_('Amount assigned'),
_('Total amount'),
]
+ [v for k, v in PAYMENT_INFO]
+ [
_('Cancelled on'),
pmarillonnet marked this conversation as resolved Outdated

Sur les libellés exposés dans l’UI, je dirais plutôt Cancelled on vu que c’est un datetime et pas juste un heure (à vue de nez “Cancelled on March 26th, 3:43p.m.” m’a l’air plus correct que avec “Cancelled at …”).

Sur les libellés exposés dans l’UI, je dirais plutôt `Cancelled on` vu que c’est un datetime et pas juste un heure (à vue de nez “Cancelled on March 26th, 3:43p.m.” m’a l’air plus correct que avec “Cancelled at …”).

fixup pour ça

fixup pour ça
_('Cancellation reason'),
]
)
writer.writerow(headers)
for payment in self.object_list:
for invoice_payment in payment.get_invoice_payments():
invoice_payments = payment.get_invoice_payments()
for invoice_payment in invoice_payments:
writer.writerow(
[
payment.formatted_number,
invoice_payment.invoice.formatted_number,
payment.created_at.date().isoformat(),
invoice_payment.invoice.payer_external_id,
invoice_payment.invoice.payer_first_name,
invoice_payment.invoice.payer_last_name,
payment.payer_external_id,
payment.payer_first_name,
payment.payer_last_name,
payment.payment_type.label,
invoice_payment.amount,
payment.amount,
]
+ [payment.payment_info.get(k) or '' for k, v in PAYMENT_INFO]
+ [
payment.cancelled_at.isoformat() if payment.cancelled_at else '',
payment.cancellation_reason or '',
]
)
if not invoice_payments:
writer.writerow(
[
payment.formatted_number,
'',
payment.created_at.date().isoformat(),
payment.payer_external_id,
payment.payer_first_name,
payment.payer_last_name,
payment.payment_type.label,
'',
payment.amount,
]
+ [payment.payment_info.get(k) or '' for k, v in PAYMENT_INFO]
+ [
payment.cancelled_at.isoformat() if payment.cancelled_at else '',
payment.cancellation_reason or '',
]
)
return response
@ -669,6 +701,38 @@ class RegiePaymentPDFView(PDFMixin, DetailView):
regie_payment_pdf = RegiePaymentPDFView.as_view()
class RegiePaymentCancelView(UpdateView):
template_name = 'lingo/invoicing/manager_payment_cancel_form.html'
pk_url_kwarg = 'payment_pk'
model = Payment
form_class = RegiePaymentCancelForm
def dispatch(self, request, *args, **kwargs):
self.regie = get_object_or_404(Regie, pk=kwargs['regie_pk'])
return super().dispatch(request, *args, **kwargs)
def get_queryset(self):
return super().get_queryset().filter(regie=self.regie, cancelled_at__isnull=True)
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['request'] = self.request
return kwargs
def get_context_data(self, **kwargs):
kwargs['regie'] = self.regie
return super().get_context_data(**kwargs)
def get_success_url(self):
return '%s?number=%s' % (
reverse('lingo-manager-invoicing-regie-payment-list', args=[self.regie.pk]),
self.object.formatted_number,
)
regie_payment_cancel = RegiePaymentCancelView.as_view()
class RegieCreditListView(ListView):
template_name = 'lingo/invoicing/manager_credit_list.html'
paginate_by = 100

View File

@ -0,0 +1,79 @@
import pytest
from lingo.invoicing.models import Payment, PaymentCancellationReason, PaymentType, Regie
from tests.utils import login
pytestmark = pytest.mark.django_db
def test_add_payment_reason(app, admin_user):
app = login(app)
resp = app.get('/manage/invoicing/regies/')
resp = resp.click('Cancellation reasons')
resp = resp.click('New payment cancellation reason')
resp.form['label'] = 'Foo bar'
assert 'slug' not in resp.context['form'].fields
assert 'disabled' not in resp.context['form'].fields
resp = resp.form.submit()
payment_reason = PaymentCancellationReason.objects.latest('pk')
assert resp.location.endswith('/manage/invoicing/cancellation-reasons/#open:payment')
assert payment_reason.label == 'Foo bar'
assert payment_reason.slug == 'foo-bar'
assert payment_reason.disabled is False
resp = app.get('/manage/invoicing/cancellation-reason/payment/add/')
resp.form['label'] = 'Foo bar'
resp = resp.form.submit()
payment_reason = PaymentCancellationReason.objects.latest('pk')
assert resp.location.endswith('/manage/invoicing/cancellation-reasons/#open:payment')
assert payment_reason.label == 'Foo bar'
assert payment_reason.slug == 'foo-bar-1'
assert payment_reason.disabled is False
def test_edit_payment_reason(app, admin_user):
payment_reason = PaymentCancellationReason.objects.create(label='Foo')
payment_reason2 = PaymentCancellationReason.objects.create(label='Baz')
app = login(app)
resp = app.get('/manage/invoicing/cancellation-reasons/')
resp = resp.click(href='/manage/invoicing/cancellation-reason/payment/%s/edit/' % (payment_reason.pk))
resp.form['label'] = 'Foo bar'
resp.form['slug'] = payment_reason2.slug
resp.form['disabled'] = True
resp = resp.form.submit()
assert resp.context['form'].errors['slug'] == [
'Another payment cancellation reason exists with the same identifier.'
]
resp.form['slug'] = 'foo-bar'
resp = resp.form.submit()
assert resp.location.endswith('/manage/invoicing/cancellation-reasons/#open:payment')
payment_reason.refresh_from_db()
assert payment_reason.label == 'Foo bar'
assert payment_reason.slug == 'foo-bar'
assert payment_reason.disabled is True
def test_delete_payment_reason(app, admin_user):
regie = Regie.objects.create(label='Foo')
payment_reason = PaymentCancellationReason.objects.create(label='Foo')
payment = Payment.objects.create(
regie=regie,
amount=1,
payment_type=PaymentType.objects.create(label='Foo', regie=regie),
cancellation_reason=payment_reason,
)
app = login(app)
resp = app.get('/manage/invoicing/cancellation-reasons/')
assert '/manage/invoicing/cancellation-reason/payment/%s/delete/' % payment_reason.pk not in resp
app.get('/manage/invoicing/cancellation-reason/payment/%s/delete/' % payment_reason.pk, status=404)
payment.delete()
resp = app.get('/manage/invoicing/cancellation-reasons/')
resp = resp.click(href='/manage/invoicing/cancellation-reason/payment/%s/delete/' % payment_reason.pk)
resp = resp.form.submit()
assert resp.location.endswith('/manage/invoicing/cancellation-reasons/#open:payment')
assert PaymentCancellationReason.objects.exists() is False

View File

@ -28,6 +28,7 @@ from lingo.invoicing.models import (
JournalLine,
Payer,
Payment,
PaymentCancellationReason,
PaymentType,
Pool,
Refund,
@ -366,7 +367,6 @@ def test_edit_payment_type(app, admin_user):
app = login(app)
resp = app.get('/manage/invoicing/regie/%s/parameters/' % regie.pk)
resp = resp.click(href='/manage/invoicing/regie/%s/payment-type/%s/edit/' % (regie.pk, payment_type.pk))
assert 'This check type is set on some existing payments, modify it with caution.' not in resp
resp.form['label'] = 'Foo bar'
resp.form['slug'] = payment_type2.slug
resp.form['disabled'] = True
@ -1866,6 +1866,22 @@ def test_regie_payments(app, admin_user):
amount=2,
)
payment4 = Payment.objects.create(
regie=regie,
amount=2,
payment_type=PaymentType.objects.get(regie=regie, slug='check'),
payer_external_id='payer:3',
payer_first_name='First3',
payer_last_name='Name3',
payer_address='43 rue des kangourous\n99999 Kangourou Ville',
cancelled_at=now(),
cancelled_by=admin_user,
cancellation_reason=PaymentCancellationReason.objects.create(label='Uncovered check'),
cancellation_description='foo bar\nblah',
)
payment4.set_number()
payment4.save()
invoice1.refresh_from_db()
assert invoice1.remaining_amount == 0
assert invoice1.paid_amount == 40
@ -1895,6 +1911,7 @@ def test_regie_payments(app, admin_user):
invoice1.created_at.strftime('%y-%m'),
),
'Number: 123456',
'Cancel payment',
]
assert resp.pyquery(
'tr[data-invoicing-element-id="%s"]' % payment2.pk
@ -1921,6 +1938,7 @@ def test_regie_payments(app, admin_user):
'Bank/Organism: Bar',
'Number: 123456',
'Reference: Ref',
'Cancel payment',
]
assert resp.pyquery(
'tr[data-invoicing-element-id="%s"]' % payment3.pk
@ -1938,20 +1956,43 @@ def test_regie_payments(app, admin_user):
regie.pk,
invoice3.created_at.strftime('%y-%m'),
),
'Cancel payment',
]
assert resp.pyquery(
'tr[data-invoicing-element-id="%s"]' % payment4.pk
).text() == 'Cancelled Payment R%02d-%s-0000004 dated %s from First3 Name3, amount 2.00€ (Check) - download' % (
regie.pk,
payment4.created_at.strftime('%y-%m'),
payment4.created_at.strftime('%d/%m/%Y'),
)
assert [
PyQuery(tr).text() for tr in resp.pyquery('tr[data-related-invoicing-element-id="%s"]' % payment4.pk)
] == [
'Invoice\nAmount charged\nAmount assigned',
'No assignments for this payment',
'Cancelled on: %s' % payment4.cancelled_at.strftime('%d/%m/%Y %H:%M'),
'Cancelled by: admin',
'Reason: Uncovered check',
'Description: foo bar\nblah',
]
resp = app.get('/manage/invoicing/regie/%s/payments/?csv' % regie.pk)
assert resp.headers['Content-Type'] == 'text/csv'
assert resp.headers['Content-Disposition'] == 'attachment; filename="payments.csv"'
assert len([a for a in resp.text.split('\r\n') if a]) == 1 + 4
assert len([a for a in resp.text.split('\r\n') if a]) == 1 + 5
assert resp.text == (
'Number,Invoice number,Date,Payer ID,Payer first name,Payer last name,Payment type,Amount assigned,'
'Total amount,Issuer,Bank/Organism,Number,Reference\r\n'
'R%02d-%s-0000003,F%02d-%s-0000003,%s,payer:3,First3,Name3,Credit card,2.00,2.00,,,,\r\n'
'R%02d-%s-0000002,F%02d-%s-0000001,%s,payer:1,First1,Name1,Check,5.00,55.00,Foo,Bar,123456,Ref\r\n'
'R%02d-%s-0000002,F%02d-%s-0000002,%s,payer:1,First1,Name1,Check,50.00,55.00,Foo,Bar,123456,Ref\r\n'
'R%02d-%s-0000001,F%02d-%s-0000001,%s,payer:1,First1,Name1,Cash,35.00,35.00,,,123456,\r\n'
'Total amount,Issuer,Bank/Organism,Number,Reference,Cancelled on,Cancellation reason\r\n'
'R%02d-%s-0000004,,%s,payer:3,First3,Name3,Check,,2.00,,,,,%s,Uncovered check\r\n'
'R%02d-%s-0000003,F%02d-%s-0000003,%s,payer:3,First3,Name3,Credit card,2.00,2.00,,,,,,\r\n'
'R%02d-%s-0000002,F%02d-%s-0000001,%s,payer:1,First1,Name1,Check,5.00,55.00,Foo,Bar,123456,Ref,,\r\n'
'R%02d-%s-0000002,F%02d-%s-0000002,%s,payer:1,First1,Name1,Check,50.00,55.00,Foo,Bar,123456,Ref,,\r\n'
'R%02d-%s-0000001,F%02d-%s-0000001,%s,payer:1,First1,Name1,Cash,35.00,35.00,,,123456,,,\r\n'
) % (
regie.pk,
payment4.created_at.strftime('%y-%m'),
payment4.created_at.strftime('%Y-%m-%d'),
payment4.cancelled_at.isoformat(),
regie.pk,
payment3.created_at.strftime('%y-%m'),
regie.pk,
@ -1978,21 +2019,21 @@ def test_regie_payments(app, admin_user):
today = datetime.date.today()
params = [
({'number': payment1.formatted_number}, 1, 1),
({'number': payment1.created_at.strftime('%y-%m')}, 3, 4),
({'created_at_after': today.strftime('%Y-%m-%d')}, 3, 4),
({'number': payment1.created_at.strftime('%y-%m')}, 4, 5),
({'created_at_after': today.strftime('%Y-%m-%d')}, 4, 5),
({'created_at_after': (today + datetime.timedelta(days=1)).strftime('%Y-%m-%d')}, 0, 0),
({'created_at_before': (today - datetime.timedelta(days=1)).strftime('%Y-%m-%d')}, 0, 0),
({'created_at_before': today.strftime('%Y-%m-%d')}, 3, 4),
({'created_at_before': today.strftime('%Y-%m-%d')}, 4, 5),
({'invoice_number': invoice1.formatted_number}, 2, 3),
({'invoice_number': invoice1.created_at.strftime('%y-%m')}, 3, 4),
({'payer_external_id': 'payer:1'}, 2, 3),
({'payer_external_id': 'payer:3'}, 1, 1),
({'payer_first_name': 'first'}, 3, 4),
({'payer_external_id': 'payer:3'}, 2, 2),
({'payer_first_name': 'first'}, 4, 5),
({'payer_first_name': 'first1'}, 2, 3),
({'payer_last_name': 'name'}, 3, 4),
({'payer_last_name': 'name'}, 4, 5),
({'payer_last_name': 'name1'}, 2, 3),
({'payment_type': PaymentType.objects.get(slug='cash').pk}, 1, 1),
({'payment_type': PaymentType.objects.get(slug='check').pk}, 1, 2),
({'payment_type': PaymentType.objects.get(slug='check').pk}, 2, 3),
(
{
'amount_min': '2',
@ -2006,29 +2047,31 @@ def test_regie_payments(app, admin_user):
'amount_min': '2',
'amount_min_lookup': 'gte',
},
3,
4,
5,
),
(
{
'amount_max': '55',
'amount_max_lookup': 'lt',
},
2,
2,
3,
3,
),
(
{
'amount_max': '55',
'amount_max_lookup': 'lte',
},
3,
4,
5,
),
({'agenda': 'agenda-a'}, 2, 3),
({'agenda': 'agenda-b'}, 1, 2),
({'event': 'agenda-a@event-a'}, 2, 3),
({'event': 'agenda-b@event-b'}, 1, 2),
({'cancelled': 'yes'}, 1, 1),
({'cancelled': 'no'}, 3, 4),
]
for param, result, csv_result in params:
resp = app.get(
@ -2163,6 +2206,129 @@ def test_regie_payment_pdf(app, admin_user):
)
def test_regie_payment_cancel(app, admin_user):
regie = Regie.objects.create(
label='Foo',
)
PaymentType.create_defaults(regie)
invoice1 = Invoice.objects.create(
date_publication=datetime.date(2022, 10, 1),
date_payment_deadline=datetime.date(2022, 10, 31),
date_due=datetime.date(2022, 10, 31),
regie=regie,
payer_external_id='payer:1',
payer_first_name='First1',
payer_last_name='Name1',
payer_address='41 rue des kangourous\n99999 Kangourou Ville',
payer_demat=True,
payer_direct_debit=False,
)
invoice1.set_number()
invoice1.save()
invoice2 = Invoice.objects.create(
date_publication=datetime.date(2022, 10, 1),
date_payment_deadline=datetime.date(2022, 10, 31),
date_due=datetime.date(2022, 10, 31),
date_debit=datetime.date(2022, 11, 15),
regie=regie,
payer_external_id='payer:1',
payer_first_name='First1',
payer_last_name='Name1',
payer_address='41 rue des kangourous\n99999 Kangourou Ville',
payer_demat=False,
payer_direct_debit=True,
)
invoice2.set_number()
invoice2.save()
invoice_line1 = InvoiceLine.objects.create(
event_date=datetime.date(2022, 9, 1),
invoice=invoice1,
quantity=1,
unit_amount=40,
user_external_id='user:1',
user_first_name='User1',
user_last_name='Name1',
)
invoice_line2 = InvoiceLine.objects.create(
event_date=datetime.date(2022, 9, 1),
invoice=invoice2,
quantity=1,
unit_amount=50,
user_external_id='user:1',
user_first_name='User1',
user_last_name='Name1',
)
payment = Payment.objects.create(
regie=regie,
amount=55,
payment_type=PaymentType.objects.get(regie=regie, slug='check'),
payer_external_id='payer:1',
payer_first_name='First1',
payer_last_name='Name1',
payer_address='41 rue des kangourous\n99999 Kangourou Ville',
)
payment.set_number()
payment.save()
InvoiceLinePayment.objects.create(
payment=payment,
line=invoice_line1,
amount=5,
)
InvoiceLinePayment.objects.create(
payment=payment,
line=invoice_line2,
amount=50,
)
cancellation_reason = PaymentCancellationReason.objects.create(label='Uncovered check')
PaymentCancellationReason.objects.create(label='Disabled', disabled=True)
payment2 = Payment.objects.create(
regie=regie,
amount=5,
payment_type=PaymentType.objects.get(regie=regie, slug='check'),
payer_external_id='payer:1',
payer_first_name='First1',
payer_last_name='Name1',
payer_address='41 rue des kangourous\n99999 Kangourou Ville',
)
payment2.set_number()
payment2.save()
InvoiceLinePayment.objects.create(
payment=payment2,
line=invoice_line1,
amount=5,
)
app = login(app)
resp = app.get('/manage/invoicing/regie/%s/payments/' % regie.pk)
resp = resp.click(href='/manage/invoicing/regie/%s/payment/%s/cancel/' % (regie.pk, payment.pk))
assert resp.form['cancellation_reason'].options == [
('', True, '---------'),
(str(cancellation_reason.pk), False, 'Uncovered check'),
]
resp.form['cancellation_reason'] = cancellation_reason.pk
resp.form['cancellation_description'] = 'foo bar blah'
resp = resp.form.submit()
assert resp.location.endswith(
'/manage/invoicing/regie/%s/payments/?number=%s' % (regie.pk, payment.formatted_number)
)
payment.refresh_from_db()
assert payment.cancelled_at is not None
assert payment.cancelled_by == admin_user
assert payment.cancellation_reason == cancellation_reason
assert payment.cancellation_description == 'foo bar blah'
assert payment.invoicelinepayment_set.count() == 0
assert payment2.invoicelinepayment_set.count() == 1
# already cancelled
app.get('/manage/invoicing/regie/%s/payment/%s/cancel/' % (regie.pk, payment.pk), status=404)
payment.cancelled_at = None
payment.save()
other_regie = Regie.objects.create(label='Foo')
app.get('/manage/invoicing/regie/%s/payment/%s/cancel/' % (other_regie.pk, payment.pk), status=404)
def test_regie_credits(app, admin_user):
regie = Regie.objects.create(label='Foo')
Agenda.objects.create(label='Agenda A', regie=regie)