diff --git a/aleksis/apps/tezor/migrations/0003_manual_invoicing.py b/aleksis/apps/tezor/migrations/0003_manual_invoicing.py
new file mode 100644
index 0000000000000000000000000000000000000000..3751573d6ba5f37a7934371fb750d92808c97437
--- /dev/null
+++ b/aleksis/apps/tezor/migrations/0003_manual_invoicing.py
@@ -0,0 +1,54 @@
+# Generated by Django 3.2.12 on 2022-03-12 21:41
+
+import django.contrib.sites.managers
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0038_notification_send_at.py'),
+        ('sites', '0002_alter_domain_unique'),
+        ('tezor', '0002_invoice_due_date'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='InvoiceItem',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('extended_data', models.JSONField(default=dict, editable=False)),
+                ('sku', models.CharField(blank=True, max_length=255, verbose_name='Article no.')),
+                ('description', models.CharField(max_length=255, verbose_name='Purchased item')),
+                ('price', models.DecimalField(decimal_places=2, default='0.0', max_digits=9, verbose_name='Item gross price')),
+                ('currency', models.CharField(max_length=10, verbose_name='Currency')),
+                ('tax_rate', models.DecimalField(decimal_places=1, default='0.0', max_digits=4, verbose_name='Tax rate')),
+            ],
+            options={
+                'abstract': False,
+            },
+            managers=[
+                ('objects', django.contrib.sites.managers.CurrentSiteManager()),
+            ],
+        ),
+        migrations.AddField(
+            model_name='invoice',
+            name='person',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.person', verbose_name='Invoice recipient (person)'),
+        ),
+        migrations.AddConstraint(
+            model_name='invoice',
+            constraint=models.CheckConstraint(check=models.Q(('for_object_id__isnull', True), ('person__isnull', True), _connector='OR'), name='object_or_person'),
+        ),
+        migrations.AddField(
+            model_name='invoiceitem',
+            name='site',
+            field=models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to='sites.site'),
+        ),
+        migrations.AddField(
+            model_name='invoice',
+            name='items',
+            field=models.ManyToManyField(to='tezor.InvoiceItem', verbose_name='Invoice items'),
+        ),
+    ]
diff --git a/aleksis/apps/tezor/models/invoice.py b/aleksis/apps/tezor/models/invoice.py
index c67e29d627d6c74d745cfffb31dbd70e970019df..7c87380376ead88f18aa9e677cf269cc1dfb6dfd 100644
--- a/aleksis/apps/tezor/models/invoice.py
+++ b/aleksis/apps/tezor/models/invoice.py
@@ -2,17 +2,18 @@ from django.conf import settings
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.db import models
+from django.db.models import Q
 from django.shortcuts import reverse
 from django.utils.translation import gettext_lazy as _
 
-from djmoney.models.fields import CurrencyField, MoneyField
 from payments import PaymentStatus, PurchasedItem
 from payments.models import BasePayment
 
 from aleksis.core.mixins import ExtensibleModel, PureDjangoModel
+from aleksis.core.models import Person
 
-from .base import Client
 from ..tables import PurchasedItemsTable, TotalsTable
+from .base import Client
 
 
 class InvoiceGroup(ExtensibleModel):
@@ -61,6 +62,10 @@ class Invoice(BasePayment, PureDjangoModel):
     for_object_id = models.PositiveIntegerField()
     for_object = GenericForeignKey("for_content_type", "for_object_id")
 
+    # For manual invoicing
+    person = models.ForeignKey(Person, on_delete=models.SET_NULL, verbose_name=_("Invoice recipient (person)"), blank=True, null=True)
+    items = models.ManyToManyField("InvoiceItem", verbose_name=_("Invoice items"))
+
     @classmethod
     def get_variant_choices(cls):
         choices = []
@@ -78,10 +83,15 @@ class Invoice(BasePayment, PureDjangoModel):
         return self.__class__.STATUS_ICONS[self.status]
 
     def get_purchased_items(self):
-        return self.for_object.get_purchased_items()
+        for item in self.items.all():
+            yield item.as_purchased_item()
+        else:
+            return self.for_object.get_purchased_items()
 
     def get_person(self):
-        if hasattr(self.for_object, "person"):
+        if self.person:
+            return self.person
+        elif hasattr(self.for_object, "person"):
             return self.for_object.person
         elif hasattr(self.for_object, "get_person"):
             return self.for_object.get_person()
@@ -90,7 +100,8 @@ class Invoice(BasePayment, PureDjangoModel):
 
     class Meta:
         constraints = [
-            models.UniqueConstraint(fields=["number", "group"], name="number_uniq_per_group")
+            models.UniqueConstraint(fields=["number", "group"], name="number_uniq_per_group"),
+            models.CheckConstraint(check=(Q(for_object_id__isnull=True) | Q(person__isnull=True)), name="object_or_person"),
         ]
 
     @property
@@ -126,3 +137,14 @@ class Invoice(BasePayment, PureDjangoModel):
 
     def get_failure_url(self):
         return reverse("invoice_by_token", kwargs={"slug": self.token})
+
+
+class InvoiceItem(ExtensibleModel):
+    sku = models.CharField(max_length=255, verbose_name=_("Article no."), blank=True)
+    description = models.CharField(max_length=255, verbose_name=_("Purchased item"))
+    price = models.DecimalField(verbose_name=_("Item gross price"), max_digits=9, decimal_places=2, default="0.0")
+    currency = models.CharField(max_length=10, verbose_name=_("Currency"))
+    tax_rate = models.DecimalField(verbose_name=_("Tax rate"), max_digits=4, decimal_places=1, default="0.0")
+
+    def as_purchased_item(self):
+        yield PurchasedItem(name=self.description, quantity=1, price=self.price, currency=self.currency, sku=self.sku, tax_rate=self.tax_rate)