diff --git a/MANIFEST.in b/MANIFEST.in
index a33d6c9..ba0b137 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -10,6 +10,9 @@ recursive-include lingo/templates *.html *.txt
recursive-include lingo/manager/templates *.html *.txt
recursive-include lingo/pricing/templates *.html *.txt
+# sql (migrations)
+recursive-include lingo/invoicing/sql *.sql
+
include COPYING README
include MANIFEST.in
include VERSION
diff --git a/lingo/agendas/chrono.py b/lingo/agendas/chrono.py
index 9fc5f8d..d7cbc72 100644
--- a/lingo/agendas/chrono.py
+++ b/lingo/agendas/chrono.py
@@ -132,14 +132,41 @@ def get_events(event_slugs, error_message=None, error_message_with_details=None)
return result['data']
-def get_subscriptions(agenda_slug, user_external_id):
- result = get_chrono_json(
- 'api/agenda/%s/subscription/?user_external_id=%s' % (agenda_slug, user_external_id)
- )
- if not result or not result.get('data'):
+def get_subscriptions(agenda_slug, user_external_id=None, date_start=None, date_end=None):
+ url = 'api/agenda/%s/subscription/' % agenda_slug
+ params = {}
+ if user_external_id:
+ params['user_external_id'] = user_external_id
+ if date_start:
+ params['date_start'] = date_start
+ if date_end:
+ params['date_end'] = date_end
+ if params:
+ url += '?%s' % '&'.join(['%s=%s' % (k, v) for k, v in params.items()])
+ result = get_chrono_json(url)
+ if not result:
raise ChronoError(_('Unable to get subscription details'))
if result.get('err'):
raise ChronoError(_('Unable to get subscription details (%s)') % result['err_desc'])
- if not result.get('data'):
+ if 'data' not in result:
raise ChronoError(_('Unable to get subscription details'))
return result['data']
+
+
+def get_check_status(agenda_slugs, user_external_id, date_start, date_end):
+ result = get_chrono_json(
+ 'api/agendas/events/check-status/?user_external_id=%s&agendas=%s&date_start=%s&date_end=%s'
+ % (
+ user_external_id,
+ ','.join(agenda_slugs),
+ date_start,
+ date_end,
+ )
+ )
+ if not result:
+ raise ChronoError(_('Unable to get check status'))
+ if result.get('err'):
+ raise ChronoError(_('Unable to get check status (%s)') % result['err_desc'])
+ if 'data' not in result:
+ raise ChronoError(_('Unable to get check status'))
+ return result['data']
diff --git a/lingo/invoicing/management/__init__.py b/lingo/invoicing/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/lingo/invoicing/management/commands/__init__.py b/lingo/invoicing/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/lingo/invoicing/management/commands/generate_invoices.py b/lingo/invoicing/management/commands/generate_invoices.py
new file mode 100644
index 0000000..8d80965
--- /dev/null
+++ b/lingo/invoicing/management/commands/generate_invoices.py
@@ -0,0 +1,45 @@
+# lingo - payment and billing system
+# Copyright (C) 2022 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 .
+
+import datetime
+
+from django.core.management.base import BaseCommand, CommandError
+
+from lingo.invoicing.utils import generate_invoices
+
+
+class Command(BaseCommand):
+ help = 'Generate invoicing for a period'
+
+ def add_arguments(self, parser):
+ parser.add_argument('date_start')
+ parser.add_argument('date_end')
+
+ def handle(self, *args, **options):
+ try:
+ date_start = datetime.datetime.fromisoformat(options['date_start']).date()
+ except ValueError:
+ raise CommandError('Bad value "%s" for date_start' % options['date_start'])
+ try:
+ date_end = datetime.datetime.fromisoformat(options['date_end']).date()
+ except ValueError:
+ raise CommandError('Bad value "%s" for date_end' % options['date_end'])
+
+ generate_invoices(date_start=date_start, date_end=date_end)
+
+ self.stdout.write(
+ self.style.SUCCESS('Invoicing generation OK (start: %s, end: %s)' % (date_start, date_end))
+ )
diff --git a/lingo/invoicing/migrations/0002_invoice.py b/lingo/invoicing/migrations/0002_invoice.py
new file mode 100644
index 0000000..6e67038
--- /dev/null
+++ b/lingo/invoicing/migrations/0002_invoice.py
@@ -0,0 +1,124 @@
+import django.contrib.postgres.fields.jsonb
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('invoicing', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Invoice',
+ fields=[
+ (
+ 'id',
+ models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
+ ),
+ ('label', models.CharField(max_length=300, verbose_name='Label')),
+ ('total_amount', models.DecimalField(decimal_places=2, max_digits=9, default=0)),
+ ('date_issue', models.DateField(verbose_name='Issue date')),
+ ('payer', models.CharField(max_length=300, verbose_name='Payer')),
+ (
+ 'regie',
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ to='invoicing.Regie',
+ ),
+ ),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ migrations.CreateModel(
+ name='InvoiceLine',
+ fields=[
+ (
+ 'id',
+ models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
+ ),
+ ('slug', models.SlugField(max_length=250)),
+ ('label', models.CharField(max_length=260)),
+ ('quantity', models.FloatField()),
+ ('unit_amount', models.DecimalField(decimal_places=2, max_digits=9)),
+ ('total_amount', models.DecimalField(decimal_places=2, max_digits=9)),
+ ('user_external_id', models.CharField(max_length=250)),
+ ('payer_external_id', models.CharField(max_length=250)),
+ ('event', django.contrib.postgres.fields.jsonb.JSONField(default=dict)),
+ ('pricing_data', django.contrib.postgres.fields.jsonb.JSONField(default=dict)),
+ (
+ 'status',
+ models.CharField(choices=[('success', 'Success'), ('error', 'Error')], max_length=10),
+ ),
+ (
+ 'invoice',
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to='invoicing.Invoice',
+ related_name='lines',
+ ),
+ ),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ migrations.CreateModel(
+ name='DraftInvoice',
+ fields=[
+ (
+ 'id',
+ models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
+ ),
+ ('label', models.CharField(max_length=300, verbose_name='Label')),
+ ('total_amount', models.DecimalField(decimal_places=2, max_digits=9, default=0)),
+ ('date_issue', models.DateField(verbose_name='Issue date')),
+ ('payer', models.CharField(max_length=300, verbose_name='Payer')),
+ (
+ 'regie',
+ models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='invoicing.Regie'),
+ ),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ migrations.CreateModel(
+ name='DraftInvoiceLine',
+ fields=[
+ (
+ 'id',
+ models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
+ ),
+ ('slug', models.SlugField(max_length=250)),
+ ('label', models.CharField(max_length=260)),
+ ('quantity', models.FloatField()),
+ ('unit_amount', models.DecimalField(decimal_places=2, max_digits=9)),
+ ('total_amount', models.DecimalField(decimal_places=2, max_digits=9)),
+ ('user_external_id', models.CharField(max_length=250)),
+ ('payer_external_id', models.CharField(max_length=250)),
+ ('event', django.contrib.postgres.fields.jsonb.JSONField(default=dict)),
+ ('pricing_data', django.contrib.postgres.fields.jsonb.JSONField(default=dict)),
+ (
+ 'status',
+ models.CharField(choices=[('success', 'Success'), ('error', 'Error')], max_length=10),
+ ),
+ (
+ 'invoice',
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to='invoicing.DraftInvoice',
+ related_name='lines',
+ ),
+ ),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/lingo/invoicing/migrations/0003_invoice.py b/lingo/invoicing/migrations/0003_invoice.py
new file mode 100644
index 0000000..c992e28
--- /dev/null
+++ b/lingo/invoicing/migrations/0003_invoice.py
@@ -0,0 +1,27 @@
+import os
+
+from django.db import migrations
+
+with open(
+ os.path.join(
+ os.path.dirname(os.path.realpath(__file__)),
+ '..',
+ 'sql',
+ 'invoice_triggers_for_amount.sql',
+ )
+) as sql_file:
+ sql_triggers = sql_file.read()
+
+
+sql_forwards = sql_triggers
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('invoicing', '0002_invoice'),
+ ]
+
+ operations = [
+ migrations.RunSQL(sql=sql_forwards, reverse_sql=migrations.RunSQL.noop),
+ ]
diff --git a/lingo/invoicing/models.py b/lingo/invoicing/models.py
index 7692aa8..4471994 100644
--- a/lingo/invoicing/models.py
+++ b/lingo/invoicing/models.py
@@ -17,6 +17,7 @@
import copy
from django.contrib.auth.models import Group
+from django.contrib.postgres.fields import JSONField
from django.db import models
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
@@ -84,3 +85,53 @@ class Regie(models.Model):
regie, created = cls.objects.update_or_create(slug=data['slug'], defaults=data)
return created, regie
+
+
+class AbstractInvoice(models.Model):
+ label = models.CharField(_('Label'), max_length=300)
+ total_amount = models.DecimalField(max_digits=9, decimal_places=2, default=0)
+ date_issue = models.DateField(_('Issue date'))
+ regie = models.ForeignKey(Regie, on_delete=models.PROTECT)
+ payer = models.CharField(_('Payer'), max_length=300)
+
+ class Meta:
+ abstract = True
+
+
+class DraftInvoice(AbstractInvoice):
+ pass
+
+
+class Invoice(AbstractInvoice):
+ pass
+
+
+class AbstractInvoiceLine(models.Model):
+ slug = models.SlugField(max_length=250)
+ label = models.CharField(max_length=260)
+ quantity = models.FloatField()
+ unit_amount = models.DecimalField(max_digits=9, decimal_places=2)
+ total_amount = models.DecimalField(max_digits=9, decimal_places=2)
+
+ user_external_id = models.CharField(max_length=250)
+ payer_external_id = models.CharField(max_length=250)
+ event = JSONField(default=dict)
+ pricing_data = JSONField(default=dict)
+ status = models.CharField(
+ max_length=10,
+ choices=[
+ ('success', _('Success')),
+ ('error', _('Error')),
+ ],
+ )
+
+ class Meta:
+ abstract = True
+
+
+class DraftInvoiceLine(AbstractInvoiceLine):
+ invoice = models.ForeignKey(DraftInvoice, on_delete=models.PROTECT, null=True, related_name='lines')
+
+
+class InvoiceLine(AbstractInvoiceLine):
+ invoice = models.ForeignKey(Invoice, on_delete=models.PROTECT, null=True, related_name='lines')
diff --git a/lingo/invoicing/sql/invoice_triggers_for_amount.sql b/lingo/invoicing/sql/invoice_triggers_for_amount.sql
new file mode 100644
index 0000000..65b874c
--- /dev/null
+++ b/lingo/invoicing/sql/invoice_triggers_for_amount.sql
@@ -0,0 +1,44 @@
+CREATE OR REPLACE FUNCTION set_invoice_total_amount() RETURNS TRIGGER AS $$
+ DECLARE
+ invoice_ids integer[];
+ BEGIN
+ IF TG_OP = 'INSERT' THEN
+ invoice_ids := ARRAY[NEW.invoice_id];
+ ELSIF TG_OP = 'DELETE' THEN
+ invoice_ids := ARRAY[OLD.invoice_id];
+ ELSIF TG_OP = 'UPDATE' THEN
+ invoice_ids := ARRAY[NEW.invoice_id, OLD.invoice_id];
+ END IF;
+
+ EXECUTE 'UPDATE ' || substring(TG_TABLE_NAME for length(TG_TABLE_NAME) - 4) || ' i
+ SET total_amount = COALESCE(
+ (
+ SELECT SUM(l.total_amount)
+ FROM ' || TG_TABLE_NAME || ' l
+ WHERE l.invoice_id = i.id
+ AND l.status = ''success''
+ ), 0
+ )
+ WHERE id = ANY($1);' USING invoice_ids;
+
+ IF TG_OP = 'DELETE' THEN
+ RETURN OLD;
+ ELSE
+ RETURN NEW;
+ END IF;
+ END;
+$$ LANGUAGE plpgsql;
+
+
+DROP TRIGGER IF EXISTS set_draftinvoice_total_amount_trg ON invoicing_draftinvoiceline;
+CREATE TRIGGER set_draftinvoice_total_amount_trg
+ AFTER INSERT OR UPDATE OF total_amount, invoice_id, status OR DELETE ON invoicing_draftinvoiceline
+ FOR EACH ROW
+ EXECUTE PROCEDURE set_invoice_total_amount();
+
+
+DROP TRIGGER IF EXISTS set_invoice_total_amount_trg ON invoicing_invoiceline;
+CREATE TRIGGER set_invoice_total_amount_trg
+ AFTER INSERT OR UPDATE OF total_amount, invoice_id, status OR DELETE ON invoicing_invoiceline
+ FOR EACH ROW
+ EXECUTE PROCEDURE set_invoice_total_amount();
diff --git a/lingo/invoicing/utils.py b/lingo/invoicing/utils.py
new file mode 100644
index 0000000..7f3899e
--- /dev/null
+++ b/lingo/invoicing/utils.py
@@ -0,0 +1,246 @@
+# lingo - payment and billing system
+# Copyright (C) 2022 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 .
+
+import collections
+import datetime
+
+from django.test.client import RequestFactory
+from django.utils.translation import ugettext_lazy as _
+
+from lingo.agendas.chrono import get_check_status, get_subscriptions
+from lingo.agendas.models import Agenda
+from lingo.invoicing.models import DraftInvoice, DraftInvoiceLine
+from lingo.pricing.models import AgendaPricing, AgendaPricingNotFound, PricingError
+
+
+def get_agendas(date_start, date_end):
+ agendas_pricings = AgendaPricing.objects.filter(flat_fee_schedule=False).extra(
+ where=["(date_start, date_end) OVERLAPS (%s, %s)"], params=[date_start, date_end]
+ )
+ return Agenda.objects.filter(pk__in=agendas_pricings.values('agendas')).order_by('pk')
+
+
+def get_all_subscriptions(agendas, date_start, date_end):
+ # result:
+ # {
+ # 'user_id': {
+ # 'agenda_slug': [sub1, sub2, ...],
+ # 'agenda_slug2': [sub1, sub2, ...],
+ # },
+ # ...
+ # }
+ all_subscriptions = {}
+ for agenda in agendas:
+ subscriptions = get_subscriptions(agenda_slug=agenda.slug, date_start=date_start, date_end=date_end)
+ for subscription in subscriptions:
+ if subscription['user_external_id'] not in all_subscriptions:
+ all_subscriptions[subscription['user_external_id']] = {}
+ user_subs = all_subscriptions[subscription['user_external_id']]
+ if agenda.slug not in user_subs:
+ user_subs[agenda.slug] = []
+ user_subs[agenda.slug].append(subscription)
+ return all_subscriptions
+
+
+def get_invoice_lines_for_user(
+ agendas, agendas_pricings, user_external_id, subscriptions, date_start, date_end
+):
+ def get_agenda_pricing(agendas_pricings_for_agenda, date_event):
+ # same logic as AgendaPricing.get_agenda_pricing
+ for agenda_pricing in agendas_pricings_for_agenda:
+ if agenda_pricing.date_start > date_event:
+ continue
+ if agenda_pricing.date_end <= date_event:
+ continue
+ return agenda_pricing
+ raise AgendaPricingNotFound
+
+ def get_subscription(subscriptions, event_date):
+ # get subscription matching event_date
+ for subscription in subscriptions:
+ sub_start_date = datetime.date.fromisoformat(subscription['date_start'])
+ sub_end_date = datetime.date.fromisoformat(subscription['date_end'])
+ if sub_start_date > event_date:
+ continue
+ if sub_end_date <= event_date:
+ continue
+ return subscription
+
+ if not agendas or not subscriptions:
+ return []
+
+ agendas_pricings_by_agendas = collections.defaultdict(list)
+ for agenda_pricing in agendas_pricings:
+ if agenda_pricing.flat_fee_schedule:
+ continue
+ for agenda in agenda_pricing.agendas.all():
+ agendas_pricings_by_agendas[agenda.slug].append(agenda_pricing)
+
+ # get check status for user_external_id, on agendas, for the period
+ check_status_list = get_check_status(
+ agenda_slugs=list(subscriptions.keys()),
+ user_external_id=user_external_id,
+ date_start=date_start,
+ date_end=date_end,
+ )
+ request = RequestFactory().get('/') # XXX
+ agendas_by_slug = {a.slug: a for a in agendas}
+
+ # build lines from check status
+ lines = []
+ for check_status in check_status_list:
+ serialized_event = check_status['event']
+ event_date = datetime.datetime.fromisoformat(serialized_event['start_datetime']).date()
+ event_slug = '%s@%s' % (serialized_event['agenda'], serialized_event['slug'])
+ if serialized_event['agenda'] not in subscriptions:
+ # should not happen, check-status endpoint is based on real subscriptions
+ continue
+ serialized_subscription = get_subscription(
+ subscriptions=subscriptions[serialized_event['agenda']],
+ event_date=event_date,
+ )
+ if not serialized_subscription:
+ # should not happen, check-status endpoint is based on real subscriptions
+ continue
+
+ agenda = agendas_by_slug[serialized_event['agenda']]
+ try:
+ agenda_pricing = get_agenda_pricing(agendas_pricings_by_agendas.get(agenda.slug), event_date)
+ pricing_data = agenda_pricing.get_pricing_data_for_event(
+ request=request,
+ agenda=agenda,
+ event=serialized_event,
+ subscription=serialized_subscription,
+ check_status=check_status['check_status'],
+ booking=check_status['booking'],
+ user_external_id=user_external_id,
+ adult_external_id=user_external_id, # XXX
+ )
+ except AgendaPricingNotFound:
+ # no pricing, no invoice
+ # can happen if pricing model defined only on a part of the requested period
+ # XXX error, warning, or ignore ?
+ continue
+ except PricingError as e:
+ # XXX explicit each error
+ # XXX and log context ?
+ pricing_error = {'error': e.details}
+ lines.append(
+ DraftInvoiceLine(
+ slug=event_slug,
+ label=serialized_event['label'],
+ quantity=0,
+ unit_amount=0,
+ total_amount=0,
+ user_external_id=user_external_id,
+ payer_external_id=user_external_id, # XXX
+ event=serialized_event,
+ pricing_data=pricing_error,
+ status='error',
+ )
+ )
+ else:
+ # XXX log all context !
+ lines.append(
+ DraftInvoiceLine(
+ slug=event_slug,
+ label=serialized_event['label'],
+ quantity=1,
+ unit_amount=pricing_data['pricing'],
+ total_amount=pricing_data['pricing'],
+ user_external_id=user_external_id,
+ payer_external_id=user_external_id, # XXX
+ event=serialized_event,
+ pricing_data=pricing_data,
+ status='success',
+ )
+ )
+ DraftInvoiceLine.objects.bulk_create(lines)
+ return lines
+
+
+def get_all_invoice_lines(agendas, subscriptions, date_start, date_end):
+ agendas_pricings = (
+ AgendaPricing.objects.filter(flat_fee_schedule=False)
+ .extra(where=["(date_start, date_end) OVERLAPS (%s, %s)"], params=[date_start, date_end])
+ .prefetch_related('agendas', 'pricing__criterias', 'pricing__categories')
+ )
+
+ lines = []
+ for user_external_id, user_subs in subscriptions.items():
+ # generate lines for each user
+ lines += get_invoice_lines_for_user(
+ agendas=agendas,
+ agendas_pricings=agendas_pricings,
+ user_external_id=user_external_id,
+ subscriptions=user_subs,
+ date_start=date_start,
+ date_end=date_end,
+ )
+ return lines
+
+
+def generate_invoices_from_lines(agendas, all_lines, date_start, date_end):
+ agendas_by_slug = {a.slug: a for a in agendas}
+
+ # regroup lines by regie, and by payer_external_id (payer)
+ lines_by_regie = {}
+ for line in all_lines:
+ if line.status != 'success':
+ # ignore lines in error
+ continue
+ agenda_slug = line.event['agenda']
+ if agenda_slug not in agendas_by_slug:
+ # should not happen
+ continue
+ regie = agendas_by_slug[agenda_slug].regie
+ if not regie:
+ # XXX what should we do if regie is not configured ?
+ continue
+ if regie.pk not in lines_by_regie:
+ lines_by_regie[regie.pk] = {}
+ regie_subs = lines_by_regie[regie.pk]
+ if line.payer_external_id not in regie_subs:
+ regie_subs[line.payer_external_id] = []
+ regie_subs[line.payer_external_id].append(line)
+
+ # generate invoices by regie and by payer_external_id (payer)
+ invoices = []
+ for regie_id, regie_subs in lines_by_regie.items():
+ for payer_external_id, adult_lines in regie_subs.items():
+ invoice = DraftInvoice.objects.create(
+ label=_('Invoice from %s to %s') % (date_start, date_end - datetime.timedelta(days=1)),
+ date_issue=date_end, # XXX
+ regie_id=regie_id,
+ payer=payer_external_id,
+ )
+ DraftInvoiceLine.objects.filter(pk__in=[line.pk for line in adult_lines]).update(invoice=invoice)
+ invoices.append(invoice)
+
+ return invoices
+
+
+def generate_invoices(date_start, date_end):
+ # get agendas with pricing corresponding to the period
+ agendas = get_agendas(date_start=date_start, date_end=date_end)
+ # get subscriptions for each agenda, for the period
+ subscriptions = get_all_subscriptions(agendas=agendas, date_start=date_start, date_end=date_end)
+ # get invoice lines for all subscribed users, for each agenda in the corresponding period
+ lines = get_all_invoice_lines(
+ agendas=agendas, subscriptions=subscriptions, date_start=date_start, date_end=date_end
+ )
+ # and generate invoices
+ generate_invoices_from_lines(agendas=agendas, all_lines=lines, date_start=date_start, date_end=date_end)
diff --git a/lingo/pricing/models.py b/lingo/pricing/models.py
index 26fc267..7bee270 100644
--- a/lingo/pricing/models.py
+++ b/lingo/pricing/models.py
@@ -507,12 +507,16 @@ class AgendaPricing(models.Model):
def compute_pricing(self, context):
criterias = {}
categories = []
- # for each category (ordered)
- for category in self.pricing.categories.all().order_by('pricingcriteriacategory__order'):
+ # for each category
+ for category in self.pricing.categories.all():
criterias[category.slug] = None
categories.append(category.slug)
# find the first matching criteria (criterias are ordered)
- for criteria in self.pricing.criterias.filter(category=category, default=False):
+ for criteria in self.pricing.criterias.all():
+ if criteria.category_id != category.pk:
+ continue
+ if criteria.default:
+ continue
condition = criteria.compute_condition(context)
if condition:
criterias[category.slug] = criteria.slug
@@ -520,7 +524,9 @@ class AgendaPricing(models.Model):
if criterias[category.slug] is not None:
continue
# if no match, take default criteria if only once defined
- default_criterias = self.pricing.criterias.filter(category=category, default=True)
+ default_criterias = [
+ c for c in self.pricing.criterias.all() if c.default and c.category_id == category.pk
+ ]
if len(default_criterias) > 1:
raise MultipleDefaultCriteriaCondition(details={'category': category.slug})
if not default_criterias:
diff --git a/tests/agendas/test_chrono.py b/tests/agendas/test_chrono.py
index 738438d..508026e 100644
--- a/tests/agendas/test_chrono.py
+++ b/tests/agendas/test_chrono.py
@@ -1,3 +1,4 @@
+import datetime
import json
from unittest import mock
@@ -8,6 +9,7 @@ from requests.models import Response
from lingo.agendas.chrono import (
ChronoError,
collect_agenda_data,
+ get_check_status,
get_event,
get_events,
get_subscriptions,
@@ -282,12 +284,12 @@ def test_get_events():
def test_get_subscriptions_no_service(settings):
settings.KNOWN_SERVICES = {}
with pytest.raises(ChronoError) as e:
- get_subscriptions('foo', 'user:1')
+ get_subscriptions(agenda_slug='foo')
assert str(e.value) == 'Unable to get subscription details'
settings.KNOWN_SERVICES = {'other': []}
with pytest.raises(ChronoError) as e:
- get_subscriptions('foo', 'user:1')
+ get_subscriptions(agenda_slug='foo')
assert str(e.value) == 'Unable to get subscription details'
@@ -295,7 +297,7 @@ def test_get_subscriptions():
with mock.patch('requests.Session.get') as requests_get:
requests_get.side_effect = ConnectionError()
with pytest.raises(ChronoError) as e:
- get_subscriptions('foo', 'user:1')
+ get_subscriptions(agenda_slug='foo')
assert str(e.value) == 'Unable to get subscription details'
with mock.patch('requests.Session.get') as requests_get:
@@ -303,7 +305,7 @@ def test_get_subscriptions():
mock_resp.status_code = 500
requests_get.return_value = mock_resp
with pytest.raises(ChronoError) as e:
- get_subscriptions('foo', 'user:1')
+ get_subscriptions(agenda_slug='foo')
assert str(e.value) == 'Unable to get subscription details'
with mock.patch('requests.Session.get') as requests_get:
@@ -311,25 +313,148 @@ def test_get_subscriptions():
mock_resp.status_code = 404
requests_get.return_value = mock_resp
with pytest.raises(ChronoError) as e:
- get_subscriptions('foo', 'user:1')
+ get_subscriptions(agenda_slug='foo')
assert str(e.value) == 'Unable to get subscription details'
with mock.patch('requests.Session.get') as requests_get:
requests_get.return_value = MockedRequestResponse(content=json.dumps({'foo': 'bar'}))
with pytest.raises(ChronoError) as e:
- get_subscriptions('foo', 'user:1')
+ get_subscriptions(agenda_slug='foo')
assert str(e.value) == 'Unable to get subscription details'
data = {'data': []}
with mock.patch('requests.Session.get') as requests_get:
requests_get.return_value = MockedRequestResponse(content=json.dumps(data))
- with pytest.raises(ChronoError) as e:
- get_subscriptions('foo', 'user:1')
- assert str(e.value) == 'Unable to get subscription details'
- assert requests_get.call_args_list[0][0] == ('api/agenda/foo/subscription/?user_external_id=user:1',)
+ assert get_subscriptions(agenda_slug='foo') == []
+ assert requests_get.call_args_list[0][0] == ('api/agenda/foo/subscription/',)
assert requests_get.call_args_list[0][1]['remote_service']['url'] == 'http://chrono.example.org'
data = {'data': ['foo', 'bar']}
with mock.patch('requests.Session.get') as requests_get:
requests_get.return_value = MockedRequestResponse(content=json.dumps(data))
- assert get_subscriptions('foo', 'user:1') == ['foo', 'bar']
+ assert get_subscriptions(agenda_slug='foo') == ['foo', 'bar']
+
+ data = {'data': []}
+ with mock.patch('requests.Session.get') as requests_get:
+ requests_get.return_value = MockedRequestResponse(content=json.dumps(data))
+ get_subscriptions(agenda_slug='foo', user_external_id='user:1')
+ assert requests_get.call_args_list[0][0] == ('api/agenda/foo/subscription/?user_external_id=user:1',)
+ assert requests_get.call_args_list[0][1]['remote_service']['url'] == 'http://chrono.example.org'
+ requests_get.reset_mock()
+ get_subscriptions(agenda_slug='foo', date_start=datetime.date(2022, 9, 1))
+ assert requests_get.call_args_list[0][0] == ('api/agenda/foo/subscription/?date_start=2022-09-01',)
+ assert requests_get.call_args_list[0][1]['remote_service']['url'] == 'http://chrono.example.org'
+ requests_get.reset_mock()
+ get_subscriptions(agenda_slug='foo', date_end=datetime.date(2022, 10, 1))
+ assert requests_get.call_args_list[0][0] == ('api/agenda/foo/subscription/?date_end=2022-10-01',)
+ assert requests_get.call_args_list[0][1]['remote_service']['url'] == 'http://chrono.example.org'
+ requests_get.reset_mock()
+ get_subscriptions(
+ agenda_slug='foo',
+ user_external_id='user:1',
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ )
+ assert requests_get.call_args_list[0][0] == (
+ 'api/agenda/foo/subscription/?user_external_id=user:1&date_start=2022-09-01&date_end=2022-10-01',
+ )
+ assert requests_get.call_args_list[0][1]['remote_service']['url'] == 'http://chrono.example.org'
+
+
+def test_get_check_status_no_service(settings):
+ settings.KNOWN_SERVICES = {}
+ with pytest.raises(ChronoError) as e:
+ get_check_status(
+ agenda_slugs=['foo'],
+ user_external_id='user:1',
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ )
+ assert str(e.value) == 'Unable to get check status'
+
+ settings.KNOWN_SERVICES = {'other': []}
+ with pytest.raises(ChronoError) as e:
+ get_check_status(
+ agenda_slugs=['foo'],
+ user_external_id='user:1',
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ )
+ assert str(e.value) == 'Unable to get check status'
+
+
+def test_get_check_status():
+ with mock.patch('requests.Session.get') as requests_get:
+ requests_get.side_effect = ConnectionError()
+ with pytest.raises(ChronoError) as e:
+ get_check_status(
+ agenda_slugs=['foo', 'bar'],
+ user_external_id='user:1',
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ )
+ assert str(e.value) == 'Unable to get check status'
+
+ with mock.patch('requests.Session.get') as requests_get:
+ mock_resp = Response()
+ mock_resp.status_code = 500
+ requests_get.return_value = mock_resp
+ with pytest.raises(ChronoError) as e:
+ get_check_status(
+ agenda_slugs=['foo', 'bar'],
+ user_external_id='user:1',
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ )
+ assert str(e.value) == 'Unable to get check status'
+
+ with mock.patch('requests.Session.get') as requests_get:
+ mock_resp = Response()
+ mock_resp.status_code = 404
+ requests_get.return_value = mock_resp
+ with pytest.raises(ChronoError) as e:
+ get_check_status(
+ agenda_slugs=['foo', 'bar'],
+ user_external_id='user:1',
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ )
+ assert str(e.value) == 'Unable to get check status'
+
+ with mock.patch('requests.Session.get') as requests_get:
+ requests_get.return_value = MockedRequestResponse(content=json.dumps({'foo': 'bar'}))
+ with pytest.raises(ChronoError) as e:
+ get_check_status(
+ agenda_slugs=['foo', 'bar'],
+ user_external_id='user:1',
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ )
+ assert str(e.value) == 'Unable to get check status'
+
+ data = {'data': []}
+ with mock.patch('requests.Session.get') as requests_get:
+ requests_get.return_value = MockedRequestResponse(content=json.dumps(data))
+ assert (
+ get_check_status(
+ agenda_slugs=['foo', 'bar'],
+ user_external_id='user:1',
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ )
+ == []
+ )
+ assert requests_get.call_args_list[0][0] == (
+ 'api/agendas/events/check-status/?user_external_id=user:1&agendas=foo,bar&date_start=2022-09-01&date_end=2022-10-01',
+ )
+ assert requests_get.call_args_list[0][1]['remote_service']['url'] == 'http://chrono.example.org'
+
+ data = {'data': ['foo', 'bar']}
+ with mock.patch('requests.Session.get') as requests_get:
+ requests_get.return_value = MockedRequestResponse(content=json.dumps(data))
+ assert get_check_status(
+ agenda_slugs=['foo', 'bar'],
+ user_external_id='user:1',
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ ) == ['foo', 'bar']
diff --git a/tests/invoicing/test_invoice_generation.py b/tests/invoicing/test_invoice_generation.py
new file mode 100644
index 0000000..f6077e8
--- /dev/null
+++ b/tests/invoicing/test_invoice_generation.py
@@ -0,0 +1,1344 @@
+import datetime
+from unittest import mock
+
+import pytest
+from django.core.management import CommandError, call_command
+from django.db import connection
+from django.test.utils import CaptureQueriesContext
+
+from lingo.agendas.chrono import ChronoError
+from lingo.agendas.models import Agenda
+from lingo.invoicing import utils
+from lingo.invoicing.models import DraftInvoice, DraftInvoiceLine, Regie
+from lingo.pricing.models import AgendaPricing, Criteria, CriteriaCategory, Pricing, PricingError
+
+pytestmark = pytest.mark.django_db
+
+
+def test_get_agendas():
+ agenda1 = Agenda.objects.create(label='Agenda 1')
+ agenda2 = Agenda.objects.create(label='Agenda 2')
+ agenda3 = Agenda.objects.create(label='Agenda 3')
+ Agenda.objects.create(label='Agenda 4')
+ pricing = Pricing.objects.create(label='Foo bar 1')
+
+ # no agenda pricing defined
+ assert (
+ list(utils.get_agendas(date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1)))
+ == []
+ )
+
+ # agenda pricing, but for flat_fee_schedule
+ agenda_pricing = AgendaPricing.objects.create(
+ pricing=pricing,
+ date_start=datetime.date(year=2022, month=9, day=1),
+ date_end=datetime.date(year=2022, month=10, day=1),
+ flat_fee_schedule=True, # wrong config
+ )
+ agenda_pricing.agendas.add(agenda1)
+ assert (
+ list(utils.get_agendas(date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1)))
+ == []
+ )
+
+ # create some agenda pricing
+ agenda_pricing1 = AgendaPricing.objects.create(
+ pricing=pricing,
+ date_start=datetime.date(year=2021, month=9, day=1),
+ date_end=datetime.date(year=2021, month=10, day=1),
+ )
+ agenda_pricing2 = AgendaPricing.objects.create(
+ pricing=pricing,
+ date_start=datetime.date(year=2022, month=9, day=1),
+ date_end=datetime.date(year=2022, month=10, day=1),
+ )
+ assert (
+ list(utils.get_agendas(date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1)))
+ == []
+ )
+
+ # link agendas to agenda pricing
+ agenda_pricing1.agendas.add(agenda1, agenda2)
+ agenda_pricing2.agendas.add(agenda3)
+
+ assert list(
+ utils.get_agendas(date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1))
+ ) == [agenda3]
+ assert list(
+ utils.get_agendas(date_start=datetime.date(2021, 9, 1), date_end=datetime.date(2021, 10, 1))
+ ) == [agenda1, agenda2]
+ assert (
+ list(utils.get_agendas(date_start=datetime.date(2022, 8, 31), date_end=datetime.date(2022, 9, 1)))
+ == []
+ )
+ assert list(
+ utils.get_agendas(date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 9, 2))
+ ) == [agenda3]
+ assert list(
+ utils.get_agendas(date_start=datetime.date(2022, 9, 30), date_end=datetime.date(2022, 10, 1))
+ ) == [agenda3]
+ assert (
+ list(utils.get_agendas(date_start=datetime.date(2022, 10, 1), date_end=datetime.date(2022, 10, 2)))
+ == []
+ )
+ assert list(
+ utils.get_agendas(date_start=datetime.date(2021, 9, 15), date_end=datetime.date(2022, 9, 15))
+ ) == [agenda1, agenda2, agenda3]
+
+
+@mock.patch('lingo.invoicing.utils.get_subscriptions')
+def test_get_all_subscriptions_error(mock_subscriptions):
+ agenda = Agenda.objects.create(label='Agenda')
+ mock_subscriptions.side_effect = ChronoError('foo baz')
+ with pytest.raises(ChronoError):
+ utils.get_all_subscriptions(
+ agendas=[agenda], date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1)
+ )
+
+
+@mock.patch('lingo.invoicing.utils.get_subscriptions')
+def test_get_all_subscriptions(mock_subscriptions):
+ agenda1 = Agenda.objects.create(label='Agenda 1')
+ agenda2 = Agenda.objects.create(label='Agenda 2')
+
+ # no agendas
+ assert (
+ utils.get_all_subscriptions(
+ agendas=[], date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1)
+ )
+ == {}
+ )
+ assert mock_subscriptions.call_args_list == []
+
+ # no subscriptions
+ mock_subscriptions.return_value = []
+ assert (
+ utils.get_all_subscriptions(
+ agendas=[agenda1, agenda2],
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ )
+ == {}
+ )
+ assert mock_subscriptions.call_args_list == [
+ mock.call(
+ agenda_slug='agenda-1', date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1)
+ ),
+ mock.call(
+ agenda_slug='agenda-2', date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1)
+ ),
+ ]
+ mock_subscriptions.reset_mock()
+
+ # with subscriptions
+ mock_subscriptions.side_effect = [
+ [
+ {
+ 'user_external_id': 'user:1',
+ 'date_start': '2022-08-01',
+ 'date_end': '2022-09-02',
+ },
+ {
+ 'user_external_id': 'user:1',
+ 'date_start': '2022-09-02',
+ 'date_end': '2022-09-03',
+ },
+ {
+ 'user_external_id': 'user:2',
+ 'date_start': '2022-09-02',
+ 'date_end': '2022-09-03',
+ },
+ ],
+ [
+ {
+ 'user_external_id': 'user:1',
+ 'date_start': '2022-08-01',
+ 'date_end': '2022-10-01',
+ },
+ ],
+ ]
+ assert utils.get_all_subscriptions(
+ agendas=[agenda1, agenda2], date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1)
+ ) == {
+ 'user:1': {
+ 'agenda-1': [
+ {
+ 'user_external_id': 'user:1',
+ 'date_start': '2022-08-01',
+ 'date_end': '2022-09-02',
+ },
+ {
+ 'user_external_id': 'user:1',
+ 'date_start': '2022-09-02',
+ 'date_end': '2022-09-03',
+ },
+ ],
+ 'agenda-2': [
+ {
+ 'user_external_id': 'user:1',
+ 'date_start': '2022-08-01',
+ 'date_end': '2022-10-01',
+ },
+ ],
+ },
+ 'user:2': {
+ 'agenda-1': [
+ {
+ 'user_external_id': 'user:2',
+ 'date_start': '2022-09-02',
+ 'date_end': '2022-09-03',
+ },
+ ],
+ },
+ }
+ assert mock_subscriptions.call_args_list == [
+ mock.call(
+ agenda_slug='agenda-1', date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1)
+ ),
+ mock.call(
+ agenda_slug='agenda-2', date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1)
+ ),
+ ]
+
+
+@mock.patch('lingo.invoicing.utils.get_check_status')
+def test_get_invoice_lines_for_user_check_status_error(mock_status):
+ mock_status.side_effect = ChronoError('foo baz')
+ with pytest.raises(ChronoError):
+ utils.get_invoice_lines_for_user(
+ agendas=['foo'],
+ agendas_pricings=[],
+ user_external_id='user:1',
+ subscriptions={'foo': 'bar'},
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ )
+
+
+@mock.patch('lingo.invoicing.utils.get_check_status')
+@mock.patch('lingo.pricing.models.AgendaPricing.get_pricing_data_for_event')
+def test_get_invoice_lines_for_user_check_status(mock_pricing_data_event, mock_status):
+ agenda1 = Agenda.objects.create(label='Agenda 1')
+ agenda2 = Agenda.objects.create(label='Agenda 2')
+ pricing = Pricing.objects.create(label='Foo bar 1')
+ agenda_pricing = AgendaPricing.objects.create(
+ pricing=pricing,
+ date_start=datetime.date(year=2022, month=9, day=1),
+ date_end=datetime.date(year=2022, month=10, day=1),
+ )
+ agenda_pricing.agendas.add(agenda1, agenda2)
+
+ # no agendas
+ assert (
+ utils.get_invoice_lines_for_user(
+ agendas=[],
+ agendas_pricings=[agenda_pricing],
+ user_external_id='user:1',
+ subscriptions={
+ 'agenda-1': [
+ {
+ 'user_external_id': 'user:1',
+ 'date_start': '2022-09-01',
+ 'date_end': '2022-10-01',
+ },
+ ],
+ },
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ )
+ == []
+ )
+ assert mock_status.call_args_list == []
+ assert mock_pricing_data_event.call_args_list == []
+
+ # no subscriptions
+ assert (
+ utils.get_invoice_lines_for_user(
+ agendas=[agenda1, agenda2],
+ agendas_pricings=[agenda_pricing],
+ user_external_id='user:1',
+ subscriptions={},
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ )
+ == []
+ )
+ assert mock_status.call_args_list == []
+ assert mock_pricing_data_event.call_args_list == []
+
+ # no status
+ mock_status.return_value = []
+ assert (
+ utils.get_invoice_lines_for_user(
+ agendas=[agenda1, agenda2],
+ agendas_pricings=[agenda_pricing],
+ user_external_id='user:1',
+ subscriptions={
+ 'agenda-1': [
+ {
+ 'user_external_id': 'user:1',
+ 'date_start': '2022-09-01',
+ 'date_end': '2022-10-01',
+ },
+ ],
+ },
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ )
+ == []
+ )
+ assert mock_status.call_args_list == [
+ mock.call(
+ agenda_slugs=['agenda-1'],
+ user_external_id='user:1',
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ )
+ ]
+ mock_status.reset_mock()
+ assert mock_pricing_data_event.call_args_list == []
+
+ # agenda and subscriptions not matching
+ mock_status.return_value = [
+ {
+ 'event': {
+ 'agenda': 'agenda-1',
+ 'start_datetime': '2022-09-01T12:00:00+02:00',
+ 'slug': 'event',
+ 'label': 'Event',
+ }
+ }
+ ]
+ assert (
+ utils.get_invoice_lines_for_user(
+ agendas=[agenda1, agenda2],
+ agendas_pricings=[agenda_pricing],
+ user_external_id='user:1',
+ subscriptions={
+ 'unknown': [
+ {
+ 'user_external_id': 'user:1',
+ 'date_start': '2022-09-01',
+ 'date_end': '2022-10-01',
+ },
+ ],
+ },
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ )
+ == []
+ )
+ assert mock_status.call_args_list == [
+ mock.call(
+ agenda_slugs=['unknown'],
+ user_external_id='user:1',
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ )
+ ]
+ mock_status.reset_mock()
+ assert mock_pricing_data_event.call_args_list == []
+
+ # correct data
+ mock_pricing_data_event.side_effect = [
+ {'foo1': 'bar1', 'pricing': 1},
+ {'foo2': 'bar2', 'pricing': 2},
+ {'foo3': 'bar3', 'pricing': 3},
+ {'foo4': 'bar4', 'pricing': 4},
+ ]
+ mock_status.return_value = [
+ # many events for agenda-1
+ {
+ 'event': {
+ 'agenda': 'agenda-1',
+ 'start_datetime': '2022-09-01T12:00:00+02:00',
+ 'slug': 'event-1',
+ 'label': 'Event 1',
+ },
+ 'check_status': {'foo': 'bar1'},
+ 'booking': {'foo': 'baz1'},
+ },
+ {
+ 'event': {
+ 'agenda': 'agenda-1',
+ 'start_datetime': '2022-09-02T12:00:00+02:00',
+ 'slug': 'event-2',
+ 'label': 'Event 2',
+ },
+ 'check_status': {'foo': 'bar2'},
+ 'booking': {'foo': 'baz2'},
+ },
+ # and for agenda-2
+ {
+ 'event': {
+ 'agenda': 'agenda-2',
+ 'start_datetime': '2022-09-01T13:00:00+02:00',
+ 'slug': 'eveeent-1',
+ 'label': 'Eveeent 1',
+ },
+ 'check_status': {'foo': 'barrr1'},
+ 'booking': {'foo': 'bazzz1'},
+ },
+ {
+ 'event': {
+ 'agenda': 'agenda-2',
+ 'start_datetime': '2022-09-02T13:00:00+02:00',
+ 'slug': 'eveeent-2',
+ 'label': 'Eveeent 2',
+ },
+ 'check_status': {'foo': 'barrr2'},
+ 'booking': {'foo': 'bazzz2'},
+ },
+ ]
+ lines = utils.get_invoice_lines_for_user(
+ agendas=[agenda1, agenda2],
+ agendas_pricings=[agenda_pricing],
+ user_external_id='user:1',
+ subscriptions={
+ # matching dates
+ 'agenda-1': [
+ {
+ 'user_external_id': 'user:1',
+ 'date_start': '2022-09-01',
+ 'date_end': '2022-10-01',
+ },
+ ],
+ 'agenda-2': [
+ {
+ 'user_external_id': 'user:1',
+ 'date_start': '2022-09-01',
+ 'date_end': '2022-10-01',
+ },
+ ],
+ },
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ )
+ assert mock_pricing_data_event.call_args_list == [
+ mock.call(
+ request=mock.ANY,
+ agenda=agenda1,
+ event={
+ 'agenda': 'agenda-1',
+ 'start_datetime': '2022-09-01T12:00:00+02:00',
+ 'slug': 'event-1',
+ 'label': 'Event 1',
+ },
+ subscription={'user_external_id': 'user:1', 'date_start': '2022-09-01', 'date_end': '2022-10-01'},
+ check_status={'foo': 'bar1'},
+ booking={'foo': 'baz1'},
+ user_external_id='user:1',
+ adult_external_id='user:1',
+ ),
+ mock.call(
+ request=mock.ANY,
+ agenda=agenda1,
+ event={
+ 'agenda': 'agenda-1',
+ 'start_datetime': '2022-09-02T12:00:00+02:00',
+ 'slug': 'event-2',
+ 'label': 'Event 2',
+ },
+ subscription={'user_external_id': 'user:1', 'date_start': '2022-09-01', 'date_end': '2022-10-01'},
+ check_status={'foo': 'bar2'},
+ booking={'foo': 'baz2'},
+ user_external_id='user:1',
+ adult_external_id='user:1',
+ ),
+ mock.call(
+ request=mock.ANY,
+ agenda=agenda2,
+ event={
+ 'agenda': 'agenda-2',
+ 'start_datetime': '2022-09-01T13:00:00+02:00',
+ 'slug': 'eveeent-1',
+ 'label': 'Eveeent 1',
+ },
+ subscription={'user_external_id': 'user:1', 'date_start': '2022-09-01', 'date_end': '2022-10-01'},
+ check_status={'foo': 'barrr1'},
+ booking={'foo': 'bazzz1'},
+ user_external_id='user:1',
+ adult_external_id='user:1',
+ ),
+ mock.call(
+ request=mock.ANY,
+ agenda=agenda2,
+ event={
+ 'agenda': 'agenda-2',
+ 'start_datetime': '2022-09-02T13:00:00+02:00',
+ 'slug': 'eveeent-2',
+ 'label': 'Eveeent 2',
+ },
+ subscription={'user_external_id': 'user:1', 'date_start': '2022-09-01', 'date_end': '2022-10-01'},
+ check_status={'foo': 'barrr2'},
+ booking={'foo': 'bazzz2'},
+ user_external_id='user:1',
+ adult_external_id='user:1',
+ ),
+ ]
+ assert len(lines) == 4
+ line1, line2, line3, line4 = lines
+ assert isinstance(line1, DraftInvoiceLine)
+ assert line1.invoice is None
+ assert line1.slug == 'agenda-1@event-1'
+ assert line1.label == 'Event 1'
+ assert line1.quantity == 1
+ assert line1.unit_amount == 1
+ assert line1.total_amount == 1
+ assert line1.user_external_id == 'user:1'
+ assert line1.payer_external_id == 'user:1'
+ assert line1.event == {
+ 'agenda': 'agenda-1',
+ 'start_datetime': '2022-09-01T12:00:00+02:00',
+ 'slug': 'event-1',
+ 'label': 'Event 1',
+ }
+ assert line1.pricing_data == {'foo1': 'bar1', 'pricing': 1}
+ assert line1.status == 'success'
+ assert isinstance(line2, DraftInvoiceLine)
+ assert line2.invoice is None
+ assert line2.slug == 'agenda-1@event-2'
+ assert line2.label == 'Event 2'
+ assert line2.quantity == 1
+ assert line2.unit_amount == 2
+ assert line2.total_amount == 2
+ assert line2.user_external_id == 'user:1'
+ assert line2.payer_external_id == 'user:1'
+ assert line2.event == {
+ 'agenda': 'agenda-1',
+ 'start_datetime': '2022-09-02T12:00:00+02:00',
+ 'slug': 'event-2',
+ 'label': 'Event 2',
+ }
+ assert line2.pricing_data == {'foo2': 'bar2', 'pricing': 2}
+ assert line2.status == 'success'
+ assert isinstance(line3, DraftInvoiceLine)
+ assert line3.invoice is None
+ assert line3.slug == 'agenda-2@eveeent-1'
+ assert line3.label == 'Eveeent 1'
+ assert line3.quantity == 1
+ assert line3.unit_amount == 3
+ assert line3.total_amount == 3
+ assert line3.user_external_id == 'user:1'
+ assert line3.payer_external_id == 'user:1'
+ assert line3.event == {
+ 'agenda': 'agenda-2',
+ 'start_datetime': '2022-09-01T13:00:00+02:00',
+ 'slug': 'eveeent-1',
+ 'label': 'Eveeent 1',
+ }
+ assert line3.pricing_data == {'foo3': 'bar3', 'pricing': 3}
+ assert line3.status == 'success'
+ assert isinstance(line4, DraftInvoiceLine)
+ assert line4.invoice is None
+ assert line4.slug == 'agenda-2@eveeent-2'
+ assert line4.label == 'Eveeent 2'
+ assert line4.quantity == 1
+ assert line4.unit_amount == 4
+ assert line4.total_amount == 4
+ assert line4.user_external_id == 'user:1'
+ assert line4.payer_external_id == 'user:1'
+ assert line4.event == {
+ 'agenda': 'agenda-2',
+ 'start_datetime': '2022-09-02T13:00:00+02:00',
+ 'slug': 'eveeent-2',
+ 'label': 'Eveeent 2',
+ }
+ assert line4.pricing_data == {'foo4': 'bar4', 'pricing': 4}
+ assert line4.status == 'success'
+
+
+@mock.patch('lingo.invoicing.utils.get_check_status')
+@mock.patch('lingo.pricing.models.AgendaPricing.get_pricing_data_for_event')
+def test_get_invoice_lines_for_user_check_status_subscriptions_dates(mock_pricing_data_event, mock_status):
+ agenda = Agenda.objects.create(label='Agenda')
+ pricing = Pricing.objects.create(label='Foo bar')
+ agenda_pricing = AgendaPricing.objects.create(
+ pricing=pricing,
+ date_start=datetime.date(year=2022, month=9, day=1),
+ date_end=datetime.date(year=2022, month=10, day=1),
+ )
+ agenda_pricing.agendas.add(agenda)
+
+ mock_pricing_data_event.return_value = {'foo': 'bar', 'pricing': 42}
+ mock_status.return_value = [
+ {
+ 'event': {
+ 'agenda': 'agenda',
+ 'start_datetime': '2022-09-01T12:00:00+02:00',
+ 'slug': 'event',
+ 'label': 'Event',
+ },
+ 'check_status': {'foo': 'bar'},
+ 'booking': {'foo': 'baz'},
+ },
+ ]
+
+ assert (
+ utils.get_invoice_lines_for_user(
+ agendas=[agenda],
+ agendas_pricings=[agenda_pricing],
+ user_external_id='user:1',
+ subscriptions={
+ 'agenda': [
+ {
+ 'user_external_id': 'user:1',
+ # event is outside the period
+ 'date_start': '2022-08-31',
+ 'date_end': '2022-09-01',
+ },
+ ],
+ },
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ )
+ == []
+ )
+ assert mock_pricing_data_event.call_args_list == []
+
+ assert (
+ utils.get_invoice_lines_for_user(
+ agendas=[agenda],
+ agendas_pricings=[agenda_pricing],
+ user_external_id='user:1',
+ subscriptions={
+ 'agenda': [
+ {
+ 'user_external_id': 'user:1',
+ # event is outside the period
+ 'date_start': '2022-09-02',
+ 'date_end': '2022-09-03',
+ },
+ ],
+ },
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ )
+ == []
+ )
+ assert mock_pricing_data_event.call_args_list == []
+
+ lines = utils.get_invoice_lines_for_user(
+ agendas=[agenda],
+ agendas_pricings=[agenda_pricing],
+ user_external_id='user:1',
+ subscriptions={
+ 'agenda': [
+ {
+ 'user_external_id': 'user:1',
+ # event is outside the period
+ 'date_start': '2022-08-31',
+ 'date_end': '2022-09-01',
+ },
+ {
+ 'user_external_id': 'user:1',
+ # bingo !
+ 'date_start': '2022-09-01',
+ 'date_end': '2022-09-02',
+ },
+ {
+ 'user_external_id': 'user:1',
+ # event is outside the period
+ 'date_start': '2022-09-02',
+ 'date_end': '2022-09-03',
+ },
+ ],
+ },
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ )
+ assert len(lines) == 1
+ assert mock_pricing_data_event.call_args_list == [
+ mock.call(
+ request=mock.ANY,
+ agenda=agenda,
+ event={
+ 'agenda': 'agenda',
+ 'start_datetime': '2022-09-01T12:00:00+02:00',
+ 'slug': 'event',
+ 'label': 'Event',
+ },
+ subscription={'user_external_id': 'user:1', 'date_start': '2022-09-01', 'date_end': '2022-09-02'},
+ check_status={'foo': 'bar'},
+ booking={'foo': 'baz'},
+ user_external_id='user:1',
+ adult_external_id='user:1',
+ )
+ ]
+
+
+@mock.patch('lingo.invoicing.utils.get_check_status')
+def test_get_invoice_lines_for_user_check_status_agenda_pricing_dates(mock_status):
+ agenda = Agenda.objects.create(label='Agenda')
+ pricing = Pricing.objects.create(label='Foo bar')
+ agenda_pricing1 = AgendaPricing.objects.create(
+ pricing=pricing,
+ date_start=datetime.date(year=2022, month=9, day=1),
+ date_end=datetime.date(year=2022, month=10, day=1),
+ )
+ agenda_pricing1.agendas.add(agenda)
+ agenda_pricing2 = AgendaPricing.objects.create(
+ pricing=pricing,
+ date_start=datetime.date(year=2022, month=10, day=1),
+ date_end=datetime.date(year=2022, month=11, day=1),
+ flat_fee_schedule=True,
+ )
+ agenda_pricing2.agendas.add(agenda)
+ agenda_pricing3 = AgendaPricing.objects.create(
+ pricing=pricing,
+ date_start=datetime.date(year=2022, month=10, day=1),
+ date_end=datetime.date(year=2022, month=11, day=1),
+ )
+ agenda_pricing3.agendas.add(agenda)
+
+ pricing_data_event_patch = mock.patch.object(AgendaPricing, 'get_pricing_data_for_event', autospec=True)
+
+ # check agenda pricing of september is used
+ for event_date in ['2022-09-01T12:00:00+02:00', '2022-09-30T12:00:00+02:00']:
+ mock_status.return_value = [
+ {
+ 'event': {
+ 'agenda': 'agenda',
+ 'start_datetime': event_date,
+ 'slug': 'event',
+ 'label': 'Event',
+ },
+ 'check_status': {'foo': 'bar'},
+ 'booking': {'foo': 'baz'},
+ },
+ ]
+ with pricing_data_event_patch as mock_pricing_data_event:
+ mock_pricing_data_event.return_value = {'foo': 'bar', 'pricing': 42}
+ lines = utils.get_invoice_lines_for_user(
+ agendas=[agenda],
+ agendas_pricings=AgendaPricing.objects.all(),
+ user_external_id='user:1',
+ subscriptions={
+ 'agenda': [
+ {
+ 'user_external_id': 'user:1',
+ 'date_start': '2022-01-01',
+ 'date_end': '2023-01-01',
+ },
+ ],
+ },
+ date_start=datetime.date(2022, 1, 1),
+ date_end=datetime.date(2023, 1, 1),
+ )
+ assert len(lines) == 1
+ assert mock_pricing_data_event.call_args_list[0][0][0] == agenda_pricing1
+
+ # check agenda pricing of october is used
+ for event_date in ['2022-10-01T12:00:00+02:00', '2022-10-31T12:00:00+02:00']:
+ mock_status.return_value = [
+ {
+ 'event': {
+ 'agenda': 'agenda',
+ 'start_datetime': event_date,
+ 'slug': 'event',
+ 'label': 'Event',
+ },
+ 'check_status': {'foo': 'bar'},
+ 'booking': {'foo': 'baz'},
+ },
+ ]
+ with pricing_data_event_patch as mock_pricing_data_event:
+ mock_pricing_data_event.return_value = {'foo': 'bar', 'pricing': 42}
+ lines = utils.get_invoice_lines_for_user(
+ agendas=[agenda],
+ agendas_pricings=AgendaPricing.objects.all(),
+ user_external_id='user:1',
+ subscriptions={
+ 'agenda': [
+ {
+ 'user_external_id': 'user:1',
+ 'date_start': '2022-01-01',
+ 'date_end': '2023-01-01',
+ },
+ ],
+ },
+ date_start=datetime.date(2022, 1, 1),
+ date_end=datetime.date(2023, 1, 1),
+ )
+ assert len(lines) == 1
+ assert mock_pricing_data_event.call_args_list[0][0][0] == agenda_pricing3
+
+ # no matching agenda pricing
+ for event_date in ['2022-08-31T12:00:00+02:00', '2022-11-01T12:00:00+02:00']:
+ mock_status.return_value = [
+ {
+ 'event': {
+ 'agenda': 'agenda',
+ 'start_datetime': event_date,
+ 'slug': 'event',
+ 'label': 'Event',
+ },
+ 'check_status': {'foo': 'bar'},
+ 'booking': {'foo': 'baz'},
+ },
+ ]
+ with pricing_data_event_patch as mock_pricing_data_event:
+ lines = utils.get_invoice_lines_for_user(
+ agendas=[agenda],
+ agendas_pricings=AgendaPricing.objects.all(),
+ user_external_id='user:1',
+ subscriptions={
+ 'agenda': [
+ {
+ 'user_external_id': 'user:1',
+ 'date_start': '2022-01-01',
+ 'date_end': '2023-01-01',
+ },
+ ],
+ },
+ date_start=datetime.date(2022, 1, 1),
+ date_end=datetime.date(2023, 1, 1),
+ )
+ assert len(lines) == 0
+ assert mock_pricing_data_event.call_args_list == []
+
+
+@mock.patch('lingo.invoicing.utils.get_check_status')
+@mock.patch('lingo.pricing.models.AgendaPricing.get_pricing_data_for_event')
+def test_get_invoice_lines_for_user_check_status_pricing_error(mock_pricing_data_event, mock_status):
+ agenda = Agenda.objects.create(label='Agenda')
+ pricing = Pricing.objects.create(label='Foo bar')
+ agenda_pricing = AgendaPricing.objects.create(
+ pricing=pricing,
+ date_start=datetime.date(year=2022, month=9, day=1),
+ date_end=datetime.date(year=2022, month=10, day=1),
+ )
+ agenda_pricing.agendas.add(agenda)
+
+ mock_pricing_data_event.side_effect = [
+ {'foo1': 'bar1', 'pricing': 1},
+ PricingError(),
+ {'foo3': 'bar3', 'pricing': 3},
+ ]
+ mock_status.return_value = [
+ {
+ 'event': {
+ 'agenda': 'agenda',
+ 'start_datetime': '2022-09-01T12:00:00+02:00',
+ 'slug': 'event-1',
+ 'label': 'Event 1',
+ },
+ 'check_status': {'foo': 'bar'},
+ 'booking': {'foo': 'baz'},
+ },
+ {
+ 'event': {
+ 'agenda': 'agenda',
+ 'start_datetime': '2022-09-02T12:00:00+02:00',
+ 'slug': 'event-2',
+ 'label': 'Event 2',
+ },
+ 'check_status': {'foo': 'bar'},
+ 'booking': {'foo': 'baz'},
+ },
+ {
+ 'event': {
+ 'agenda': 'agenda',
+ 'start_datetime': '2022-09-03T12:00:00+02:00',
+ 'slug': 'event-3',
+ 'label': 'Event 3',
+ },
+ 'check_status': {'foo': 'bar'},
+ 'booking': {'foo': 'baz'},
+ },
+ ]
+ lines = utils.get_invoice_lines_for_user(
+ agendas=[agenda],
+ agendas_pricings=[agenda_pricing],
+ user_external_id='user:1',
+ subscriptions={
+ 'agenda': [
+ {
+ 'user_external_id': 'user:1',
+ 'date_start': '2022-09-01',
+ 'date_end': '2022-10-01',
+ },
+ ],
+ },
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ )
+ assert len(lines) == 3
+ line1, line2, line3 = lines
+ assert isinstance(line1, DraftInvoiceLine)
+ assert line1.invoice is None
+ assert line1.slug == 'agenda@event-1'
+ assert line1.label == 'Event 1'
+ assert line1.quantity == 1
+ assert line1.unit_amount == 1
+ assert line1.total_amount == 1
+ assert line1.user_external_id == 'user:1'
+ assert line1.payer_external_id == 'user:1'
+ assert line1.event == {
+ 'agenda': 'agenda',
+ 'start_datetime': '2022-09-01T12:00:00+02:00',
+ 'slug': 'event-1',
+ 'label': 'Event 1',
+ }
+ assert line1.pricing_data == {'foo1': 'bar1', 'pricing': 1}
+ assert line1.status == 'success'
+ assert isinstance(line2, DraftInvoiceLine)
+ assert line2.invoice is None
+ assert line2.slug == 'agenda@event-2'
+ assert line2.label == 'Event 2'
+ assert line2.quantity == 0
+ assert line2.unit_amount == 0
+ assert line2.total_amount == 0
+ assert line2.user_external_id == 'user:1'
+ assert line2.payer_external_id == 'user:1'
+ assert line2.event == {
+ 'agenda': 'agenda',
+ 'start_datetime': '2022-09-02T12:00:00+02:00',
+ 'slug': 'event-2',
+ 'label': 'Event 2',
+ }
+ assert line2.pricing_data == {'error': {}}
+ assert line2.status == 'error'
+ assert isinstance(line3, DraftInvoiceLine)
+ assert line3.invoice is None
+ assert line3.slug == 'agenda@event-3'
+ assert line3.label == 'Event 3'
+ assert line3.quantity == 1
+ assert line3.unit_amount == 3
+ assert line3.total_amount == 3
+ assert line3.user_external_id == 'user:1'
+ assert line3.payer_external_id == 'user:1'
+ assert line3.event == {
+ 'agenda': 'agenda',
+ 'start_datetime': '2022-09-03T12:00:00+02:00',
+ 'slug': 'event-3',
+ 'label': 'Event 3',
+ }
+ assert line3.pricing_data == {'foo3': 'bar3', 'pricing': 3}
+ assert line3.status == 'success'
+
+
+@mock.patch('lingo.invoicing.utils.get_invoice_lines_for_user')
+def test_get_all_invoice_lines(mock_user_lines):
+ agenda1 = Agenda.objects.create(label='Agenda 1')
+ agenda2 = Agenda.objects.create(label='Agenda 2')
+ Agenda.objects.create(label='Agenda 3')
+
+ line1 = DraftInvoiceLine.objects.create(
+ quantity=0,
+ unit_amount=0,
+ total_amount=0,
+ )
+ line2 = DraftInvoiceLine.objects.create(
+ quantity=0,
+ unit_amount=0,
+ total_amount=0,
+ )
+ line3 = DraftInvoiceLine.objects.create(
+ quantity=0,
+ unit_amount=0,
+ total_amount=0,
+ )
+
+ mock_user_lines.side_effect = [[line1, line2], [line3]]
+
+ # no subscriptions
+ assert (
+ utils.get_all_invoice_lines(
+ agendas=[agenda1, agenda2],
+ subscriptions={},
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ )
+ == []
+ )
+ assert mock_user_lines.call_args_list == []
+
+ # with subscriptions
+ assert utils.get_all_invoice_lines(
+ agendas=[agenda1, agenda2],
+ subscriptions={
+ 'user:1': {'foo': 'bar1'},
+ 'user:2': {'foo': 'bar2'},
+ },
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ ) == [line1, line2, line3]
+ assert mock_user_lines.call_args_list == [
+ mock.call(
+ agendas=[agenda1, agenda2],
+ agendas_pricings=mock.ANY,
+ user_external_id='user:1',
+ subscriptions={'foo': 'bar1'},
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ ),
+ mock.call(
+ agendas=[agenda1, agenda2],
+ agendas_pricings=mock.ANY,
+ user_external_id='user:2',
+ subscriptions={'foo': 'bar2'},
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ ),
+ ]
+
+
+@mock.patch('lingo.invoicing.utils.get_check_status')
+def test_get_all_invoice_lines_queryset(mock_status):
+ # don't mock get_pricing_data_for_event to check all querysets
+ category1 = CriteriaCategory.objects.create(label='Foo1', slug='foo1')
+ criteria1 = Criteria.objects.create(label='Bar1', slug='bar1', condition='True', category=category1)
+ category2 = CriteriaCategory.objects.create(label='Foo2', slug='foo2')
+ criteria2 = Criteria.objects.create(label='Bar2', slug='bar2', condition='True', category=category2)
+
+ pricing1 = Pricing.objects.create(label='Foo bar 1')
+ pricing1.criterias.add(criteria1, criteria2)
+ pricing1.categories.add(category1, through_defaults={'order': 1})
+ pricing1.categories.add(category2, through_defaults={'order': 2})
+ pricing2 = Pricing.objects.create(label='Foo bar 2')
+ pricing2.criterias.add(criteria1, criteria2)
+ pricing2.categories.add(category1, through_defaults={'order': 1})
+ pricing2.categories.add(category2, through_defaults={'order': 2})
+
+ agenda1 = Agenda.objects.create(label='Agenda 1')
+ agenda_pricing11 = AgendaPricing.objects.create(
+ pricing=pricing1,
+ date_start=datetime.date(year=2022, month=9, day=1),
+ date_end=datetime.date(year=2022, month=10, day=1),
+ )
+ agenda_pricing11.agendas.add(agenda1)
+ agenda_pricing12 = AgendaPricing.objects.create(
+ pricing=pricing1,
+ date_start=datetime.date(year=2022, month=10, day=1),
+ date_end=datetime.date(year=2022, month=11, day=1),
+ )
+ agenda_pricing12.agendas.add(agenda1)
+
+ agenda2 = Agenda.objects.create(label='Agenda 2')
+ agenda_pricing21 = AgendaPricing.objects.create(
+ pricing=pricing2,
+ date_start=datetime.date(year=2022, month=9, day=1),
+ date_end=datetime.date(year=2022, month=10, day=1),
+ )
+ agenda_pricing21.agendas.add(agenda2)
+ agenda_pricing22 = AgendaPricing.objects.create(
+ pricing=pricing2,
+ date_start=datetime.date(year=2022, month=10, day=1),
+ date_end=datetime.date(year=2022, month=11, day=1),
+ )
+ agenda_pricing22.agendas.add(agenda2)
+
+ mock_status.return_value = [
+ {
+ 'event': {
+ 'agenda': 'agenda-1',
+ 'start_datetime': '2022-09-01T12:00:00+02:00',
+ 'slug': 'event',
+ 'label': 'Event',
+ },
+ 'check_status': {'foo': 'bar'},
+ 'booking': {'foo': 'baz'},
+ },
+ {
+ 'event': {
+ 'agenda': 'agenda-1',
+ 'start_datetime': '2022-10-01T12:00:00+02:00',
+ 'slug': 'event',
+ 'label': 'Event',
+ },
+ 'check_status': {'foo': 'bar'},
+ 'booking': {'foo': 'baz'},
+ },
+ {
+ 'event': {
+ 'agenda': 'agenda-2',
+ 'start_datetime': '2022-09-01T12:00:00+02:00',
+ 'slug': 'event',
+ 'label': 'Event',
+ },
+ 'check_status': {'foo': 'bar'},
+ 'booking': {'foo': 'baz'},
+ },
+ {
+ 'event': {
+ 'agenda': 'agenda-2',
+ 'start_datetime': '2022-10-01T12:00:00+02:00',
+ 'slug': 'event',
+ 'label': 'Event',
+ },
+ 'check_status': {'foo': 'bar'},
+ 'booking': {'foo': 'baz'},
+ },
+ ]
+
+ with CaptureQueriesContext(connection) as ctx:
+ lines = utils.get_all_invoice_lines(
+ agendas=[agenda1, agenda2],
+ subscriptions={
+ 'user:1': {
+ 'agenda-1': [
+ {
+ 'user_external_id': 'user:1',
+ 'date_start': '2022-09-01',
+ 'date_end': '2022-11-01',
+ },
+ ],
+ 'agenda-2': [
+ {
+ 'user_external_id': 'user:1',
+ 'date_start': '2022-09-01',
+ 'date_end': '2022-11-01',
+ },
+ ],
+ },
+ 'user:2': {
+ 'agenda-1': [
+ {
+ 'user_external_id': 'user:2',
+ 'date_start': '2022-09-01',
+ 'date_end': '2022-11-01',
+ },
+ ],
+ 'agenda-2': [
+ {
+ 'user_external_id': 'user:2',
+ 'date_start': '2022-09-01',
+ 'date_end': '2022-11-01',
+ },
+ ],
+ },
+ },
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 11, 1),
+ )
+ assert lines
+ assert len(ctx.captured_queries) == 7
+
+
+def test_generate_invoices_from_lines():
+ regie1 = Regie.objects.create(label='Regie 1')
+ regie2 = Regie.objects.create(label='Regie 2')
+ agenda1 = Agenda.objects.create(label='Agenda 1', regie=regie1)
+ agenda2 = Agenda.objects.create(label='Agenda 2', regie=regie2)
+ agenda3 = Agenda.objects.create(label='Agenda 3', regie=regie1)
+ agenda4 = Agenda.objects.create(label='Agenda 4') # regie not configured
+
+ line_error = DraftInvoiceLine.objects.create(
+ event={'agenda': 'agenda-1'},
+ quantity=0,
+ unit_amount=0,
+ total_amount=0,
+ user_external_id='user:1',
+ payer_external_id='user:1',
+ status='error',
+ )
+ line1 = DraftInvoiceLine.objects.create(
+ event={'agenda': 'agenda-1'},
+ quantity=1,
+ unit_amount=1,
+ total_amount=1,
+ user_external_id='user:1',
+ payer_external_id='user:1',
+ status='success',
+ )
+ line2 = DraftInvoiceLine.objects.create(
+ event={'agenda': 'agenda-1'},
+ quantity=1,
+ unit_amount=2,
+ total_amount=2,
+ user_external_id='user:1',
+ payer_external_id='user:1',
+ status='success',
+ )
+ line3 = DraftInvoiceLine.objects.create(
+ event={'agenda': 'agenda-2'},
+ quantity=1,
+ unit_amount=3,
+ total_amount=3,
+ user_external_id='user:1',
+ payer_external_id='user:1',
+ status='success',
+ )
+ line4 = DraftInvoiceLine.objects.create(
+ event={'agenda': 'agenda-2'},
+ quantity=1,
+ unit_amount=4,
+ total_amount=4,
+ user_external_id='user:1',
+ payer_external_id='user:2',
+ status='success',
+ )
+ line5 = DraftInvoiceLine.objects.create(
+ event={'agenda': 'agenda-3'},
+ quantity=1,
+ unit_amount=5,
+ total_amount=5,
+ user_external_id='user:2',
+ payer_external_id='user:1',
+ status='success',
+ )
+ DraftInvoiceLine.objects.create( # not used for generation
+ event={'agenda': 'agenda-3'},
+ quantity=1,
+ unit_amount=5,
+ total_amount=5,
+ user_external_id='user:2',
+ payer_external_id='user:1',
+ status='success',
+ )
+ line6 = DraftInvoiceLine.objects.create(
+ event={'agenda': 'agenda-4'}, # regie not configured
+ quantity=1,
+ unit_amount=6,
+ total_amount=6,
+ user_external_id='user:1',
+ payer_external_id='user:1',
+ status='success',
+ )
+
+ invoices = utils.generate_invoices_from_lines(
+ agendas=[],
+ all_lines=[line_error, line1, line2, line3, line4, line5, line6],
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ )
+ assert len(invoices) == 0
+
+ invoices = utils.generate_invoices_from_lines(
+ agendas=[agenda1, agenda2, agenda3, agenda4],
+ all_lines=[line_error, line1, line2, line3, line4, line5, line6],
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ )
+ assert len(invoices) == 3
+ invoice1, invoice2, invoice3 = invoices
+ # refresh total_amount field (triggered)
+ invoice1.refresh_from_db()
+ invoice2.refresh_from_db()
+ invoice3.refresh_from_db()
+ assert isinstance(invoice1, DraftInvoice)
+ assert invoice1.label == 'Invoice from 2022-09-01 to 2022-09-30'
+ assert invoice1.total_amount == 8
+ assert invoice1.date_issue == datetime.date(2022, 10, 1)
+ assert invoice1.regie == regie1
+ assert invoice1.payer == 'user:1'
+ assert list(invoice1.lines.order_by('pk')) == [line1, line2, line5]
+ assert isinstance(invoice2, DraftInvoice)
+ assert invoice2.label == 'Invoice from 2022-09-01 to 2022-09-30'
+ assert invoice2.total_amount == 3
+ assert invoice2.date_issue == datetime.date(2022, 10, 1)
+ assert invoice2.regie == regie2
+ assert invoice2.payer == 'user:1'
+ assert list(invoice2.lines.order_by('pk')) == [line3]
+ assert isinstance(invoice3, DraftInvoice)
+ assert invoice3.label == 'Invoice from 2022-09-01 to 2022-09-30'
+ assert invoice3.total_amount == 4
+ assert invoice3.date_issue == datetime.date(2022, 10, 1)
+ assert invoice3.regie == regie2
+ assert invoice3.payer == 'user:2'
+ assert list(invoice3.lines.order_by('pk')) == [line4]
+
+
+@mock.patch('lingo.invoicing.utils.get_agendas')
+@mock.patch('lingo.invoicing.utils.get_all_subscriptions')
+@mock.patch('lingo.invoicing.utils.get_all_invoice_lines')
+@mock.patch('lingo.invoicing.utils.generate_invoices_from_lines')
+def test_generate_invoices(mock_generate, mock_lines, mock_subscriptions, mock_agendas):
+ agenda1 = Agenda.objects.create(label='Agenda 1')
+ agenda2 = Agenda.objects.create(label='Agenda 2')
+ Agenda.objects.create(label='Agenda 3')
+ line1 = DraftInvoiceLine.objects.create(
+ event={'agenda': 'agenda-1'},
+ quantity=1,
+ unit_amount=1,
+ total_amount=1,
+ user_external_id='user:1',
+ payer_external_id='user:1',
+ status='success',
+ )
+ line2 = DraftInvoiceLine.objects.create(
+ event={'agenda': 'agenda-1'},
+ quantity=1,
+ unit_amount=2,
+ total_amount=2,
+ user_external_id='user:1',
+ payer_external_id='user:1',
+ status='success',
+ )
+ line3 = DraftInvoiceLine.objects.create(
+ event={'agenda': 'agenda-2'},
+ quantity=1,
+ unit_amount=3,
+ total_amount=3,
+ user_external_id='user:1',
+ payer_external_id='user:1',
+ status='success',
+ )
+ DraftInvoiceLine.objects.create(
+ event={'agenda': 'agenda-1'},
+ quantity=1,
+ unit_amount=5,
+ total_amount=5,
+ user_external_id='user:2',
+ payer_external_id='user:1',
+ status='success',
+ )
+
+ mock_agendas.return_value = [agenda1, agenda2]
+ mock_subscriptions.return_value = {'foo': 'bar'}
+ mock_lines.return_value = [line1, line2, line3]
+
+ # check only calls between functions
+ utils.generate_invoices(date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1))
+ assert mock_agendas.call_args_list == [
+ mock.call(date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1))
+ ]
+ assert mock_subscriptions.call_args_list == [
+ mock.call(
+ agendas=[agenda1, agenda2],
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ )
+ ]
+ assert mock_lines.call_args_list == [
+ mock.call(
+ agendas=[agenda1, agenda2],
+ subscriptions={'foo': 'bar'},
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ )
+ ]
+ assert mock_generate.call_args_list == [
+ mock.call(
+ agendas=[agenda1, agenda2],
+ all_lines=[line1, line2, line3],
+ date_start=datetime.date(2022, 9, 1),
+ date_end=datetime.date(2022, 10, 1),
+ )
+ ]
+
+
+def test_generate_invoices_cmd():
+ with mock.patch(
+ 'lingo.invoicing.management.commands.generate_invoices.generate_invoices'
+ ) as mock_generate:
+ with pytest.raises(CommandError) as excinfo:
+ call_command('generate_invoices')
+ assert '%s' % excinfo.value == 'Error: the following arguments are required: date_start, date_end'
+ assert mock_generate.call_args_list == []
+
+ with pytest.raises(CommandError) as excinfo:
+ call_command('generate_invoices', 'bad-value', '2022-10-01')
+ assert '%s' % excinfo.value == 'Bad value "bad-value" for date_start'
+ assert mock_generate.call_args_list == []
+
+ with pytest.raises(CommandError) as excinfo:
+ call_command('generate_invoices', '2022-09-01', 'bad-value')
+ assert '%s' % excinfo.value == 'Bad value "bad-value" for date_end'
+ assert mock_generate.call_args_list == []
+
+ call_command('generate_invoices', '2022-09-01', '2022-10-01')
+ assert mock_generate.call_args_list == [
+ mock.call(date_start=datetime.date(2022, 9, 1), date_end=datetime.date(2022, 10, 1))
+ ]
diff --git a/tests/invoicing/test_models.py b/tests/invoicing/test_models.py
new file mode 100644
index 0000000..27c87fa
--- /dev/null
+++ b/tests/invoicing/test_models.py
@@ -0,0 +1,136 @@
+import datetime
+
+import pytest
+
+from lingo.invoicing.models import DraftInvoice, DraftInvoiceLine, Invoice, InvoiceLine, Regie
+
+pytestmark = pytest.mark.django_db
+
+
+@pytest.mark.parametrize('draft', [True, False])
+def test_invoice_total_amount(draft):
+ regie = Regie.objects.create()
+ invoice_model = DraftInvoice if draft else Invoice
+ line_model = DraftInvoiceLine if draft else InvoiceLine
+
+ invoice = invoice_model.objects.create(date_issue=datetime.date.today(), regie=regie)
+ assert invoice.total_amount == 0
+ invoice2 = invoice_model.objects.create(date_issue=datetime.date.today(), regie=regie)
+ assert invoice2.total_amount == 0
+
+ # line with error status, ignored
+ line = line_model.objects.create(
+ invoice=invoice, # with invoice
+ quantity=0,
+ unit_amount=0,
+ total_amount=0,
+ status='error',
+ )
+ invoice.refresh_from_db()
+ assert invoice.total_amount == 0
+ invoice2.refresh_from_db()
+ assert invoice2.total_amount == 0
+
+ # update line
+ line.unit_amount = 10
+ line.quantity = 1
+ line.total_amount = 10
+ line.save()
+ invoice.refresh_from_db()
+ # still in error
+ assert invoice.total_amount == 0
+ invoice2.refresh_from_db()
+ assert invoice2.total_amount == 0
+
+ # update line status
+ line.status = 'success'
+ line.save()
+ invoice.refresh_from_db()
+ assert invoice.total_amount == 10
+ invoice2.refresh_from_db()
+ assert invoice2.total_amount == 0
+
+ # update other field
+ line.unit_amount = 12 # total amount is wrong
+ line.save()
+ invoice.refresh_from_db()
+ assert invoice.total_amount == 10
+ invoice2.refresh_from_db()
+ assert invoice2.total_amount == 0
+
+ # update total_amount
+ line.total_amount = 12
+ line.save()
+ invoice.refresh_from_db()
+ assert invoice.total_amount == 12
+ invoice2.refresh_from_db()
+ assert invoice2.total_amount == 0
+
+ # create line with invoice, status success
+ line2 = line_model.objects.create(
+ invoice=invoice,
+ quantity=1,
+ unit_amount=20,
+ total_amount=20,
+ status='success',
+ )
+ invoice.refresh_from_db()
+ assert invoice.total_amount == 32
+ invoice2.refresh_from_db()
+ assert invoice2.total_amount == 0
+
+ # change invoice
+ line2.invoice = invoice2
+ line2.save()
+ invoice.refresh_from_db()
+ assert invoice.total_amount == 12
+ invoice2.refresh_from_db()
+ assert invoice2.total_amount == 20
+
+ # delete line
+ line2.delete()
+ invoice.refresh_from_db()
+ assert invoice.total_amount == 12
+ invoice2.refresh_from_db()
+ assert invoice2.total_amount == 0
+
+ # create line without invoice, status success
+ line3 = line_model.objects.create(
+ quantity=1,
+ unit_amount=20,
+ total_amount=20,
+ status='success',
+ )
+ invoice.refresh_from_db()
+ assert invoice.total_amount == 12
+ invoice2.refresh_from_db()
+ assert invoice2.total_amount == 0
+
+ # set invoice
+ line3.invoice = invoice
+ line3.save()
+ invoice.refresh_from_db()
+ assert invoice.total_amount == 32
+ invoice2.refresh_from_db()
+ assert invoice2.total_amount == 0
+
+ # reset invoice
+ line3.invoice = None
+ line3.save()
+ invoice.refresh_from_db()
+ assert invoice.total_amount == 12
+ invoice2.refresh_from_db()
+ assert invoice2.total_amount == 0
+ # no changes
+ line3.save()
+ invoice.refresh_from_db()
+ assert invoice.total_amount == 12
+ invoice2.refresh_from_db()
+ assert invoice2.total_amount == 0
+
+ # delete line
+ line3.delete()
+ invoice.refresh_from_db()
+ assert invoice.total_amount == 12
+ invoice2.refresh_from_db()
+ assert invoice2.total_amount == 0