basic support for Stripe Custom accounts
This commit is contained in:
parent
9aa647a6f0
commit
209600b217
@ -16,6 +16,7 @@ Liberapay.init = function() {
|
|||||||
Liberapay.lookup.init();
|
Liberapay.lookup.init();
|
||||||
Liberapay.s3_uploader_init();
|
Liberapay.s3_uploader_init();
|
||||||
Liberapay.stripe_init();
|
Liberapay.stripe_init();
|
||||||
|
Liberapay.stripe_connect.init();
|
||||||
|
|
||||||
$('div[href]').css('cursor', 'pointer').on('click auxclick', function(event) {
|
$('div[href]').css('cursor', 'pointer').on('click auxclick', function(event) {
|
||||||
if (event.target.tagName == 'A') {
|
if (event.target.tagName == 'A') {
|
||||||
|
97
js/stripe-connect.js
Normal file
97
js/stripe-connect.js
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
};
|
54
js/stripe.js
54
js/stripe.js
@ -1,11 +1,6 @@
|
|||||||
Liberapay.stripe_init = function() {
|
Liberapay.stripe_init = function() {
|
||||||
var $form = $('form#stripe');
|
var $form = $('form#stripe');
|
||||||
if ($form.length === 1) Liberapay.stripe_form_init($form);
|
if ($form.length !== 1) return;
|
||||||
var $next_action = $('#stripe_next_action');
|
|
||||||
if ($next_action.length === 1) Liberapay.stripe_next_action($next_action);
|
|
||||||
};
|
|
||||||
|
|
||||||
Liberapay.stripe_form_init = function($form) {
|
|
||||||
$('fieldset.hidden').prop('disabled', true);
|
$('fieldset.hidden').prop('disabled', true);
|
||||||
$('button[data-modify]').click(function() {
|
$('button[data-modify]').click(function() {
|
||||||
var $btn = $(this);
|
var $btn = $(this);
|
||||||
@ -13,6 +8,15 @@ Liberapay.stripe_form_init = function($form) {
|
|||||||
$btn.parent().addClass('hidden');
|
$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 $container = $('#stripe-element');
|
||||||
var $postal_address_alert = $form.find('.msg-postal-address-required');
|
var $postal_address_alert = $form.find('.msg-postal-address-required');
|
||||||
var $postal_address_country = $form.find('select[name="postal_address.country"]');
|
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');
|
if ($container.length === 1) {
|
||||||
var stripe = null;
|
|
||||||
if (window.Stripe) {
|
|
||||||
stripe = Stripe($form.data('stripe-pk'));
|
|
||||||
var elements = stripe.elements({
|
var elements = stripe.elements({
|
||||||
onBehalfOf: $form.data('stripe-on-behalf-of') || undefined,
|
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 = $('<input type="hidden" name="account_token">');
|
||||||
|
$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 the Payment Element is hidden, simply let the browser submit the form
|
||||||
if ($container.parents('.hidden').length > 0) {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
// If Stripe.js is missing, stop the submission
|
// If Stripe.js is missing, stop the submission
|
||||||
@ -125,14 +141,4 @@ Liberapay.stripe_form_init = function($form) {
|
|||||||
return false;
|
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();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
@ -48,7 +48,7 @@ from liberapay.payin.cron import (
|
|||||||
send_upcoming_debit_notifications,
|
send_upcoming_debit_notifications,
|
||||||
)
|
)
|
||||||
from liberapay.security import authentication, csrf, set_default_security_headers
|
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 (
|
from liberapay.utils import (
|
||||||
b64decode_s, b64encode_s, erase_cookie, http_caching, set_cookie,
|
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')
|
raise Warning('pando.Response.csp_allow_stripe() already exists')
|
||||||
pando.Response.csp_allow_stripe = csp_allow_stripe
|
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'):
|
if hasattr(pando.Response, 'encode_url'):
|
||||||
raise Warning('pando.Response.encode_url() already exists')
|
raise Warning('pando.Response.encode_url() already exists')
|
||||||
def _encode_url(url):
|
def _encode_url(url):
|
||||||
|
@ -462,6 +462,7 @@ def resolve_team_donation(
|
|||||||
AND a.country IN %(SEPA)s
|
AND a.country IN %(SEPA)s
|
||||||
ORDER BY a.participant
|
ORDER BY a.participant
|
||||||
, a.default_currency = %(currency)s DESC
|
, a.default_currency = %(currency)s DESC
|
||||||
|
, a.country = %(payer_country)s DESC
|
||||||
, a.connection_ts
|
, a.connection_ts
|
||||||
""", dict(locals(), SEPA=SEPA, member_ids={t.member for t in takes}))}
|
""", 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:
|
if sepa_only or len(sepa_accounts) > 1 and takes[0].member in sepa_accounts:
|
||||||
|
@ -43,3 +43,14 @@ def csp_allow_stripe(response) -> None:
|
|||||||
(b'frame-src', b"*.js.stripe.com js.stripe.com hooks.stripe.com"),
|
(b'frame-src', b"*.js.stripe.com js.stripe.com hooks.stripe.com"),
|
||||||
(b'script-src', b"*.js.stripe.com js.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="),
|
||||||
|
)
|
||||||
|
@ -398,15 +398,17 @@ class Harness(unittest.TestCase):
|
|||||||
data.setdefault('verified', True)
|
data.setdefault('verified', True)
|
||||||
data.setdefault('display_name', None)
|
data.setdefault('display_name', None)
|
||||||
data.setdefault('token', None)
|
data.setdefault('token', None)
|
||||||
|
data.setdefault('independent', True)
|
||||||
|
data.setdefault('loss_taker', 'provider')
|
||||||
data.update(p_id=participant.id, provider=provider, country=country)
|
data.update(p_id=participant.id, provider=provider, country=country)
|
||||||
r = self.db.one("""
|
r = self.db.one("""
|
||||||
INSERT INTO payment_accounts
|
INSERT INTO payment_accounts
|
||||||
(participant, provider, country, id,
|
(participant, provider, country, id,
|
||||||
default_currency, charges_enabled, verified,
|
default_currency, charges_enabled, verified,
|
||||||
display_name, token)
|
display_name, token, independent, loss_taker)
|
||||||
VALUES (%(p_id)s, %(provider)s, %(country)s, %(id)s,
|
VALUES (%(p_id)s, %(provider)s, %(country)s, %(id)s,
|
||||||
%(default_currency)s, %(charges_enabled)s, %(verified)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 *
|
RETURNING *
|
||||||
""", data)
|
""", data)
|
||||||
participant.set_attributes(payment_providers=self.db.one("""
|
participant.set_attributes(payment_providers=self.db.one("""
|
||||||
|
14
sql/branch.sql
Normal file
14
sql/branch.sql
Normal file
@ -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;
|
@ -165,8 +165,6 @@ elif payin_id:
|
|||||||
except NextAction as act:
|
except NextAction as act:
|
||||||
if act.type == 'redirect_to_url':
|
if act.type == 'redirect_to_url':
|
||||||
response.refresh(state, url=act.redirect_to_url.url)
|
response.refresh(state, url=act.redirect_to_url.url)
|
||||||
elif act.type == 'use_stripe_sdk':
|
|
||||||
client_secret = act.client_secret
|
|
||||||
else:
|
else:
|
||||||
raise NotImplementedError(act.type)
|
raise NotImplementedError(act.type)
|
||||||
tell_payer_to_fill_profile = (
|
tell_payer_to_fill_profile = (
|
||||||
@ -256,13 +254,7 @@ title = _("Funding your donations")
|
|||||||
|
|
||||||
% block thin_content
|
% block thin_content
|
||||||
|
|
||||||
% if client_secret is defined
|
% if payin is defined
|
||||||
|
|
||||||
<p>{{ _("More information is required in order to process this payment.") }}</p>
|
|
||||||
<noscript><div class="alert alert-danger">{{ _("JavaScript is required") }}</div></noscript>
|
|
||||||
<div id="stripe_next_action" data-client_secret="{{ client_secret }}"></div>
|
|
||||||
|
|
||||||
% elif payin is defined
|
|
||||||
% set status = payin.status
|
% set status = payin.status
|
||||||
% if status == 'succeeded'
|
% if status == 'succeeded'
|
||||||
<div class="alert alert-success">{{ _(
|
<div class="alert alert-success">{{ _(
|
||||||
@ -354,7 +346,7 @@ title = _("Funding your donations")
|
|||||||
<noscript><div class="alert alert-danger">{{ _("JavaScript is required") }}</div></noscript>
|
<noscript><div class="alert alert-danger">{{ _("JavaScript is required") }}</div></noscript>
|
||||||
|
|
||||||
<form action="javascript:" method="POST" id="stripe"
|
<form action="javascript:" method="POST" id="stripe"
|
||||||
data-before-submit="call:Liberapay.stripe_before_submit"
|
data-before-submit="call:Liberapay.stripe_before_element_submit"
|
||||||
data-msg-stripe-missing='{{ _(
|
data-msg-stripe-missing='{{ _(
|
||||||
"The initialization of a required component has failed. If you use a "
|
"The initialization of a required component has failed. If you use a "
|
||||||
"browser extension that blocks requests, for example NoScript, please "
|
"browser extension that blocks requests, for example NoScript, please "
|
||||||
|
@ -18,7 +18,7 @@ if request.method == 'POST':
|
|||||||
AND pk = %s
|
AND pk = %s
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""", (participant.id, account_pk))
|
""", (participant.id, account_pk))
|
||||||
if account and account.provider == 'stripe':
|
if account and account.provider == 'stripe' and account.independent:
|
||||||
try:
|
try:
|
||||||
stripe.oauth.OAuth.deauthorize(stripe_user_id=account.id)
|
stripe.oauth.OAuth.deauthorize(stripe_user_id=account.id)
|
||||||
except stripe.oauth_error.InvalidClientError as e:
|
except stripe.oauth_error.InvalidClientError as e:
|
||||||
@ -190,17 +190,41 @@ subhead = _("Payment Processors")
|
|||||||
% endif
|
% endif
|
||||||
</p>
|
</p>
|
||||||
<p class="text-muted">{{ _("Added on {date}", date=account.connection_ts.date()) }}</p>
|
<p class="text-muted">{{ _("Added on {date}", date=account.connection_ts.date()) }}</p>
|
||||||
% if not account.charges_enabled
|
% if account.independent
|
||||||
<p class="text-warning">{{ icon('exclamation-sign') }} {{ _(
|
% if not account.charges_enabled
|
||||||
"This account cannot receive payments. To fix this, log in to the "
|
<p class="text-warning">{{ icon('exclamation-sign') }} {{ _(
|
||||||
"account and complete the verification process. After that, reconnect "
|
"This account cannot receive payments. To fix this, log in to the "
|
||||||
"the account if you still see this message."
|
"account and complete the verification process. After that, reconnect "
|
||||||
) }}</p>
|
"the account if you still see this message."
|
||||||
|
) }}</p>
|
||||||
|
% endif
|
||||||
|
<a href="https://dashboard.stripe.com/{{ account.id }}" target="_blank" rel="noopener noreferrer">{{
|
||||||
|
icon("external-link") }} {{ _(
|
||||||
|
"Manage this {platform} account", platform="Stripe"
|
||||||
|
) }}</a>
|
||||||
|
% elif account.details_submitted
|
||||||
|
% if not account.charges_enabled
|
||||||
|
<p class="text-warning">{{ icon('exclamation-sign') }} {{ _(
|
||||||
|
"This account cannot receive payments. To fix this, click "
|
||||||
|
"on the link below and complete the verification process."
|
||||||
|
) }}</p>
|
||||||
|
% endif
|
||||||
|
<a href="{{ participant.path('payment/stripe/manage?sn=%s' % account.pk) }}">{{
|
||||||
|
icon("gear") }} {{ _(
|
||||||
|
"Manage this {platform} account", platform="Stripe"
|
||||||
|
) }}</a>
|
||||||
|
% else
|
||||||
|
% if not account.charges_enabled
|
||||||
|
<p class="text-warning">{{ icon('exclamation-sign') }} {{ _(
|
||||||
|
"This account cannot receive payments. To fix this, click "
|
||||||
|
"on the link below and complete the activation process."
|
||||||
|
) }}</p>
|
||||||
|
% endif
|
||||||
|
<a href="{{ participant.path('payment/stripe/onboard?sn=%s' % account.pk) }}">{{
|
||||||
|
icon("manual") }} {{ _(
|
||||||
|
"Activate this {platform} account", platform="Stripe"
|
||||||
|
) }}</a>
|
||||||
% endif
|
% endif
|
||||||
<a href="https://dashboard.stripe.com/{{ account.id }}" target="_blank" rel="noopener noreferrer">{{
|
|
||||||
icon("external-link") }} {{ _(
|
|
||||||
"Manage this {platform} account", platform="Stripe"
|
|
||||||
) }}</a>
|
|
||||||
</form>
|
</form>
|
||||||
% endfor
|
% endfor
|
||||||
</div>
|
</div>
|
||||||
|
146
www/%username/payment/stripe/create.spt
Normal file
146
www/%username/payment/stripe/create.spt
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import stripe
|
||||||
|
|
||||||
|
from liberapay.utils import get_participant
|
||||||
|
|
||||||
|
[---]
|
||||||
|
participant = get_participant(state, restrict=True, allow_member=False)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
if user != participant:
|
||||||
|
raise response.error(403)
|
||||||
|
country = request.body.get_choice('country', constants.PAYOUT_COUNTRIES['stripe'])
|
||||||
|
country_in_sepa = country in constants.SEPA
|
||||||
|
account_token_id = request.body['account_token']
|
||||||
|
if website.app_conf.stripe_secret_key.startswith('sk_test_'):
|
||||||
|
profile_url = f"https://fake.liberapay.com/{participant.username}"
|
||||||
|
else:
|
||||||
|
profile_url = participant.url()
|
||||||
|
account = stripe.Account.create(
|
||||||
|
account_token=account_token_id,
|
||||||
|
business_profile={
|
||||||
|
"url": profile_url,
|
||||||
|
},
|
||||||
|
controller={
|
||||||
|
"fees": {
|
||||||
|
"payer": "application",
|
||||||
|
},
|
||||||
|
"losses": {
|
||||||
|
"payments": "application",
|
||||||
|
},
|
||||||
|
"requirement_collection": "application",
|
||||||
|
"stripe_dashboard": {
|
||||||
|
"type": "none",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
capabilities={
|
||||||
|
"transfers": {"requested": True},
|
||||||
|
} if country_in_sepa else {
|
||||||
|
"card_payments": {"requested": True},
|
||||||
|
"transfers": {"requested": True},
|
||||||
|
},
|
||||||
|
country=country,
|
||||||
|
metadata={
|
||||||
|
"participant_id": str(participant.id),
|
||||||
|
},
|
||||||
|
settings={
|
||||||
|
"payouts": {
|
||||||
|
"debit_negative_balances": True,
|
||||||
|
"schedule": {
|
||||||
|
"interval": "manual",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
idempotency_key=f"create_{country}_account_for_{participant.id}",
|
||||||
|
)
|
||||||
|
independent = (
|
||||||
|
account.type == 'standard' or
|
||||||
|
account.controller.stripe_dashboard.type != "none"
|
||||||
|
)
|
||||||
|
if account.type == 'standard' or account.controller.losses.payments == 'stripe':
|
||||||
|
loss_taker = 'provider'
|
||||||
|
else:
|
||||||
|
loss_taker = 'platform'
|
||||||
|
serial_number = website.db.one("""
|
||||||
|
UPDATE payment_accounts
|
||||||
|
SET is_current = NULL
|
||||||
|
WHERE participant = %(p_id)s
|
||||||
|
AND provider = 'stripe'
|
||||||
|
AND country = %(country)s;
|
||||||
|
|
||||||
|
INSERT INTO payment_accounts
|
||||||
|
(participant, provider, country, id,
|
||||||
|
default_currency, charges_enabled, verified,
|
||||||
|
display_name, independent, loss_taker)
|
||||||
|
VALUES (%(p_id)s, 'stripe', %(country)s, %(account_id)s,
|
||||||
|
%(default_currency)s, %(charges_enabled)s, true,
|
||||||
|
%(display_name)s, %(independent)s, %(loss_taker)s)
|
||||||
|
ON CONFLICT (provider, id, participant) DO UPDATE
|
||||||
|
SET is_current = true
|
||||||
|
, country = excluded.country
|
||||||
|
, default_currency = excluded.default_currency
|
||||||
|
, charges_enabled = excluded.charges_enabled
|
||||||
|
, verified = true
|
||||||
|
, display_name = excluded.display_name
|
||||||
|
, independent = excluded.independent
|
||||||
|
, loss_taker = excluded.loss_taker
|
||||||
|
RETURNING pk;
|
||||||
|
""", dict(
|
||||||
|
p_id=participant.id,
|
||||||
|
country=country,
|
||||||
|
account_id=account.id,
|
||||||
|
default_currency=account.default_currency.upper(),
|
||||||
|
charges_enabled=account.charges_enabled,
|
||||||
|
display_name=account.settings.dashboard.display_name,
|
||||||
|
independent=independent,
|
||||||
|
loss_taker=loss_taker,
|
||||||
|
))
|
||||||
|
raise response.redirect(participant.path(
|
||||||
|
f'payment/stripe/onboard?sn={serial_number}'
|
||||||
|
))
|
||||||
|
|
||||||
|
title = _("Create a {provider} account", provider='Stripe')
|
||||||
|
|
||||||
|
[---] text/html
|
||||||
|
% extends "templates/layouts/settings.html"
|
||||||
|
|
||||||
|
% block content
|
||||||
|
<noscript><div class="alert alert-danger">{{ _("JavaScript is required") }}</div></noscript>
|
||||||
|
<output id="stripe-errors" class="alert alert-danger"></output>
|
||||||
|
|
||||||
|
<form action="javascript:" method="POST" id="stripe"
|
||||||
|
data-before-submit="call:Liberapay.stripe_before_account_submit"
|
||||||
|
data-msg-submitting="{{ _('Request in progress, please wait…') }}"
|
||||||
|
data-stripe-pk="{{ website.app_conf.stripe_publishable_key }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||||
|
<p>{{ _(
|
||||||
|
"Please select the country you live in or have a registered business "
|
||||||
|
"in, and confirm that you agree to Stripe's terms."
|
||||||
|
) }}</p>
|
||||||
|
<div class="form-group form-inline">
|
||||||
|
<select name="country" class="form-control country" required>
|
||||||
|
% set country = user.guessed_country
|
||||||
|
% if country not in constants.PAYOUT_COUNTRIES['stripe']
|
||||||
|
<option></option>
|
||||||
|
% endif
|
||||||
|
% for code, name in locale.countries.items() if code in constants.PAYOUT_COUNTRIES['stripe']
|
||||||
|
<option value="{{ code }}" {{ 'selected' if code == country }}>{{ name }}</option>
|
||||||
|
% endfor
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p class="form-group"><label>
|
||||||
|
<input type="checkbox" name="tos_shown_and_accepted" value="true" required />
|
||||||
|
{{ _("I agree to the {link_start}Stripe Connected Account Agreement{link_end}.",
|
||||||
|
link_start='<a href="https://stripe.com/connect-account/legal" target="_blank" rel="noopener noreferrer">'|safe,
|
||||||
|
link_end='</a>'|safe) }}
|
||||||
|
</label></p>
|
||||||
|
<div class="buttons">
|
||||||
|
<button class="btn btn-primary">{{ _("Create account") }}</button>
|
||||||
|
<a class="btn btn-default" href="{{ participant.path('payment/') }}">{{ _("Go back") }}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
% endblock
|
||||||
|
|
||||||
|
% block scripts
|
||||||
|
% do response.csp_allow_stripe()
|
||||||
|
<script src="https://js.stripe.com/v3/"></script>
|
||||||
|
% endblock
|
106
www/%username/payment/stripe/manage.spt
Normal file
106
www/%username/payment/stripe/manage.spt
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import stripe
|
||||||
|
|
||||||
|
from liberapay.utils import get_participant
|
||||||
|
|
||||||
|
[---]
|
||||||
|
participant = get_participant(state, restrict=True, allow_member=False)
|
||||||
|
user.require_write_permission()
|
||||||
|
if user != participant:
|
||||||
|
raise response.error(403)
|
||||||
|
|
||||||
|
payment_account = website.db.one("""
|
||||||
|
SELECT *
|
||||||
|
FROM payment_accounts
|
||||||
|
WHERE participant = %s
|
||||||
|
AND pk = %s
|
||||||
|
""", (participant.id, request.qs.get_int('sn')))
|
||||||
|
if not payment_account:
|
||||||
|
raise response.error(400, "invalid `sn` value in querystring")
|
||||||
|
|
||||||
|
if not payment_account.details_submitted:
|
||||||
|
account = stripe.Account.retrieve(payment_account.id)
|
||||||
|
if account.details_submitted:
|
||||||
|
payment_account = website.db.one("""
|
||||||
|
UPDATE payment_accounts
|
||||||
|
SET details_submitted = true
|
||||||
|
, charges_enabled = %s
|
||||||
|
, display_name = %s
|
||||||
|
WHERE pk = %s
|
||||||
|
RETURNING *
|
||||||
|
""", (
|
||||||
|
account.charges_enabled,
|
||||||
|
account.settings.dashboard.display_name,
|
||||||
|
payment_account.pk
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
response.redirect(participant.path(f'payment/stripe/onboard?sn={payment_account.pk}'))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
allow_payout = bool(user.has_privilege('admin')) or payment_account.allow_payout
|
||||||
|
account_session = stripe.AccountSession.create(
|
||||||
|
account=payment_account.id,
|
||||||
|
components={
|
||||||
|
"account_management": {
|
||||||
|
"enabled": True,
|
||||||
|
"features": {
|
||||||
|
"disable_stripe_user_authentication": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"notification_banner": {
|
||||||
|
"enabled": True,
|
||||||
|
"features": {
|
||||||
|
"disable_stripe_user_authentication": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"payments": {
|
||||||
|
"enabled": True,
|
||||||
|
"features": {
|
||||||
|
"capture_payments": False,
|
||||||
|
"dispute_management": False,
|
||||||
|
"refund_management": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"payouts": {
|
||||||
|
"enabled": True,
|
||||||
|
"features": {
|
||||||
|
"disable_stripe_user_authentication": True,
|
||||||
|
"edit_payout_schedule": allow_payout,
|
||||||
|
"instant_payouts": allow_payout,
|
||||||
|
"standard_payouts": allow_payout,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raise response.json({"client_secret": account_session.client_secret})
|
||||||
|
|
||||||
|
title = _("Manage a {provider} account", provider='Stripe')
|
||||||
|
|
||||||
|
[---] text/html
|
||||||
|
% extends "templates/layouts/settings.html"
|
||||||
|
|
||||||
|
% block content
|
||||||
|
<noscript><p class="alert alert-danger">{{ _("JavaScript is required") }}</p></noscript>
|
||||||
|
<div id="stripe-notification"></div>
|
||||||
|
<nav id="stripe-component-nav" class="hidden">
|
||||||
|
<ul class="nav nav-pills">
|
||||||
|
<li><a data-component="account-management" href="javascript:">{{ _("Account") }}</a></li>
|
||||||
|
<li><a data-component="payments" href="javascript:">{{ _("Payments") }}</a></li>
|
||||||
|
<li><a data-component="payouts" href="javascript:">{{ _("Payouts") }}</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<br>
|
||||||
|
<div id="stripe-connect" data-csrf-token="{{ csrf_token }}"
|
||||||
|
data-msg-stripe-missing='{{ _(
|
||||||
|
"The initialization of a required component has failed. If you use a "
|
||||||
|
"browser extension that blocks requests, for example NoScript, please "
|
||||||
|
"make sure it’s allowing requests to the “stripe.com” domain."
|
||||||
|
) }}'
|
||||||
|
data-stripe-pub-key="{{ website.app_conf.stripe_publishable_key }}"></div>
|
||||||
|
<br><br>
|
||||||
|
<a class="btn btn-default" href="{{ participant.path('payment') }}">{{ _("Go back") }}</a>
|
||||||
|
% endblock
|
||||||
|
|
||||||
|
% block scripts
|
||||||
|
% do response.csp_allow_stripe_connect()
|
||||||
|
<script src="https://connect-js.stripe.com/v1.0/connect.js"></script>
|
||||||
|
% endblock
|
61
www/%username/payment/stripe/onboard.spt
Normal file
61
www/%username/payment/stripe/onboard.spt
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import stripe
|
||||||
|
|
||||||
|
from liberapay.utils import get_participant
|
||||||
|
|
||||||
|
[---]
|
||||||
|
participant = get_participant(state, restrict=True, allow_member=False)
|
||||||
|
user.require_write_permission()
|
||||||
|
if user != participant:
|
||||||
|
raise response.error(403)
|
||||||
|
|
||||||
|
payment_account = website.db.one("""
|
||||||
|
SELECT *
|
||||||
|
FROM payment_accounts
|
||||||
|
WHERE participant = %s
|
||||||
|
AND pk = %s
|
||||||
|
""", (participant.id, request.qs.get_int('sn')))
|
||||||
|
if not payment_account:
|
||||||
|
raise response.error(400, "invalid `sn` value in querystring")
|
||||||
|
|
||||||
|
account = stripe.Account.retrieve(payment_account.id)
|
||||||
|
if account.details_submitted:
|
||||||
|
response.redirect(participant.path(f'payment/stripe/manage?sn={payment_account.pk}'))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
account_session = stripe.AccountSession.create(
|
||||||
|
account=payment_account.id,
|
||||||
|
components={
|
||||||
|
"account_onboarding": {
|
||||||
|
"enabled": True,
|
||||||
|
"features": {
|
||||||
|
"disable_stripe_user_authentication": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raise response.json({"client_secret": account_session.client_secret})
|
||||||
|
|
||||||
|
title = _("Activate a {provider} account", provider='Stripe')
|
||||||
|
|
||||||
|
[---] text/html
|
||||||
|
% from 'templates/macros/nav.html' import querystring_nav with context
|
||||||
|
|
||||||
|
% extends "templates/layouts/settings.html"
|
||||||
|
|
||||||
|
% block content
|
||||||
|
<noscript><p class="alert alert-danger">{{ _("JavaScript is required") }}</p></noscript>
|
||||||
|
<div id="stripe-connect" data-csrf-token="{{ csrf_token }}"
|
||||||
|
data-msg-stripe-missing='{{ _(
|
||||||
|
"The initialization of a required component has failed. If you use a "
|
||||||
|
"browser extension that blocks requests, for example NoScript, please "
|
||||||
|
"make sure it’s allowing requests to the “stripe.com” domain."
|
||||||
|
) }}'
|
||||||
|
data-stripe-pub-key="{{ website.app_conf.stripe_publishable_key }}"></div>
|
||||||
|
<br><br>
|
||||||
|
<a class="btn btn-default" href="{{ participant.path('payment') }}">{{ _("Go back") }}</a>
|
||||||
|
% endblock
|
||||||
|
|
||||||
|
% block scripts
|
||||||
|
% do response.csp_allow_stripe_connect()
|
||||||
|
<script src="https://connect-js.stripe.com/v1.0/connect.js"></script>
|
||||||
|
% endblock
|
@ -52,7 +52,7 @@ title = _("Add a payment instrument")
|
|||||||
<noscript><div class="alert alert-danger">{{ _("JavaScript is required") }}</div></noscript>
|
<noscript><div class="alert alert-danger">{{ _("JavaScript is required") }}</div></noscript>
|
||||||
|
|
||||||
<form action="javascript:" method="POST" id="stripe"
|
<form action="javascript:" method="POST" id="stripe"
|
||||||
data-before-submit="call:Liberapay.stripe_before_submit"
|
data-before-submit="call:Liberapay.stripe_before_element_submit"
|
||||||
data-msg-stripe-missing='{{ _(
|
data-msg-stripe-missing='{{ _(
|
||||||
"The initialization of a required component has failed. If you use a "
|
"The initialization of a required component has failed. If you use a "
|
||||||
"browser extension that blocks requests, for example NoScript, please "
|
"browser extension that blocks requests, for example NoScript, please "
|
||||||
|
@ -130,6 +130,8 @@ elif 'state' in request.qs:
|
|||||||
charges_enabled=None,
|
charges_enabled=None,
|
||||||
display_name=user_info['name'],
|
display_name=user_info['name'],
|
||||||
token=token_response_data,
|
token=token_response_data,
|
||||||
|
independent=True,
|
||||||
|
loss_taker='provider',
|
||||||
)
|
)
|
||||||
elif provider_name == 'stripe':
|
elif provider_name == 'stripe':
|
||||||
data_from_stripe = token_response.json()
|
data_from_stripe = token_response.json()
|
||||||
@ -139,6 +141,16 @@ elif 'state' in request.qs:
|
|||||||
))
|
))
|
||||||
account_id = data_from_stripe['stripe_user_id']
|
account_id = data_from_stripe['stripe_user_id']
|
||||||
account = stripe.Account.retrieve(account_id)
|
account = stripe.Account.retrieve(account_id)
|
||||||
|
independent = (
|
||||||
|
account.type == 'standard' or
|
||||||
|
account.controller.stripe_dashboard.type != "none"
|
||||||
|
)
|
||||||
|
assert independent
|
||||||
|
if account.type == 'standard' or account.controller.losses.payments == 'stripe':
|
||||||
|
loss_taker = 'provider'
|
||||||
|
else:
|
||||||
|
loss_taker = 'platform'
|
||||||
|
assert loss_taker == 'provider'
|
||||||
account_data.update(
|
account_data.update(
|
||||||
country=account.country,
|
country=account.country,
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
@ -146,6 +158,8 @@ elif 'state' in request.qs:
|
|||||||
charges_enabled=account.charges_enabled,
|
charges_enabled=account.charges_enabled,
|
||||||
display_name=account.settings.dashboard.display_name,
|
display_name=account.settings.dashboard.display_name,
|
||||||
token=None,
|
token=None,
|
||||||
|
independent=independent,
|
||||||
|
loss_taker=loss_taker,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError(provider_name)
|
raise ValueError(provider_name)
|
||||||
@ -180,10 +194,10 @@ elif 'state' in request.qs:
|
|||||||
INSERT INTO payment_accounts
|
INSERT INTO payment_accounts
|
||||||
(participant, provider, country, id,
|
(participant, provider, country, id,
|
||||||
default_currency, charges_enabled, verified,
|
default_currency, charges_enabled, verified,
|
||||||
display_name, token)
|
display_name, token, independent, loss_taker)
|
||||||
VALUES (%(p_id)s, %(provider)s, %(country)s, %(account_id)s,
|
VALUES (%(p_id)s, %(provider)s, %(country)s, %(account_id)s,
|
||||||
%(default_currency)s, %(charges_enabled)s, true,
|
%(default_currency)s, %(charges_enabled)s, true,
|
||||||
%(display_name)s, %(token)s)
|
%(display_name)s, %(token)s, %(independent)s, %(loss_taker)s)
|
||||||
ON CONFLICT (provider, id, participant) DO UPDATE
|
ON CONFLICT (provider, id, participant) DO UPDATE
|
||||||
SET is_current = true
|
SET is_current = true
|
||||||
, country = excluded.country
|
, country = excluded.country
|
||||||
@ -192,7 +206,9 @@ elif 'state' in request.qs:
|
|||||||
, verified = true
|
, verified = true
|
||||||
, authorized = true
|
, authorized = true
|
||||||
, display_name = excluded.display_name
|
, display_name = excluded.display_name
|
||||||
, token = excluded.token;
|
, token = excluded.token
|
||||||
|
, independent = excluded.independent
|
||||||
|
, loss_taker = excluded.loss_taker;
|
||||||
""", account_data)
|
""", account_data)
|
||||||
|
|
||||||
response.erase_cookie(cookie_name)
|
response.erase_cookie(cookie_name)
|
||||||
|
@ -32,15 +32,19 @@ if request.method == 'POST':
|
|||||||
AND country = %(country)s;
|
AND country = %(country)s;
|
||||||
|
|
||||||
INSERT INTO payment_accounts
|
INSERT INTO payment_accounts
|
||||||
(participant, provider, country, id, verified)
|
(participant, provider, country, id, verified,
|
||||||
VALUES (%(p_id)s, 'paypal', %(country)s, %(account_id)s, %(verified)s)
|
independent, loss_taker)
|
||||||
|
VALUES (%(p_id)s, 'paypal', %(country)s, %(account_id)s, %(verified)s,
|
||||||
|
true, 'provider')
|
||||||
ON CONFLICT (provider, id, participant) DO UPDATE
|
ON CONFLICT (provider, id, participant) DO UPDATE
|
||||||
SET is_current = true
|
SET is_current = true
|
||||||
, country = excluded.country
|
, country = excluded.country
|
||||||
, default_currency = excluded.default_currency
|
, default_currency = excluded.default_currency
|
||||||
, charges_enabled = excluded.charges_enabled
|
, charges_enabled = excluded.charges_enabled
|
||||||
, display_name = excluded.display_name
|
, display_name = excluded.display_name
|
||||||
, token = excluded.token;
|
, token = excluded.token
|
||||||
|
, independent = excluded.independent
|
||||||
|
, loss_taker = excluded.loss_taker;
|
||||||
""", account_data)
|
""", account_data)
|
||||||
response.redirect(participant.path('payment'))
|
response.redirect(participant.path('payment'))
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user