@@ -1,3 +1,4 @@
|
|||||||
|
* Use payment element for checkout
|
||||||
|
|
||||||
Version 7.4.0 - 2024-11-04
|
Version 7.4.0 - 2024-11-04
|
||||||
--------------------------
|
--------------------------
|
||||||
|
|||||||
@@ -21,125 +21,64 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
display: block;
|
display: block;
|
||||||
font-size:2em;
|
font-size:2em;
|
||||||
margin: 0 auto;
|
margin: 10px auto;
|
||||||
padding: 0.5em 1em;
|
padding: 0.5em 1em;
|
||||||
}
|
}
|
||||||
.wrapper {
|
form {
|
||||||
width: 670px;
|
width: 670px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
.StripeElement {
|
#payment-errors {
|
||||||
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 {
|
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
color: #fa755a;
|
color: #fa755a;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body py:with="intent = record.stripe_intent">
|
||||||
<py:for each="record in records[:1]">
|
|
||||||
<py:with vars="intent = record.stripe_intent">
|
|
||||||
<h1>${record.party.rec_name}</h1>
|
<h1>${record.party.rec_name}</h1>
|
||||||
<div class="wrapper">
|
<form id="payment-form">
|
||||||
<form method="POST" id="form">
|
<div id="payment-element">
|
||||||
<div>
|
</div>
|
||||||
<py:if test="not data.get('payment_method')">
|
<div id="payment-error">
|
||||||
<label for="card-element">
|
</div>
|
||||||
Credit or debit card
|
<button id="payment-submit" data-model="${data['model']}">
|
||||||
</label>
|
Submit
|
||||||
<div id="card-element">
|
</button>
|
||||||
</div>
|
</form>
|
||||||
</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>
|
|
||||||
<script>
|
<script>
|
||||||
var stripe = Stripe('${record.stripe_account.publishable_key}');
|
const model = '${data['model']}';
|
||||||
var elements = stripe.elements();
|
const customer_session_client_secret = '${data['customer_session_client_secret']}';
|
||||||
var style = {
|
const stripe = Stripe('${record.stripe_account.publishable_key}');
|
||||||
base: {
|
const elements = stripe.elements({
|
||||||
color: '#32325d',
|
'clientSecret': '${intent.client_secret}',
|
||||||
fontSize: '16px',
|
'customerSessionClientSecret': customer_session_client_secret || null,
|
||||||
'::placeholder': {
|
});
|
||||||
color: '#aab7c4'
|
const paymentElement = elements.create('payment', {
|
||||||
}
|
});
|
||||||
},
|
paymentElement.mount('#payment-element');
|
||||||
invalid: {
|
const form = document.getElementById('payment-form');
|
||||||
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');
|
|
||||||
form.addEventListener('submit', function(event) {
|
form.addEventListener('submit', function(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
const options = {
|
||||||
var prm;
|
elements,
|
||||||
|
confirmParams: {
|
||||||
|
return_url: '${data['return_url']}',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
let prm;
|
||||||
if (model == 'account.payment.stripe.customer') {
|
if (model == 'account.payment.stripe.customer') {
|
||||||
prm = stripe.handleCardSetup(clientSecret, card);
|
prm = stripe.confirmSetup(options);
|
||||||
} else {
|
} else {
|
||||||
prm = stripe.handleCardPayment(clientSecret, card);
|
prm = stripe.confirmPayment(options);
|
||||||
}
|
}
|
||||||
prm.then(function(result) {
|
prm.then(function(result) {
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
var errorElement = document.getElementById('card-errors');
|
const messageContainer = document.getElementById('payment-error');
|
||||||
errorElement.textContent = result.error.message;
|
messageContainer.textContent = result.error.message;
|
||||||
} else {
|
|
||||||
form.submit();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</py:with>
|
|
||||||
</py:for>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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>
|
<field name="text">Are you sure you want to modify the keys of the Stripe account?</field>
|
||||||
</record>
|
</record>
|
||||||
<record model="ir.message" id="msg_no_stripe_token">
|
<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>
|
||||||
<record model="ir.message" id="msg_stripe_receivable">
|
<record model="ir.message" id="msg_stripe_receivable">
|
||||||
<field name="text">To pay "%(payment)s", you cannot use a stripe journal "%(journal)s".</field>
|
<field name="text">To pay "%(payment)s", you cannot use a stripe journal "%(journal)s".</field>
|
||||||
|
|||||||
@@ -75,7 +75,9 @@ class Group(metaclass=PoolMeta):
|
|||||||
pool = Pool()
|
pool = Pool()
|
||||||
Payment = pool.get('account.payment')
|
Payment = pool.get('account.payment')
|
||||||
for payment in self.payments:
|
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
|
account = payment.journal.stripe_account
|
||||||
for customer in payment.party.stripe_customers:
|
for customer in payment.party.stripe_customers:
|
||||||
if (customer.stripe_account == account
|
if (customer.stripe_account == account
|
||||||
@@ -421,12 +423,14 @@ class Payment(StripeCustomerMethodMixin, CheckoutMixin, metaclass=PoolMeta):
|
|||||||
'stripe_version': STRIPE_VERSION,
|
'stripe_version': STRIPE_VERSION,
|
||||||
'amount': self.stripe_amount,
|
'amount': self.stripe_amount,
|
||||||
'currency': self.currency.code,
|
'currency': self.currency.code,
|
||||||
|
'automatic_payment_methods': {
|
||||||
|
'enabled': True,
|
||||||
|
},
|
||||||
'capture_method': 'automatic' if self.stripe_capture else 'manual',
|
'capture_method': 'automatic' if self.stripe_capture else 'manual',
|
||||||
'customer': (self.stripe_customer.stripe_customer_id
|
'customer': (self.stripe_customer.stripe_customer_id
|
||||||
if self.stripe_customer else None),
|
if self.stripe_customer else None),
|
||||||
'description': self.description,
|
'description': self.description,
|
||||||
'off_session': off_session,
|
'off_session': off_session,
|
||||||
'payment_method_types': ['card'],
|
|
||||||
'idempotency_key': idempotency_key,
|
'idempotency_key': idempotency_key,
|
||||||
}
|
}
|
||||||
if self.stripe_customer_payment_method:
|
if self.stripe_customer_payment_method:
|
||||||
@@ -685,7 +689,7 @@ class Payment(StripeCustomerMethodMixin, CheckoutMixin, metaclass=PoolMeta):
|
|||||||
def stripe_update(self, charge):
|
def stripe_update(self, charge):
|
||||||
assert (
|
assert (
|
||||||
(charge.id == self.stripe_charge_id)
|
(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
|
amount = charge.amount - charge.amount_refunded
|
||||||
if (self.state not in {'succeeded', 'failed'}
|
if (self.state not in {'succeeded', 'failed'}
|
||||||
or self.stripe_amount != amount
|
or self.stripe_amount != amount
|
||||||
@@ -1764,6 +1768,27 @@ class Customer(CheckoutMixin, DeactivableMixin, ModelSQL, ModelView):
|
|||||||
name = '****' + payment_method.sepa_debit.last4
|
name = '****' + payment_method.sepa_debit.last4
|
||||||
return name
|
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
|
@property
|
||||||
def stripe_setup_intent(self):
|
def stripe_setup_intent(self):
|
||||||
if not self.stripe_setup_intent_id:
|
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">
|
<record model="ir.action.report" id="report_checkout">
|
||||||
<field name="name">Stripe Checkout</field>
|
<field name="name">Stripe Checkout</field>
|
||||||
<field name="model" eval="None"/>
|
<field name="model" eval="None"/>
|
||||||
|
<field name="single" eval="True"/>
|
||||||
<field name="report_name">account.payment.stripe.checkout</field>
|
<field name="report_name">account.payment.stripe.checkout</field>
|
||||||
<field name="report">account_payment_stripe/checkout.html</field>
|
<field name="report">account_payment_stripe/checkout.html</field>
|
||||||
<field name="template_extension">html</field>
|
<field name="template_extension">html</field>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
@app.route(
|
@app.route(
|
||||||
'/<database_name>/account_payment_stripe/checkout/<model>/<id>',
|
'/<database_name>/account_payment_stripe/checkout/<model>/<id>',
|
||||||
methods=['GET', 'POST'])
|
methods=['GET'])
|
||||||
@with_pool
|
@with_pool
|
||||||
@with_transaction(context={'_skip_warnings': True})
|
@with_transaction(context={'_skip_warnings': True})
|
||||||
def checkout(request, pool, model, id):
|
def checkout(request, pool, model, id):
|
||||||
@@ -33,22 +33,46 @@ def checkout(request, pool, model, id):
|
|||||||
])
|
])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
abort(HTTPStatus.NOT_FOUND)
|
abort(HTTPStatus.NOT_FOUND)
|
||||||
if request.method == 'GET':
|
customer_session_client_secret = ''
|
||||||
Report = pool.get('account.payment.stripe.checkout', type='report')
|
if model == Payment.__name__ and record.stripe_customer:
|
||||||
# TODO language
|
if customer_session := record.stripe_customer.get_session():
|
||||||
data = {
|
customer_session_client_secret = customer_session.client_secret
|
||||||
'model': Model.__name__,
|
Report = pool.get('account.payment.stripe.checkout', type='report')
|
||||||
}
|
# TODO language
|
||||||
if model == Payment.__name__ and record.stripe_customer_payment_method:
|
data = {
|
||||||
data['payment_method'] = record.stripe_customer_payment_method
|
'model': Model.__name__,
|
||||||
ext, content, _, _ = Report.execute([record.id], data)
|
'customer_session_client_secret': customer_session_client_secret,
|
||||||
assert ext == 'html'
|
'return_url': request.base_url + '/end'
|
||||||
return Response(content, HTTPStatus.OK, content_type='text/html')
|
}
|
||||||
elif request.method == 'POST':
|
ext, content, _, _ = Report.execute([record.id], data)
|
||||||
record.stripe_intent_update()
|
assert ext == 'html'
|
||||||
return Response(
|
return Response(content, HTTPStatus.OK, content_type='text/html')
|
||||||
'<body onload="window.close()">', 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(
|
@app.route(
|
||||||
|
|||||||
Reference in New Issue
Block a user