Stripe checkout #104411

Merged
Anna Sirota merged 61 commits from stripe into main 2024-06-17 18:08:41 +02:00
11 changed files with 283 additions and 176 deletions
Showing only changes of commit 34d88e51fe - Show all commits

View File

@ -74,24 +74,8 @@ class BillingAddressForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Load additional model data from Customer and set form placeholders.""" """Load additional model data from Customer and set form placeholders."""
self.request = kwargs.pop('request')
self.customer = self.request.user.customer
self.plan_variation = kwargs.pop('plan_variation')
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# 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
# Set placeholder values on all form fields # Set placeholder values on all form fields
for field_name, field in self.fields.items(): for field_name, field in self.fields.items():
placeholder = BILLING_DETAILS_PLACEHOLDERS.get(field_name) placeholder = BILLING_DETAILS_PLACEHOLDERS.get(field_name)
@ -171,14 +155,30 @@ class PaymentForm(BillingAddressForm):
but are still used by the payment flow. but are still used by the payment flow.
""" """
# Price value is a decimal number in major units of selected currency.
price = forms.CharField(widget=forms.HiddenInput(), required=True)
# These are used when a payment fails, so that the next attempt to pay can reuse # These are used when a payment fails, so that the next attempt to pay can reuse
# the already-created subscription and order. # the already-created subscription and order.
subscription_pk = forms.CharField(widget=forms.HiddenInput(), required=False) subscription_pk = forms.CharField(widget=forms.HiddenInput(), required=False)
order_pk = forms.CharField(widget=forms.HiddenInput(), required=False) order_pk = forms.CharField(widget=forms.HiddenInput(), required=False)
def __init__(self, *args, **kwargs):
"""Pre-fill additional initial data from request."""
self.request = kwargs.pop('request')
self.customer = self.request.user.customer
super().__init__(*args, **kwargs)
# 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
class SelectPlanVariationForm(forms.Form): class SelectPlanVariationForm(forms.Form):
"""Form used in the plan selector.""" """Form used in the plan selector."""

View File

@ -89,7 +89,8 @@ def send_mail_bank_transfer_required(subscription_id: int):
def send_mail_subscription_status_changed(subscription_id: int): def send_mail_subscription_status_changed(subscription_id: int):
"""Send out an email notifying about the activated subscription.""" """Send out an email notifying about the activated subscription."""
subscription = looper.models.Subscription.objects.get(pk=subscription_id) subscription = looper.models.Subscription.objects.get(pk=subscription_id)
user = subscription.user customer = subscription.customer
user = customer.user
email = user.customer.billing_address.email or user.email email = user.customer.billing_address.email or user.email
assert email, f'Cannot send notification about subscription {subscription.pk} status: no email' assert email, f'Cannot send notification about subscription {subscription.pk} status: no email'
if is_noreply(email): if is_noreply(email):
@ -109,7 +110,7 @@ def send_mail_subscription_status_changed(subscription_id: int):
verb = 'deactivated' verb = 'deactivated'
context = { context = {
'user': subscription.user, 'user': user,
'subscription': subscription, 'subscription': subscription,
'verb': verb, 'verb': verb,
**get_template_context(), **get_template_context(),

View File

@ -1,12 +1,11 @@
{% extends 'users/settings/base.html' %} {% extends 'users/settings/base.html' %}
{% load common_extras %} {% load common_extras %}
{% load pipeline %}
{% block settings %} {% block settings %}
<p class="text-muted">Settings: Subscription</p> <p class="text-muted">Settings: Subscription</p>
<h1 class="mb-3">Billing Address</h1> <h1 class="mb-3">Billing Address</h1>
<form id="billing-address-form" method="post"> <form id="billing-address-form" method="post" autocomplete="off">
{% with form|add_form_classes as form %} {% with form|add_form_classes as form %}
<div class="form">{% csrf_token %} <div class="form">{% csrf_token %}
{% include "subscriptions/components/billing_address_form.html" %} {% include "subscriptions/components/billing_address_form.html" %}
@ -15,7 +14,3 @@
{% endwith %} {% endwith %}
</form> </form>
{% endblock settings %} {% endblock settings %}
{% block scripts %}
{% javascript "subscriptions" %}
{% endblock scripts %}

View File

@ -1,41 +0,0 @@
{% if form.non_field_errors %}
<div class="alert alert-sm alert-danger mt-2">
<p>Unable to proceed due to the following issue:</p>
<p>{{ form.non_field_errors }}</p>
</div>
{% endif %}
{# Payment process specific form fields, most of them a hidden and don't require special templating. #}
<div class="row">
{{ form.next_url_after_done }}
{{ form.payment_method_nonce }}
{{ form.device_data }}
{{ form.price }}
<div class="col">
<ul class="d-flooper-select ps-0" id="id_gateway">
{% with field=form.gateway %}
{% for radio in field %}
<li class="list-style-none looper-select-option looper-select-option-{{ forloop.counter0 }} mb-2">
{{ radio }}
</li>
{% endfor %}
{% endwith %}
</ul>
</div>
</div>
<div class="row">
<div class="col">
<div id="gateway-errors"></div>
</div>
</div>
{# The content of below script must be valid JSON, as type suggests. #}
<script id="bt-dropin-styles" type="application/json">
{
"input": {
"color": "black"
},
":focus": {
"color": "black"
}
}
</script>

View File

@ -0,0 +1,58 @@
responses:
- response:
auto_calculate_content_length: false
body: "{\n \"id\": \"cus_QGhXPj2pOTBdSo\",\n \"object\": \"customer\",\n \"\
address\": null,\n \"balance\": 0,\n \"created\": 1718033249,\n \"currency\"\
: null,\n \"default_source\": null,\n \"delinquent\": false,\n \"description\"\
: null,\n \"discount\": null,\n \"email\": \"billing@example.com\",\n \"\
invoice_prefix\": \"B062701B\",\n \"invoice_settings\": {\n \"custom_fields\"\
: null,\n \"default_payment_method\": null,\n \"footer\": null,\n \"\
rendering_options\": null\n },\n \"livemode\": false,\n \"metadata\": {},\n\
\ \"name\": \"\u0410\u043B\u0435\u043A\u0441\u0435\u0439 \u041D.\",\n \"phone\"\
: null,\n \"preferred_locales\": [],\n \"shipping\": null,\n \"tax_exempt\"\
: \"none\",\n \"test_clock\": null\n}"
content_type: text/plain; charset=utf-8
method: POST
status: 200
url: https://api.stripe.com/v1/customers
- response:
auto_calculate_content_length: false
body: "{\n \"id\": \"cs_test_a1XUS0akCexOKoMTKKnt9SjK1UjPI9UTrF7LiLzXWYInOcANZzOFLBkA5w\"\
,\n \"object\": \"checkout.session\",\n \"after_expiration\": null,\n \"\
allow_promotion_codes\": null,\n \"amount_subtotal\": 1110,\n \"amount_total\"\
: 1110,\n \"automatic_tax\": {\n \"enabled\": false,\n \"liability\"\
: null,\n \"status\": null\n },\n \"billing_address_collection\": null,\n\
\ \"cancel_url\": \"http://testserver/subscription/1/manage/\",\n \"client_reference_id\"\
: null,\n \"client_secret\": null,\n \"consent\": null,\n \"consent_collection\"\
: null,\n \"created\": 1718033250,\n \"currency\": \"usd\",\n \"currency_conversion\"\
: null,\n \"custom_fields\": [],\n \"custom_text\": {\n \"after_submit\"\
: null,\n \"shipping_address\": null,\n \"submit\": null,\n \"terms_of_service_acceptance\"\
: null\n },\n \"customer\": \"cus_QGhXPj2pOTBdSo\",\n \"customer_creation\"\
: null,\n \"customer_details\": {\n \"address\": null,\n \"email\": \"\
billing@example.com\",\n \"name\": null,\n \"phone\": null,\n \"tax_exempt\"\
: \"none\",\n \"tax_ids\": null\n },\n \"customer_email\": null,\n \"\
expires_at\": 1718119650,\n \"invoice\": null,\n \"invoice_creation\": {\n\
\ \"enabled\": false,\n \"invoice_data\": {\n \"account_tax_ids\"\
: null,\n \"custom_fields\": null,\n \"description\": null,\n \
\ \"footer\": null,\n \"issuer\": null,\n \"metadata\": {},\n \
\ \"rendering_options\": null\n }\n },\n \"livemode\": false,\n \"locale\"\
: null,\n \"metadata\": {},\n \"mode\": \"payment\",\n \"payment_intent\"\
: null,\n \"payment_link\": null,\n \"payment_method_collection\": \"if_required\"\
,\n \"payment_method_configuration_details\": null,\n \"payment_method_options\"\
: {\n \"card\": {\n \"request_three_d_secure\": \"automatic\"\n }\n\
\ },\n \"payment_method_types\": [\n \"card\",\n \"link\",\n \"paypal\"\
\n ],\n \"payment_status\": \"unpaid\",\n \"phone_number_collection\": {\n\
\ \"enabled\": false\n },\n \"recovered_from\": null,\n \"saved_payment_method_options\"\
: {\n \"allow_redisplay_filters\": [\n \"always\"\n ],\n \"payment_method_remove\"\
: null,\n \"payment_method_save\": null\n },\n \"setup_intent\": null,\n\
\ \"shipping_address_collection\": null,\n \"shipping_cost\": null,\n \"\
shipping_details\": null,\n \"shipping_options\": [],\n \"status\": \"open\"\
,\n \"submit_type\": \"pay\",\n \"subscription\": null,\n \"success_url\"\
: \"http://testserver/looper/stripe_success/1/{CHECKOUT_SESSION_ID}\",\n \"\
total_details\": {\n \"amount_discount\": 0,\n \"amount_shipping\": 0,\n\
\ \"amount_tax\": 0\n },\n \"ui_mode\": \"hosted\",\n \"url\": \"https://checkout.stripe.com/c/pay/cs_test_a1XUS0akCexOKoMTKKnt9SjK1UjPI9UTrF7LiLzXWYInOcANZzOFLBkA5w#fidkdWxOYHwnPyd1blpxYHZxWjA0VUpnNzNAMU5EUEcwYW92dWIwclxRMzQ8ZkxsUDRET2dPbTVidnBCNEJTdlBJQTRJYFF2c09BMEFBdlxVT19USGpWSXRSXFJwdm5UQXRpdVw2Rmp%2FZ11NNTU3fHdHUl1JTCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabHFgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl\"\
\n}"
content_type: text/plain
method: POST
status: 200
url: https://api.stripe.com/v1/checkout/sessions

View File

@ -0,0 +1,132 @@
responses:
- response:
auto_calculate_content_length: false
body: "{\n \"id\": \"cs_test_a1XUS0akCexOKoMTKKnt9SjK1UjPI9UTrF7LiLzXWYInOcANZzOFLBkA5w\"\
,\n \"object\": \"checkout.session\",\n \"after_expiration\": null,\n \"\
allow_promotion_codes\": null,\n \"amount_subtotal\": 1110,\n \"amount_total\"\
: 1110,\n \"automatic_tax\": {\n \"enabled\": false,\n \"liability\"\
: null,\n \"status\": null\n },\n \"billing_address_collection\": null,\n\
\ \"cancel_url\": \"http://testserver/subscription/1/manage/\",\n \"client_reference_id\"\
: null,\n \"client_secret\": null,\n \"consent\": null,\n \"consent_collection\"\
: null,\n \"created\": 1718033250,\n \"currency\": \"usd\",\n \"currency_conversion\"\
: null,\n \"custom_fields\": [],\n \"custom_text\": {\n \"after_submit\"\
: null,\n \"shipping_address\": null,\n \"submit\": null,\n \"terms_of_service_acceptance\"\
: null\n },\n \"customer\": \"cus_QGhXPj2pOTBdSo\",\n \"customer_creation\"\
: null,\n \"customer_details\": {\n \"address\": {\n \"city\": null,\n\
\ \"country\": null,\n \"line1\": null,\n \"line2\": null,\n\
\ \"postal_code\": null,\n \"state\": null\n },\n \"email\"\
: \"billing@example.com\",\n \"name\": \"\u0410\u043B\u0435\u043A\u0441\u0435\
\u0439 \u041D.\",\n \"phone\": null,\n \"tax_exempt\": \"none\",\n \
\ \"tax_ids\": []\n },\n \"customer_email\": null,\n \"expires_at\": 1718119650,\n\
\ \"invoice\": null,\n \"invoice_creation\": {\n \"enabled\": false,\n\
\ \"invoice_data\": {\n \"account_tax_ids\": null,\n \"custom_fields\"\
: null,\n \"description\": null,\n \"footer\": null,\n \"issuer\"\
: null,\n \"metadata\": {},\n \"rendering_options\": null\n }\n\
\ },\n \"livemode\": false,\n \"locale\": null,\n \"metadata\": {},\n \"\
mode\": \"payment\",\n \"payment_intent\": {\n \"id\": \"pi_3PQAVnE4KAUB5djs1ciLiZeV\"\
,\n \"object\": \"payment_intent\",\n \"amount\": 1110,\n \"amount_capturable\"\
: 0,\n \"amount_details\": {\n \"tip\": {}\n },\n \"amount_received\"\
: 1110,\n \"application\": null,\n \"application_fee_amount\": null,\n\
\ \"automatic_payment_methods\": null,\n \"canceled_at\": null,\n \"\
cancellation_reason\": null,\n \"capture_method\": \"automatic\",\n \"\
client_secret\": \"pi_3PQAVnE4KAUB5djs1ciLiZeV_secret_Phx5lFDf54GRwi2NpHGJBYB8Q\"\
,\n \"confirmation_method\": \"automatic\",\n \"created\": 1718034719,\n\
\ \"currency\": \"usd\",\n \"customer\": {\n \"id\": \"cus_QGhXPj2pOTBdSo\"\
,\n \"object\": \"customer\",\n \"address\": null,\n \"balance\"\
: 0,\n \"created\": 1718033249,\n \"currency\": null,\n \"default_source\"\
: null,\n \"delinquent\": false,\n \"description\": null,\n \"\
discount\": null,\n \"email\": \"billing@example.com\",\n \"invoice_prefix\"\
: \"B062701B\",\n \"invoice_settings\": {\n \"custom_fields\": null,\n\
\ \"default_payment_method\": null,\n \"footer\": null,\n \
\ \"rendering_options\": null\n },\n \"livemode\": false,\n \
\ \"metadata\": {},\n \"name\": \"\u0410\u043B\u0435\u043A\u0441\u0435\
\u0439 \u041D.\",\n \"phone\": null,\n \"preferred_locales\": [],\n\
\ \"shipping\": null,\n \"tax_exempt\": \"none\",\n \"test_clock\"\
: null\n },\n \"description\": null,\n \"invoice\": null,\n \"last_payment_error\"\
: null,\n \"latest_charge\": {\n \"id\": \"py_3PQAVnE4KAUB5djs1yXEDdPh\"\
,\n \"object\": \"charge\",\n \"amount\": 1110,\n \"amount_captured\"\
: 1110,\n \"amount_refunded\": 0,\n \"application\": null,\n \
\ \"application_fee\": null,\n \"application_fee_amount\": null,\n \
\ \"balance_transaction\": \"txn_3PQAVnE4KAUB5djs1bl5WHpi\",\n \"billing_details\"\
: {\n \"address\": {\n \"city\": null,\n \"country\"\
: null,\n \"line1\": null,\n \"line2\": null,\n \"\
postal_code\": null,\n \"state\": null\n },\n \"email\"\
: \"billing@example.com\",\n \"name\": \"Josh Dane\",\n \"phone\"\
: null\n },\n \"calculated_statement_descriptor\": null,\n \"\
captured\": true,\n \"created\": 1718034735,\n \"currency\": \"usd\"\
,\n \"customer\": \"cus_QGhXPj2pOTBdSo\",\n \"description\": null,\n\
\ \"destination\": null,\n \"dispute\": null,\n \"disputed\"\
: false,\n \"failure_balance_transaction\": null,\n \"failure_code\"\
: null,\n \"failure_message\": null,\n \"fraud_details\": {},\n \
\ \"invoice\": null,\n \"livemode\": false,\n \"metadata\": {\n\
\ \"order_id\": \"1\"\n },\n \"on_behalf_of\": null,\n \
\ \"order\": null,\n \"outcome\": {\n \"network_status\": \"approved_by_network\"\
,\n \"reason\": null,\n \"risk_level\": \"not_assessed\",\n \
\ \"seller_message\": \"Payment complete.\",\n \"type\": \"authorized\"\
\n },\n \"paid\": true,\n \"payment_intent\": \"pi_3PQAVnE4KAUB5djs1ciLiZeV\"\
,\n \"payment_method\": \"pm_1PQAVnE4KAUB5djsss37I9F6\",\n \"payment_method_details\"\
: {\n \"paypal\": {\n \"country\": \"FR\",\n \"payer_email\"\
: \"billing@example.com\",\n \"payer_id\": \"2RDOSMFNFJCL0\",\n \
\ \"payer_name\": \"Name Surname\",\n \"seller_protection\":\
\ {\n \"dispute_categories\": [\n \"product_not_received\"\
,\n \"fraudulent\"\n ],\n \"status\": \"\
eligible\"\n },\n \"transaction_id\": \"a3c6e965-49d6-4133-9d8e-0220b0dd8ec1\"\
\n },\n \"type\": \"paypal\"\n },\n \"radar_options\"\
: {},\n \"receipt_email\": null,\n \"receipt_number\": null,\n \
\ \"receipt_url\": \"https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xUE9iMjZFNEtBVUI1ZGpzKMzGnLMGMgaXZmL8WPc6LBa5Ndm2RPGLSlKNdxfFTIgoO_Z2BsGsdDaSQlb1ZFU2skexQxL9D0JFnSV9\"\
,\n \"refunded\": false,\n \"review\": null,\n \"shipping\":\
\ null,\n \"source\": null,\n \"source_transfer\": null,\n \"\
statement_descriptor\": null,\n \"statement_descriptor_suffix\": null,\n\
\ \"status\": \"succeeded\",\n \"transfer_data\": null,\n \"\
transfer_group\": null\n },\n \"livemode\": false,\n \"metadata\":\
\ {\n \"order_id\": \"1\"\n },\n \"next_action\": null,\n \"on_behalf_of\"\
: null,\n \"payment_method\": {\n \"id\": \"pm_1PQAVnE4KAUB5djsss37I9F6\"\
,\n \"object\": \"payment_method\",\n \"allow_redisplay\": \"limited\"\
,\n \"billing_details\": {\n \"address\": {\n \"city\"\
: null,\n \"country\": null,\n \"line1\": null,\n \
\ \"line2\": null,\n \"postal_code\": null,\n \"state\":\
\ null\n },\n \"email\": \"billing@example.com\",\n \"\
name\": \"Josh Dane\",\n \"phone\": null\n },\n \"created\"\
: 1718034719,\n \"customer\": \"cus_QGhXPj2pOTBdSo\",\n \"livemode\"\
: false,\n \"metadata\": {},\n \"paypal\": {\n \"country\"\
: \"FR\",\n \"payer_email\": \"billing@example.com\",\n \"payer_id\"\
: \"2RDOSMFNFJCL0\"\n },\n \"type\": \"paypal\"\n },\n \"payment_method_configuration_details\"\
: null,\n \"payment_method_options\": {\n \"paypal\": {\n \"\
preferred_locale\": null,\n \"reference\": null\n }\n },\n \
\ \"payment_method_types\": [\n \"paypal\"\n ],\n \"processing\"\
: null,\n \"receipt_email\": null,\n \"review\": null,\n \"setup_future_usage\"\
: \"off_session\",\n \"shipping\": null,\n \"source\": null,\n \"statement_descriptor\"\
: null,\n \"statement_descriptor_suffix\": null,\n \"status\": \"succeeded\"\
,\n \"transfer_data\": null,\n \"transfer_group\": null\n },\n \"payment_link\"\
: null,\n \"payment_method_collection\": \"if_required\",\n \"payment_method_configuration_details\"\
: null,\n \"payment_method_options\": {},\n \"payment_method_types\": [\n\
\ \"card\",\n \"link\",\n \"paypal\"\n ],\n \"payment_status\": \"\
paid\",\n \"phone_number_collection\": {\n \"enabled\": false\n },\n \"\
recovered_from\": null,\n \"saved_payment_method_options\": {\n \"allow_redisplay_filters\"\
: [\n \"always\"\n ],\n \"payment_method_remove\": null,\n \"\
payment_method_save\": null\n },\n \"setup_intent\": null,\n \"shipping_address_collection\"\
: null,\n \"shipping_cost\": null,\n \"shipping_details\": null,\n \"shipping_options\"\
: [],\n \"status\": \"complete\",\n \"submit_type\": \"pay\",\n \"subscription\"\
: null,\n \"success_url\": \"http://testserver/looper/stripe_success/1/{CHECKOUT_SESSION_ID}\"\
,\n \"total_details\": {\n \"amount_discount\": 0,\n \"amount_shipping\"\
: 0,\n \"amount_tax\": 0\n },\n \"ui_mode\": \"hosted\",\n \"url\": null\n\
}"
content_type: text/plain; charset=utf-8
method: GET
status: 200
url: https://api.stripe.com/v1/checkout/sessions/cs_test_a1XUS0akCexOKoMTKKnt9SjK1UjPI9UTrF7LiLzXWYInOcANZzOFLBkA5w?expand%5B0%5D=payment_intent&expand%5B1%5D=payment_intent.customer&expand%5B2%5D=payment_intent.latest_charge&expand%5B3%5D=payment_intent.latest_charge.payment_method_details&expand%5B4%5D=payment_intent.payment_method
- response:
auto_calculate_content_length: false
body: "{\n \"id\": \"pm_1PQAVnE4KAUB5djsss37I9F6\",\n \"object\": \"payment_method\"\
,\n \"allow_redisplay\": \"limited\",\n \"billing_details\": {\n \"address\"\
: {\n \"city\": null,\n \"country\": null,\n \"line1\": null,\n\
\ \"line2\": null,\n \"postal_code\": null,\n \"state\": null\n\
\ },\n \"email\": \"billing@example.com\",\n \"name\": \"Josh Dane\"\
,\n \"phone\": null\n },\n \"created\": 1718034719,\n \"customer\": \"\
cus_QGhXPj2pOTBdSo\",\n \"livemode\": false,\n \"metadata\": {},\n \"paypal\"\
: {\n \"country\": \"FR\",\n \"payer_email\": \"billing@example.com\"\
,\n \"payer_id\": \"2RDOSMFNFJCL0\"\n },\n \"type\": \"paypal\"\n}"
content_type: text/plain
method: GET
status: 200
url: https://api.stripe.com/v1/payment_methods/pm_1PQAVnE4KAUB5djsss37I9F6

View File

@ -41,10 +41,7 @@ urlpatterns = [
), ),
path( path(
'subscription/<int:subscription_id>/payment-method/change/', 'subscription/<int:subscription_id>/payment-method/change/',
looper_settings.PaymentMethodChangeView.as_view( settings.PaymentMethodChangeView.as_view(),
success_url='subscriptions:payment-method-change-done',
cancel_url='user-settings-billing', # FIXME: go back to subscription manage instead
),
name='payment-method-change', name='payment-method-change',
), ),
path( path(
@ -58,9 +55,7 @@ urlpatterns = [
name='pay-existing-order', name='pay-existing-order',
), ),
path( path(
'settings/billing-address/', 'settings/billing-address/', settings.BillingAddressView.as_view(), name='billing-address'
looper_settings.BillingAddressView.as_view(),
name='billing-address',
), ),
path('settings/receipts/', looper_settings.settings_receipts, name='receipts'), path('settings/receipts/', looper_settings.settings_receipts, name='receipts'),
path( path(

View File

@ -91,7 +91,6 @@ class JoinView(LoginRequiredMixin, FormView):
form_kwargs.update( form_kwargs.update(
{ {
'request': self.request, 'request': self.request,
'plan_variation': self.plan_variation,
'instance': self.customer.billing_address, 'instance': self.customer.billing_address,
} }
) )
@ -99,12 +98,8 @@ class JoinView(LoginRequiredMixin, FormView):
def get_initial(self) -> dict: def get_initial(self) -> dict:
"""Prefill default payment gateway, country and selected plan options.""" """Prefill default payment gateway, country and selected plan options."""
product_type = self.plan_variation.plan.product.type
customer_tax = self.customer.get_tax(product_type=product_type)
taxable = looper.taxes.Taxable(self.plan_variation.price, *customer_tax)
return { return {
**super().get_initial(), **super().get_initial(),
'price': taxable.price.decimals_string,
'gateway': self.gateway.name, 'gateway': self.gateway.name,
} }
@ -168,7 +163,7 @@ class JoinView(LoginRequiredMixin, FormView):
def form_invalid(self, form, *args, **kwargs): def form_invalid(self, form, *args, **kwargs):
"""Temporarily log all validation errors.""" """Temporarily log all validation errors."""
logger.exception('Validation error in ConfirmAndPayView: %s', form.errors) logger.exception('Validation error in ConfirmAndPayView: %s, %s', form.errors, form.data)
return super().form_invalid(form, *args, **kwargs) return super().form_invalid(form, *args, **kwargs)
def form_valid(self, form): def form_valid(self, form):
@ -212,7 +207,6 @@ class JoinView(LoginRequiredMixin, FormView):
if order.price != price: if order.price != price:
logger.error("Order price %s doesn't match form price %s", order.price, price) logger.error("Order price %s doesn't match form price %s", order.price, price)
msg = 'Please reload the page and try again' msg = 'Please reload the page and try again'
form.add_error('price', msg)
messages.warning(self.request, msg) messages.warning(self.request, msg)
return self.form_invalid(form) return self.form_invalid(form)

View File

@ -2,17 +2,14 @@
import logging import logging
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.http import HttpResponseForbidden
from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy, reverse from django.urls import reverse_lazy, reverse
from django.views.generic import UpdateView, FormView from django.views.generic import UpdateView, FormView
import looper.models import looper.models
import looper.views.checkout_braintree
import looper.views.settings import looper.views.settings
import looper.views.settings_braintree
from subscriptions.forms import ( from subscriptions.forms import (
BillingAddressForm,
CancelSubscriptionForm, CancelSubscriptionForm,
PayExistingOrderForm, PayExistingOrderForm,
TeamForm, TeamForm,
@ -24,6 +21,14 @@ logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
class BillingAddressView(looper.views.settings.BillingAddressView):
"""Override form class and success URL of looper's view."""
template_name = 'settings/billing_address.html'
form_class = BillingAddressForm
success_url = reverse_lazy('subscriptions:billing-address')
class CancelSubscriptionView(SingleSubscriptionMixin, FormView): class CancelSubscriptionView(SingleSubscriptionMixin, FormView):
"""Confirm and cancel a subscription.""" """Confirm and cancel a subscription."""
@ -46,6 +51,19 @@ class CancelSubscriptionView(SingleSubscriptionMixin, FormView):
return super().form_valid(form) return super().form_valid(form)
class PaymentMethodChangeView(looper.views.settings.PaymentMethodChangeView):
"""Override cancel and success URLs."""
success_url = 'subscriptions:payment-method-change-done'
def get_cancel_url(self):
"""Return to this subscription's manage page."""
return reverse(
'subscriptions:manage',
kwargs={'subscription_id': self.kwargs['subscription_id']},
)
class PaymentMethodChangeDoneView(looper.views.settings.PaymentMethodChangeDoneView): class PaymentMethodChangeDoneView(looper.views.settings.PaymentMethodChangeDoneView):
"""Change payment method in response to a successful payment setup.""" """Change payment method in response to a successful payment setup."""
@ -58,48 +76,18 @@ class PaymentMethodChangeDoneView(looper.views.settings.PaymentMethodChangeDoneV
) )
class PayExistingOrderView(looper.views.checkout_braintree.CheckoutExistingOrderView): class PayExistingOrderView(looper.views.checkout_stripe.CheckoutExistingOrderView):
"""Override looper's view with our forms.""" """Override looper's view with our forms."""
# Redirect to LOGIN_URL instead of raising an exception # Redirect to LOGIN_URL instead of raising an exception
raise_exception = False raise_exception = False
template_name = 'subscriptions/pay_existing_order.html' template_name = 'subscriptions/pay_existing_order.html'
form_class = PayExistingOrderForm form_class = PayExistingOrderForm
success_url = reverse_lazy('user-settings-billing')
def get_initial(self) -> dict: def get_cancel_url(self):
"""Prefill the payment amount and missing form data, if any.""" """Return to this subscription's manage page."""
initial = { order = self.get_object()
'price': self.order.price.decimals_string, return reverse('subscriptions:manage', kwargs={'subscription_id': order.subscription_id})
'email': self.customer.billing_address.email,
}
# 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.customer and self.customer.billing_address.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:
initial['full_name'] = self.request.user.full_name
return initial
def form_invalid(self, form):
"""Temporarily log all validation errors."""
logger.exception('Validation error in PayExistingOrderView: %s', form.errors)
return super().form_invalid(form)
def dispatch(self, request, *args, **kwargs):
"""Return 403 unless current session and the order belong to the same user.
Looper renders a template instead, we just want to display the standard 403 page
or redirect to login, like LoginRequiredMixin does with raise_exception=False.
"""
self.order = get_object_or_404(looper.models.Order, pk=kwargs['order_id'])
if request.user.is_authenticated and self.order.user_id != request.user.id:
return HttpResponseForbidden()
self.plan = self.order.subscription.plan
return super(looper.views.checkout_braintree.CheckoutExistingOrderView, self).dispatch(
request, *args, **kwargs
)
class ManageSubscriptionView( class ManageSubscriptionView(

View File

@ -30,17 +30,18 @@ full_billing_address_data = {
} }
class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase): class TestSettingsBillingAddress(BaseSubscriptionTestCase):
def test_saves_both_address_and_customer(self): url = reverse('subscriptions:billing-address')
def test_saves_full_billing_address(self):
user = UserFactory() user = UserFactory()
self.client.force_login(user) self.client.force_login(user)
url = reverse('user-settings-billing') response = self.client.post(self.url, full_billing_address_data)
response = self.client.post(url, full_billing_address_data)
# Check that the redirect on success happened # Check that the redirect on success happened
self.assertEqual(response.status_code, 302, response.content) self.assertEqual(response.status_code, 302, response.content)
self.assertEqual(response['Location'], url) self.assertEqual(response['Location'], self.url)
# Check that all address fields were updated # Check that all address fields were updated
customer = user.customer customer = user.customer
@ -62,7 +63,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
user = UserFactory() user = UserFactory()
self.client.force_login(user) self.client.force_login(user)
response = self.client.post(reverse('user-settings-billing'), {}) response = self.client.post(self.url, {})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, 'errorlist') self.assertContains(response, 'errorlist')
@ -75,7 +76,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
data = { data = {
'email': 'new@example.com', 'email': 'new@example.com',
} }
response = self.client.post(reverse('user-settings-billing'), data) response = self.client.post(self.url, data)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, 'errorlist') self.assertContains(response, 'errorlist')
@ -88,7 +89,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
data = { data = {
'full_name': 'New Full Name', 'full_name': 'New Full Name',
} }
response = self.client.post(reverse('user-settings-billing'), data) response = self.client.post(self.url, data)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, 'errorlist') self.assertContains(response, 'errorlist')
@ -222,7 +223,6 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase):
class TestPayExistingOrder(BaseSubscriptionTestCase): class TestPayExistingOrder(BaseSubscriptionTestCase):
url_name = 'subscriptions:pay-existing-order' url_name = 'subscriptions:pay-existing-order'
success_url_name = 'user-settings-billing'
def test_redirect_to_login_when_anonymous(self): def test_redirect_to_login_when_anonymous(self):
subscription = SubscriptionFactory( subscription = SubscriptionFactory(
@ -235,11 +235,7 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
self.client.logout() self.client.logout()
url = reverse(self.url_name, kwargs={'order_id': order.pk}) url = reverse(self.url_name, kwargs={'order_id': order.pk})
data = { response = self.client.get(url)
'gateway': 'braintree',
'payment_method_nonce': 'fake-three-d-secure-visa-full-authentication-nonce',
}
response = self.client.post(url, data=data)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], f'/oauth/login?next={url}') self.assertEqual(response['Location'], f'/oauth/login?next={url}')
@ -256,40 +252,12 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
self.client.force_login(user) self.client.force_login(user)
url = reverse(self.url_name, kwargs={'order_id': order.pk}) url = reverse(self.url_name, kwargs={'order_id': order.pk})
data = { response = self.client.get(url)
'gateway': 'braintree',
'payment_method_nonce': 'fake-three-d-secure-visa-full-authentication-nonce',
}
response = self.client.post(url, data=data)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 404)
def test_invalid_missing_required_form_data(self):
subscription = SubscriptionFactory(
customer=self.user.customer,
payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=Gateway.objects.get(name='bank'),
status='on-hold',
)
order = subscription.generate_order()
self.client.force_login(self.user)
url = reverse(self.url_name, kwargs={'order_id': order.pk})
response = self.client.post(url, data={})
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.context['form'].errors,
{
# 'full_name': ['This field is required.'],
# 'country': ['This field is required.'],
# 'email': ['This field is required.'],
'payment_method_nonce': ['This field is required.'],
'gateway': ['This field is required.'],
'price': ['This field is required.'],
},
)
# @_recorder.record(file_path=f'{responses_dir}stripe_create_checkout_session_usd.yaml')
# @_recorder.record(file_path=f'{responses_dir}stripe_retrieve_checkout_session_usd.yaml')
@patch( @patch(
# Make sure background task is executed as a normal function # Make sure background task is executed as a normal function
'subscriptions.signals.tasks.send_mail_subscription_status_changed', 'subscriptions.signals.tasks.send_mail_subscription_status_changed',
@ -297,6 +265,7 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
) )
def test_can_pay_for_manual_subscription_with_an_order(self): def test_can_pay_for_manual_subscription_with_an_order(self):
subscription = SubscriptionFactory( subscription = SubscriptionFactory(
plan__name='Automatic renewal subscription',
customer=self.user.customer, customer=self.user.customer,
payment_method__customer_id=self.user.customer.pk, payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=Gateway.objects.get(name='bank'), payment_method__gateway=Gateway.objects.get(name='bank'),
@ -304,19 +273,30 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
price=Money('USD', 1110), price=Money('USD', 1110),
status='on-hold', status='on-hold',
) )
self.assertEqual(subscription.collection_method, 'automatic')
order = subscription.generate_order() order = subscription.generate_order()
self.client.force_login(self.user) self.client.force_login(self.user)
url = reverse(self.url_name, kwargs={'order_id': order.pk}) url = reverse(self.url_name, kwargs={'order_id': order.pk})
data = { with responses.RequestsMock() as rsps:
**required_address_data, rsps._add_from_file(f'{responses_dir}stripe_create_checkout_session_usd.yaml')
'price': order.price, response = self.client.get(url)
'gateway': 'braintree',
'payment_method_nonce': 'fake-three-d-secure-visa-full-authentication-nonce',
}
response = self.client.post(url, data=data)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
expected_redirect_url = 'https://checkout.stripe.com/c/pay/cs_test_a1XUS0akCexOKoMTKKnt9SjK1UjPI9UTrF7LiLzXWYInOcANZzOFLBkA5w#fidkdWxOYHwnPyd1blpxYHZxWjA0VUpnNzNAMU5EUEcwYW92dWIwclxRMzQ8ZkxsUDRET2dPbTVidnBCNEJTdlBJQTRJYFF2c09BMEFBdlxVT19USGpWSXRSXFJwdm5UQXRpdVw2Rmp%2FZ11NNTU3fHdHUl1JTCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabHFgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl'
self.assertEqual(response['Location'], expected_redirect_url)
# Pretend that checkout session was completed and we've returned to the success page with its ID:
checkout_session_id = 'cs_test_a1XUS0akCexOKoMTKKnt9SjK1UjPI9UTrF7LiLzXWYInOcANZzOFLBkA5w'
url = reverse(
'looper:stripe_success',
kwargs={'pk': order.pk, 'stripe_session_id': 'CHECKOUT_SESSION_ID'},
)
url = url.replace('CHECKOUT_SESSION_ID', checkout_session_id)
with responses.RequestsMock() as rsps:
rsps._add_from_file(f'{responses_dir}stripe_retrieve_checkout_session_usd.yaml')
response = self.client.get(url)
self.assertEqual(order.transaction_set.count(), 1) self.assertEqual(order.transaction_set.count(), 1)
transaction = order.latest_transaction() transaction = order.latest_transaction()
self.assertEqual( self.assertEqual(
@ -332,7 +312,7 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
self.assertNotEqual(subscription.payment_method, 'bank') self.assertNotEqual(subscription.payment_method, 'bank')
self.assertEqual( self.assertEqual(
str(subscription.payment_method), str(subscription.payment_method),
'Visa credit card ending in 0002', 'PayPal account billing@example.com',
) )
self.assertEqual(subscription.status, 'active') self.assertEqual(subscription.status, 'active')

View File

@ -1,4 +1,5 @@
{% extends 'common/base.html' %} {% extends 'common/base.html' %}
{% load pipeline %}
{% block nav_drawer_inner %} {% block nav_drawer_inner %}
{% include 'users/settings/tabs.html' %} {% include 'users/settings/tabs.html' %}
@ -27,3 +28,7 @@
</div> </div>
</div> </div>
{% endblock content %} {% endblock content %}
{% block scripts %}
{% javascript "subscriptions" %}
{% endblock scripts %}