Repository: svthalia/concrexit

Issue:
Fix moneybird pagination problem in get_or_create_project
### Describe the bug
https://thalia.sentry.io/issues/4111947935/?project=1463433&query=is%3Aunresolved+issue.category%3Aerror&referrer=issue-stream&statsPeriod=14d&stream_index=5

Moneybird stuff nearly always fails when getting an existing project, because only 25 projects are returned by default (iirc 100 is the max that can be set).
So we need to either handle pagination, or find out if we can filter by name in https://github.com/svthalia/concrexit/blob/d04b57585e58d692916d4a5225eeea4bfa4cde4c/website/moneybirdsynchronization/moneybird.py#L23-L25.

### How to reproduce
Steps to reproduce the behaviour:
1.  Try moneybird if over 25 projects exist.




Discussion:
https://developer.moneybird.com/api/projects/: there's no name filter right now (@JobDoesburg any chance they'd want to implement it?). So I guess the only option is to fetch all pages, and perhaps cache the projects list.
> @JobDoesburg any chance they'd want to implement it?

We could send them a message and then they'll probably add it to their backlog. But they won't add it fast I think

We can set the page size to 100 so that would help

I would propose to make a MoneybirdProject model that stores it. That's the way Moneybird wants you to use their API anyhow. This way, we could also easily implement deactivating (deleting) projects that we don't need to use anymore (from previous book years, for example). Moneybird does have a filter for that.



Resolving patch:
diff --git a/website/moneybirdsynchronization/migrations/0004_moneybirdproject.py b/website/moneybirdsynchronization/migrations/0004_moneybirdproject.py
new file mode 100644
--- /dev/null
+++ b/website/moneybirdsynchronization/migrations/0004_moneybirdproject.py
@@ -0,0 +1,72 @@
+# Generated by Django 4.2.5 on 2023-09-11 19:45
+
+from django.conf import settings
+from django.db import migrations, models
+
+from moneybirdsynchronization.moneybird import get_moneybird_api_service
+
+
+def create_moneybird_projects(apps, schema_editor):
+    MoneybirdProject = apps.get_model("moneybirdsynchronization", "MoneybirdProject")
+
+    if not settings.MONEYBIRD_SYNC_ENABLED:
+        return None
+
+    moneybird = get_moneybird_api_service()
+
+    # Get all projects from the API.
+    projects = []
+    while True:
+        page = moneybird._administration.get("projects", params={"per_page": 100})
+        projects.extend(page)
+        if len(page) < 100:
+            break
+
+    # Create a MoneybirdProject for each project.
+    for project in projects:
+        MoneybirdProject.objects.get_or_create(
+            name=project["name"],
+            moneybird_id=project["id"],
+        )
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("moneybirdsynchronization", "0003_alter_moneybirdcontact_moneybird_id"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="MoneybirdProject",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                (
+                    "name",
+                    models.CharField(
+                        db_index=True, max_length=255, unique=True, verbose_name="Name"
+                    ),
+                ),
+                (
+                    "moneybird_id",
+                    models.CharField(
+                        blank=True,
+                        max_length=255,
+                        null=True,
+                        verbose_name="Moneybird ID",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "moneybird project",
+                "verbose_name_plural": "moneybird projects",
+            },
+        ),
+    ]
diff --git a/website/moneybirdsynchronization/models.py b/website/moneybirdsynchronization/models.py
--- a/website/moneybirdsynchronization/models.py
+++ b/website/moneybirdsynchronization/models.py
@@ -60,6 +60,38 @@ def ledger_id_for_payable_model(obj):
     return None
 
 
+class MoneybirdProject(models.Model):
+    name = models.CharField(
+        _("Name"),
+        max_length=255,
+        blank=False,
+        null=False,
+        unique=True,
+        db_index=True,
+    )
+
+    moneybird_id = models.CharField(
+        _("Moneybird ID"),
+        max_length=255,
+        blank=True,
+        null=True,
+    )
+
+    class Meta:
+        verbose_name = _("moneybird project")
+        verbose_name_plural = _("moneybird projects")
+
+    def __str__(self):
+        return f"Moneybird project {self.name}"
+
+    def to_moneybird(self):
+        return {
+            "project": {
+                "name": self.name,
+            }
+        }
+
+
 class MoneybirdContact(models.Model):
     member = models.OneToOneField(
         Member,
@@ -189,10 +221,10 @@ def to_moneybird(self):
         if self.payable.payment_payer is None:
             contact_id = settings.MONEYBIRD_UNKNOWN_PAYER_CONTACT_ID
         else:
-            moneybird_contact, created = MoneybirdContact.objects.get_or_create(
+            moneybird_contact, __ = MoneybirdContact.objects.get_or_create(
                 member=self.payable.payment_payer
             )
-            if created:
+            if moneybird_contact.moneybird_id is None:
                 response = moneybird.create_contact(moneybird_contact.to_moneybird())
                 moneybird_contact.moneybird_id = response["id"]
                 moneybird_contact.save()
@@ -202,8 +234,13 @@ def to_moneybird(self):
         invoice_date = date_for_payable_model(self.payable_object).strftime("%Y-%m-%d")
 
         project_name = project_name_for_payable_model(self.payable_object)
+        project, __ = MoneybirdProject.objects.get_or_create(name=project_name)
+        if project.moneybird_id is None:
+            response = moneybird.create_project(project.to_moneybird())
+            project.moneybird_id = response["id"]
+            project.save()
 
-        project_id = moneybird.get_or_create_project(project_name)
+        project_id = project.moneybird_id
 
         ledger_id = ledger_id_for_payable_model(self.payable_object)
 
diff --git a/website/moneybirdsynchronization/moneybird.py b/website/moneybirdsynchronization/moneybird.py
--- a/website/moneybirdsynchronization/moneybird.py
+++ b/website/moneybirdsynchronization/moneybird.py
@@ -15,15 +15,8 @@ def update_contact(self, contact_id, contact_data):
     def delete_contact(self, contact_id):
         self._administration.delete(f"contacts/{contact_id}")
 
-    def get_or_create_project(self, name):
-        if name is None:
-            return None
-
-        for project in self._administration.get("projects"):
-            if project["name"] == name:
-                return project["id"]
-
-        return self._administration.post("projects", {"project": {"name": name}})["id"]
+    def create_project(self, name):
+        return self._administration.post("projects", {"project": {"name": name}})
 
     def create_external_sales_invoice(self, invoice_data):
         return self._administration.post("external_sales_invoices", invoice_data)


Test patch: