Skip to content

Commit

Permalink
implement limiting where donations can come from
Browse files Browse the repository at this point in the history
closes #1102
  • Loading branch information
Changaco committed May 5, 2024
1 parent 8c0ceb1 commit 1860d37
Show file tree
Hide file tree
Showing 13 changed files with 168 additions and 10 deletions.
25 changes: 25 additions & 0 deletions liberapay/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,31 @@ def msg(self, _):
)


class ProhibitedSourceCountry(LazyResponseXXX):
code = 403

def __init__(self, recipient, country):
super().__init__()
self.recipient = recipient
self.country = country

def msg(self, _, locale):
return _(
"{username} does not accept donations from {country}.",
username=self.recipient.username, country=locale.Country(self.country)
)


class UnableToDeterminePayerCountry(LazyResponseXXX):
code = 500
def msg(self, _):
return _(
"The processing of your payment has failed because our software was "
"unable to determine which country the money would come from. This "
"isn't supposed to happen."
)


class TooManyCurrencyChanges(LazyResponseXXX):
code = 429
def msg(self, _):
Expand Down
2 changes: 1 addition & 1 deletion liberapay/i18n/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -421,8 +421,8 @@ def make_sorted_dict(keys, d, d2={}, clean=_return_):
SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR
TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW
""".split()

COUNTRIES = make_sorted_dict(COUNTRY_CODES, LOCALE_EN.territories)
del COUNTRY_CODES


def make_currencies_map():
Expand Down
13 changes: 11 additions & 2 deletions liberapay/models/participant.py
Original file line number Diff line number Diff line change
Expand Up @@ -1740,17 +1740,26 @@ def send_newsletters(cls):

@cached_property
def recipient_settings(self):
return self.db.one("""
r = self.db.one("""
SELECT *
FROM recipient_settings
WHERE participant = %s
""", (self.id,), default=Object(
participant=self.id,
patron_visibilities=(7 if self.status == 'stub' else 0),
patron_countries=None,
))
if r.patron_countries:
if r.patron_countries.startswith('-'):
r.patron_countries = set(i18n.COUNTRIES) - set(r.patron_countries[1:].split(','))
else:
r.patron_countries = set(r.patron_countries.split(','))
return r

def update_recipient_settings(self, **kw):
cols, vals = zip(*kw.items())
new_recipient_settings = dict(self.recipient_settings.__dict__, **kw)
new_recipient_settings.pop('participant')
cols, vals = zip(*new_recipient_settings.items())
updates = ','.join('{0}=excluded.{0}'.format(col) for col in cols)
cols = ', '.join(cols)
placeholders = ', '.join(['%s']*len(vals))
Expand Down
10 changes: 9 additions & 1 deletion liberapay/payin/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
from ..constants import SEPA
from ..exceptions import (
AccountSuspended, BadDonationCurrency, MissingPaymentAccount,
RecipientAccountSuspended, NoSelfTipping, UserDoesntAcceptTips,
NoSelfTipping, ProhibitedSourceCountry, RecipientAccountSuspended,
UnableToDeterminePayerCountry, UserDoesntAcceptTips,
)
from ..i18n.currencies import Money, MoneyBasket
from ..utils import group_by
Expand Down Expand Up @@ -58,6 +59,13 @@ def prepare_payin(db, payer, amount, route, proto_transfers, off_session=False):
if payer.is_suspended or not payer.get_email_address():
raise AccountSuspended()

for pt in proto_transfers:
if (allowed_countries := pt.recipient.recipient_settings.patron_countries):
if route.country not in allowed_countries:
if not route.country:
raise UnableToDeterminePayerCountry()
raise ProhibitedSourceCountry(pt.recipient, route.country)

with db.get_cursor() as cursor:
payin = cursor.one("""
INSERT INTO payins
Expand Down
32 changes: 31 additions & 1 deletion liberapay/payin/paypal.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
import requests
from pando.utils import utcnow

from ..exceptions import PaymentError
from ..exceptions import (
PaymentError, ProhibitedSourceCountry, UnableToDeterminePayerCountry,
)
from ..i18n.currencies import Money
from ..website import website
from .common import (
Expand Down Expand Up @@ -181,6 +183,34 @@ def capture_order(db, payin):
Doc: https://developer.paypal.com/docs/api/orders/v2/#orders_capture
"""
# Check the country the payment is coming from, if a recipient cares
limited_recipients = db.all("""
SELECT recipient_p
FROM payin_transfers pt
LEFT JOIN recipient_settings rs ON rs.participant = pt.recipient
JOIN participants recipient_p ON recipient_p.id = pt.recipient
WHERE pt.payin = %s
AND rs.patron_countries IS NOT NULL
""", (payin.id,))
if limited_recipients:
url = 'https://api.%s/v2/checkout/orders/%s' % (
website.app_conf.paypal_domain, payin.remote_id
)
response = _init_session().get(url)
if response.status_code != 200:
return payin
order = response.json()
payer_country = order.get('payer', {}).get('address', {}).get('country_code')
if not payer_country:
raise UnableToDeterminePayerCountry()
for recipient in limited_recipients:
if (allowed_countries := recipient.recipient_settings.patron_countries):
if payer_country not in allowed_countries:
state = website.state.get()
_, locale = state['_'], state['locale']
error = ProhibitedSourceCountry(recipient, payer_country).msg(_, locale)
return abort_payin(db, payin, error)
# Ask PayPal to settle the payment
url = 'https://api.%s/v2/checkout/orders/%s/capture' % (
website.app_conf.paypal_domain, payin.remote_id
)
Expand Down
1 change: 1 addition & 0 deletions sql/branch.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE recipient_settings ADD COLUMN patron_countries text;
8 changes: 8 additions & 0 deletions style/base/columns.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
column-count: 2;
column-gap: 2ex;
}
.columns-sm-3 {
column-count: 3;
column-gap: 1.5ex;
}
}
@media (min-width: $screen-md-min) {
.columns-md-3 {
Expand All @@ -13,4 +17,8 @@
column-count: 4;
column-gap: 2ex;
}
.columns-md-5 {
column-count: 5;
column-gap: 1.5ex;
}
}
8 changes: 8 additions & 0 deletions style/base/lists.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
.checklist {
list-style: none;
padding-left: 0;
& > li {
padding-left: 0;
}
}

