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

Conversation

odkhang
Copy link
Collaborator

@odkhang odkhang commented Oct 25, 2024

This PR resolve issue #380 Organiser profile billing settings option missing
Implement a new Billing settings page in organizer setting area, allow organizer to input billing information.

Summary by Sourcery

Implement a new Billing Settings page in the organizer settings area, allowing organizers to input and manage billing information, including integration with Stripe for payment processing.

New Features:

  • Introduce a new Billing Settings page in the organizer settings area, allowing organizers to input and manage their billing information.

Enhancements:

  • Add support for Stripe integration to handle billing and payment information for organizers.

Documentation:

  • Create a new template for the Billing Settings page, providing a user interface for organizers to manage billing information.

Copy link

sourcery-ai bot commented Oct 25, 2024

Reviewer's Guide by Sourcery

This PR implements a new Billing Settings feature in the organizer settings area, allowing organizers to manage their billing information and integrate with Stripe for payment processing. The implementation includes form handling, database models, Stripe API integration, and UI components for managing billing details and payment methods.

ER diagram for OrganizerBillingModel

erDiagram
    ORGANIZER ||--o{ ORGANIZER_BILLING_MODEL : has
    ORGANIZER_BILLING_MODEL {
        int id
        string primary_contact_name
        string primary_contact_email
        string company_or_organization_name
        string address_line_1
        string address_line_2
        string city
        string zip_code
        string country
        string preferred_language
        string tax_id
        string stripe_customer_id
        string stripe_payment_method_id
        string stripe_setup_intent_id
    }
Loading

Class diagram for BillingSettingsForm and OrganizerBillingModel

classDiagram
    class BillingSettingsForm {
        +CharField primary_contact_name
        +EmailField primary_contact_email
        +CharField company_or_organization_name
        +CharField address_line_1
        +CharField address_line_2
        +CharField zip_code
        +CharField city
        +ChoiceField country
        +ChoiceField preferred_language
        +CharField tax_id
        +__init__(*args, **kwargs)
        +set_initial_data()
        +save(commit=True)
    }
    class OrganizerBillingModel {
        +CharField primary_contact_name
        +EmailField primary_contact_email
        +CharField company_or_organization_name
        +CharField address_line_1
        +CharField address_line_2
        +CharField city
        +CharField zip_code
        +CharField country
        +CharField preferred_language
        +CharField tax_id
        +CharField stripe_customer_id
        +CharField stripe_payment_method_id
        +CharField stripe_setup_intent_id
        +delete(*args, **kwargs)
        +save(*args, **kwargs)
    }
    BillingSettingsForm --> OrganizerBillingModel : uses
Loading

File-Level Changes

Change Details Files
Implemented a new billing settings model and form
  • Created OrganizerBillingModel with fields for contact info, address, and payment details
  • Added BillingSettingsForm with validation and Stripe customer creation
  • Added form fields for primary contact, company details, and address information
src/pretix/base/models/organizer.py
src/pretix/control/forms/organizer_forms/organizer_form.py
Added Stripe integration utilities for payment processing
  • Created utility functions for Stripe API operations
  • Implemented error handling for Stripe API calls
  • Added functions for managing payment methods and setup intents
src/pretix/control/utils.py
Created UI components for billing settings management
  • Added billing settings template with form and payment card display
  • Implemented JavaScript for Stripe Elements integration
  • Added CSS styling for payment card display
src/pretix/control/templates/pretixcontrol/organizers/billing.html
src/pretix/static/billing/js/billing.js
src/pretix/static/billing/css/billing.css
Added routing and navigation for billing settings
  • Added URL patterns for billing settings and payment endpoints
  • Updated navigation to include billing settings option
  • Added API endpoints for Stripe setup intent and payment information
src/pretix/control/urls.py
src/pretix/control/navigation.py
src/pretix/control/views/organizer_views/organizer_view.py

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time. You can also use
    this command to specify where the summary should be inserted.

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@odkhang odkhang marked this pull request as ready for review October 25, 2024 07:55
Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey @odkhang - I've reviewed your changes - here's some feedback:

Overall Comments:

  • The payment_information field in OrganizerBillingModel has an erroneous trailing comma in its tuple definition that needs to be removed
  • Consider adding validation for tax ID formats based on the selected country, as different regions have different requirements
  • Storing payment information in plaintext raises security concerns - consider encrypting sensitive data or using a more secure storage method
Here's what I looked at during the review
  • 🟢 General issues: all looks good
  • 🟢 Security: all looks good
  • 🟢 Testing: all looks good
  • 🟡 Complexity: 2 issues found
  • 🟢 Documentation: all looks good

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

)
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

