basket: credit generation on validate with negative basket (#83457)
gitea/lingo/pipeline/head Build queued... Details

This commit is contained in:
Lauréline Guérin 2023-11-14 16:30:59 +01:00
parent 4517920611
commit c05baf5da8
No known key found for this signature in database
GPG Key ID: 1FAB9B9B4F93D473
5 changed files with 244 additions and 3 deletions

View File

@ -0,0 +1,19 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('invoicing', '0074_credit'),
('basket', '0005_basket_expiry'),
]
operations = [
migrations.AddField(
model_name='basket',
name='credit',
field=models.ForeignKey(
null=True, on_delete=django.db.models.deletion.PROTECT, to='invoicing.credit'
),
),
]

View File

@ -24,7 +24,7 @@ from django.db import models, transaction
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from lingo.invoicing.models import DraftInvoice, Invoice, Regie
from lingo.invoicing.models import Credit, DraftInvoice, Invoice, Regie
from lingo.utils.requests_wrapper import requests as requests_wrapper
@ -34,6 +34,7 @@ class Basket(models.Model):
regie = models.ForeignKey(Regie, on_delete=models.PROTECT)
draft_invoice = models.ForeignKey(DraftInvoice, on_delete=models.PROTECT)
invoice = models.ForeignKey(Invoice, on_delete=models.PROTECT, null=True)
credit = models.ForeignKey(Credit, on_delete=models.PROTECT, null=True)
payer_nameid = models.CharField(max_length=250)
payer_external_id = models.CharField(max_length=250)

View File

@ -14,11 +14,14 @@
# 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/>.
import datetime
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.timezone import now
from django.utils.translation import gettext as _
from django.views.generic import FormView, TemplateView, View
from lingo.basket.models import Basket
@ -79,12 +82,28 @@ class BasketValidateView(LoginRequiredMixin, FormView):
return super().get_context_data(**kwargs)
def post(self, request, *args, **kwargs):
if self.basket.draft_invoice.total_amount >= 0:
self.generate_invoice()
else:
self.generate_credit()
return redirect(reverse('lingo-basket-detail'))
def generate_invoice(self):
self.basket.invoice = self.basket.draft_invoice.promote()
self.basket.status = 'tobepaid'
self.basket.validated_at = now()
self.basket.save()
self.basket.notify('validation')
return redirect(reverse('lingo-basket-detail'))
def generate_credit(self):
label = _('Credit from %s') % datetime.date.today().strftime('%d/%m/%Y')
self.basket.credit = self.basket.draft_invoice.promote_into_credit(label)
self.basket.status = 'completed'
self.basket.validated_at = now()
self.basket.completed_at = now()
self.basket.save()
self.basket.notify('validation')
self.basket.notify('credit')
basket_validate = BasketValidateView.as_view()

View File

@ -954,6 +954,22 @@ class DraftInvoice(AbstractInvoice):
return final_invoice
def promote_into_credit(self, label):
credit = copy.deepcopy(self)
credit.__class__ = Credit
credit.pk = None
credit.uuid = uuid.uuid4()
credit.set_number()
credit.assigned_amount = 0
credit.remaining_amount = 0
credit.label = label
credit.save()
for line in self.lines.all().order_by('pk'):
line.promote_into_credit(credit=credit)
return credit
class Invoice(AbstractInvoice):
number = models.PositiveIntegerField(default=0)
@ -1109,6 +1125,16 @@ class DraftInvoiceLine(AbstractInvoiceLine):
for line in self.journal_lines.all().order_by('pk'):
line.promote(pool=pool, invoice_line=final_line)
def promote_into_credit(self, credit=None):
credit_line = copy.deepcopy(self)
credit_line.__class__ = CreditLine
credit_line.pk = None
credit_line.credit = credit
credit_line.quantity = -self.quantity # inverse quantities, so credit total_amout is positive
credit_line.assigned_amount = 0
credit_line.remaining_amount = 0
credit_line.save()
class InvoiceLine(AbstractInvoiceLine):
invoice = models.ForeignKey(Invoice, on_delete=models.PROTECT, null=True, related_name='lines')

View File

