Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use the PaymenMethod API for SEPA Direct Debit #2376

Merged
merged 1 commit into from
May 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 58 additions & 53 deletions js/stripe.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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;
Expand Down Expand Up @@ -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 = $('<input type="hidden" name="token">');
$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 = $('<input type="hidden" name="stripe_pm_id">');
$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 = $('<input type="hidden" name="stripe_pm_id">');
$hidden_input.val(result.paymentMethod.id);
$form.append($hidden_input);
$form.submit();
}
}));
}));
$form.attr('action', '');
};
Expand Down
38 changes: 35 additions & 3 deletions liberapay/models/exchange_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down
11 changes: 8 additions & 3 deletions liberapay/payin/stripe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(),
Expand Down
5 changes: 5 additions & 0 deletions www/%username/giving/pay/stripe/%payin_id.spt
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,7 @@ title = _("Funding your donations")
</div>
% endif

<input type="hidden" name="owner.email" value="{{ payer.get_email_address() }}" />
% if payment_type == 'card'
<fieldset id="card-form" class="form-group {{ 'hidden' if routes else '' }}">
<p>{{ _("Please input your name and card number:") }}</p>
Expand Down Expand Up @@ -576,6 +577,10 @@ title = _("Funding your donations")
% endif

<br>
<output class="alert alert-danger hidden msg-postal-address-required">{{ _(
"Please fill in your postal address. It's required because the IBAN "
"you've provided emanates from outside the European Union."
) }}</output>
<button class="btn btn-primary btn-lg btn-block">{{ _(
"Initiate the payment"
) }}</button>
Expand Down
20 changes: 5 additions & 15 deletions www/%username/routes/add.spt
Original file line number Diff line number Diff line change
Expand Up @@ -15,33 +15,19 @@ 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 = {
'email': participant.get_email_address(),
'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}”.",
Expand Down Expand Up @@ -143,6 +129,10 @@ title = _("Add a payment instrument")
postal_address_form_v2(saved=identity.get('postal_address'), required=False)
}}</div>
<br>
<output class="alert alert-danger hidden msg-postal-address-required">{{ _(
"Please fill in your postal address. It's required because the IBAN "
"you've provided emanates from outside the European Union."
) }}</output>
<button class="btn btn-primary btn-lg">{{ _("Save") }}</button>
</form>

Expand Down
Loading