stripe.api_key = get_stripe_secret_key()


def handle_stripe_errors(operation_name: str):
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 the error handling decorator to use a configuration-based approach.

The error handling decorator can be simplified while maintaining the same detailed error handling capabilities. Here's a suggested refactor:

ERROR_HANDLERS = {
    stripe.error.APIError: {
        'log_format': "Stripe API error during {operation}: {error}",
        'message_format': "Stripe service error: {message}"
    },
    stripe.error.CardError: {
        'log_format': "Card error during {operation}: {error} | Code: {code} | Decline code: {decline_code}",
        'message_format': "Card error: {user_message}"
    },
    # Add other error types similarly...
}

def handle_stripe_errors(operation_name: str):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            try:
                return func(*args, **kwargs)
            except tuple(ERROR_HANDLERS.keys()) as e:
                handler = ERROR_HANDLERS[type(e)]

                # Log the error with specific format
                log_args = {
                    'operation': operation_name,
                    'error': str(e),
                    'code': getattr(e, 'code', 'N/A'),
                    'decline_code': getattr(e, 'decline_code', 'N/A')
                }
                logger.error(handler['log_format'].format(**log_args))

                # Raise formatted validation error
                message_args = {
                    'message': str(e),
                    'user_message': getattr(e, 'user_message', str(e))
                }
                raise ValidationError(handler['message_format'].format(**message_args))
            except Exception as e:
                logger.error(
                    f"Unexpected error during {operation_name}: {str(e)}",
                    exc_info=True
                )
                raise ValidationError(f"An unexpected error occurred: {str(e)}")
        return wrapper
    return decorator

This approach:

  1. Moves error handling configuration to a data structure
  2. Reduces code duplication while maintaining detailed error handling
  3. Makes it easier to modify error handling patterns
  4. Keeps all existing functionality and logging detail

Copy link
Member

@mariobehling mariobehling left a comment

Choose a reason for hiding this comment

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

@odkhang Please also take the sourcery reviews into account, specifically the "erroneous trailing comma". Thanks

{% endif %}
</fieldset>
</div>
{% endblock %}
Copy link
Member

Choose a reason for hiding this comment

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

It has been reminded many times that you need to configure your editor to automatically add 1 blank line at the end of file.


def get_stripe_key(key_type: str) -> str:
gs = GlobalSettingsObject()
prod_key = getattr(gs.settings, f"payment_stripe_connect_{key_type}_key")
Copy link
Member

Choose a reason for hiding this comment

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

AttributeError exception could happen here. Please hanlde it.

const url = window.location.href
const organizerMatch = url.match(/organizer\/([^/]+)/);
const organizerSlug = organizerMatch ? organizerMatch[1] : null;
window.location.href = `${basePath}/control/organizer/${organizerSlug}/settings/billing`;
Copy link
Member

Choose a reason for hiding this comment

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

Hhmm, it is easy to see that the URL can be generated as .../control/organizer/null/settings/billing. Do you really want to make browser redirect to invalid URL (with null)?


if billing_settings and billing_settings.stripe_customer_id:
return billing_settings.stripe_customer_id
else:
Copy link
Member

Choose a reason for hiding this comment

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

You don't need to write else keyword here, because the if branch makes function return.

raise ValidationError(
f"Payment processing error: {getattr(e, 'user_message', str(e))}"
)
except Exception as e:
Copy link
Member

Choose a reason for hiding this comment

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

Don't catch the general Exception class here.

