Stripe checkout #104411
@ -74,24 +74,8 @@ class BillingAddressForm(forms.ModelForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""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)
|
||||
|
||||
# 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
|
||||
for field_name, field in self.fields.items():
|
||||
placeholder = BILLING_DETAILS_PLACEHOLDERS.get(field_name)
|
||||
@ -171,14 +155,30 @@ class PaymentForm(BillingAddressForm):
|
||||
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
|
||||
# the already-created subscription and order.
|
||||
subscription_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):
|
||||
"""Form used in the plan selector."""
|
||||
|
@ -89,7 +89,8 @@ def send_mail_bank_transfer_required(subscription_id: int):
|
||||
def send_mail_subscription_status_changed(subscription_id: int):
|
||||
"""Send out an email notifying about the activated subscription."""
|
||||
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
|
||||
assert email, f'Cannot send notification about subscription {subscription.pk} status: no email'
|
||||
if is_noreply(email):
|
||||
@ -109,7 +110,7 @@ def send_mail_subscription_status_changed(subscription_id: int):
|
||||
verb = 'deactivated'
|
||||
|
||||
context = {
|
||||
'user': subscription.user,
|
||||
'user': user,
|
||||
'subscription': subscription,
|
||||
'verb': verb,
|
||||
**get_template_context(),
|
||||
|
@ -1,12 +1,11 @@
|
||||
{% extends 'users/settings/base.html' %}
|
||||
{% load common_extras %}
|
||||
{% load pipeline %}
|
||||
|
||||
{% block settings %}
|
||||
<p class="text-muted">Settings: Subscription</p>
|
||||
<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 %}
|
||||
<div class="form">{% csrf_token %}
|
||||
{% include "subscriptions/components/billing_address_form.html" %}
|
||||
@ -15,7 +14,3 @@
|
||||
{% endwith %}
|
||||
</form>
|
||||
{% endblock settings %}
|
||||
|
||||
{% block scripts %}
|
||||
{% javascript "subscriptions" %}
|
||||
{% endblock scripts %}
|
||||
|
@ -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>
|
@ -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
|
@ -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
|
@ -41,10 +41,7 @@ urlpatterns = [
|
||||
),
|
||||
path(
|
||||
'subscription/<int:subscription_id>/payment-method/change/',
|
||||
looper_settings.PaymentMethodChangeView.as_view(
|
||||
success_url='subscriptions:payment-method-change-done',
|
||||
cancel_url='user-settings-billing', # FIXME: go back to subscription manage instead
|
||||
),
|
||||
settings.PaymentMethodChangeView.as_view(),
|
||||
name='payment-method-change',
|
||||
),
|
||||
path(
|
||||
@ -58,9 +55,7 @@ urlpatterns = [
|
||||
name='pay-existing-order',
|
||||
),
|
||||
path(
|
||||
'settings/billing-address/',
|
||||
looper_settings.BillingAddressView.as_view(),
|
||||
name='billing-address',
|
||||
'settings/billing-address/', settings.BillingAddressView.as_view(), name='billing-address'
|
||||
),
|
||||
path('settings/receipts/', looper_settings.settings_receipts, name='receipts'),
|
||||
path(
|
||||
|
@ -91,7 +91,6 @@ class JoinView(LoginRequiredMixin, FormView):
|
||||
form_kwargs.update(
|
||||
{
|
||||
'request': self.request,
|
||||
'plan_variation': self.plan_variation,
|
||||
'instance': self.customer.billing_address,
|
||||
}
|
||||
)
|
||||
@ -99,12 +98,8 @@ class JoinView(LoginRequiredMixin, FormView):
|
||||
|
||||
def get_initial(self) -> dict:
|
||||
"""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 {
|
||||
**super().get_initial(),
|
||||
'price': taxable.price.decimals_string,
|
||||
'gateway': self.gateway.name,
|
||||
}
|
||||
|
||||
@ -168,7 +163,7 @@ class JoinView(LoginRequiredMixin, FormView):
|
||||
|
||||
def form_invalid(self, form, *args, **kwargs):
|
||||
"""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)
|
||||
|
||||
def form_valid(self, form):
|
||||
@ -212,7 +207,6 @@ class JoinView(LoginRequiredMixin, FormView):
|
||||
if 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'
|
||||
form.add_error('price', msg)
|
||||
messages.warning(self.request, msg)
|
||||
return self.form_invalid(form)
|
||||
|
||||
|
@ -2,17 +2,14 @@
|
||||
import logging
|
||||
|
||||
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.views.generic import UpdateView, FormView
|
||||
|
||||
import looper.models
|
||||
import looper.views.checkout_braintree
|
||||
import looper.views.settings
|
||||
import looper.views.settings_braintree
|
||||
|
||||
from subscriptions.forms import (
|
||||
BillingAddressForm,
|
||||
CancelSubscriptionForm,
|
||||
PayExistingOrderForm,
|
||||
TeamForm,
|
||||
@ -24,6 +21,14 @@ logger = logging.getLogger(__name__)
|
||||
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):
|
||||
"""Confirm and cancel a subscription."""
|
||||
|
||||
@ -46,6 +51,19 @@ class CancelSubscriptionView(SingleSubscriptionMixin, FormView):
|
||||
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):
|
||||
"""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."""
|
||||
|
||||
# Redirect to LOGIN_URL instead of raising an exception
|
||||
raise_exception = False
|
||||
template_name = 'subscriptions/pay_existing_order.html'
|
||||
form_class = PayExistingOrderForm
|
||||
success_url = reverse_lazy('user-settings-billing')
|
||||
|
||||
def get_initial(self) -> dict:
|
||||
"""Prefill the payment amount and missing form data, if any."""
|
||||
initial = {
|
||||
'price': self.order.price.decimals_string,
|
||||
'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
|
||||
)
|
||||
def get_cancel_url(self):
|
||||
"""Return to this subscription's manage page."""
|
||||
order = self.get_object()
|
||||
return reverse('subscriptions:manage', kwargs={'subscription_id': order.subscription_id})
|
||||
|
||||
|
||||
class ManageSubscriptionView(
|
||||
|
@ -30,17 +30,18 @@ full_billing_address_data = {
|
||||
}
|
||||
|
||||
|
||||
class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
|
||||
def test_saves_both_address_and_customer(self):
|
||||
class TestSettingsBillingAddress(BaseSubscriptionTestCase):
|
||||
url = reverse('subscriptions:billing-address')
|
||||
|
||||
def test_saves_full_billing_address(self):
|
||||
user = UserFactory()
|
||||
self.client.force_login(user)
|
||||
|
||||
url = reverse('user-settings-billing')
|
||||
response = self.client.post(url, full_billing_address_data)
|
||||
response = self.client.post(self.url, full_billing_address_data)
|
||||
|
||||
# Check that the redirect on success happened
|
||||
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
|
||||
customer = user.customer
|
||||
@ -62,7 +63,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
|
||||
user = UserFactory()
|
||||
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.assertContains(response, 'errorlist')
|
||||
@ -75,7 +76,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
|
||||
data = {
|
||||
'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.assertContains(response, 'errorlist')
|
||||
@ -88,7 +89,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
|
||||
data = {
|
||||
'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.assertContains(response, 'errorlist')
|
||||
@ -222,7 +223,6 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase):
|
||||
|
||||
class TestPayExistingOrder(BaseSubscriptionTestCase):
|
||||
url_name = 'subscriptions:pay-existing-order'
|
||||
success_url_name = 'user-settings-billing'
|
||||
|
||||
def test_redirect_to_login_when_anonymous(self):
|
||||
subscription = SubscriptionFactory(
|
||||
@ -235,11 +235,7 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
|
||||
|
||||
self.client.logout()
|
||||
url = reverse(self.url_name, kwargs={'order_id': order.pk})
|
||||
data = {
|
||||
'gateway': 'braintree',
|
||||
'payment_method_nonce': 'fake-three-d-secure-visa-full-authentication-nonce',
|
||||
}
|
||||
response = self.client.post(url, data=data)
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response['Location'], f'/oauth/login?next={url}')
|
||||
@ -256,40 +252,12 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
|
||||
|
||||
self.client.force_login(user)
|
||||
url = reverse(self.url_name, kwargs={'order_id': order.pk})
|
||||
data = {
|
||||
'gateway': 'braintree',
|
||||
'payment_method_nonce': 'fake-three-d-secure-visa-full-authentication-nonce',
|
||||
}
|
||||
response = self.client.post(url, data=data)
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
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.'],
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
# @_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(
|
||||
# Make sure background task is executed as a normal function
|
||||
'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):
|
||||
subscription = SubscriptionFactory(
|
||||
plan__name='Automatic renewal subscription',
|
||||
customer=self.user.customer,
|
||||
payment_method__customer_id=self.user.customer.pk,
|
||||
payment_method__gateway=Gateway.objects.get(name='bank'),
|
||||
@ -304,19 +273,30 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
|
||||
price=Money('USD', 1110),
|
||||
status='on-hold',
|
||||
)
|
||||
self.assertEqual(subscription.collection_method, 'automatic')
|
||||
order = subscription.generate_order()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
url = reverse(self.url_name, kwargs={'order_id': order.pk})
|
||||
data = {
|
||||
**required_address_data,
|
||||
'price': order.price,
|
||||
'gateway': 'braintree',
|
||||
'payment_method_nonce': 'fake-three-d-secure-visa-full-authentication-nonce',
|
||||
}
|
||||
response = self.client.post(url, data=data)
|
||||
with responses.RequestsMock() as rsps:
|
||||
rsps._add_from_file(f'{responses_dir}stripe_create_checkout_session_usd.yaml')
|
||||
response = self.client.get(url)
|
||||
|
||||
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)
|
||||
transaction = order.latest_transaction()
|
||||
self.assertEqual(
|
||||
@ -332,7 +312,7 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
|
||||
self.assertNotEqual(subscription.payment_method, 'bank')
|
||||
self.assertEqual(
|
||||
str(subscription.payment_method),
|
||||
'Visa credit card ending in 0002',
|
||||
'PayPal account billing@example.com',
|
||||
)
|
||||
|
||||
self.assertEqual(subscription.status, 'active')
|
||||
|
@ -1,4 +1,5 @@
|
||||
{% extends 'common/base.html' %}
|
||||
{% load pipeline %}
|
||||
|
||||
{% block nav_drawer_inner %}
|
||||
{% include 'users/settings/tabs.html' %}
|
||||
@ -21,9 +22,13 @@
|
||||
</nav>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
{% block settings%}
|
||||
{% block settings %}
|
||||
{% endblock settings %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
{% block scripts %}
|
||||
{% javascript "subscriptions" %}
|
||||
{% endblock scripts %}
|
||||
|
Loading…
Reference in New Issue
Block a user