diff --git a/js/10-base.js b/js/10-base.js index d6654cba7..5143025cb 100644 --- a/js/10-base.js +++ b/js/10-base.js @@ -16,6 +16,7 @@ Liberapay.init = function() { Liberapay.lookup.init(); Liberapay.s3_uploader_init(); Liberapay.stripe_init(); + Liberapay.stripe_connect.init(); $('div[href]').css('cursor', 'pointer').on('click auxclick', function(event) { if (event.target.tagName == 'A') { diff --git a/js/stripe-connect.js b/js/stripe-connect.js new file mode 100644 index 000000000..cf5a72e8f --- /dev/null +++ b/js/stripe-connect.js @@ -0,0 +1,97 @@ +Liberapay.stripe_connect = {}; + +Liberapay.stripe_connect.init = function() { + var container = document.getElementById('stripe-connect'); + if (!container) return; + + async function fetchClientSecret() { + try { + var response = await fetch('', { + headers: { + "Accept": "application/json", + "X-CSRF-TOKEN": container.dataset.csrfToken, + }, + method: "POST", + }); + if (response.ok) { + return (await response.json()).client_secret; + } else { + Liberapay.error(response.status); + } + } catch(exc) { + Liberapay.error(exc); + return undefined; + } + }; + + const self = Liberapay.stripe_connect; + self.components = {}; + const component_nav = document.getElementById('stripe-component-nav'); + var target_component_name; + if (component_nav) { + target_component_name = 'account-management'; + component_nav.querySelector( + 'a[data-component="' + target_component_name + '"]' + ).parentElement.classList.add('active'); + component_nav.classList.remove('hidden'); + const component_nav_links = component_nav.querySelectorAll('a'); + component_nav_links.forEach((a) => { + a.addEventListener('click', (e) => { + e.preventDefault(); + component_nav_links.forEach((a) => { + a.parentElement.classList.remove('active') + }); + a.parentElement.classList.add('active'); + const component_name = a.dataset.component; + if (self.components[component_name]) { + self.components[component_name].classList.remove('hidden'); + } else { + self.components[component_name] = self.instance.create( + component_name + ); + container.appendChild(self.components[component_name]); + } + self.current_component.classList.add('hidden'); + self.current_component = self.components[component_name]; + container.scrollIntoView(); + }); + }); + } else { + target_component_name = 'account-onboarding'; + } + + if (!window.StripeConnect) { + alert(container.dataset.msgStripeMissing); + return; + } + StripeConnect.onLoad = () => { + self.instance = StripeConnect.init({ + appearance: { + variables: { + colorPrimary: '#337ab7', + colorText: rgb_to_hex($(container).css('color')), + fontFamily: $(container).css('font-family'), + fontSizeBase: $(container).css('font-size'), + }, + }, + fetchClientSecret: fetchClientSecret, + fonts: [{cssSrc: 'https://liberapay.com/assets/fonts.css'}], + locale: document.documentElement.getAttribute('lang'), + publishableKey: container.dataset.stripePubKey, + }); + const notification_div = document.getElementById('stripe-notification'); + if (notification_div) { + notification_div.appendChild(self.instance.create('notification-banner')); + } + var component = self.instance.create(target_component_name); + self.current_component = self.components[target_component_name] = component; + if (target_component_name == 'account-onboarding') { + component.setOnExit(() => { + window.location = location.href.replace( + /\/stripe\/onboard\/?\?.+$/, '/' + ); + }); + } + container.appendChild(self.current_component); + } +}; diff --git a/js/stripe.js b/js/stripe.js index 935923b35..eb1bfd008 100644 --- a/js/stripe.js +++ b/js/stripe.js @@ -1,11 +1,6 @@ Liberapay.stripe_init = function() { var $form = $('form#stripe'); - if ($form.length === 1) Liberapay.stripe_form_init($form); - var $next_action = $('#stripe_next_action'); - if ($next_action.length === 1) Liberapay.stripe_next_action($next_action); -}; - -Liberapay.stripe_form_init = function($form) { + if ($form.length !== 1) return; $('fieldset.hidden').prop('disabled', true); $('button[data-modify]').click(function() { var $btn = $(this); @@ -13,6 +8,15 @@ Liberapay.stripe_form_init = function($form) { $btn.parent().addClass('hidden'); }); + var $errorElement = $('#stripe-errors'); + var stripe = null; + if (window.Stripe) { + stripe = Stripe($form.data('stripe-pk')); + } else { + $errorElement.text($form.attr('data-msg-stripe-missing')); + $errorElement.hide().fadeIn()[0].scrollIntoView(); + } + 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"]'); @@ -32,10 +36,7 @@ Liberapay.stripe_form_init = function($form) { ); } - var $errorElement = $('#stripe-errors'); - var stripe = null; - if (window.Stripe) { - stripe = Stripe($form.data('stripe-pk')); + if ($container.length === 1) { var elements = stripe.elements({ onBehalfOf: $form.data('stripe-on-behalf-of') || undefined, }); @@ -66,14 +67,29 @@ Liberapay.stripe_form_init = function($form) { } } }); - } else { - $errorElement.text($form.attr('data-msg-stripe-missing')); } - Liberapay.stripe_before_submit = async function() { + Liberapay.stripe_before_account_submit = async function() { + const response = await stripe.createToken('account', { + tos_shown_and_accepted: true, + }); + if (response.token) { + $form.find('input[name="account_token"]').remove(); + var $pm_id_input = $(''); + $pm_id_input.val(response.token.id); + $pm_id_input.appendTo($form); + return true; + } else { + $errorElement.text(response.error || response); + $errorElement.hide().fadeIn()[0].scrollIntoView(); + return false; + } + } + + Liberapay.stripe_before_element_submit = async function() { // If the Payment Element is hidden, simply let the browser submit the form if ($container.parents('.hidden').length > 0) { - console.debug("stripe_before_submit: ignoring hidden payment element"); + console.debug("stripe_before_element_submit: ignoring hidden payment element"); return true; } // If Stripe.js is missing, stop the submission @@ -125,14 +141,4 @@ Liberapay.stripe_form_init = function($form) { return false; } }; - - Liberapay.stripe_next_action = function ($next_action) { - stripe.handleCardAction($next_action.data('client_secret')).then(function (result) { - if (result.error) { - $next_action.addClass('alert alert-danger').text(result.error.message); - } else { - window.location.reload(); - } - }) - }; }; diff --git a/liberapay/main.py b/liberapay/main.py index 43086b8ff..88282309c 100644 --- a/liberapay/main.py +++ b/liberapay/main.py @@ -48,7 +48,7 @@ from liberapay.payin.cron import ( send_upcoming_debit_notifications, ) from liberapay.security import authentication, csrf, set_default_security_headers -from liberapay.security.csp import csp_allow, csp_allow_stripe +from liberapay.security.csp import csp_allow, csp_allow_stripe, csp_allow_stripe_connect from liberapay.utils import ( b64decode_s, b64encode_s, erase_cookie, http_caching, set_cookie, ) @@ -412,6 +412,10 @@ if hasattr(pando.Response, 'csp_allow_stripe'): raise Warning('pando.Response.csp_allow_stripe() already exists') pando.Response.csp_allow_stripe = csp_allow_stripe +if hasattr(pando.Response, 'csp_allow_stripe_connect'): + raise Warning('pando.Response.csp_allow_stripe_connect() already exists') +pando.Response.csp_allow_stripe_connect = csp_allow_stripe_connect + if hasattr(pando.Response, 'encode_url'): raise Warning('pando.Response.encode_url() already exists') def _encode_url(url): diff --git a/liberapay/payin/common.py b/liberapay/payin/common.py index 5536bd123..d9256ac79 100644 --- a/liberapay/payin/common.py +++ b/liberapay/payin/common.py @@ -462,6 +462,7 @@ def resolve_team_donation( AND a.country IN %(SEPA)s ORDER BY a.participant , a.default_currency = %(currency)s DESC + , a.country = %(payer_country)s DESC , a.connection_ts """, dict(locals(), SEPA=SEPA, member_ids={t.member for t in takes}))} if sepa_only or len(sepa_accounts) > 1 and takes[0].member in sepa_accounts: diff --git a/liberapay/security/csp.py b/liberapay/security/csp.py index 6e5844261..0956f4acd 100644 --- a/liberapay/security/csp.py +++ b/liberapay/security/csp.py @@ -43,3 +43,14 @@ def csp_allow_stripe(response) -> None: (b'frame-src', b"*.js.stripe.com js.stripe.com hooks.stripe.com"), (b'script-src', b"*.js.stripe.com js.stripe.com"), ) + + +def csp_allow_stripe_connect(response) -> None: + # https://docs.stripe.com/security/guide?csp=csp-connect#content-security-policy + csp_allow( + response, + (b'frame-src', b"connect-js.stripe.com js.stripe.com"), + (b'img-src', b"*.stripe.com"), + (b'script-src', b"connect-js.stripe.com js.stripe.com"), + (b'style-src', b"sha256-0hAheEzaMe6uXIKV4EehS9pu1am1lj/KnnzrOYqckXk="), + ) diff --git a/liberapay/testing/__init__.py b/liberapay/testing/__init__.py index f5d387e01..d983f05b6 100644 --- a/liberapay/testing/__init__.py +++ b/liberapay/testing/__init__.py @@ -398,15 +398,17 @@ class Harness(unittest.TestCase): data.setdefault('verified', True) data.setdefault('display_name', None) data.setdefault('token', None) + data.setdefault('independent', True) + data.setdefault('loss_taker', 'provider') data.update(p_id=participant.id, provider=provider, country=country) r = self.db.one(""" INSERT INTO payment_accounts (participant, provider, country, id, default_currency, charges_enabled, verified, - display_name, token) + display_name, token, independent, loss_taker) VALUES (%(p_id)s, %(provider)s, %(country)s, %(id)s, %(default_currency)s, %(charges_enabled)s, %(verified)s, - %(display_name)s, %(token)s) + %(display_name)s, %(token)s, %(independent)s, %(loss_taker)s) RETURNING * """, data) participant.set_attributes(payment_providers=self.db.one(""" diff --git a/sql/branch.sql b/sql/branch.sql new file mode 100644 index 000000000..049bf90f1 --- /dev/null +++ b/sql/branch.sql @@ -0,0 +1,14 @@ +BEGIN; + CREATE TYPE loss_taker AS ENUM ('provider', 'platform'); + ALTER TABLE payment_accounts + ADD COLUMN independent boolean DEFAULT true, + ADD COLUMN loss_taker loss_taker DEFAULT 'provider', + ADD COLUMN details_submitted boolean, + ADD COLUMN allow_payout boolean; +END; +SELECT 'after deployment'; +BEGIN; + ALTER TABLE payment_accounts + ALTER COLUMN independent DROP DEFAULT, + ALTER COLUMN loss_taker DROP DEFAULT; +END; diff --git a/www/%username/giving/pay/stripe/%payin_id.spt b/www/%username/giving/pay/stripe/%payin_id.spt index 82c312b87..04ee99644 100644 --- a/www/%username/giving/pay/stripe/%payin_id.spt +++ b/www/%username/giving/pay/stripe/%payin_id.spt @@ -165,8 +165,6 @@ elif payin_id: except NextAction as act: if act.type == 'redirect_to_url': response.refresh(state, url=act.redirect_to_url.url) - elif act.type == 'use_stripe_sdk': - client_secret = act.client_secret else: raise NotImplementedError(act.type) tell_payer_to_fill_profile = ( @@ -256,13 +254,7 @@ title = _("Funding your donations") % block thin_content -% if client_secret is defined - -
{{ _("More information is required in order to process this payment.") }}
- - - -% elif payin is defined +% if payin is defined % set status = payin.status % if status == 'succeeded'