@odkhang odkhang marked this pull request as draft October 28, 2024 09:23
)
except stripe.error.APIConnectionError as e:
logger.error(
"API connection error during {}: %s", operation_name, str(e)
Copy link
Contributor

Choose a reason for hiding this comment

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

ensuring the message is constructed only if the log level matches.
except stripe.error.APIConnectionError as e:
logger.error(
"API connection error during %s: %s", operation_name, str(e)
)
raise ValidationError("Network communication error: {}".format(str(e)))


payment_information = models.TextField(
verbose_name=_("Payment Information"),
),
Copy link
Contributor

Choose a reason for hiding this comment

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

The payment_information field in OrganizerBillingModel has an erroneous trailing comma in its tuple definition that needs to be removed

@odkhang
Copy link
Collaborator Author

odkhang commented Oct 28, 2024

hi @mariobehling, as sourcery-ai mentioned: "Consider adding validation for tax ID formats based on the selected country, as different regions have different requirements"
should we have validation for tax ID input?
if yes, can I use this lib for validation? https://django-internationalflavor.readthedocs.io/en/latest/vat_number.html

@mariobehling
Copy link
Member

if yes, can I use this lib for validation? https://django-internationalflavor.readthedocs.io/en/latest/vat_number.html

Yes, thanks. Sounds good. Let's try that.

@odkhang odkhang marked this pull request as ready for review November 1, 2024 10:23
Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey @odkhang - I've reviewed your changes and found some issues that need to be addressed.

Blocking issues:

  • Avoid exposing internal Stripe error details in user-facing error messages (link)

Overall Comments:

  • Consider adding logging for successful Stripe operations, not just errors, to help with monitoring and debugging.
  • The frontend JavaScript should provide better error feedback to users when Stripe operations fail.
Here's what I looked at during the review
  • 🟢 General issues: all looks good
  • 🔴 Security: 1 blocking issue
  • 🟢 Testing: all looks good
  • 🟡 Complexity: 2 issues found
  • 🟢 Documentation: all looks good

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except stripe.error.APIError as e:
Copy link

Choose a reason for hiding this comment

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

🚨 issue (security): Avoid exposing internal Stripe error details in user-facing error messages

Replace getattr(e, 'user_message', str(e)) with generic error messages to avoid leaking sensitive information about the Stripe integration.

return get_stripe_key("publishable")


def handle_stripe_errors(operation_name: str):
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 context manager pattern instead of decorators for Stripe operations

The current implementation uses decorators that duplicate error handling logic across multiple functions. Consider using a context manager pattern to reduce complexity and handle both API key setting and error handling in one place:

@contextmanager
def stripe_operation(operation_name: str):
    stripe.api_key = get_stripe_secret_key()
    try:
        yield
    except stripe.error.APIError as e:
        logger.error("Stripe API error during %s: %s", operation_name, str(e))
        raise ValidationError(
            "Stripe service error: {}".format(getattr(e, "user_message", str(e)))
        )
    # ... other error handlers ...

# Usage example:
def create_setup_intent(customer_id: str) -> str:
    with stripe_operation("create_setup_intent"):
        stripe_setup_intent = stripe.SetupIntent.create(
            customer=customer_id,
            payment_method_types=["card"],
            usage="off_session",
        )
        OrganizerBillingModel.objects.filter(stripe_customer_id=customer_id).update(
            stripe_setup_intent_id=stripe_setup_intent.id
        )
        return stripe_setup_intent.client_secret

This approach:

  1. Eliminates duplicate error handling code
  2. Centralizes API key setting
  3. Maintains the same error handling functionality
  4. Makes functions shorter and more focused on their core logic

@@ -22,3 +25,153 @@ 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 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

for code, name in settings.LANGUAGES
if code in self.organizer.settings.locales
]
self.base_fields["preferred_language"].choices = selected_languages
Copy link
Member

Choose a reason for hiding this comment

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

Why do you modify self.base_fields instead of self.fields?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I use self.base_fields instead of self.fields because I want to modify the original field definitions before the form is initialized. Therefore, I use self.base_fields before calling super().init(*args, **kwargs). On the other hand, if you need to modify form fields after the form has been initialized, you should use self.fields. Thank you for reviewing. If you have any further questions, please let me know

name=self.cleaned_data.get("primary_contact_name"),
)
billing_settings.save()
return billing_settings
Copy link
Member

Choose a reason for hiding this comment

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

Why this method suddenly returns a different object than what is declared in Meta? This is a hidden magic which will cause confusion and bugs in the future.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Thank you for reviewing. I have resolved the issue. Please check again. If you have any further questions, please let me know.

const organizerSlug = organizerMatch ? organizerMatch[1] : null;
if(!organizerSlug) {
console.error('Organizer slug not found');
return window.location.href = `${basePath}/control/organizers/`
Copy link
Member

Choose a reason for hiding this comment

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

It is weird that you put a "statement" in place of "expression". The return keyword, if going with something, expects an "expression". Please write a proper code.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Thank you for reviewing. I have resolved the issue. Please check again. If you have any further questions, please let me know.

form.save()
messages.success(self.request, _("Your changes have been saved."))
return redirect(self.get_success_url())
except ValidationError as e:
Copy link
Member

Choose a reason for hiding this comment

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

You are using transaction.atomic in wrong way: You handle exception inside atomic block (doc).

Another issue, after calling form.is_valid(), you still doing more validation (if vat_number, if not is_valid_vat_number). It shows that you are using Django Form wrong way. Validations should be done in form.is_valid() call, not after it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Thank you for reviewing. I have resolved the issue.

  • I removed the use of transaction.atomic because I am only saving billing setting information into the billing settings table, which is independent of other tables. If an error occurs while adding data, it won't affect the other tables.
  • I moved the tax_id validation into the is_valid function in the form.
    Please check again. If you have any further questions, please let me know.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants