From b1886c5c52ce28891e58d8c378b1f22009e114fb Mon Sep 17 00:00:00 2001 From: Charly C Date: Thu, 23 May 2024 16:48:33 +0200 Subject: [PATCH] use the PaymenMethod API for SEPA Direct Debit (#2376) first step of dealing with #2374 --- js/stripe.js | 111 +++++++++--------- liberapay/models/exchange_route.py | 38 +++++- liberapay/payin/stripe.py | 11 +- www/%username/giving/pay/stripe/%payin_id.spt | 5 + www/%username/routes/add.spt | 20 +--- 5 files changed, 111 insertions(+), 74 deletions(-) diff --git a/js/stripe.js b/js/stripe.js index 5752cb41ed..60777d81c6 100644 --- a/js/stripe.js +++ b/js/stripe.js @@ -14,6 +14,22 @@ Liberapay.stripe_form_init = function($form) { }); var $container = $('#stripe-element'); + var $postal_address_alert = $form.find('.msg-postal-address-required'); + var $postal_address_country = $form.find('select[name="postal_address.country"]'); + var $postal_address_region = $form.find('input[name="postal_address.region"]'); + var $postal_address_city = $form.find('input[name="postal_address.city"]'); + var $postal_address_code = $form.find('input[name="postal_address.postal_code"]'); + var $postal_address_local = $form.find('textarea[name="postal_address.local_address"]'); + function is_postal_address_filled() { + return $postal_address_country.val() > '' && + $postal_address_city.val() > '' && + $postal_address_code.val() > '' && + $postal_address_local.val() > ''; + } + function is_postal_address_required() { + return /AD|BL|CH|GB|GG|GI|IM|JE|MC|NC|PF|PM|SM|TF|VA|WF/.test($container.data('country')); + } + var stripe = Stripe($form.data('stripe-pk')); var elements = stripe.elements(); var element_type = $container.data('type'); @@ -37,6 +53,12 @@ Liberapay.stripe_form_init = function($form) { } else { $errorElement.text(''); } + if (event.country) { + $container.data('country', event.country); + if (!is_postal_address_required()) { + $postal_address_alert.hide(); + } + } }); var submitting = false; @@ -64,63 +86,46 @@ Liberapay.stripe_form_init = function($form) { $form.submit(); return; } - var local_address = $form.find('input[name="postal_address.local_address"]').val(); + var pmType = element_type; + if (element_type == 'iban') { + pmType = 'sepa_debit'; + if (is_postal_address_required() && !is_postal_address_filled()) { + $postal_address_alert.removeClass('hidden').hide().fadeIn()[0].scrollIntoView(); + return; + } + } + var local_address = $postal_address_local.val(); local_address = !!local_address ? local_address.split(/(?:\r\n?|\n)/g) : [null]; if (local_address.length === 1) { local_address.push(null); } - if (element_type == 'iban') { - var tokenData = {}; - tokenData.currency = 'EUR'; - tokenData.account_holder_name = $form.find('input[name="owner.name"]').val(); - tokenData.address_country = $form.find('input[name="postal_address.country"]').val(); - tokenData.address_state = $form.find('input[name="postal_address.region"]').val(); - tokenData.address_city = $form.find('input[name="postal_address.city"]').val(); - tokenData.address_zip = $form.find('input[name="postal_address.postal_code"]').val(); - tokenData.address_line1 = local_address[0]; - tokenData.address_line2 = local_address[1]; - stripe.createToken(element, tokenData).then(Liberapay.wrap(function(result) { - if (result.error) { - $errorElement.text(result.error.message); - } else { - submitting = true; - $form.find('input[name="route"]').remove(); - $form.find('input[name="token"]').remove(); - var $hidden_input = $(''); - $hidden_input.val(result.token.id); - $form.append($hidden_input); - $form.submit(); - } - })); - } else if (element_type == 'card') { - var pmData = { - billing_details: { - address: { - city: $form.find('input[name="postal_address.city"]').val(), - country: $form.find('input[name="postal_address.country"]').val(), - line1: local_address[0], - line2: local_address[1], - postal_code: $form.find('input[name="postal_address.postal_code"]').val(), - state: $form.find('input[name="postal_address.region"]').val(), - }, - email: $form.find('input[name="owner.email"]').val(), - name: $form.find('input[name="owner.name"]').val(), - } - }; - stripe.createPaymentMethod('card', element, pmData).then(Liberapay.wrap(function(result) { - if (result.error) { - $errorElement.text(result.error.message); - } else { - submitting = true; - $form.find('input[name="route"]').remove(); - $form.find('input[name="stripe_pm_id"]').remove(); - var $hidden_input = $(''); - $hidden_input.val(result.paymentMethod.id); - $form.append($hidden_input); - $form.submit(); - } - })); - } + var pmData = { + billing_details: { + address: { + city: $postal_address_city.val(), + country: $postal_address_country.val(), + line1: local_address[0], + line2: local_address[1], + postal_code: $postal_address_code.val(), + state: $postal_address_region.val(), + }, + email: $form.find('input[name="owner.email"]').val(), + name: $form.find('input[name="owner.name"]').val(), + } + }; + stripe.createPaymentMethod(pmType, element, pmData).then(Liberapay.wrap(function(result) { + if (result.error) { + $errorElement.text(result.error.message); + } else { + submitting = true; + $form.find('input[name="route"]').remove(); + $form.find('input[name="stripe_pm_id"]').remove(); + var $hidden_input = $(''); + $hidden_input.val(result.paymentMethod.id); + $form.append($hidden_input); + $form.submit(); + } + })); })); $form.attr('action', ''); }; diff --git a/liberapay/models/exchange_route.py b/liberapay/models/exchange_route.py index af92d8533c..e06d71a5c4 100644 --- a/liberapay/models/exchange_route.py +++ b/liberapay/models/exchange_route.py @@ -7,6 +7,8 @@ from ..constants import CARD_BRANDS from ..exceptions import InvalidId, TooManyAttempts +from ..utils import utcnow +from ..website import website class ExchangeRoute(Model): @@ -144,6 +146,35 @@ def attach_stripe_payment_method(cls, participant, pm, one_off): country=pm_country, currency=pm_currency, ) route.stripe_payment_method = pm + if network == 'stripe-sdd': + state = website.state.get() + request, response = state['request'], state['response'] + user_agent = request.headers.get(b'User-Agent', b'') + try: + user_agent = user_agent.decode('ascii', 'backslashreplace') + except UnicodeError: + raise response.error(400, "User-Agent must be ASCII only") + si = stripe.SetupIntent.create( + confirm=True, + customer=route.remote_user_id, + mandate_data={ + "customer_acceptance": { + "type": "online", + "accepted_at": int(utcnow().timestamp()), + "online": { + "ip_address": str(request.source), + "user_agent": user_agent, + }, + }, + }, + metadata={"route_id": route.id}, + payment_method=pm.id, + payment_method_types=[pm.type], + usage='off_session', + idempotency_key='create_SI_for_route_%i' % route.id, + ) + route.set_mandate(si.mandate) + assert not si.next_action, si.next_action return route @classmethod @@ -268,7 +299,7 @@ def get_brand(self): return self.stripe_source.card.brand elif self.network == 'stripe-sdd': if self.address.startswith('pm_'): - raise NotImplementedError() + return getattr(self.stripe_payment_method.sepa_debit, 'bank_name', '') else: return getattr(self.stripe_source.sepa_debit, 'bank_name', '') else: @@ -292,7 +323,8 @@ def get_mandate_url(self): return elif self.network == 'stripe-sdd': if self.address.startswith('pm_'): - raise NotImplementedError() + mandate = stripe.Mandate.retrieve(self.mandate) + return mandate.payment_method_details.sepa_debit.url else: return self.stripe_source.sepa_debit.mandate_url else: @@ -307,7 +339,7 @@ def get_partial_number(self): elif self.network == 'stripe-sdd': from ..payin.stripe import get_partial_iban if self.address.startswith('pm_'): - raise NotImplementedError() + return get_partial_iban(self.stripe_payment_method.sepa_debit) else: return get_partial_iban(self.stripe_source.sepa_debit) else: diff --git a/liberapay/payin/stripe.py b/liberapay/payin/stripe.py index 9fba5148e2..732a6d4c85 100644 --- a/liberapay/payin/stripe.py +++ b/liberapay/payin/stripe.py @@ -367,9 +367,14 @@ def send_payin_notification(db, payin, payer, charge, route): """ if route.network == 'stripe-sdd' and charge.status != 'failed': if route.address.startswith('pm_'): - raise NotImplementedError() + sepa_debit = stripe.PaymentMethod.retrieve(route.address).sepa_debit + mandate = stripe.Mandate.retrieve(route.mandate) + mandate_url = mandate.payment_method_details.sepa_debit.url + mandate_reference = mandate.payment_method_details.sepa_debit.reference else: sepa_debit = stripe.Source.retrieve(route.address).sepa_debit + mandate_url = sepa_debit.mandate_url + mandate_reference = sepa_debit.mandate_reference tippees = db.all(""" SELECT DISTINCT tippee_p.id AS tippee_id, tippee_p.username AS tippee_username FROM payin_transfers pt @@ -384,8 +389,8 @@ def send_payin_notification(db, payin, payer, charge, route): payin_amount=payin.amount, bank_name=getattr(sepa_debit, 'bank_name', None), partial_bank_account_number=get_partial_iban(sepa_debit), - mandate_url=sepa_debit.mandate_url, - mandate_id=sepa_debit.mandate_reference, + mandate_url=mandate_url, + mandate_id=mandate_reference, mandate_creation_date=route.ctime.date(), creditor_identifier=website.app_conf.sepa_creditor_identifier, average_settlement_seconds=PAYIN_SETTLEMENT_DELAYS['stripe-sdd'].total_seconds(), diff --git a/www/%username/giving/pay/stripe/%payin_id.spt b/www/%username/giving/pay/stripe/%payin_id.spt index 3a25637c2a..5e9d021913 100644 --- a/www/%username/giving/pay/stripe/%payin_id.spt +++ b/www/%username/giving/pay/stripe/%payin_id.spt @@ -500,6 +500,7 @@ title = _("Funding your donations") % endif + % if payment_type == 'card'

{{ _("Please input your name and card number:") }}

@@ -576,6 +577,10 @@ title = _("Funding your donations") % endif
+ diff --git a/www/%username/routes/add.spt b/www/%username/routes/add.spt index 107a0ebc52..c171a35d4c 100644 --- a/www/%username/routes/add.spt +++ b/www/%username/routes/add.spt @@ -15,7 +15,6 @@ if request.method == 'POST': raise AccountSuspended() body = request.body one_off = body.get('one_off') == 'true' - return_url = participant.url('routes/') try: if 'token' in body: owner_info = { @@ -23,25 +22,12 @@ if request.method == 'POST': 'name': body.get('owner.name'), } source = create_source_from_token( - body.word('token'), one_off, None, owner_info, return_url + body.word('token'), one_off, None, owner_info, participant.url('routes/') ) route = ExchangeRoute.attach_stripe_source(participant, source, one_off) else: pm = stripe.PaymentMethod.retrieve(body.word('stripe_pm_id')) route = ExchangeRoute.attach_stripe_payment_method(participant, pm, one_off) - si = stripe.SetupIntent.create( - confirm=True, - customer=route.remote_user_id, - payment_method=pm.id, - metadata={"route_id": route.id}, - return_url=return_url, - usage='off_session', - idempotency_key='create_SI_for_route_%i' % route.id, - ) - if si.next_action: - if si.next_action.type != 'redirect_to_url': - raise NotImplementedError(si.next_action.type) - raise response.redirect(si.next_action.redirect_to_url.url) except stripe.error.StripeError as e: raise response.error(e.http_status or 500, _( "The payment processor {name} returned an error: “{error_message}”.", @@ -143,6 +129,10 @@ title = _("Add a payment instrument") postal_address_form_v2(saved=identity.get('postal_address'), required=False) }}
+