Anna Sirota
dd367f1476
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>
246 lines
9.0 KiB
Python
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}),
|
|
}
|