Use payment element for Stripe checkout

Closes #10031
This commit is contained in:
Cédric Krier
2024-11-28 11:51:20 +01:00
parent 43ca1d4968
commit 5b5377abca
6 changed files with 107 additions and 117 deletions
+1
View File
@@ -1,3 +1,4 @@
* Use payment element for checkout
Version 7.4.0 - 2024-11-04
--------------------------
+35 -96
View File
@@ -21,125 +21,64 @@
color: #fff;
display: block;
font-size:2em;
margin: 0 auto;
margin: 10px auto;
padding: 0.5em 1em;
}
.wrapper {
form {
width: 670px;
margin: 0 auto;
height: 100%;
}
.StripeElement {
background-color: white;
border-radius: 4px;
border: 1px solid transparent;
box-shadow: 0 1px 3px 0 #e6ebf1;
box-sizing: border-box;
height: 40px;
padding: 10px 12px;
transition: box-shadow 150ms ease;
}
.StripeElement--focus {
box-shadow: 0 1px 3px 0 #cfd7df;
}
.StripeElement--invalid {
border-color: #fa755a;
}
.StripeElement--webkit-autofill {
background-color: #fefde5 !important;
}
#card-errors {
#payment-errors {
padding: 4px 0;
color: #fa755a;
}
</style>
</head>
<body>
<py:for each="record in records[:1]">
<py:with vars="intent = record.stripe_intent">
<body py:with="intent = record.stripe_intent">
<h1>${record.party.rec_name}</h1>
<div class="wrapper">
<form method="POST" id="form">
<div>
<py:if test="not data.get('payment_method')">
<label for="card-element">
Credit or debit card
</label>
<div id="card-element">
</div>
</py:if>
<div id="card-errors" role="alert">
</div>
</div>
<button id="card-button" data-secret="${intent.client_secret}" data-model="${data['model']}">
<py:choose test="data['model']">
<py:when test="'account.payment'">
Pay ${record.currency.symbol}${record.amount}
</py:when>
<py:when test="'account.payment.stripe.customer'">
Register card
</py:when>
</py:choose>
</button>
</form>
</div>
<form id="payment-form">
<div id="payment-element">
</div>
<div id="payment-error">
</div>
<button id="payment-submit" data-model="${data['model']}">
Submit
</button>
</form>
<script>
var stripe = Stripe('${record.stripe_account.publishable_key}');
var elements = stripe.elements();
var style = {
base: {
color: '#32325d',
fontSize: '16px',
'::placeholder': {
color: '#aab7c4'
}
},
invalid: {
color: '#fa755a',
iconColor: '#fa755a'
}
};
var card;
if (document.getElementById('card-element')) {
card = elements.create('card', {style: style});
card.mount('#card-element');
card.addEventListener('change', function(event) {
var displayError = document.getElementById('card-errors');
if (event.error) {
displayError.textContent = event.error.message;
} else {
displayError.textContent = '';
}
});
} else {
card = {payment_method: '${data.get('payment_method', '')}'}
}
var cardButton = document.getElementById('card-button');
var clientSecret = cardButton.dataset.secret;
var model = cardButton.dataset.model;
var form = document.getElementById('form');
const model = '${data['model']}';
const customer_session_client_secret = '${data['customer_session_client_secret']}';
const stripe = Stripe('${record.stripe_account.publishable_key}');
const elements = stripe.elements({
'clientSecret': '${intent.client_secret}',
'customerSessionClientSecret': customer_session_client_secret || null,
});
const paymentElement = elements.create('payment', {
});
paymentElement.mount('#payment-element');
const form = document.getElementById('payment-form');
form.addEventListener('submit', function(event) {
event.preventDefault();
var prm;
const options = {
elements,
confirmParams: {
return_url: '${data['return_url']}',
},
}
let prm;
if (model == 'account.payment.stripe.customer') {
prm = stripe.handleCardSetup(clientSecret, card);
prm = stripe.confirmSetup(options);
} else {
prm = stripe.handleCardPayment(clientSecret, card);
prm = stripe.confirmPayment(options);
}
prm.then(function(result) {
if (result.error) {
var errorElement = document.getElementById('card-errors');
errorElement.textContent = result.error.message;
} else {
form.submit();
const messageContainer = document.getElementById('payment-error');
messageContainer.textContent = result.error.message;
}
});
});
</script>
</py:with>
</py:for>
</body>
</html>
+1 -1
View File
@@ -7,7 +7,7 @@ this repository contains the full copyright notices and license terms. -->
<field name="text">Are you sure you want to modify the keys of the Stripe account?</field>
</record>
<record model="ir.message" id="msg_no_stripe_token">
<field name="text">To process payment "%(payment)s" you must set a Stripe token or customer.</field>
<field name="text">To process payment "%(payment)s" you must set a Stripe token, payment intent or customer.</field>
</record>
<record model="ir.message" id="msg_stripe_receivable">
<field name="text">To pay "%(payment)s", you cannot use a stripe journal "%(journal)s".</field>
+28 -3
View File
@@ -75,7 +75,9 @@ class Group(metaclass=PoolMeta):
pool = Pool()
Payment = pool.get('account.payment')
for payment in self.payments:
if not payment.stripe_token and not payment.stripe_customer:
if (not payment.stripe_token
and not payment.stripe_payment_intent_id
and not payment.stripe_customer):
account = payment.journal.stripe_account
for customer in payment.party.stripe_customers:
if (customer.stripe_account == account
@@ -421,12 +423,14 @@ class Payment(StripeCustomerMethodMixin, CheckoutMixin, metaclass=PoolMeta):
'stripe_version': STRIPE_VERSION,
'amount': self.stripe_amount,
'currency': self.currency.code,
'automatic_payment_methods': {
'enabled': True,
},
'capture_method': 'automatic' if self.stripe_capture else 'manual',
'customer': (self.stripe_customer.stripe_customer_id
if self.stripe_customer else None),
'description': self.description,
'off_session': off_session,
'payment_method_types': ['card'],
'idempotency_key': idempotency_key,
}
if self.stripe_customer_payment_method:
@@ -685,7 +689,7 @@ class Payment(StripeCustomerMethodMixin, CheckoutMixin, metaclass=PoolMeta):
def stripe_update(self, charge):
assert (
(charge.id == self.stripe_charge_id)
or (charge.payment_intent.id == self.stripe_payment_intent_id))
or (charge.payment_intent == self.stripe_payment_intent_id))
amount = charge.amount - charge.amount_refunded
if (self.state not in {'succeeded', 'failed'}
or self.stripe_amount != amount
@@ -1764,6 +1768,27 @@ class Customer(CheckoutMixin, DeactivableMixin, ModelSQL, ModelView):
name = '****' + payment_method.sepa_debit.last4
return name
def get_session(self):
if self.stripe_customer_id:
return stripe.CustomerSession.create(
api_key=self.stripe_account.secret_key,
customer=self.stripe_customer_id,
components={
'payment_element': {
'enabled': True,
'features': self._session_payment_element_features,
},
})
@property
def _session_payment_element_features(self):
return {
'payment_method_redisplay': 'enabled',
'payment_method_save': 'enabled',
'payment_method_save_usage': 'on_session',
'payment_method_remove': 'enabled',
}
@property
def stripe_setup_intent(self):
if not self.stripe_setup_intent_id:
@@ -298,6 +298,7 @@ this repository contains the full copyright notices and license terms. -->
<record model="ir.action.report" id="report_checkout">
<field name="name">Stripe Checkout</field>
<field name="model" eval="None"/>
<field name="single" eval="True"/>
<field name="report_name">account.payment.stripe.checkout</field>
<field name="report">account_payment_stripe/checkout.html</field>
<field name="template_extension">html</field>
+41 -17
View File
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
@app.route(
'/<database_name>/account_payment_stripe/checkout/<model>/<id>',
methods=['GET', 'POST'])
methods=['GET'])
@with_pool
@with_transaction(context={'_skip_warnings': True})
def checkout(request, pool, model, id):
@@ -33,22 +33,46 @@ def checkout(request, pool, model, id):
])
except ValueError:
abort(HTTPStatus.NOT_FOUND)
if request.method == 'GET':
Report = pool.get('account.payment.stripe.checkout', type='report')
# TODO language
data = {
'model': Model.__name__,
}
if model == Payment.__name__ and record.stripe_customer_payment_method:
data['payment_method'] = record.stripe_customer_payment_method
ext, content, _, _ = Report.execute([record.id], data)
assert ext == 'html'
return Response(content, HTTPStatus.OK, content_type='text/html')
elif request.method == 'POST':
record.stripe_intent_update()
return Response(
'<body onload="window.close()">', HTTPStatus.OK,
content_type='text/html')
customer_session_client_secret = ''
if model == Payment.__name__ and record.stripe_customer:
if customer_session := record.stripe_customer.get_session():
customer_session_client_secret = customer_session.client_secret
Report = pool.get('account.payment.stripe.checkout', type='report')
# TODO language
data = {
'model': Model.__name__,
'customer_session_client_secret': customer_session_client_secret,
'return_url': request.base_url + '/end'
}
ext, content, _, _ = Report.execute([record.id], data)
assert ext == 'html'
return Response(content, HTTPStatus.OK, content_type='text/html')
@app.route(
'/<database_name>/account_payment_stripe/checkout/<model>/<id>/end',
methods=['GET'])
@with_pool
@with_transaction(readonly=False, context={'_skip_warnings': True})
def checkout_end(request, pool, model, id):
Payment = pool.get('account.payment')
Customer = pool.get('account.payment.stripe.customer')
if model == Payment.__name__:
Model = Payment
elif model == Customer.__name__:
Model = Customer
else:
abort(HTTPStatus.FORBIDDEN)
try:
record, = Model.search([
('stripe_checkout_id', '=', id),
])
except ValueError:
abort(HTTPStatus.NOT_FOUND)
record.stripe_intent_update()
return Response(
'<body onload="window.close()">', HTTPStatus.OK,
content_type='text/html')
@app.route(