Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Organiser profile billing settings option missing #411

Open
wants to merge 32 commits into
base: development
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
75a08f6
Implement billing settings form for organizer
odkhang Oct 31, 2024
b0d7932
Resolve conflict
odkhang Oct 31, 2024
9b499db
Fix flake8 in pipeline
odkhang Oct 31, 2024
a17173a
implement create and save payment_information
odkhang Oct 21, 2024
055b2d7
Fix isort, flake8 in pipeline
odkhang Oct 31, 2024
045bfe4
implement payment-information-v1
odkhang Oct 24, 2024
e7510d2
src/pretix/control/views/organizer_views/organizer_view.py
odkhang Oct 31, 2024
0c4b5f6
Code refactoring
odkhang Oct 28, 2024
f596f95
Code refactoring
odkhang Oct 28, 2024
ede87ff
Remove payment_information attribute
odkhang Oct 28, 2024
eb22165
Implement tax validation
odkhang Oct 30, 2024
8cf14e5
Update code
odkhang Oct 30, 2024
25f70a9
Update code
odkhang Oct 30, 2024
abc5731
Fix flake8 in pipeline
odkhang Oct 30, 2024
dbf24b2
Add pyvat package
odkhang Oct 30, 2024
7b1fe49
Fix flake8 in pipeline
odkhang Oct 31, 2024
27b71a6
Latest code
odkhang Oct 31, 2024
063d2a6
Fix flake8 in pipeline
odkhang Oct 31, 2024
c4b9ce5
Add logger error
odkhang Oct 31, 2024
7133019
Fix flake8 in pipeline
odkhang Oct 31, 2024
97fbc9c
Merge branch 'development' into feature-380
odkhang Nov 1, 2024
8b5f87e
Fix conflict pretix base migration
odkhang Nov 1, 2024
17064b1
Update pretix base migration
odkhang Nov 1, 2024
db4f7b2
Add logging information and modify error logging
odkhang Nov 4, 2024
5d5e638
Add comment,save tax_id, show error and sucess message
odkhang Nov 7, 2024
13b2944
Update code
odkhang Nov 13, 2024
38154c7
Fix flake8 in pipeline
odkhang Nov 13, 2024
ea52541
Merge branch 'development' into feature-380
odkhang Nov 13, 2024
8512f8c
Update code
odkhang Nov 14, 2024
4aa0e3c
Update code
odkhang Nov 14, 2024
e41650b
Update code
odkhang Nov 14, 2024
aa86746
fix flake8 in pipeline
odkhang Nov 14, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ dependencies = [
'django-sso==3.0.2',
'PyJWT~=2.8.0',
'exhibitors @ git+https://github.com/fossasia/eventyay-tickets-exhibitors.git@master',
'pyvat==1.3.18',
]

[project.optional-dependencies]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Generated by Django 5.1.2 on 2024-10-31 09:30

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("pretixbase", "0003_event_is_video_creation_and_more"),
]

operations = [
migrations.CreateModel(
name="OrganizerBillingModel",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("primary_contact_name", models.CharField(max_length=255)),
("primary_contact_email", models.EmailField(max_length=255)),
("company_or_organization_name", models.CharField(max_length=255)),
("address_line_1", models.CharField(max_length=255)),
("address_line_2", models.CharField(max_length=255)),
("city", models.CharField(max_length=255)),
("zip_code", models.CharField(max_length=255)),
("country", models.CharField(max_length=255)),
("preferred_language", models.CharField(max_length=255)),
("tax_id", models.CharField(max_length=255)),
("stripe_customer_id", models.CharField(max_length=255, null=True)),
(
"stripe_payment_method_id",
models.CharField(max_length=255, null=True),
),
("stripe_setup_intent_id", models.CharField(max_length=255, null=True)),
(
"organizer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="billing",
to="pretixbase.organizer",
),
),
],
),
]
89 changes: 89 additions & 0 deletions src/pretix/base/models/organizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,3 +395,92 @@ def get_events_with_permission(self, permission, request=None):
return self.get_events_with_any_permission()
else:
return self.team.organizer.events.none()


