Compare commits

...

3 Commits

Author SHA1 Message Date
Lauréline Guérin 1e3b11ce86
invoicing: cancel invoice from UI (#88590)
gitea/lingo/pipeline/head There was a failure building this commit Details
2024-04-25 12:28:32 +02:00
Lauréline Guérin 1731fdd2e9
api: set cancell_callback_url on invoice (#88590) 2024-04-25 12:28:32 +02:00
Lauréline Guérin c4d2dc9d35
invoicing: add cancel callback on invoice (#88590) 2024-04-25 12:27:36 +02:00
11 changed files with 241 additions and 2 deletions

View File

@ -261,6 +261,7 @@ class DraftInvoiceSerializer(serializers.ModelSerializer):
'payer_address',
'payer_demat',
'payment_callback_url',
'cancel_callback_url',
]

View File

@ -766,6 +766,24 @@ class RegieInvoiceFilterSet(AgendaFieldsFilterSetMixin, django_filters.FilterSet
return queryset.filter(pk__in=lines)
class RegieInvoiceCancelForm(forms.ModelForm):
class Meta:
model = Invoice
fields = ['cancellation_reason', 'cancellation_description']
def __init__(self, *args, **kwargs):
self.request = kwargs.pop('request')
super().__init__(*args, **kwargs)
self.fields['cancellation_reason'].queryset = InvoiceCancellationReason.objects.filter(disabled=False)
def save(self):
super().save(commit=False)
self.instance.cancelled_at = now()
self.instance.cancelled_by = self.request.user
self.instance.save()
return self.instance
class RegiePaymentFilterSet(AgendaFieldsFilterSetMixin, django_filters.FilterSet):
number = django_filters.CharFilter(
label=_('Payment number'),

View File

@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('invoicing', '0089_credit_cancellation'),
]
operations = [
migrations.AddField(
model_name='draftinvoice',
name='cancel_callback_url',
field=models.URLField(blank=True),
),
migrations.AddField(
model_name='invoice',
name='cancel_callback_url',
field=models.URLField(blank=True),
),
]

View File

@ -923,6 +923,7 @@ class AbstractInvoice(AbstractInvoiceObject):
payer_direct_debit = models.BooleanField(default=False)
payment_callback_url = models.URLField(blank=True)
cancel_callback_url = models.URLField(blank=True)
class Meta:
abstract = True

View File

@ -0,0 +1,25 @@
{% extends "lingo/invoicing/manager_invoice_list.html" %}
{% load i18n gadjo %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'lingo-manager-invoicing-regie-invoice-cancel' regie.pk object.pk %}">{% trans "Cancel invoice" %}</a>
{% endblock %}
{% block appbar %}
<h2>{% trans "Cancel invoice" %} - {{ 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-invoice-list' regie.pk %}">{% trans 'Cancel' %}</a>
</div>
</form>
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

@ -187,12 +187,20 @@
</tr>
{% endif %}
{% if invoice.remaining_amount %}
<tr class="line last-line" data-related-invoicing-element-id="{{ invoice.pk }}">
<tr class="line {% if invoice.paid_amount %}last-line{% endif %}" data-related-invoicing-element-id="{{ invoice.pk }}">
<td colspan="2"></td>
<td class="invoice-details" colspan="5">
<i>{% trans "Remaining amount:" %} {% blocktrans with amount=invoice.remaining_amount|floatformat:"2" %}{{ amount }}€{% endblocktrans %}</i>
</td>
</tr>
{% if not invoice.paid_amount %}
<tr class="line last-line" data-related-invoicing-element-id="{{ invoice.pk }}">
<td colspan="2"></td>
<td class="invoice-details" colspan="5">
<a href="{% url 'lingo-manager-invoicing-regie-invoice-cancel' regie_pk=regie.pk invoice_pk=invoice.pk %}" rel="popup">{% trans "Cancel invoice" %}</a>
</td>
</tr>
{% endif %}
{% else %}
<tr class="line last-line" data-related-invoicing-element-id="{{ invoice.pk }}">
<td colspan="2"></td>

View File

@ -193,6 +193,11 @@ urlpatterns = [
regie_views.regie_invoice_line_list,
name='lingo-manager-invoicing-regie-invoice-line-list',
),
path(
'regie/<int:regie_pk>/invoice/<int:invoice_pk>/cancel/',
regie_views.regie_invoice_cancel,
name='lingo-manager-invoicing-regie-invoice-cancel',
),
path(
'regie/<int:regie_pk>/payments/',
regie_views.regie_payment_list,

View File

@ -50,6 +50,7 @@ from lingo.invoicing.forms import (
RegieCreditFilterSet,
RegieDocketPaymentFilterSet,
RegieForm,
RegieInvoiceCancelForm,
RegieInvoiceFilterSet,
RegiePaymentCancelForm,
RegiePaymentFilterSet,
@ -603,6 +604,46 @@ class RegieInvoiceLineListView(ListView):
regie_invoice_line_list = RegieInvoiceLineListView.as_view()
class RegieInvoiceCancelView(UpdateView):
template_name = 'lingo/invoicing/manager_invoice_cancel_form.html'
pk_url_kwarg = 'invoice_pk'
model = Invoice
form_class = RegieInvoiceCancelForm
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,
)
.exclude(pk__in=InvoiceLine.objects.filter(invoicelinepayment__isnull=False).values('invoice'))
)
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-invoice-list', args=[self.regie.pk]),
self.object.formatted_number,
)
regie_invoice_cancel = RegieInvoiceCancelView.as_view()
class RegiePaymentListView(ListView):
template_name = 'lingo/invoicing/manager_payment_list.html'
paginate_by = 100

View File

@ -1940,6 +1940,7 @@ def test_add_draft_invoice(app, user):
'payer_last_name': 'Last',
'payer_address': '41 rue des kangourous\n99999 Kangourou Ville',
'payment_callback_url': 'http://payment.com',
'cancel_callback_url': 'http://cancel.com',
}
if demat is not None:
params['payer_demat'] = demat
@ -1962,6 +1963,7 @@ def test_add_draft_invoice(app, user):
assert invoice.payer_direct_debit is False
assert invoice.pool is None
assert invoice.payment_callback_url == 'http://payment.com'
assert invoice.cancel_callback_url == 'http://cancel.com'
assert invoice.lines.count() == 0
@ -2135,6 +2137,7 @@ def test_close_draft_invoice(app, user):
payer_demat=random.choice([True, False]),
payer_direct_debit=random.choice([True, False]),
payment_callback_url='http://payment.com',
cancel_callback_url='http://cancel.com',
)
line = DraftInvoiceLine.objects.create(
event_date=datetime.date(2023, 4, 21),
@ -2169,6 +2172,7 @@ def test_close_draft_invoice(app, user):
assert final_invoice.number == 1
assert final_invoice.formatted_number == 'F%02d-%s-0000001' % (regie.pk, today.strftime('%y-%m'))
assert final_invoice.payment_callback_url == 'http://payment.com'
assert final_invoice.cancel_callback_url == 'http://cancel.com'
final_line = InvoiceLine.objects.order_by('pk')[0]
assert final_line.event_date == line.event_date

View File

@ -572,7 +572,7 @@ def test_regie_invoices(app, admin_user, orphan):
invoice3.pk,
)
lines_resp = app.get(lines_url)
assert len(lines_resp.pyquery('tr')) == 16
assert len(lines_resp.pyquery('tr')) == 17
assert [PyQuery(tr).text() for tr in lines_resp.pyquery('tr')] == [
'Demat: no',
'Direct debit: no',
@ -590,6 +590,7 @@ def test_regie_invoices(app, admin_user, orphan):
'Payment\nDate\nType\nAmount',
'No payments for this invoice',
'Remaining amount: 1.00€',
'Cancel invoice',
]
if not orphan:
assert [PyQuery(a).attr('href') for a in lines_resp.pyquery('tr a')] == [
@ -597,6 +598,11 @@ def test_regie_invoices(app, admin_user, orphan):
% (regie.pk, campaign2.pk, pool2.pk),
'/manage/invoicing/regie/%s/campaign/%s/pool/%s/journal/?invoice_line=%s'
% (regie.pk, campaign2.pk, pool2.pk, invoice_line31.pk),
'/manage/invoicing/regie/%s/invoice/%s/cancel/' % (regie.pk, invoice3.pk),
]
else:
assert [PyQuery(a).attr('href') for a in lines_resp.pyquery('tr a')] == [
'/manage/invoicing/regie/%s/invoice/%s/cancel/' % (regie.pk, invoice3.pk),
]
assert resp.pyquery(
@ -1135,3 +1141,111 @@ def test_regie_invoice_payments_pdf(app, admin_user):
invoice.cancelled_at = now()
invoice.save()
app.get('/manage/invoicing/regie/%s/invoice/%s/payments/pdf/?html' % (regie.pk, invoice.pk), status=200)
def test_regie_invoice_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()
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=1,
payment_type=PaymentType.objects.get(regie=regie, slug='cash'),
)
payment.set_number()
payment.save()
InvoiceLinePayment.objects.create(
payment=payment,
line=invoice_line2,
amount=1,
)
invoice2.refresh_from_db()
assert invoice2.remaining_amount == 49
assert invoice2.paid_amount == 1
cancellation_reason = InvoiceCancellationReason.objects.create(label='Mistake')
InvoiceCancellationReason.objects.create(label='Disabled', disabled=True)
app = login(app)
resp = app.get('/manage/invoicing/ajax/regie/%s/invoice/%s/lines/' % (regie.pk, invoice1.pk))
resp = resp.click(href='/manage/invoicing/regie/%s/invoice/%s/cancel/' % (regie.pk, invoice1.pk))
assert resp.form['cancellation_reason'].options == [
('', True, '---------'),
(str(cancellation_reason.pk), False, 'Mistake'),
]
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/invoices/?number=%s' % (regie.pk, invoice1.formatted_number)
)
invoice1.refresh_from_db()
assert invoice1.cancelled_at is not None
assert invoice1.cancelled_by == admin_user
assert invoice1.cancellation_reason == cancellation_reason
assert invoice1.cancellation_description == 'foo bar blah'
assert invoice1.lines.count() == 1
invoice2.refresh_from_db()
assert invoice2.cancelled_at is None
# already cancelled
app.get('/manage/invoicing/regie/%s/invoice/%s/cancel/' % (regie.pk, invoice1.pk), status=404)
invoice1.cancelled_at = None
invoice1.save()
# other regie
other_regie = Regie.objects.create(label='Foo')
app.get('/manage/invoicing/regie/%s/invoice/%s/cancel/' % (other_regie.pk, invoice1.pk), status=404)
# invoice with payment
app.get('/manage/invoicing/regie/%s/invoice/%s/cancel/' % (regie.pk, invoice2.pk), status=404)

View File

@ -706,6 +706,8 @@ def test_regie_payment_cancel(app, admin_user):
app.get('/manage/invoicing/regie/%s/payment/%s/cancel/' % (regie.pk, payment.pk), status=404)
payment.cancelled_at = None
payment.save()
# other regie
other_regie = Regie.objects.create(label='Foo')
app.get('/manage/invoicing/regie/%s/payment/%s/cancel/' % (other_regie.pk, payment.pk), status=404)