@ -7,7 +7,7 @@ from django.utils.timezone import now
from pyquery import PyQuery
from lingo.basket.models import Basket, BasketLine, BasketLineItem
from lingo.invoicing.models import DraftInvoice, Invoice, Regie
from lingo.invoicing.models import Credit, DraftInvoice, DraftInvoiceLine, Invoice, Regie
from tests.utils import login
pytestmark = pytest.mark.django_db
@ -261,6 +261,7 @@ def test_basket_validate(app, simple_user):
assert Invoice.objects.count() == 1
invoice = Invoice.objects.latest('pk')
assert basket.invoice == invoice
assert basket.credit is None
# wrong status
for status in ['tobepaid', 'cancelled', 'expired', 'completed']:
@ -272,12 +273,14 @@ def test_basket_validate(app, simple_user):
basket.status = 'open'
basket.save()
line.validation_callback_url = 'http://validation1.com'
line.credit_callback_url = 'http://validation1.com'
line.save()
BasketLine.objects.create(
basket=basket,
closed=True,
user_external_id='user:2',
validation_callback_url='http://validation2.com',
credit_callback_url='http://validation2.com',
)
resp = app.get('/basket/validate/')
with mock.patch('lingo.utils.requests_wrapper.RequestsSession.send') as mock_send:
@ -294,6 +297,179 @@ def test_basket_validate(app, simple_user):
app.get('/basket/validate/', status=404)
def test_basket_validate_generate_invoice(app, simple_user):
app = login(app, username='user', password='user')
regie = Regie.objects.create(label='Foo')
draft_invoice = DraftInvoice.objects.create(
regie=regie,
date_publication=datetime.date(2023, 4, 21),
date_payment_deadline=datetime.date(2023, 4, 22),
date_due=datetime.date(2023, 4, 23),
)
DraftInvoiceLine.objects.create(
slug='event-a-foo-bar',
label='Event A',
event_date=datetime.date(2022, 9, 1),
invoice=draft_invoice,
quantity=1,
unit_amount=1,
user_external_id='user:1',
)
# invoice total amount is positive
draft_invoice.refresh_from_db()
assert draft_invoice.total_amount == 1
basket = Basket.objects.create(
regie=regie,
draft_invoice=draft_invoice,
payer_nameid='ab' * 16,
expiry_at=now() + datetime.timedelta(hours=1),
)
BasketLine.objects.create(
basket=basket,
closed=True,
user_external_id='user:1',
group_items=False,
)
resp = app.get('/basket/validate/')
resp = resp.form.submit()
assert resp.location.endswith('/basket/')
basket.refresh_from_db()
assert basket.status == 'tobepaid'
assert basket.validated_at is not None
assert basket.completed_at is None
invoice = Invoice.objects.latest('pk')
assert basket.invoice == invoice
assert invoice.total_amount == 1
assert Credit.objects.count() == 0
# total is zero
DraftInvoiceLine.objects.create(
slug='event-b-foo-bar',
label='Event B',
event_date=datetime.date(2022, 9, 1),
invoice=draft_invoice,
quantity=-1,
unit_amount=1,
user_external_id='user:1',
)
draft_invoice.refresh_from_db()
assert draft_invoice.total_amount == 0
basket.status = 'open'
basket.save()
resp = app.get('/basket/validate/')
resp = resp.form.submit()
assert resp.location.endswith('/basket/')
basket.refresh_from_db()
assert basket.status == 'tobepaid'
assert basket.validated_at is not None
assert basket.completed_at is None
invoice = Invoice.objects.latest('pk')
assert basket.invoice == invoice
assert invoice.total_amount == 0
assert Credit.objects.count() == 0
def test_basket_validate_generate_credit(app, simple_user):
app = login(app, username='user', password='user')
regie = Regie.objects.create(label='Foo')
draft_invoice = DraftInvoice.objects.create(
regie=regie,
date_publication=datetime.date(2023, 4, 21),
date_payment_deadline=datetime.date(2023, 4, 22),
date_due=datetime.date(2023, 4, 23),
payer_external_id='payer:1',
payer_first_name='First',
payer_last_name='Last',
payer_address='41 rue des kangourous\n99999 Kangourou Ville',
)
DraftInvoiceLine.objects.create(
slug='event-a-foo-bar',
label='Event A',
event_date=datetime.date(2022, 9, 1),
invoice=draft_invoice,
quantity=-1,
unit_amount=1,
user_external_id='user:1',
user_first_name='First1',
user_last_name='Last1',
)
draft_invoice.refresh_from_db()
assert draft_invoice.total_amount == -1
basket = Basket.objects.create(
regie=regie,
draft_invoice=draft_invoice,
payer_nameid='ab' * 16,
expiry_at=now() + datetime.timedelta(hours=1),
)
line = BasketLine.objects.create(
basket=basket,
closed=True,
user_external_id='user:1',
)
resp = app.get('/basket/validate/')
with mock.patch('lingo.utils.requests_wrapper.RequestsSession.send') as mock_send:
resp = resp.form.submit()
assert {x[0][0].url for x in mock_send.call_args_list} == set()
basket.refresh_from_db()
assert basket.status == 'completed'
assert basket.validated_at is not None
assert basket.completed_at is not None
assert basket.invoice is None
credit = Credit.objects.latest('pk')
assert basket.credit == credit
assert credit.label == 'Credit from %s' % datetime.date.today().strftime('%d/%m/%Y')
assert credit.total_amount == 1
assert credit.regie == regie
assert credit.payer_external_id == 'payer:1'
assert credit.payer_first_name == 'First'
assert credit.payer_last_name == 'Last'
assert credit.payer_address == '41 rue des kangourous\n99999 Kangourou Ville'
assert credit.lines.count() == 1
(line1,) = credit.lines.all().order_by('pk')
assert line1.event_date == datetime.date(2022, 9, 1)
assert line1.slug == 'event-a-foo-bar'
assert line1.label == 'Event A'
assert line1.quantity == 1
assert line1.unit_amount == 1
assert line1.total_amount == 1
assert line1.user_external_id == 'user:1'
assert line1.user_first_name == 'First1'
assert line1.user_last_name == 'Last1'
assert Invoice.objects.count() == 0
# check callback
basket.status = 'open'
basket.save()
line.validation_callback_url = 'http://validation1.com'
line.credit_callback_url = 'http://credit1.com'
line.save()
BasketLine.objects.create(
basket=basket,
closed=True,
user_external_id='user:2',
validation_callback_url='http://validation2.com',
credit_callback_url='http://credit2.com',
)
resp = app.get('/basket/validate/')
with mock.patch('lingo.utils.requests_wrapper.RequestsSession.send') as mock_send:
resp = resp.form.submit()
basket.refresh_from_db()
credit = Credit.objects.latest('pk')
assert basket.credit == credit
assert Invoice.objects.count() == 0
assert {x[0][0].url for x in mock_send.call_args_list} == {
'http://validation1.com/',
'http://validation2.com/',
'http://credit1.com/',
'http://credit2.com/',
}
def test_basket_cancel(app, simple_user):
resp = app.get('/basket/cancel/')
assert resp.location.endswith('/login/?next=/basket/cancel/')