Skip to content

Commit

Permalink
Merge pull request #2302 from liberapay/patron_countries
Browse files Browse the repository at this point in the history
Enable creators to choose which countries donations are allowed to come from
  • Loading branch information
Changaco authored May 7, 2024
2 parents 78e214b + 27fec00 commit 6c8e9b3
Show file tree
Hide file tree
Showing 21 changed files with 671 additions and 23 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
11 changes: 9 additions & 2 deletions liberapay/models/participant.py
Original file line number Diff line number Diff line change
Expand Up @@ -1740,14 +1740,21 @@ 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_visibilities=(7 if self.status == 'stub' else None),
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())
Expand Down
16 changes: 15 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,19 @@ def prepare_payin(db, payer, amount, route, proto_transfers, off_session=False):
if payer.is_suspended or not payer.get_email_address():
raise AccountSuspended()

if route.network == 'paypal':
# The country of origin check for PayPal payments is in the
# `liberapay.payin.paypal.capture_order` function.
pass
else:
for pt in proto_transfers:
if (allowed_countries := pt.recipient.recipient_settings.patron_countries):
if route.country not in allowed_countries:
if route.country:
raise ProhibitedSourceCountry(pt.recipient, route.country)
else:
raise UnableToDeterminePayerCountry()

with db.get_cursor() as cursor:
payin = cursor.one("""
INSERT INTO payins
Expand Down
33 changes: 32 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,35 @@ 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
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:
raise PaymentError('PayPal')
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)
error += " (error code: ProhibitedSourceCountry)"
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
3 changes: 3 additions & 0 deletions sql/branch.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE recipient_settings
ALTER COLUMN patron_visibilities DROP NOT NULL,
ADD COLUMN patron_countries text CHECK (patron_countries <> '');
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
1 change: 1 addition & 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
13 changes: 12 additions & 1 deletion 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 Expand Up @@ -228,7 +239,7 @@ <h5 class="list-group-item-heading">{{ _("Manual renewal") }}</h5>
% macro tip_visibility_choice(tippee_name, patron_visibilities, payment_providers, tip)
% set paypal_only = payment_providers == 2
% if paypal_only
% if patron_visibilities == 0
% if not patron_visibilities
% set patron_visibilities = 2
% elif patron_visibilities.__and__(1)
% set patron_visibilities = patron_visibilities.__xor__(1).__or__(2)
Expand Down
Loading

0 comments on commit 6c8e9b3

Please sign in to comment.