class OrganizerBillingModel(models.Model):
"""
Billing model - support billing information for organizer
"""

organizer = models.ForeignKey(
"Organizer", on_delete=models.CASCADE, related_name="billing"
)

primary_contact_name = models.CharField(
max_length=255,
verbose_name=_("Primary Contact Name"),
)

primary_contact_email = models.EmailField(
max_length=255,
verbose_name=_("Primary Contact Email"),
)

company_or_organization_name = models.CharField(
max_length=255,
verbose_name=_("Company or Organization Name"),
)

address_line_1 = models.CharField(
max_length=255,
verbose_name=_("Address Line 1"),
)

address_line_2 = models.CharField(
max_length=255,
verbose_name=_("Address Line 2"),
)

city = models.CharField(
max_length=255,
verbose_name=_("City"),
)

zip_code = models.CharField(
max_length=255,
verbose_name=_("Zip Code"),
)

country = models.CharField(
max_length=255,
verbose_name=_("Country"),
)

preferred_language = models.CharField(
max_length=255,
verbose_name=_("Preferred Language"),
)

tax_id = models.CharField(
max_length=255,
verbose_name=_("Tax ID"),
)

stripe_customer_id = models.CharField(
max_length=255,
verbose_name=_("Stripe Customer ID"),
blank=True,
null=True,
)

stripe_payment_method_id = models.CharField(
max_length=255,
verbose_name=_("Payment Method"),
blank=True,
null=True,
)

stripe_setup_intent_id = models.CharField(
max_length=255,
verbose_name=_("Setup Intent ID"),
blank=True,
null=True,
)

def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
self.organizer.cache.clear()

def save(self, *args, **kwargs):
super().save(*args, **kwargs)
self.organizer.cache.clear()
192 changes: 191 additions & 1 deletion src/pretix/control/forms/organizer_forms/organizer_form.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import pyvat
from django import forms
from django.conf import settings
from django.utils.translation import gettext_lazy as _

from pretix.base.forms import I18nModelForm
from pretix.base.models.organizer import Organizer
from pretix.base.models.organizer import Organizer, OrganizerBillingModel
from pretix.helpers.countries import CachedCountries
from pretix.helpers.stripe_utils import (
create_stripe_customer, update_customer_info,
)


class OrganizerForm(I18nModelForm):
Expand All @@ -22,3 +28,187 @@ def clean_slug(self):
code='duplicate_slug',
)
return slug


class BillingSettingsForm(forms.ModelForm):
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider using a data structure to define common form fields with shared properties.

The form field definitions can be simplified by using a data structure to define common fields while keeping special cases explicit. This reduces duplication while maintaining clarity:

class BillingSettingsForm(forms.ModelForm):
    # Define common fields in a data structure
    COMMON_FIELDS = {
        'primary_contact_name': {
            'label': _("Primary Contact Name"),
            'help_text': _("Please provide your name or the name of the person responsible for this account in your organization.")
        },
        'primary_contact_email': {
            'label': _("Primary Contact Email"),
            'help_text': _("We will use this email address for all communication related to your contract and billing, as well as for important updates about your account and our services."),
            'field_class': forms.EmailField
        },
        # ... define other common fields similarly
    }

    # Generate common fields
    for field_name, config in COMMON_FIELDS.items():
        locals()[field_name] = config.get('field_class', forms.CharField)(
            label=config['label'],
            help_text=config['help_text'],
            required=True,
            max_length=255,
            widget=forms.TextInput(attrs={'placeholder': ''})
        )

    # Special cases defined explicitly
    country = forms.ChoiceField(
        label=_("Country"),
        help_text=_("Select your country."),
        required=True,
        choices=CachedCountries(),
        initial="US",
    )

    # ... rest of the form implementation

