Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • AlekSIS/onboarding/AlekSIS-App-Tezor
  • sunweaver/AlekSIS-App-Tezor
  • 3lisvequii/AlekSIS-App-Tezor
3 results
Show changes
Commits on Source (17)
Showing
with 749 additions and 202 deletions
...@@ -3,10 +3,11 @@ from material import Layout, Row ...@@ -3,10 +3,11 @@ from material import Layout, Row
from payments import PaymentStatus from payments import PaymentStatus
from .models.invoice import Invoice from .models.invoice import Invoice
from .models.payment_variant import PaymentVariant
class InvoicesFilter(FilterSet): class InvoicesFilter(FilterSet):
variant = ChoiceFilter(choices=Invoice.get_variant_choices()) variant = ChoiceFilter(choices=PaymentVariant.as_choices)
status = ChoiceFilter(choices=PaymentStatus.CHOICES) status = ChoiceFilter(choices=PaymentStatus.CHOICES)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
......
...@@ -2,7 +2,7 @@ from django.http import HttpResponse ...@@ -2,7 +2,7 @@ from django.http import HttpResponse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from djp_sepa.models import SEPAMandate from djp_sepa.models import SEPAMandate
from material import Fieldset, Layout, Row from material import Layout, Row
from payments import PaymentStatus from payments import PaymentStatus
from aleksis.core.forms import ActionForm from aleksis.core.forms import ActionForm
...@@ -50,26 +50,7 @@ class EditClientForm(ExtensibleForm): ...@@ -50,26 +50,7 @@ class EditClientForm(ExtensibleForm):
layout = Layout( layout = Layout(
Row("name", "email"), Row("name", "email"),
Fieldset( Row("payment_variants"),
_("Payment pledge"),
Row("pledge_enabled"),
),
Fieldset(
_("Sofort / Klarna"),
"sofort_enabled",
Row("sofort_api_id", "sofort_api_key", "sofort_project_id"),
),
Fieldset(
_("PayPal"),
"paypal_enabled",
Row("paypal_client_id", "paypal_secret", "paypal_capture"),
),
Fieldset(
_("Debit"),
"sdd_enabled",
Row("sdd_creditor", "sdd_creditor_identifier"),
Row("sdd_iban", "sdd_bic"),
),
) )
class Meta: class Meta:
...@@ -77,20 +58,6 @@ class EditClientForm(ExtensibleForm): ...@@ -77,20 +58,6 @@ class EditClientForm(ExtensibleForm):
fields = [ fields = [
"name", "name",
"email", "email",
"pledge_enabled",
"sofort_enabled",
"sofort_api_id",
"sofort_api_key",
"sofort_project_id",
"paypal_enabled",
"paypal_client_id",
"paypal_secret",
"paypal_capture",
"sdd_enabled",
"sdd_creditor",
"sdd_creditor_identifier",
"sdd_iban",
"sdd_bic",
] ]
......
...@@ -100,6 +100,52 @@ export default { ...@@ -100,6 +100,52 @@ export default {
}, },
name: "tezor.deleteInvoiceGroupByPk", name: "tezor.deleteInvoiceGroupByPk",
}, },
{
path: "payment_variants/",
component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
props: {
byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
},
name: "tezor.payment_variants",
meta: {
inMenu: true,
titleKey: "tezor.payment_variants.menu_title",
icon: "mdi-cash-register",
permission: "tezor.view_paymentvariants_rule",
},
},
{
path: "payment_variant/create/:model/",
component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
props: {
byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
},
name: "tezor.createPaymentVariant",
},
{
path: "payment_variant/:pk/edit/",
component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
props: {
byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
},
name: "tezor.editPaymentVariantByPk",
},
{
path: "payment_variant/:pk/delete/",
component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
props: {
byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
},
name: "tezor.deletePaymentVariantByPk",
},
{
path: "payment_variant/:pk/",
component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
props: {
byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
},
name: "tezor.paymentVariantByPk",
},
{ {
path: "invoices/my/", path: "invoices/my/",
component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"), component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
......
{ {
"tezor": { "tezor": {
"menu_title": "Payments and Money", "menu_title": "Payments",
"clients": { "clients": {
"menu_title": "Manage clients" "menu_title": "Clients"
}, },
"personal_invoices": { "personal_invoices": {
"menu_title": "My invoices" "menu_title": "My Invoices"
},
"payment_variants": {
"menu_title": "Payment Variants"
} }
} }
} }
...@@ -33,7 +33,7 @@ class Migration(migrations.Migration): ...@@ -33,7 +33,7 @@ class Migration(migrations.Migration):
('extended_data', models.JSONField(default=dict, editable=False)), ('extended_data', models.JSONField(default=dict, editable=False)),
('name', models.CharField(max_length=255, verbose_name='Invoice group name')), ('name', models.CharField(max_length=255, verbose_name='Invoice group name')),
('template_name', models.CharField(blank=True, max_length=255, verbose_name='Template to render invoices with as PDF')), ('template_name', models.CharField(blank=True, max_length=255, verbose_name='Template to render invoices with as PDF')),
('client', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invoice_groups', to='tezor.client', verbose_name='Linked client')), ('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invoice_groups', to='tezor.client', verbose_name='Linked client')),
], ],
managers=[ managers=[
('objects', aleksis.core.managers.AlekSISBaseManager()), ('objects', aleksis.core.managers.AlekSISBaseManager()),
...@@ -72,7 +72,7 @@ class Migration(migrations.Migration): ...@@ -72,7 +72,7 @@ class Migration(migrations.Migration):
('number', models.CharField(max_length=255, verbose_name='Invoice number')), ('number', models.CharField(max_length=255, verbose_name='Invoice number')),
('for_object_id', models.PositiveIntegerField()), ('for_object_id', models.PositiveIntegerField()),
('for_content_type', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='contenttypes.contenttype')), ('for_content_type', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='contenttypes.contenttype')),
('group', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invoices', to='tezor.invoicegroup', verbose_name='Invoice group')), ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invoices', to='tezor.invoicegroup', verbose_name='Invoice group')),
], ],
bases=(models.Model, aleksis.core.mixins.PureDjangoModel), bases=(models.Model, aleksis.core.mixins.PureDjangoModel),
), ),
......
# Generated by Django 4.1.4 on 2022-12-29 13:12
import aleksis.core.managers
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import localflavor.generic.models
def _migrate_payment_variants(apps, schema_editor):
SofortPaymentVariant = apps.get_model("tezor", "SofortPaymentVariant")
PaypalPaymentVariant = apps.get_model("tezor", "PaypalPaymentVariant")
SEPADirectDebitPaymentVariant = apps.get_model("tezor", "SEPADirectDebitPaymentVariant")
PledgePaymentVariant = apps.get_model("tezor", "PledgePaymentVariant")
Client = apps.get_model("tezor", "Client")
for client in Client.objects.all():
if client.sofort_enabled:
variant = SofortPaymentVariant.objects.create(
api_id=client.sofort_api_id,
api_key=client.sofort_api_key,
project_id=client.sofort_project_id,
client=client
)
if client.paypal_enabled:
variant = PaypalPaymentVariant.objects.create(
api_client_id=client.paypal_client_id,
client_secret=client.paypal_client_secret,
capture=client.paypal_capture,
client=client
)
if client.sdd_enabled:
variant = SEPADirectDebitPaymentVariant.objects.create(
creditor=client.sdd_creditor,
creditor_identifier=client.sdd_creditor_identifier,
iban=client.sdd_iban,
bic=client.sdd_bic,
client=client
)
if client.pledge_enabled:
variant = PledgePaymentVariant.objects.create(client=client)
class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("tezor", "0011_drop_sites"),
]
operations = [
migrations.CreateModel(
name="PaymentVariant",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("description", models.CharField(max_length=255, verbose_name="Description"
)),
("client", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payment_variants', to='tezor.client', verbose_name='Client')),
("managed_by_app_label", models.CharField(blank=True, editable=False, max_length=255, verbose_name='App label of app responsible for managing this instance')),
("extended_data", models.JSONField(default=dict, editable=False)),
],
options={
"verbose_name": "Payment variant",
"verbose_name_plural": "Payment variants",
},
managers=[
],
),
migrations.AlterModelOptions(
name="client",
options={"verbose_name": "Client", "verbose_name_plural": "Clients"},
),
migrations.RemoveConstraint(
model_name="client",
name="sofort_enabled_configured",
),
migrations.RemoveConstraint(
model_name="client",
name="sdd_enabled_configured",
),
migrations.RemoveConstraint(
model_name="client",
name="paypal_enabled_configured",
),
migrations.CreateModel(
name="PaypalPaymentVariant",
fields=[
(
"paymentvariant_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="tezor.paymentvariant",
),
),
(
"api_client_id",
models.CharField(max_length=255, verbose_name="Client ID"),
),
(
"secret",
models.CharField(max_length=255, verbose_name="Secret"),
),
(
"capture",
models.BooleanField(
default=False, verbose_name="Use PayPal Authorize & Capture"
),
),
("endpoint", models.URLField(default="https://api.paypal.com", verbose_name="Endpoint"))
],
options={
"verbose_name": "PayPal payment variant",
"verbose_name_plural": "PayPal payment variants",
},
bases=("tezor.paymentvariant",),
managers=[
],
),
migrations.CreateModel(
name="PledgePaymentVariant",
fields=[
(
"paymentvariant_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="tezor.paymentvariant",
),
),
],
options={
"verbose_name": "Pledge payment variant",
"verbose_name_plural": "Pledge payment variants",
},
bases=("tezor.paymentvariant",),
managers=[
],
),
migrations.CreateModel(
name="SEPADirectDebitPaymentVariant",
fields=[
(
"paymentvariant_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="tezor.paymentvariant",
),
),
(
"creditor",
models.CharField(max_length=255, verbose_name="Creditor name"),
),
(
"creditor_identifier",
models.CharField(
max_length=35,
validators=[
django.core.validators.RegexValidator(
"^[A-Z]{2}[0-9]{2}[A-Z0-9]{1,31}$"
)
],
verbose_name="Creditor identifier",
),
),
(
"iban",
localflavor.generic.models.IBANField(
include_countries=None,
max_length=34,
use_nordea_extensions=False,
verbose_name="IBAN of bank account",
),
),
(
"bic",
localflavor.generic.models.BICField(
max_length=11, verbose_name="BIC/SWIFT code of bank"
),
),
],
options={
"verbose_name": "SEPA Direct Debit payment variant",
"verbose_name_plural": "SEPA Direct Debit payment variants",
},
bases=("tezor.paymentvariant",),
managers=[
],
),
migrations.CreateModel(
name="SofortPaymentVariant",
fields=[
(
"paymentvariant_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="tezor.paymentvariant",
),
),
(
"api_id",
models.CharField( max_length=255, verbose_name="API ID"),
),
(
"api_key",
models.CharField( max_length=255, verbose_name="API key"),
),
(
"project_id",
models.CharField( max_length=255, verbose_name="Project ID"),
),
],
options={
"verbose_name": "Sofort/Klarna payment variant",
"verbose_name_plural": "Sofort/Klarna payment variants",
},
bases=("tezor.paymentvariant",),
managers=[
],
),
migrations.AddField(
model_name="paymentvariant",
name="polymorphic_ctype",
field=models.ForeignKey(
editable=False,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="polymorphic_%(app_label)s.%(class)s_set+",
to="contenttypes.contenttype",
),
),
migrations.RunPython(_migrate_payment_variants),
migrations.RemoveField(
model_name="client",
name="paypal_capture",
),
migrations.RemoveField(
model_name="client",
name="paypal_client_id",
),
migrations.RemoveField(
model_name="client",
name="paypal_enabled",
),
migrations.RemoveField(
model_name="client",
name="paypal_secret",
),
migrations.RemoveField(
model_name="client",
name="pledge_enabled",
),
migrations.RemoveField(
model_name="client",
name="sdd_bic",
),
migrations.RemoveField(
model_name="client",
name="sdd_creditor",
),
migrations.RemoveField(
model_name="client",
name="sdd_creditor_identifier",
),
migrations.RemoveField(
model_name="client",
name="sdd_enabled",
),
migrations.RemoveField(
model_name="client",
name="sdd_iban",
),
migrations.RemoveField(
model_name="client",
name="sofort_api_id",
),
migrations.RemoveField(
model_name="client",
name="sofort_api_key",
),
migrations.RemoveField(
model_name="client",
name="sofort_enabled",
),
migrations.RemoveField(
model_name="client",
name="sofort_project_id",
),
]
# Generated by Django 5.1.7 on 2025-03-10 10:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tezor', '0012_paymentvariant'),
]
operations = [
migrations.AddField(
model_name='invoiceitem',
name='quantity',
field=models.PositiveIntegerField(default=1, verbose_name='Quantity'),
),
]
from django.core.validators import RegexValidator
from django.db import models from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from localflavor.generic.models import BICField, IBANField
from aleksis.core.mixins import ExtensibleModel from aleksis.core.mixins import ExtensibleModel
class Client(ExtensibleModel): class Client(ExtensibleModel):
VARIANT_DISPLAY = {
"paypal": (_("PayPal"), "logos:paypal"),
"sofort": (_("Klarna / Sofort"), "simple-icons:klarna"),
"pledge": (_("Payment pledge / manual payment"), "mdi:hand-coin"),
"sdd": (_("SEPA Direct Debit"), "mdi:bank-transfer"),
}
name = models.CharField(verbose_name=_("Name"), max_length=255, unique=True) name = models.CharField(verbose_name=_("Name"), max_length=255, unique=True)
email = models.EmailField(verbose_name=_("Email")) email = models.EmailField(verbose_name=_("Email"))
sofort_enabled = models.BooleanField(verbose_name=_("Sofort / Klarna enabled"), default=False) def get_payment_variant_choices(self):
sofort_api_id = models.CharField( return [(v.name, v.verbose_name) for v in self.payment_variants.all()]
verbose_name=_("Sofort / Klarna API ID"), blank=True, max_length=255
)
sofort_api_key = models.CharField(
verbose_name=_("Sofort / Klarna API key"), blank=True, max_length=255
)
sofort_project_id = models.CharField(
verbose_name=_("Sofort / Klarna Project ID"), blank=True, max_length=255
)
paypal_enabled = models.BooleanField(verbose_name=_("PayPal enabled"), default=False)
paypal_client_id = models.CharField(
verbose_name=_("PayPal client ID"), blank=True, max_length=255
)
paypal_secret = models.CharField(verbose_name=_("PayPal secret"), blank=True, max_length=255)
paypal_capture = models.BooleanField(
verbose_name=_("Use PayPal Authorize & Capture"), default=False
)
sdd_enabled = models.BooleanField(verbose_name=_("Debit enabled"), default=False) def __str__(self):
sdd_creditor = models.CharField( return self.name
verbose_name=_("SEPA Direct Debit - Creditor name"), blank=True, max_length=255
)
sdd_creditor_identifier = models.CharField(
verbose_name=_("SEPA Direct Debit - Creditor identifier"),
blank=True,
max_length=35,
validators=[RegexValidator("^[A-Z]{2}[0-9]{2}[A-Z0-9]{1,31}$")],
)
sdd_iban = IBANField(verbose_name=_("IBAN of bank account"), blank=True)
sdd_bic = BICField(verbose_name=_("BIC/SWIFT code of bank"), blank=True)
pledge_enabled = models.BooleanField(verbose_name=_("Pledge enabled"), default=False)
class Meta: class Meta:
verbose_name = _("Client") verbose_name = _("Client")
verbose_name_plural = _("Clients") verbose_name_plural = _("Clients")
constraints = [
models.CheckConstraint(
check=(
(
Q(sofort_enabled=True)
& ~Q(sofort_api_id="")
& ~Q(sofort_api_key="")
& ~Q(sofort_project_id="")
)
| Q(sofort_enabled=False)
),
name="sofort_enabled_configured",
),
models.CheckConstraint(
check=(
(
Q(sdd_enabled=True)
& ~Q(sdd_creditor="")
& ~Q(sdd_creditor_identifier="")
& ~Q(sdd_iban="")
& ~Q(sdd_bic="")
)
| Q(sdd_enabled=False)
),
name="sdd_enabled_configured",
),
models.CheckConstraint(
check=(
(Q(paypal_enabled=True) & ~Q(paypal_client_id="") & ~Q(paypal_secret=""))
| Q(paypal_enabled=False)
),
name="paypal_enabled_configured",
),
]
def __str__(self) -> str:
return self.name
def get_variant_choices(self=None):
choices = []
for variant in Client.VARIANT_DISPLAY:
if self and not getattr(self, f"{variant}_enabled"):
continue
choices.append((variant, Client.VARIANT_DISPLAY[variant][0]))
return choices
from collections.abc import Iterator
from decimal import Decimal
from typing import NamedTuple
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db import models from django.db import models
from django.shortcuts import reverse from django.shortcuts import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from payments import PaymentStatus, PurchasedItem from payments import PaymentStatus
from payments.models import BasePayment from payments.models import BasePayment
from aleksis.core.mixins import ExtensibleModel, PureDjangoModel from aleksis.core.mixins import ExtensibleModel, PureDjangoModel
...@@ -12,6 +16,19 @@ from aleksis.core.models import Person ...@@ -12,6 +16,19 @@ from aleksis.core.models import Person
from ..tables import PurchasedItemsTable, TotalsTable from ..tables import PurchasedItemsTable, TotalsTable
from .base import Client from .base import Client
from .payment_variant import PaymentVariant
class CustomPurchasedItem(NamedTuple):
"""A single item in a purchase."""
name: str
quantity: int
unit_price: Decimal
price: Decimal
currency: str
sku: str
tax_rate: Decimal | None = None
class InvoiceGroup(ExtensibleModel): class InvoiceGroup(ExtensibleModel):
...@@ -20,8 +37,7 @@ class InvoiceGroup(ExtensibleModel): ...@@ -20,8 +37,7 @@ class InvoiceGroup(ExtensibleModel):
Client, Client,
verbose_name=_("Linked client"), verbose_name=_("Linked client"),
related_name="invoice_groups", related_name="invoice_groups",
on_delete=models.SET_NULL, on_delete=models.CASCADE,
null=True,
) )
template_name = models.CharField( template_name = models.CharField(
...@@ -38,12 +54,6 @@ class InvoiceGroup(ExtensibleModel): ...@@ -38,12 +54,6 @@ class InvoiceGroup(ExtensibleModel):
def __str__(self) -> str: def __str__(self) -> str:
return self.name return self.name
def get_variant_choices(self=None):
if self and self.client:
return self.client.get_variant_choices()
else:
return Client.get_variant_choices()
class Invoice(BasePayment, PureDjangoModel): class Invoice(BasePayment, PureDjangoModel):
STATUS_ICONS = { STATUS_ICONS = {
...@@ -60,8 +70,7 @@ class Invoice(BasePayment, PureDjangoModel): ...@@ -60,8 +70,7 @@ class Invoice(BasePayment, PureDjangoModel):
InvoiceGroup, InvoiceGroup,
verbose_name=_("Invoice group"), verbose_name=_("Invoice group"),
related_name="invoices", related_name="invoices",
on_delete=models.SET_NULL, on_delete=models.CASCADE,
null=True,
) )
number = models.CharField(verbose_name=_("Invoice number"), max_length=255) number = models.CharField(verbose_name=_("Invoice number"), max_length=255)
...@@ -117,25 +126,23 @@ class Invoice(BasePayment, PureDjangoModel): ...@@ -117,25 +126,23 @@ class Invoice(BasePayment, PureDjangoModel):
super().save(*args, **kwargs) super().save(*args, **kwargs)
def get_variant(self):
variants = [v for v in self.group.client.payment_variants.all() if v.name == self.variant]
return variants[0] if variants else None
def get_absolute_url(self): def get_absolute_url(self):
return reverse("invoice_by_token", kwargs={"slug": self.token}) return reverse("invoice_by_token", kwargs={"slug": self.token})
def get_variant_choices(self=None): def get_variant_name(self) -> str:
if self and self.group: return PaymentVariant.registered_objects_dict[self.variant].name
return self.group.get_variant_choices()
else:
return InvoiceGroup.get_variant_choices()
def get_variant_name(self): def get_variant_icon(self) -> str:
return Client.VARIANT_DISPLAY[self.variant][0] return PaymentVariant.registered_objects_dict[self.variant].icon
def get_variant_icon(self):
return Client.VARIANT_DISPLAY[self.variant][1]
def get_status_icon(self): def get_status_icon(self):
return self.__class__.STATUS_ICONS[self.status] return self.STATUS_ICONS[self.status]
def get_purchased_items(self): def get_purchased_items(self) -> Iterator[CustomPurchasedItem]:
if self.items.count(): if self.items.count():
for item in self.items.all(): for item in self.items.all():
yield item.as_purchased_item() yield item.as_purchased_item()
...@@ -202,6 +209,7 @@ class Invoice(BasePayment, PureDjangoModel): ...@@ -202,6 +209,7 @@ class Invoice(BasePayment, PureDjangoModel):
class InvoiceItem(ExtensibleModel): class InvoiceItem(ExtensibleModel):
sku = models.CharField(max_length=255, verbose_name=_("Article no."), blank=True) sku = models.CharField(max_length=255, verbose_name=_("Article no."), blank=True)
description = models.CharField(max_length=255, verbose_name=_("Purchased item")) description = models.CharField(max_length=255, verbose_name=_("Purchased item"))
quantity = models.PositiveIntegerField(verbose_name=_("Quantity"), default=1)
price = models.DecimalField( price = models.DecimalField(
verbose_name=_("Item net price"), max_digits=9, decimal_places=2, default="0.0" verbose_name=_("Item net price"), max_digits=9, decimal_places=2, default="0.0"
) )
...@@ -217,11 +225,16 @@ class InvoiceItem(ExtensibleModel): ...@@ -217,11 +225,16 @@ class InvoiceItem(ExtensibleModel):
def __str__(self): def __str__(self):
return f"{self.sku}: {self.description}" return f"{self.sku}: {self.description}"
def as_purchased_item(self): @property
return PurchasedItem( def total_price(self):
return self.price * self.quantity
def as_purchased_item(self) -> CustomPurchasedItem:
return CustomPurchasedItem(
name=self.description, name=self.description,
quantity=1, quantity=self.quantity,
price=self.price, unit_price=self.price,
price=self.total_price,
currency=self.currency, currency=self.currency,
sku=self.sku, sku=self.sku,
tax_rate=self.tax_rate, tax_rate=self.tax_rate,
......
from typing import ClassVar
from django.core.validators import RegexValidator
from django.db import models
from django.utils.functional import classproperty
from django.utils.translation import gettext_lazy as _
from localflavor.generic.models import BICField, IBANField
from payments.core import BasicProvider
from aleksis.core.mixins import ExtensiblePolymorphicModel, RegistryObject
from .base import Client
class PaymentVariant(RegistryObject, ExtensiblePolymorphicModel):
"""A single, configured payment variant."""
icon: ClassVar[str] = None
verbose_name: ClassVar[str] = None
description = models.CharField(max_length=255, verbose_name=_("Description"))
client = models.ForeignKey(
Client, on_delete=models.CASCADE, verbose_name=_("Client"), related_name="payment_variants"
)
def __str__(self) -> str:
return self.description
def get_provider(self) -> BasicProvider:
"""Get django-payments provider for this payment variant."""
raise NotImplementedError()
@classproperty
def as_choices(cls) -> list[tuple[str, str]]:
"""Get payment variants as choices."""
return [(variant.name, variant.verbose_name) for variant in cls.registered_objects_list]
class Meta:
verbose_name = _("Payment variant")
verbose_name_plural = _("Payment variants")
class SofortPaymentVariant(PaymentVariant):
icon = "simple-icons:klarna"
verbose_name = _("Klarna/Sofort")
name = "sofort"
api_id = models.CharField(verbose_name=_("API ID"), max_length=255)
api_key = models.CharField(verbose_name=_("API key"), max_length=255)
project_id = models.CharField(verbose_name=_("Project ID"), max_length=255)
def get_provider(self):
from payments.sofort import SofortProvider # noqa
provider = SofortProvider(key=self.api_key, id=self.api_sid, project_id=self.project_id)
return provider
class Meta:
verbose_name = _("Sofort/Klarna payment variant")
verbose_name_plural = _("Sofort/Klarna payment variants")
class PaypalPaymentVariant(PaymentVariant):
icon = "logos:paypal"
verbose_name = _("PayPal")
name = "paypal"
api_client_id = models.CharField(verbose_name=_("Client ID"), max_length=255)
secret = models.CharField(verbose_name=_("Secret"), max_length=255)
capture = models.BooleanField(verbose_name=_("Use PayPal Authorize & Capture"), default=False)
endpoint = models.URLField(verbose_name=_("Endpoint"), default="https://api.paypal.com")
def get_provider(self):
from payments.paypal import PaypalProvider # noqa
provider = PaypalProvider(
client_id=self.client_id,
secret=self.secret,
capture=self.capture,
endpoint=self.endpoint,
)
return provider
class Meta:
verbose_name = _("PayPal payment variant")
verbose_name_plural = _("PayPal payment variants")
class SEPADirectDebitPaymentVariant(PaymentVariant):
icon = "mdi:bank-transfer"
verbose_name = _("SEPA Direct Debit")
name = "sdd"
creditor = models.CharField(verbose_name=_("Creditor name"), max_length=255)
creditor_identifier = models.CharField(
verbose_name=_("Creditor identifier"),
max_length=35,
validators=[RegexValidator("^[A-Z]{2}[0-9]{2}[A-Z0-9]{1,31}$")],
)
iban = IBANField(verbose_name=_("IBAN of bank account"))
bic = BICField(verbose_name=_("BIC/SWIFT code of bank"))
def get_provider(self):
from djp_sepa.providers import DirectDebitProvider # noqa
provider = DirectDebitProvider(
creditor=self.creditor,
creditor_identifier=self.creditor_identifier,
iban=self.iban,
bic=self.bic,
)
return provider
class Meta:
verbose_name = _("SEPA Direct Debit payment variant")
verbose_name_plural = _("SEPA Direct Debit payment variants")
class PledgePaymentVariant(PaymentVariant):
icon = "mdi:hand-coin"
verbose_name = _("Payment pledge/Manual payment")
name = "pledge"
def get_provider(self):
from djp_sepa.providers import PaymentPledgeProvider # noqa
provider = PaymentPledgeProvider()
return provider
class Meta:
verbose_name = _("Pledge payment variant")
verbose_name_plural = _("Pledge payment variants")
...@@ -11,6 +11,7 @@ from aleksis.core.util.predicates import ( ...@@ -11,6 +11,7 @@ from aleksis.core.util.predicates import (
from .models.base import Client from .models.base import Client
from .models.invoice import InvoiceGroup from .models.invoice import InvoiceGroup
from .models.payment_variant import PaymentVariant
from .predicates import ( from .predicates import (
has_no_payment_variant, has_no_payment_variant,
has_payment_variant, has_payment_variant,
...@@ -48,6 +49,35 @@ delete_client_predicate = has_person & ( ...@@ -48,6 +49,35 @@ delete_client_predicate = has_person & (
) )
rules.add_perm("tezor.delete_client_rule", delete_client_predicate) rules.add_perm("tezor.delete_client_rule", delete_client_predicate)
# View payment variants
view_payment_variants_predicate = has_person & (
has_global_perm("tezor.view_paymentvariant")
| has_any_object("tezor.view_paymentvariant", PaymentVariant)
)
rules.add_perm("tezor.view_paymentvariants_rule", view_payment_variants_predicate)
# View payment variant
view_payment_variant_predicate = has_person & (
has_global_perm("tezor.view_paymentvariant") | has_object_perm("tezor.view_paymentvariant")
)
rules.add_perm("tezor.view_paymentvariant_rule", view_payment_variant_predicate)
# Edit payment variants
edit_payment_variant_predicate = has_person & (
has_global_perm("tezor.change_paymentvariant") | has_object_perm("tezor.change_paymentvariant")
)
rules.add_perm("tezor.edit_paymentvariant_rule", edit_payment_variant_predicate)
# Create payment variants
create_payment_variant_predicate = has_person & (has_global_perm("tezor.add_paymentvariant"))
rules.add_perm("tezor.create_paymentvariant_rule", create_payment_variant_predicate)
# Delete payment variants
delete_payment_variant_predicate = has_person & (
has_global_perm("tezor.delete_paymentvariant") | has_object_perm("tezor.delete_paymentvariant")
)
rules.add_perm("tezor.delete_paymentvariant_rule", delete_payment_variant_predicate)
# View invoice groups # View invoice groups
view_invoice_groups_predicate = has_person & ( view_invoice_groups_predicate = has_person & (
has_global_perm("tezor.view_invoice_group") has_global_perm("tezor.view_invoice_group")
......
...@@ -15,6 +15,11 @@ class PurchasedItemsTable(tables.Table): ...@@ -15,6 +15,11 @@ class PurchasedItemsTable(tables.Table):
attrs={"td": {"class": "right-align"}}, attrs={"td": {"class": "right-align"}},
) )
quantity = tables.Column(verbose_name=_("Qty."), attrs={"td": {"class": "right-align"}}) quantity = tables.Column(verbose_name=_("Qty."), attrs={"td": {"class": "right-align"}})
unit_price = tables.TemplateColumn(
verbose_name=_("Unit Net"),
template_code="{{value|floatformat:2}} {{record.currency}}",
attrs={"td": {"class": "right-align"}},
)
price = tables.TemplateColumn( price = tables.TemplateColumn(
verbose_name=_("Net"), verbose_name=_("Net"),
template_code="{{value|floatformat:2}} {{record.currency}}", template_code="{{value|floatformat:2}} {{record.currency}}",
...@@ -86,6 +91,23 @@ class InvoiceGroupsTable(tables.Table): ...@@ -86,6 +91,23 @@ class InvoiceGroupsTable(tables.Table):
) )
class PaymentVariantsTable(tables.Table):
description = tables.Column()
name = tables.Column(verbose_name=_("Payment variant"))
edit = tables.LinkColumn(
"edit_payment_variant_by_pk",
args=[A("id")],
verbose_name=_("Edit"),
text=_("Edit"),
)
delete = tables.LinkColumn(
"delete_payment_variant_by_pk",
args=[A("id")],
verbose_name=_("Delete"),
text=_("Delete"),
)
class InvoicesTable(tables.Table): class InvoicesTable(tables.Table):
selected = SelectColumn() selected = SelectColumn()
......
{% extends "core/base.html" %} {% extends "core/base.html" %}
{% load material_form i18n %} {% load material_form i18n data_helpers %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
...@@ -7,8 +7,45 @@ ...@@ -7,8 +7,45 @@
{% block browser_title %}{% blocktrans %}Client{% endblocktrans %} {{ client }}{% endblock %} {% block browser_title %}{% blocktrans %}Client{% endblocktrans %} {{ client }}{% endblock %}
{% block content %} {% block content %}
<div class="row">
<div class="col s12">
<a class="btn waves-effect waves-light" href="{% url "clients" %}">
<i class="material-icons left">chevron_left</i>
{% trans "Back to clients" %}
</a>
<a class="btn waves-effect waves-light orange" href="{% url "edit_client_by_pk" client.pk %}">
<i class="material-icons left">edit</i>
{% trans "Edit" %}
</a>
<a class="btn waves-effect waves-light red" href="{% url "delete_client_by_pk" client.pk %}">
<i class="material-icons left">delete</i>
{% trans "Delete" %}
</a>
</div>
</div>
<div class="row">
<div class="col s12 m12 l6">
<div class="card">
<div class="card-content">
<a class="btn green waves-effect waves-light right"
href="{% url 'create_invoice_group' client.id %}">
<i class="material-icons left">add</i>
{% trans "Add invoice group" %}
</a>
<div class="card-title">{% trans "Invoice groups" %}</div>
{% render_table invoice_groups_table %}
</div>
</div>
<a class="btn colour-primary waves-effect waves-light" href="{% url 'create_invoice_group' client.id %}">{% trans "Add invoice group" %}</a> </div>
{% render_table invoice_groups_table %} <div class="col s12 m12 l6">
<div class="card">
<div class="card-content">
<div class="card-title">{% trans "Payment variants" %}</div>
{% render_table payment_variants_table %}
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
...@@ -7,8 +7,10 @@ ...@@ -7,8 +7,10 @@
{% block browser_title %}{% blocktrans %}Clients{% endblocktrans %}{% endblock %} {% block browser_title %}{% blocktrans %}Clients{% endblocktrans %}{% endblock %}
{% block content %} {% block content %}
<a class="btn green waves-effect waves-light" href="{% url 'create_client' %}">
<i class="material-icons left iconify" data-icon="mdi:add"></i>
{% trans "Create client" %}
</a>
<a class="btn colour-primary waves-effect waves-light" href="{% url 'create_client' %}">{% trans "Create client" %}</a> {% render_table table %}
{% render_table table %}
{% endblock %} {% endblock %}
...@@ -75,8 +75,8 @@ ...@@ -75,8 +75,8 @@
</td> </td>
<td> <td>
<select name="variant" {% if not can_change_variant %}disabled{% endif %}> <select name="variant" {% if not can_change_variant %}disabled{% endif %}>
{% for choice in object.get_variant_choices %} {% for payment_variant in object.group.client.payment_variants.all %}
<option value="{{ choice.0 }}" {% if object.variant == choice.0 %}selected{% endif %}>{{ choice.1 }}</option> <option value="{{ payment_variant.name }}" {% if object.variant == payment_variant.name %}selected{% endif %}>{{ payment_variant.verbose_name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</td> </td>
......
{% extends "core/base.html" %}
{% load material_form i18n data_helpers %}
{% block page_title %}
{% verbose_name_object model as widget_name %}
{% blocktrans with name=widget_name %}Create {{ name }}{% endblocktrans %}
{% endblock %}
{% block browser_title %}{% blocktrans %}Create payment variant{% endblocktrans %}{% endblock %}
{% block extra_head %}
{{ form.media.css }}
{% endblock %}
{% block content %}
<form method="post">
{% csrf_token %}
{% form form=form %}{% endform %}
{% include "core/partials/save_button.html" %}
</form>
{{ form.media.js }}
{% endblock %}
{% extends "core/base.html" %}
{% load material_form i18n %}
{% block page_title %}{% blocktrans %}Edit payment variant{% endblocktrans %}{% endblock %}
{% block browser_title %}{% blocktrans %}Edit payment variant{% endblocktrans %}{% endblock %}
{% block extra_head %}
{{ form.media.css }}
{% endblock %}
{% block content %}
<form method="post">
{% csrf_token %}
{% form form=form %}{% endform %}
{% include "core/partials/save_button.html" %}
</form>
{{ form.media.js }}
{% endblock %}
{% extends "core/base.html" %}
{% load material_form i18n data_helpers %}
{% load render_table from django_tables2 %}
{% block page_title %}{% blocktrans %}Payment variants{% endblocktrans %}{% endblock %}
{% block browser_title %}{% blocktrans %}Payment variants{% endblocktrans %}{% endblock %}
{% block content %}
<a class="btn green waves-effect waves-light dropdown-trigger" href="#" data-target="payment-variant-dropdown">
<i class="material-icons left iconify" data-icon="mdi:add"></i>
{% trans "Create payment variant" %}
</a>
<ul id="payment-variant-dropdown" class="dropdown-content">
{% for ct, model in payment_variants %}
<li>
<a href="{% url 'create_payment_variant' ct.model %}">
{% verbose_name_object model as widget_name %}
{% blocktrans with name=widget_name %}Create {{ name }}{% endblocktrans %}
</a>
</li>
{% endfor %}
</ul>
{% render_table table %}
{% endblock %}
...@@ -68,4 +68,24 @@ urlpatterns = [ ...@@ -68,4 +68,24 @@ urlpatterns = [
views.MarkPaidView.as_view(), views.MarkPaidView.as_view(),
name="mark_invoice_paid_by_token", name="mark_invoice_paid_by_token",
), ),
path(
"payment_variants/",
views.PaymentVariantListView.as_view(),
name="payment_variants",
),
path(
"payment_variant/create/<str:model>/",
views.PaymentVariantCreateView.as_view(),
name="create_payment_variant",
),
path(
"payment_variant/<int:pk>/edit/",
views.PaymentVariantEditView.as_view(),
name="edit_payment_variant_by_pk",
),
path(
"payment_variant/<int:pk>/delete/",
views.PaymentVariantDeleteView.as_view(),
name="delete_payment_variant_by_pk",
),
] ]
...@@ -2,52 +2,16 @@ _provider_cache = {} ...@@ -2,52 +2,16 @@ _provider_cache = {}
def provider_factory(variant, payment=None): def provider_factory(variant, payment=None):
from djp_sepa.providers import DirectDebitProvider, PaymentPledgeProvider # noqa
from payments.paypal import PaypalProvider # noqa
from payments.sofort import SofortProvider # noqa
if not payment: if not payment:
raise KeyError("Could not configure payment provider without a payment.") raise KeyError("Could not configure payment provider without a payment.")
if not payment.group:
raise KeyError(
"Could not configure payment provider for a payment without an invoice group."
)
if not payment.group.client:
raise KeyError(
"Could not configure payment provider for an invoice group without a client."
)
cache_key = (variant, payment.group.client.pk) cache_key = (variant, payment.group.client.pk)
if cache_key in _provider_cache: if cache_key in _provider_cache:
return _provider_cache[cache_key] return _provider_cache[cache_key]
client = payment.group.client variant = payment.get_variant()
provider = None provider = variant.get_provider() if variant else None
if variant == "sofort" and client.sofort_enabled:
provider = SofortProvider(
key=client.sofort_api_key, id=client.sofort_api_id, project_id=client.sofort_project_id
)
if variant == "paypal" and client.paypal_enabled:
provider = PaypalProvider(
client_id=client.paypal_client_id,
secret=client.paypal_secret,
capture=client.paypal_capture,
endpoint="https://api.paypal.com",
)
if variant == "pledge" and client.pledge_enabled:
provider = PaymentPledgeProvider()
if variant == "sdd" and client.sdd_enabled:
provider = DirectDebitProvider(
creditor=client.sdd_creditor,
creditor_identifier=client.sdd_creditor_identifier,
iban=client.sdd_iban,
bic=client.sdd_bic,
)
if provider is None: if provider is None:
raise KeyError("Provider not found or not configured for client.") raise KeyError("Provider not found or not configured for client.")
......