blender-studio/subscriptions/forms.py
Anna Sirota dd367f1476 Stripe checkout (#104411)
The goal of this change is the following:

* make Stripe a default payment gateway:
	* for newly created subscriptions;
	* for paying for existing orders;
    * for changing payment method on existing subscriptions;

Reviewed-on: #104411
Reviewed-by: Oleg-Komarov <oleg-komarov@noreply.localhost>
2024-06-17 18:08:39 +02:00

246 lines
9.0 KiB
Python

"""Override some of looper model forms."""
from typing import List, Tuple
import logging
from django import forms
from django.core.exceptions import ValidationError
from django.forms.fields import Field
from localflavor.administrative_areas import ADMINISTRATIVE_AREAS
from localflavor.generic.validators import validate_country_postcode
from looper.middleware import COUNTRY_CODE_SESSION_KEY
from stdnum.eu import vat
import localflavor.exceptions
import looper.form_fields
import looper.forms
import looper.models
from subscriptions.form_fields import VATNumberField
from subscriptions.form_widgets import RegionSelect
import subscriptions.models
logger = logging.getLogger(__name__)
BILLING_DETAILS_PLACEHOLDERS = {
'full_name': 'Your Full Name',
'street_address': 'Street address',
'postal_code': 'ZIP/Postal code',
'region': 'State or province, if applicable',
'company': 'Company, if applicable',
'extended_address': 'Extended address, if applicable',
'locality': 'City',
'country': 'Country',
'email': 'mail@example.com',
'vat_number': 'VAT number, if applicable',
}
LABELS = {
'vat_number': 'VAT Number',
'company': 'Company',
'extended_address': 'Extended address',
}
# email, country and full name, postcode
REQUIRED_FIELDS = {
'country',
'email',
'full_name',
}
class BillingAddressForm(forms.ModelForm):
"""Fill in billing address and prepare for intitiating Stripe checkout session."""
class Meta:
model = looper.models.Address
exclude = ['category', 'customer', 'tax_exempt']
# What kind of choices are allowed depends on the selected country
# and is not yet known when the form is rendered.
region = forms.ChoiceField(required=False, widget=RegionSelect)
vat_number = VATNumberField(required=False)
email = forms.EmailField(required=True)
def _get_region_choices_and_label(self, country_code: str) -> List[Tuple[str, str]]:
regions = ADMINISTRATIVE_AREAS.get(country_code)
if regions:
has_choices = regions.get('used_in_address', False)
if has_choices:
# Regions are not required, so an empty "choice" must be prepended to the list
choices = [
('', f'Select {regions["type"]}'),
*regions['choices'],
]
return choices, regions['type'].capitalize()
return [], ''
def __init__(self, *args, **kwargs):
"""Load additional model data from Customer and set form placeholders."""
super().__init__(*args, **kwargs)
# Set placeholder values on all form fields
for field_name, field in self.fields.items():
placeholder = BILLING_DETAILS_PLACEHOLDERS.get(field_name)
if placeholder:
field.widget.attrs['placeholder'] = placeholder
label = LABELS.get(field_name)
if label:
field.label = label
# Require the required fields
if field_name in REQUIRED_FIELDS:
field.required = True
# Set region choices, in case country is selected or loaded from the instance
country_code = self.data.get('country') or self.initial.get('country')
region_field = self.fields['region']
choices, label = self._get_region_choices_and_label(country_code)
region_field.choices = choices
region_field.label = label
def clean(self):
"""Perform additional validation of the billing address."""
cleaned_data = super().clean()
self.clean_postal_code_and_country(cleaned_data)
self.clean_vat_number_and_country(cleaned_data)
return cleaned_data
def clean_postal_code_and_country(self, cleaned_data):
"""Validate the country and postal codes together."""
country_code = cleaned_data.get('country')
postal_code = cleaned_data.get('postal_code')
if postal_code:
if not country_code:
self.add_error(
'country',
ValidationError(Field.default_error_messages['required'], 'required'),
)
try:
cleaned_data['postal_code'] = validate_country_postcode(postal_code, country_code)
except localflavor.exceptions.ValidationError as e:
self.add_error(
'postal_code',
ValidationError(str(e), 'invalid'),
)
def clean_vat_number_and_country(self, cleaned_data):
"""Validate the VATIN and country code together."""
country_code = cleaned_data.get('country')
vat_number = cleaned_data.get('vat_number')
# TODO(anna): we could prefill the company address based VATIN data here.
if vat_number:
vat_number_country_code = next(iter(vat.guess_country(vat_number)), '').upper()
if vat_number_country_code != country_code:
self.add_error(
'vat_number',
ValidationError(
'Billing address country must match country of VATIN',
'invalid',
),
)
def save(self, commit=True):
"""Save cleared region field."""
# Validation against region choices is already done, because choices are set on __init__,
# however Django won't set the updated blank region value if was omitted from the form.
if self.cleaned_data['region'] == '':
self.instance.region = ''
instance = super().save(commit=commit)
return instance
class PaymentForm(BillingAddressForm):
"""Handle PlanVariation ID and payment method details in the second step of the checkout.
Billing details are displayed as read-only and cannot be edited,
but are still used by the payment flow.
"""
gateway = looper.form_fields.GatewayChoiceField(
queryset=looper.models.Gateway.objects.filter(name__in={'stripe', 'bank'}).order_by(
'-is_default'
)
)
def __init__(self, *args, **kwargs):
"""Pre-fill additional initial data from request."""
self.request = kwargs.pop('request', None)
self.plan_variation = kwargs.pop('plan_variation', None)
super().__init__(*args, **kwargs)
self._set_initial_from_request()
def _set_initial_from_request(self):
if not self.request:
return
# Only preset country when it's not already selected by the customer
geoip_country = self.request.session.get(COUNTRY_CODE_SESSION_KEY)
if geoip_country and (not self.instance.country):
self.initial['country'] = geoip_country
# Only set initial values if they aren't already saved to the billing address.
# Initial values always override form data, which leads to confusing issues with views.
if not self.instance.full_name:
# Fall back to user's full name, if no full name set already in the billing address:
if self.request.user.full_name:
self.initial['full_name'] = self.request.user.full_name
def clean_gateway(self):
"""Validate gateway against selected plan variation."""
gw = self.cleaned_data['gateway']
if not self.plan_variation:
return gw
if self.plan_variation.collection_method not in gw.provider.supported_collection_methods:
msg = self.fields['gateway'].default_error_messages['invalid_choice']
self.add_error('gateway', msg)
return gw
class SelectPlanVariationForm(forms.Form):
"""Form used in the plan selector."""
plan_variation_id = forms.IntegerField(required=True)
def clean_plan_variation_id(self):
"""Check that selected plan_variation_id exists, is active and matches the currency."""
plan_variation_id = self.cleaned_data['plan_variation_id']
try:
plan_variation = looper.models.PlanVariation.objects.active().get(
pk=plan_variation_id, currency=self.initial['currency']
)
return plan_variation.pk
except looper.models.PlanVariation.DoesNotExist:
logger.exception('Invalid PlanVariation is selected')
self.add_error(
'plan_variation_id',
ValidationError(
'This plan is not available, please reload the page and try again',
'invalid',
),
)
class CancelSubscriptionForm(forms.Form):
"""Confirm cancellation of a subscription."""
confirm = forms.BooleanField(label='Confirm Subscription Cancellation')
class TeamForm(forms.ModelForm):
"""Configure team subscription at the manage subscription page."""
class Meta:
model = subscriptions.models.Team
fields = (
'name',
'emails',
'email_domain',
'invoice_reference',
'is_visible_as_sponsor',
'logo',
)
widgets = {
'emails': forms.Textarea(attrs={'rows': 2}),
}