This approach:

  • Reduces duplicate code while maintaining readability
  • Makes it easier to modify common field properties
  • Keeps special cases explicit where needed
  • Preserves all functionality

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider refactoring form field definitions into a declarative specification dictionary to reduce code duplication.

The form fields can be simplified using a declarative approach while maintaining readability. Here's how:

class BillingSettingsForm(forms.ModelForm):
    FIELD_SPECS = {
        'primary_contact_name': {
            'type': forms.CharField,
            'label': _("Primary Contact Name"),
            'help_text': _("Please provide your name or the name of the person responsible for this account in your organization."),
            'required': True,
        },
        'primary_contact_email': {
            'type': forms.EmailField,
            'label': _("Primary Contact Email"),
            'help_text': _("We will use this email address for all communication related to your contract and billing, "
                          "as well as for important updates about your account and our services."),
            'required': True,
        },
        # ... define other fields similarly
    }

    class Meta:
        model = OrganizerBillingModel
        fields = list(FIELD_SPECS.keys())

    def __init__(self, *args, **kwargs):
        self.organizer = kwargs.pop("organizer", None)
        super().__init__(*args, **kwargs)

        # Generate fields from specifications
        for field_name, specs in self.FIELD_SPECS.items():
            field_type = specs.pop('type')
            self.fields[field_name] = field_type(
                max_length=255,
                widget=forms.TextInput(attrs={"placeholder": ""}),
                **specs
            )

        # Special handling for specific fields
        self.fields['country'].widget = forms.Select(choices=CachedCountries())
        self._setup_language_field()
        self.set_initial_data()

This approach:

  1. Reduces repetition while maintaining clarity
  2. Makes field properties easier to audit and modify consistently
  3. Keeps special cases explicit
  4. Reduces the chance of inconsistencies between similar fields

class Meta:
model = OrganizerBillingModel
fields = [
"primary_contact_name",
"primary_contact_email",
"company_or_organization_name",
"address_line_1",
"address_line_2",
"zip_code",
"city",
"country",
"preferred_language",
"tax_id",
]

primary_contact_name = forms.CharField(
label=_("Primary Contact Name"),
help_text=_(
"Please provide your name or the name of the person responsible for this account in your organization."
),
required=True,
max_length=255,
widget=forms.TextInput(attrs={"placeholder": ""}),
)

primary_contact_email = forms.EmailField(
label=_("Primary Contact Email"),
help_text=_(
"We will use this email address for all communication related to your contract and billing, "
"as well as for important updates about your account and our services."
),
required=True,
max_length=255,
widget=forms.TextInput(attrs={"placeholder": ""}),
)

company_or_organization_name = forms.CharField(
label=_("Company or Organization Name"),
help_text=_("Enter your organization’s legal name."),
required=True,
max_length=255,
widget=forms.TextInput(attrs={"placeholder": ""}),
)

address_line_1 = forms.CharField(
label=_("Address Line 1"),
help_text=_("Street address or P.O. box."),
required=True,
max_length=255,
widget=forms.TextInput(attrs={"placeholder": ""}),
)

address_line_2 = forms.CharField(
label=_("Address Line 2"),
help_text=_("Apartment, suite, unit, etc. (optional)."),
required=False,
max_length=255,
widget=forms.TextInput(attrs={"placeholder": ""}),
)

zip_code = forms.CharField(
label=_("Zip Code"),
help_text=_("Enter your postal code."),
required=True,
max_length=255,
widget=forms.TextInput(attrs={"placeholder": ""}),
)

city = forms.CharField(
label=_("City"),
help_text=_("Enter your city."),
required=True,
max_length=255,
widget=forms.TextInput(attrs={"placeholder": ""}),
)

country = forms.ChoiceField(
label=_("Country"),
help_text=_("Select your country."),
required=True,
choices=CachedCountries(),
initial="US",
)