.right-pointing-arrows {
list-style-type: '';
padding-left: 2ex;
Expand Down
2 changes: 2 additions & 0 deletions templates/macros/nav.html
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
('/username', _("Name")),
('/avatar', _("Avatar")),
('/currencies', _("Currencies")),
('/countries', _("Countries")),
('/goal', _("Goal")),
('/statement', _("Descriptions")),
('/elsewhere', _("Linked Accounts")),
Expand Down Expand Up @@ -135,6 +136,7 @@
('/username', _("Name")),
('/avatar', _("Avatar")),
('/currencies', _("Currencies")),
('/countries', _("Countries")),
('/goal', _("Goal")),
('/statement', _("Descriptions")),
('/elsewhere', _("Linked Accounts")),
Expand Down
11 changes: 11 additions & 0 deletions templates/macros/your-tip.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@
% if request.qs.get('currency') in accepted_currencies
% set new_currency = request.qs['currency']
% endif
% if not tippee_is_stub
% set patron_countries = tippee.recipient_settings.patron_countries
% set source_country = request.source_country
% if patron_countries and source_country and source_country not in patron_countries
<p class="alert alert-warning">{{ _(
"It looks like you are in {country}. {username} does not accept "
"donations coming from that country.",
country=locale.Country(source_country), username=tippee_name,
) }}</p>
% endif
% endif
% set currency_mismatch = tip_currency not in accepted_currencies
% if tip.renewal_mode > 0 and not pledging
% if currency_mismatch
Expand Down
57 changes: 57 additions & 0 deletions www/%username/edit/countries.spt
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from liberapay.utils import form_post_success, get_participant

[---]
participant = get_participant(state, restrict=True, allow_member=True)

if request.method == 'POST':
accepted_countries, rejected_countries = [], []
for country_code in locale.countries:
if request.body.get('accepted_countries:' + country_code) == 'yes':
accepted_countries.append(country_code)
else:
rejected_countries.append(country_code)
if not accepted_countries:
raise response.error(400, _("You have to check at least one box."))
if not rejected_countries:
new_patron_countries = None
elif len(accepted_countries) > len(rejected_countries):
new_patron_countries = '-' + ','.join(rejected_countries)
else:
new_patron_countries = ','.join(accepted_countries)
participant.update_recipient_settings(patron_countries=new_patron_countries)
form_post_success(state)

accepted_countries = participant.recipient_settings.patron_countries
accept_all = accepted_countries is None

title = participant.username
subhead = _("Territories")

[---] text/html
% from "templates/macros/icons.html" import icon with context

% extends "templates/layouts/profile-edit.html"

% block form

<form action="" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />

<p>{{ _("Which countries should your donors be allowed to send you money from?") }}</p>

<p class="text-info">{{ icon('info-sign') }} {{ _(
"We recommend limiting the origins of donations only if you are required to by law."
) }}</p>

<ul class="columns-sm-3 columns-md-5 checklist">
% for country_code, country_name in locale.countries.items()
<li><label><input type="checkbox" name="accepted_countries:{{ country_code }}" value="yes"
{{ 'checked' if accept_all or country_code in accepted_countries else '' }} /> {{ country_name }}</label></li>
% endfor
</ul>

<br>
<button class="save btn btn-lg btn-success">{{ _("Save") }}</button>
</form>

% endblock
5 changes: 1 addition & 4 deletions www/%username/giving/pay/paypal/%payin_id.spt
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,7 @@ title = _("Funding your donations")
% elif status == 'failed'
<div class="alert alert-danger">
<h4>{{ _("Failure") }}</h4>
<p>{{ _(
"The payment processor {name} returned an error: “{error_message}”.",
name='PayPal', error_message=payin.error
) }}</p>
<p>{{ _("error message: {0}", payin.error) }}</p>
</div>
<p><a class="btn btn-primary" href="{{ payer.path('giving/pay') }}?retry={{ payin.id }}">{{ _("Try again") }}</a></p>
% elif status == 'pending'
Expand Down
4 changes: 3 additions & 1 deletion www/%username/ledger/index.spt
Original file line number Diff line number Diff line change
Expand Up @@ -281,8 +281,10 @@ if participant.join_time:
% if event['error'] == 'RECEIVING_PREFERENCE_MANDATES_MANUAL_ACTION'
{{ _("This payment must be manually approved by the recipient through {provider}'s website.",
provider='PayPal') }}
% else
% elif event['error'].isupper()
{{ _("PayPal status code: {0}", event['error']) }}
% else
{{ _("error message: {0}", event['error']) }}
% endif
% else
{{ _("error message: {0}", event['error']) }}
Expand Down

0 comments on commit 1860d37

Please sign in to comment.