preferred_language = forms.ChoiceField(
label=_("Preferred Language for Correspondence"),
help_text=_("Select your preferred language for all communication."),
required=True,
)

tax_id = forms.CharField(
label=_("Tax ID (e.g., VAT, GST)"),
help_text=_(
"If you are located in the EU, please provide your VAT ID. "
"Without this, we will need to charge VAT on our services and will not be able to issue reverse charge invoices."
),
max_length=255,
widget=forms.TextInput(attrs={"placeholder": ""}),
required=False,
)

def __init__(self, *args, **kwargs):
self.organizer = kwargs.pop("organizer", None)
self.warning_message = None
super().__init__(*args, **kwargs)
selected_languages = [
(code, name)
for code, name in settings.LANGUAGES
if code in self.organizer.settings.locales
]
self.fields["preferred_language"].choices = selected_languages
self.fields["preferred_language"].initial = self.organizer.settings.locale
self.set_initial_data()

def set_initial_data(self):
billing_settings = OrganizerBillingModel.objects.filter(
organizer_id=self.organizer.id
).first()

if billing_settings:
for field in self.Meta.fields:
self.initial[field] = getattr(billing_settings, field, "")

@staticmethod
def get_country_name(country_code):
country = CachedCountries().countries
return country.get(country_code, None)

def validate_vat_number(self, country_code, vat_number):
if country_code not in pyvat.VAT_REGISTRIES:
country_name = self.get_country_name(country_code)
self.warning_message = _("VAT number validation is not supported for {}".format(country_name))
return True
result = pyvat.is_vat_number_format_valid(vat_number, country_code)
return result

def is_valid(self):
if not super().is_valid():
return False

cleaned_data = self.cleaned_data
country_code = cleaned_data.get("country")
vat_number = cleaned_data.get("tax_id")

if vat_number:
country_name = self.get_country_name(country_code)
is_valid_vat_number = self.validate_vat_number(country_code, vat_number)
if not is_valid_vat_number:
self.add_error("tax_id", _("Invalid VAT number for {}".format(country_name)))
return False
return True

def save(self, commit=True):
instance = OrganizerBillingModel.objects.filter(
organizer_id=self.organizer.id
).first()

if instance:
for field in self.Meta.fields:
setattr(instance, field, self.cleaned_data[field])

if commit:
update_customer_info(
instance.stripe_customer_id,
email=self.cleaned_data.get("primary_contact_email"),
name=self.cleaned_data.get("primary_contact_name"),
)
instance.save()
else:
instance = OrganizerBillingModel(organizer_id=self.organizer.id)
for field in self.Meta.fields:
setattr(instance, field, self.cleaned_data[field])

if commit:
stripe_customer = create_stripe_customer(
email=self.cleaned_data.get("primary_contact_email"),
name=self.cleaned_data.get("primary_contact_name")
)
instance.stripe_customer_id = stripe_customer.id
instance.save()
return instance
8 changes: 8 additions & 0 deletions src/pretix/control/navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,14 @@ def get_organizer_navigation(request):
}),
'active': 'organizer.webhook' in url.url_name,
'icon': 'bolt',
},
{
"label": _("Billing settings"),
"url": reverse(
"control:organizer.settings.billing",
kwargs={"organizer": request.organizer.slug},
),
"active": url.url_name == "organizer.settings.billing",
}
]
})
Expand Down
1 change: 1 addition & 0 deletions src/pretix/control/templates/pretixcontrol/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<head>
<title>{% block title %}{% endblock %}{% if url_name != "index" %} :: {% endif %}
{{ django_settings.INSTANCE_NAME }}</title>
<script src="https://js.stripe.com/v3/"></script>
{% compress css %}
<link rel="stylesheet" type="text/x-scss" href="{% static "pretixcontrol/scss/main.scss" %}" />
<link rel="stylesheet" type="text/x-scss" href="{% static "lightbox/css/lightbox.scss" %}" />
Expand Down
Loading
Loading