diff --git a/liberapay/exceptions.py b/liberapay/exceptions.py
index 0770e089f..6d26aa1d1 100644
--- a/liberapay/exceptions.py
+++ b/liberapay/exceptions.py
@@ -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, _):
diff --git a/liberapay/i18n/base.py b/liberapay/i18n/base.py
index 4c854e57d..188e5c972 100644
--- a/liberapay/i18n/base.py
+++ b/liberapay/i18n/base.py
@@ -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():
diff --git a/liberapay/models/participant.py b/liberapay/models/participant.py
index 49a124f9a..74c67da75 100644
--- a/liberapay/models/participant.py
+++ b/liberapay/models/participant.py
@@ -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())
diff --git a/liberapay/payin/common.py b/liberapay/payin/common.py
index ede57c9a2..1eb35d88d 100644
--- a/liberapay/payin/common.py
+++ b/liberapay/payin/common.py
@@ -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
@@ -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
diff --git a/liberapay/payin/paypal.py b/liberapay/payin/paypal.py
index 041502ed8..4f7163658 100644
--- a/liberapay/payin/paypal.py
+++ b/liberapay/payin/paypal.py
@@ -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 (
@@ -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
)
diff --git a/sql/branch.sql b/sql/branch.sql
new file mode 100644
index 000000000..72c605b64
--- /dev/null
+++ b/sql/branch.sql
@@ -0,0 +1,3 @@
+ALTER TABLE recipient_settings
+ ALTER COLUMN patron_visibilities DROP NOT NULL,
+ ADD COLUMN patron_countries text CHECK (patron_countries <> '');
diff --git a/style/base/columns.scss b/style/base/columns.scss
index 36a38a1b0..0b8b51ea3 100644
--- a/style/base/columns.scss
+++ b/style/base/columns.scss
@@ -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 {
@@ -13,4 +17,8 @@
column-count: 4;
column-gap: 2ex;
}
+ .columns-md-5 {
+ column-count: 5;
+ column-gap: 1.5ex;
+ }
}
diff --git a/style/base/lists.scss b/style/base/lists.scss
index 7a1a24cc8..f3120383e 100644
--- a/style/base/lists.scss
+++ b/style/base/lists.scss
@@ -1,3 +1,11 @@
+.checklist {
+ list-style: none;
+ padding-left: 0;
+ & > li {
+ padding-left: 0;
+ }
+}
+
.right-pointing-arrows {
list-style-type: '→ ';
padding-left: 2ex;
diff --git a/templates/macros/nav.html b/templates/macros/nav.html
index f8873e9ed..3b781fd5b 100644
--- a/templates/macros/nav.html
+++ b/templates/macros/nav.html
@@ -102,6 +102,7 @@
('/username', _("Name")),
('/avatar', _("Avatar")),
('/currencies', _("Currencies")),
+ ('/countries', _("Countries")),
('/goal', _("Goal")),
('/statement', _("Descriptions")),
('/elsewhere', _("Linked Accounts")),
diff --git a/templates/macros/your-tip.html b/templates/macros/your-tip.html
index 81759184d..a9c7b23aa 100644
--- a/templates/macros/your-tip.html
+++ b/templates/macros/your-tip.html
@@ -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
+
{{ _(
+ "It looks like you are in {country}. {username} does not accept "
+ "donations coming from that country.",
+ country=locale.Country(source_country), username=tippee_name,
+ ) }}
+ % endif
+ % endif
% set currency_mismatch = tip_currency not in accepted_currencies
% if tip.renewal_mode > 0 and not pledging
% if currency_mismatch
@@ -228,7 +239,7 @@ {{ _("Manual renewal") }}
% 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)
diff --git a/tests/py/fixtures/TestPayinsPayPal.yml b/tests/py/fixtures/TestPayinsPayPal.yml
index 0606debbe..113d510e6 100644
--- a/tests/py/fixtures/TestPayinsPayPal.yml
+++ b/tests/py/fixtures/TestPayinsPayPal.yml
@@ -32,8 +32,26 @@ interactions:
method: POST
uri: https://api.sandbox.paypal.com/v2/checkout/orders/91V21788MR556192E/capture
response:
- body: {string: "{\"id\":\"91V21788MR556192E\",\"intent\":\"CAPTURE\",\"purchase_units\":[{\"reference_id\":\"1\",\"amount\":{\"currency_code\":\"EUR\",\"value\":\"10.00\"},\"payee\":{\"email_address\":\"bob@example.com\",\"merchant_id\":\"JHLE5TP6LL2XG\"},\"description\":\"Liberapay
- donation to creator_2 | 1,000 weeks of \u20AC0.01\",\"custom_id\":\"1\",\"payments\":{\"captures\":[{\"id\":\"17C68461PR5228043\",\"status\":\"PENDING\",\"status_details\":{\"reason\":\"RECEIVING_PREFERENCE_MANDATES_MANUAL_ACTION\"},\"amount\":{\"currency_code\":\"EUR\",\"value\":\"10.00\"},\"final_capture\":true,\"seller_protection\":{\"status\":\"ELIGIBLE\",\"dispute_categories\":[\"ITEM_NOT_RECEIVED\",\"UNAUTHORIZED_TRANSACTION\"]},\"links\":[{\"href\":\"https://api.sandbox.paypal.com/v2/payments/captures/17C68461PR5228043\",\"rel\":\"self\",\"method\":\"GET\"},{\"href\":\"https://api.sandbox.paypal.com/v2/payments/captures/17C68461PR5228043/refund\",\"rel\":\"refund\",\"method\":\"POST\"},{\"href\":\"https://api.sandbox.paypal.com/v2/checkout/orders/91V21788MR556192E\",\"rel\":\"up\",\"method\":\"GET\"}],\"create_time\":\"2019-02-13T19:16:21Z\",\"update_time\":\"2019-02-13T19:16:21Z\"}]}}],\"payer\":{\"name\":{\"given_name\":\"test\",\"surname\":\"buyer\"},\"email_address\":\"admin-buyer@liberapay.com\",\"payer_id\":\"6C9EQBCEQY4MA\",\"phone\":{\"phone_number\":{\"national_number\":\"045-924-8496\"}},\"address\":{\"country_code\":\"FR\"}},\"create_time\":\"2019-02-13T19:16:21Z\",\"update_time\":\"2019-02-13T19:16:21Z\",\"links\":[{\"href\":\"https://api.sandbox.paypal.com/v2/checkout/orders/91V21788MR556192E\",\"rel\":\"self\",\"method\":\"GET\"}],\"status\":\"COMPLETED\"}"}
+ body: {string: "{\"id\":\"91V21788MR556192E\",\"intent\":\"CAPTURE\",\"purchase_units\"\
+ :[{\"reference_id\":\"1\",\"amount\":{\"currency_code\":\"EUR\",\"value\"\
+ :\"10.00\"},\"payee\":{\"email_address\":\"bob@example.com\",\"merchant_id\"\
+ :\"JHLE5TP6LL2XG\"},\"description\":\"Liberapay donation to creator_2 | 1,000\
+ \ weeks of \u20AC0.01\",\"custom_id\":\"1\",\"payments\":{\"captures\":[{\"\
+ id\":\"17C68461PR5228043\",\"status\":\"PENDING\",\"status_details\":{\"reason\"\
+ :\"RECEIVING_PREFERENCE_MANDATES_MANUAL_ACTION\"},\"amount\":{\"currency_code\"\
+ :\"EUR\",\"value\":\"10.00\"},\"final_capture\":true,\"seller_protection\"\
+ :{\"status\":\"ELIGIBLE\",\"dispute_categories\":[\"ITEM_NOT_RECEIVED\",\"\
+ UNAUTHORIZED_TRANSACTION\"]},\"links\":[{\"href\":\"https://api.sandbox.paypal.com/v2/payments/captures/17C68461PR5228043\"\
+ ,\"rel\":\"self\",\"method\":\"GET\"},{\"href\":\"https://api.sandbox.paypal.com/v2/payments/captures/17C68461PR5228043/refund\"\
+ ,\"rel\":\"refund\",\"method\":\"POST\"},{\"href\":\"https://api.sandbox.paypal.com/v2/checkout/orders/91V21788MR556192E\"\
+ ,\"rel\":\"up\",\"method\":\"GET\"}],\"create_time\":\"2019-02-13T19:16:21Z\"\
+ ,\"update_time\":\"2019-02-13T19:16:21Z\"}]}}],\"payer\":{\"name\":{\"given_name\"\
+ :\"test\",\"surname\":\"buyer\"},\"email_address\":\"admin-buyer@liberapay.com\"\
+ ,\"payer_id\":\"6C9EQBCEQY4MA\",\"phone\":{\"phone_number\":{\"national_number\"\
+ :\"045-924-8496\"}},\"address\":{\"country_code\":\"FR\"}},\"create_time\"\
+ :\"2019-02-13T19:16:21Z\",\"update_time\":\"2019-02-13T19:16:21Z\",\"links\"\
+ :[{\"href\":\"https://api.sandbox.paypal.com/v2/checkout/orders/91V21788MR556192E\"\
+ ,\"rel\":\"self\",\"method\":\"GET\"}],\"status\":\"COMPLETED\"}"}
headers:
Connection: [close]
Content-Length: ['1474']
@@ -84,8 +102,26 @@ interactions:
method: GET
uri: https://api.sandbox.paypal.com/v2/checkout/orders/91V21788MR556192E
response:
- body: {string: "{\"id\":\"91V21788MR556192E\",\"intent\":\"CAPTURE\",\"purchase_units\":[{\"reference_id\":\"1\",\"amount\":{\"currency_code\":\"EUR\",\"value\":\"10.00\"},\"payee\":{\"email_address\":\"bob@example.com\",\"merchant_id\":\"JHLE5TP6LL2XG\",\"display_data\":{\"brand_name\":\"Liberapay\"}},\"description\":\"Liberapay
- donation to creator_2 | 1,000 weeks of \u20AC0.01\",\"custom_id\":\"1\",\"soft_descriptor\":\"Liberapay\",\"payments\":{\"captures\":[{\"id\":\"17C68461PR5228043\",\"status\":\"PENDING\",\"status_details\":{\"reason\":\"RECEIVING_PREFERENCE_MANDATES_MANUAL_ACTION\"},\"amount\":{\"currency_code\":\"EUR\",\"value\":\"10.00\"},\"final_capture\":true,\"seller_protection\":{\"status\":\"ELIGIBLE\",\"dispute_categories\":[\"ITEM_NOT_RECEIVED\",\"UNAUTHORIZED_TRANSACTION\"]},\"links\":[{\"href\":\"https://api.sandbox.paypal.com/v2/payments/captures/17C68461PR5228043\",\"rel\":\"self\",\"method\":\"GET\"},{\"href\":\"https://api.sandbox.paypal.com/v2/payments/captures/17C68461PR5228043/refund\",\"rel\":\"refund\",\"method\":\"POST\"},{\"href\":\"https://api.sandbox.paypal.com/v2/checkout/orders/91V21788MR556192E\",\"rel\":\"up\",\"method\":\"GET\"}],\"create_time\":\"2019-02-13T19:16:21Z\",\"update_time\":\"2019-02-13T19:16:21Z\"}]}}],\"payer\":{\"name\":{\"given_name\":\"test\",\"surname\":\"buyer\"},\"email_address\":\"admin-buyer@liberapay.com\",\"payer_id\":\"6C9EQBCEQY4MA\",\"address\":{\"country_code\":\"FR\"}},\"create_time\":\"2019-02-13T19:14:47Z\",\"update_time\":\"2019-02-13T19:16:21Z\",\"links\":[{\"href\":\"https://api.sandbox.paypal.com/v2/checkout/orders/91V21788MR556192E\",\"rel\":\"self\",\"method\":\"GET\"}],\"status\":\"COMPLETED\"}"}
+ body: {string: "{\"id\":\"91V21788MR556192E\",\"intent\":\"CAPTURE\",\"purchase_units\"\
+ :[{\"reference_id\":\"1\",\"amount\":{\"currency_code\":\"EUR\",\"value\"\
+ :\"10.00\"},\"payee\":{\"email_address\":\"bob@example.com\",\"merchant_id\"\
+ :\"JHLE5TP6LL2XG\",\"display_data\":{\"brand_name\":\"Liberapay\"}},\"description\"\
+ :\"Liberapay donation to creator_2 | 1,000 weeks of \u20AC0.01\",\"custom_id\"\
+ :\"1\",\"soft_descriptor\":\"Liberapay\",\"payments\":{\"captures\":[{\"id\"\
+ :\"17C68461PR5228043\",\"status\":\"PENDING\",\"status_details\":{\"reason\"\
+ :\"RECEIVING_PREFERENCE_MANDATES_MANUAL_ACTION\"},\"amount\":{\"currency_code\"\
+ :\"EUR\",\"value\":\"10.00\"},\"final_capture\":true,\"seller_protection\"\
+ :{\"status\":\"ELIGIBLE\",\"dispute_categories\":[\"ITEM_NOT_RECEIVED\",\"\
+ UNAUTHORIZED_TRANSACTION\"]},\"links\":[{\"href\":\"https://api.sandbox.paypal.com/v2/payments/captures/17C68461PR5228043\"\
+ ,\"rel\":\"self\",\"method\":\"GET\"},{\"href\":\"https://api.sandbox.paypal.com/v2/payments/captures/17C68461PR5228043/refund\"\
+ ,\"rel\":\"refund\",\"method\":\"POST\"},{\"href\":\"https://api.sandbox.paypal.com/v2/checkout/orders/91V21788MR556192E\"\
+ ,\"rel\":\"up\",\"method\":\"GET\"}],\"create_time\":\"2019-02-13T19:16:21Z\"\
+ ,\"update_time\":\"2019-02-13T19:16:21Z\"}]}}],\"payer\":{\"name\":{\"given_name\"\
+ :\"test\",\"surname\":\"buyer\"},\"email_address\":\"admin-buyer@liberapay.com\"\
+ ,\"payer_id\":\"6C9EQBCEQY4MA\",\"address\":{\"country_code\":\"FR\"}},\"\
+ create_time\":\"2019-02-13T19:14:47Z\",\"update_time\":\"2019-02-13T19:16:21Z\"\
+ ,\"links\":[{\"href\":\"https://api.sandbox.paypal.com/v2/checkout/orders/91V21788MR556192E\"\
+ ,\"rel\":\"self\",\"method\":\"GET\"}],\"status\":\"COMPLETED\"}"}
headers:
Connection: [close]
Content-Length: ['1486']
@@ -100,4 +136,170 @@ interactions:
Vary: [Authorization]
paypal-debug-id: [3d5f5d06b432b, 3d5f5d06b432b]
status: {code: 200, message: OK}
+- request:
+ body: '{"intent": "CAPTURE", "application_context": {"brand_name": "Liberapay",
+ "cancel_url": "http://localhost/donor/giving/pay/paypal/1?cancel", "locale":
+ "en-US", "landing_page": "BILLING", "shipping_preference": "NO_SHIPPING", "user_action":
+ "PAY_NOW", "return_url": "http://localhost/donor/giving/pay/paypal/1"}, "purchase_units":
+ [{"amount": {"value": "10.00", "currency_code": "EUR"}, "custom_id": "1", "description":
+ "Liberapay donation to creator_2 | 1,000 weeks of \u20ac0.01", "payee": {"email_address":
+ "bob@example.com"}, "reference_id": "1", "soft_descriptor": "Liberapay"}]}'
+ headers: {}
+ method: POST
+ uri: https://api.sandbox.paypal.com/v2/checkout/orders
+ response:
+ body: {string: '{"id":"FFFFFFFFFFFFFFFFF","status":"CREATED","links":[{"href":"https://api.sandbox.paypal.com/v2/checkout/orders/FFFFFFFFFFFFFFFFF","rel":"self","method":"GET"},{"href":"https://www.sandbox.paypal.com/checkoutnow?token=FFFFFFFFFFFFFFFFF","rel":"approve","method":"GET"},{"href":"https://api.sandbox.paypal.com/v2/checkout/orders/FFFFFFFFFFFFFFFFF","rel":"update","method":"PATCH"},{"href":"https://api.sandbox.paypal.com/v2/checkout/orders/FFFFFFFFFFFFFFFFF/capture","rel":"capture","method":"POST"}]}'}
+ headers:
+ Access-Control-Expose-Headers: [Server-Timing]
+ Application_id: [APP-80W284485P519543T]
+ Cache-Control: ['max-age=0, no-cache, no-store, must-revalidate']
+ Caller_acct_num: [J6JDZYCYZPCXU]
+ Connection: [keep-alive]
+ Content-Length: ['501']
+ Content-Type: [application/json]
+ Date: ['Sun, 05 May 2024 09:52:01 GMT']
+ Paypal-Debug-Id: [0120dd42cdb2b]
+ Server-Timing: [traceparent;desc="00-00000000000000000000120dd42cdb2b-a4d6dd88cac2014d-01"]
+ Strict-Transport-Security: [max-age=31536000; includeSubDomains]
+ Vary: [Accept-Encoding]
+ status: {code: 201, message: Created}
+- request:
+ body: null
+ headers: {}
+ method: GET
+ uri: https://api.sandbox.paypal.com/v2/checkout/orders/FFFFFFFFFFFFFFFFF
+ response:
+ body: {string: "{\"id\":\"FFFFFFFFFFFFFFFFF\",\"intent\":\"CAPTURE\",\"status\"\
+ :\"APPROVED\",\"payment_source\":{\"paypal\":{\"email_address\":\"admin-buyer@liberapay.com\"\
+ ,\"account_id\":\"6C9EQBCEQY4MA\",\"account_status\":\"VERIFIED\",\"name\"\
+ :{\"given_name\":\"test\",\"surname\":\"buyer\"},\"address\":{\"country_code\"\
+ :\"FI\"}}},\"purchase_units\":[{\"reference_id\":\"1\",\"amount\":{\"currency_code\"\
+ :\"EUR\",\"value\":\"10.00\"},\"payee\":{\"email_address\":\"bob@example.com\"\
+ ,\"display_data\":{\"brand_name\":\"Liberapay\"}},\"description\":\"Liberapay\
+ \ donation to creator_2 | 1,000 weeks of \u20AC0.01\",\"custom_id\":\"1\"\
+ ,\"soft_descriptor\":\"Liberapay\"}],\"payer\":{\"name\":{\"given_name\":\"\
+ test\",\"surname\":\"buyer\"},\"email_address\":\"admin-buyer@liberapay.com\"\
+ ,\"payer_id\":\"6C9EQBCEQY4MA\",\"address\":{\"country_code\":\"FI\"}},\"\
+ create_time\":\"2024-05-05T09:52:00Z\",\"links\":[{\"href\":\"https://api.sandbox.paypal.com/v2/checkout/orders/FFFFFFFFFFFFFFFFF\"\
+ ,\"rel\":\"self\",\"method\":\"GET\"},{\"href\":\"https://api.sandbox.paypal.com/v2/checkout/orders/FFFFFFFFFFFFFFFFF\"\
+ ,\"rel\":\"update\",\"method\":\"PATCH\"},{\"href\":\"https://api.sandbox.paypal.com/v2/checkout/orders/FFFFFFFFFFFFFFFFF/capture\"\
+ ,\"rel\":\"capture\",\"method\":\"POST\"}]}"}
+ headers:
+ Access-Control-Expose-Headers: [Server-Timing]
+ Application_id: [APP-80W284485P519543T]
+ Cache-Control: ['max-age=0, no-cache, no-store, must-revalidate']
+ Caller_acct_num: [J6JDZYCYZPCXU]
+ Connection: [keep-alive]
+ Content-Length: ['1113']
+ Content-Type: [application/json]
+ Date: ['Sun, 05 May 2024 11:12:42 GMT']
+ Paypal-Debug-Id: [616f140566957]
+ Server-Timing: [traceparent;desc="00-0000000000000000000616f140566957-f6cc0fa8b57e0c06-01"]
+ Strict-Transport-Security: [max-age=31536000; includeSubDomains]
+ Vary: [Accept-Encoding]
+ status: {code: 200, message: OK}
+- request:
+ body: '{"intent": "CAPTURE", "application_context": {"brand_name": "Liberapay",
+ "cancel_url": "http://localhost/donor/giving/pay/paypal/1?cancel", "locale":
+ "en-US", "landing_page": "BILLING", "shipping_preference": "NO_SHIPPING", "user_action":
+ "PAY_NOW", "return_url": "http://localhost/donor/giving/pay/paypal/1"}, "purchase_units":
+ [{"amount": {"value": "10.00", "currency_code": "EUR"}, "custom_id": "1", "description":
+ "Liberapay donation to creator_2 | 1,000 weeks of \u20ac0.01", "payee": {"email_address":
+ "bob@example.com"}, "reference_id": "1", "soft_descriptor": "Liberapay"}]}'
+ headers: {}
+ method: POST
+ uri: https://api.sandbox.paypal.com/v2/checkout/orders
+ response:
+ body: {string: '{"id":"8UK19239YC952053V","status":"CREATED","links":[{"href":"https://api.sandbox.paypal.com/v2/checkout/orders/8UK19239YC952053V","rel":"self","method":"GET"},{"href":"https://www.sandbox.paypal.com/checkoutnow?token=8UK19239YC952053V","rel":"approve","method":"GET"},{"href":"https://api.sandbox.paypal.com/v2/checkout/orders/8UK19239YC952053V","rel":"update","method":"PATCH"},{"href":"https://api.sandbox.paypal.com/v2/checkout/orders/8UK19239YC952053V/capture","rel":"capture","method":"POST"}]}'}
+ headers:
+ Access-Control-Expose-Headers: [Server-Timing]
+ Application_id: [APP-80W284485P519543T]
+ Cache-Control: ['max-age=0, no-cache, no-store, must-revalidate']
+ Caller_acct_num: [J6JDZYCYZPCXU]
+ Connection: [keep-alive]
+ Content-Length: ['501']
+ Content-Type: [application/json]
+ Date: ['Sun, 05 May 2024 09:52:01 GMT']
+ Paypal-Debug-Id: [0120dd42cdb2b]
+ Server-Timing: [traceparent;desc="00-00000000000000000000120dd42cdb2b-a4d6dd88cac2014d-01"]
+ Strict-Transport-Security: [max-age=31536000; includeSubDomains]
+ Vary: [Accept-Encoding]
+ status: {code: 201, message: Created}
+- request:
+ body: null
+ headers: {}
+ method: GET
+ uri: https://api.sandbox.paypal.com/v2/checkout/orders/8UK19239YC952053V
+ response:
+ body: {string: "{\"id\":\"8UK19239YC952053V\",\"intent\":\"CAPTURE\",\"status\"\
+ :\"APPROVED\",\"payment_source\":{\"paypal\":{\"email_address\":\"admin-buyer@liberapay.com\"\
+ ,\"account_id\":\"6C9EQBCEQY4MA\",\"account_status\":\"VERIFIED\",\"name\"\
+ :{\"given_name\":\"test\",\"surname\":\"buyer\"},\"address\":{\"country_code\"\
+ :\"FR\"}}},\"purchase_units\":[{\"reference_id\":\"1\",\"amount\":{\"currency_code\"\
+ :\"EUR\",\"value\":\"10.00\"},\"payee\":{\"email_address\":\"bob@example.com\"\
+ ,\"display_data\":{\"brand_name\":\"Liberapay\"}},\"description\":\"Liberapay\
+ \ donation to creator_2 | 1,000 weeks of \u20AC0.01\",\"custom_id\":\"1\"\
+ ,\"soft_descriptor\":\"Liberapay\"}],\"payer\":{\"name\":{\"given_name\":\"\
+ test\",\"surname\":\"buyer\"},\"email_address\":\"admin-buyer@liberapay.com\"\
+ ,\"payer_id\":\"6C9EQBCEQY4MA\",\"address\":{\"country_code\":\"FR\"}},\"\
+ create_time\":\"2024-05-05T09:52:00Z\",\"links\":[{\"href\":\"https://api.sandbox.paypal.com/v2/checkout/orders/8UK19239YC952053V\"\
+ ,\"rel\":\"self\",\"method\":\"GET\"},{\"href\":\"https://api.sandbox.paypal.com/v2/checkout/orders/8UK19239YC952053V\"\
+ ,\"rel\":\"update\",\"method\":\"PATCH\"},{\"href\":\"https://api.sandbox.paypal.com/v2/checkout/orders/8UK19239YC952053V/capture\"\
+ ,\"rel\":\"capture\",\"method\":\"POST\"}]}"}
+ headers:
+ Access-Control-Expose-Headers: [Server-Timing]
+ Application_id: [APP-80W284485P519543T]
+ Cache-Control: ['max-age=0, no-cache, no-store, must-revalidate']
+ Caller_acct_num: [J6JDZYCYZPCXU]
+ Connection: [keep-alive]
+ Content-Length: ['1113']
+ Content-Type: [application/json]
+ Date: ['Sun, 05 May 2024 11:12:42 GMT']
+ Paypal-Debug-Id: [616f140566957]
+ Server-Timing: [traceparent;desc="00-0000000000000000000616f140566957-f6cc0fa8b57e0c06-01"]
+ Strict-Transport-Security: [max-age=31536000; includeSubDomains]
+ Vary: [Accept-Encoding]
+ status: {code: 200, message: OK}
+- request:
+ body: '{}'
+ headers: {}
+ method: POST
+ uri: https://api.sandbox.paypal.com/v2/checkout/orders/8UK19239YC952053V/capture
+ response:
+ body: {string: "{\"id\":\"8UK19239YC952053V\",\"intent\":\"CAPTURE\",\"status\"\
+ :\"COMPLETED\",\"payment_source\":{\"paypal\":{\"email_address\":\"admin-buyer@liberapay.com\"\
+ ,\"account_id\":\"6C9EQBCEQY4MA\",\"account_status\":\"VERIFIED\",\"name\"\
+ :{\"given_name\":\"test\",\"surname\":\"buyer\"},\"address\":{\"country_code\"\
+ :\"FR\"}}},\"purchase_units\":[{\"reference_id\":\"1\",\"amount\":{\"currency_code\"\
+ :\"EUR\",\"value\":\"10.00\"},\"payee\":{\"email_address\":\"bob@example.com\"\
+ ,\"merchant_id\":\"JHLE5TP6LL2XG\"},\"description\":\"Liberapay donation to\
+ \ creator_2 | 1,000 weeks of \u20AC0.01\",\"custom_id\":\"1\",\"payments\"\
+ :{\"captures\":[{\"id\":\"6RP19274J6003302M\",\"status\":\"PENDING\",\"status_details\"\
+ :{\"reason\":\"RECEIVING_PREFERENCE_MANDATES_MANUAL_ACTION\"},\"amount\":{\"\
+ currency_code\":\"EUR\",\"value\":\"10.00\"},\"final_capture\":true,\"seller_protection\"\
+ :{\"status\":\"ELIGIBLE\",\"dispute_categories\":[\"ITEM_NOT_RECEIVED\",\"\
+ UNAUTHORIZED_TRANSACTION\"]},\"custom_id\":\"1\",\"links\":[{\"href\":\"https://api.sandbox.paypal.com/v2/payments/captures/6RP19274J6003302M\"\
+ ,\"rel\":\"self\",\"method\":\"GET\"},{\"href\":\"https://api.sandbox.paypal.com/v2/payments/captures/6RP19274J6003302M/refund\"\
+ ,\"rel\":\"refund\",\"method\":\"POST\"},{\"href\":\"https://api.sandbox.paypal.com/v2/checkout/orders/8UK19239YC952053V\"\
+ ,\"rel\":\"up\",\"method\":\"GET\"}],\"create_time\":\"2024-05-05T11:12:43Z\"\
+ ,\"update_time\":\"2024-05-05T11:12:43Z\"}]}}],\"payer\":{\"name\":{\"given_name\"\
+ :\"test\",\"surname\":\"buyer\"},\"email_address\":\"admin-buyer@liberapay.com\"\
+ ,\"payer_id\":\"6C9EQBCEQY4MA\",\"address\":{\"country_code\":\"FR\"}},\"\
+ create_time\":\"2024-05-05T09:52:00Z\",\"update_time\":\"2024-05-05T11:12:43Z\"\
+ ,\"links\":[{\"href\":\"https://api.sandbox.paypal.com/v2/checkout/orders/8UK19239YC952053V\"\
+ ,\"rel\":\"self\",\"method\":\"GET\"}]}"}
+ headers:
+ Access-Control-Expose-Headers: [Server-Timing]
+ Application_id: [APP-80W284485P519543T]
+ Cache-Control: ['max-age=0, no-cache, no-store, must-revalidate']
+ Caller_acct_num: [J6JDZYCYZPCXU]
+ Connection: [keep-alive]
+ Content-Length: ['1640']
+ Content-Type: [application/json]
+ Date: ['Sun, 05 May 2024 11:12:43 GMT']
+ Paypal-Debug-Id: [045a0cf469ece]
+ Server-Timing: [traceparent;desc="00-0000000000000000000045a0cf469ece-e82f294828b847b8-01"]
+ Strict-Transport-Security: [max-age=31536000; includeSubDomains]
+ Vary: [Accept-Encoding]
+ status: {code: 201, message: Created}
version: 1
diff --git a/tests/py/fixtures/TestPayinsStripe.yml b/tests/py/fixtures/TestPayinsStripe.yml
index 67f500b9d..1ba0ff0a9 100644
--- a/tests/py/fixtures/TestPayinsStripe.yml
+++ b/tests/py/fixtures/TestPayinsStripe.yml
@@ -3944,4 +3944,175 @@ interactions:
Strict-Transport-Security: [max-age=63072000; includeSubDomains; preload]
Stripe-Version: ['2019-08-14']
status: {code: 200, message: OK}
+- request:
+ body: null
+ headers: {}
+ method: GET
+ uri: https://api.stripe.com/v1/tokens/tok_cn
+ response:
+ body: {string: "{\n \"id\": \"tok_1PDQiWFk4eGpfLOCiSX7NtF1\",\n \"object\":\
+ \ \"token\",\n \"card\": {\n \"id\": \"card_1PDQiWFk4eGpfLOC5QydDSvU\"\
+ ,\n \"object\": \"card\",\n \"address_city\": null,\n \"address_country\"\
+ : null,\n \"address_line1\": null,\n \"address_line1_check\": null,\n\
+ \ \"address_line2\": null,\n \"address_state\": null,\n \"address_zip\"\
+ : null,\n \"address_zip_check\": null,\n \"brand\": \"Visa\",\n \"\
+ country\": \"CN\",\n \"cvc_check\": \"unchecked\",\n \"dynamic_last4\"\
+ : null,\n \"exp_month\": 5,\n \"exp_year\": 2025,\n \"fingerprint\"\
+ : \"tK1E0SQqkJE83bzR\",\n \"funding\": \"credit\",\n \"last4\": \"0002\"\
+ ,\n \"metadata\": {},\n \"name\": null,\n \"networks\": {\n \
+ \ \"preferred\": null\n },\n \"tokenization_method\": null,\n \"\
+ wallet\": null\n },\n \"client_ip\": null,\n \"created\": 1714998748,\n\
+ \ \"livemode\": false,\n \"type\": \"card\",\n \"used\": false\n}"}
+ headers:
+ Access-Control-Allow-Credentials: ['true']
+ Access-Control-Allow-Methods: ['GET,HEAD,PUT,PATCH,POST,DELETE']
+ Access-Control-Allow-Origin: ['*']
+ Access-Control-Expose-Headers: ['Request-Id, Stripe-Manage-Version, Stripe-Should-Retry,
+ X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required']
+ Access-Control-Max-Age: ['300']
+ Cache-Control: ['no-cache, no-store']
+ Connection: [keep-alive]
+ Content-Length: ['837']
+ Content-Security-Policy: ['report-uri https://q.stripe.com/csp-report?p=v1%2Ftokens%2F%3Atoken;
+ block-all-mixed-content; default-src ''none''; base-uri ''none''; form-action
+ ''none''; frame-ancestors ''none''; img-src ''self''; script-src ''self''
+ ''report-sample''; style-src ''self''']
+ Content-Type: [application/json]
+ Cross-Origin-Opener-Policy-Report-Only: [same-origin; report-to="coop"]
+ Date: ['Mon, 06 May 2024 12:32:28 GMT']
+ Report-To: ['{"group":"coop","max_age":8640,"endpoints":[{"url":"https://q.stripe.com/coop-report"}],"include_subdomains":true}']
+ Reporting-Endpoints: ['coop="https://q.stripe.com/coop-report"']
+ Request-Id: [req_EQHPgk1POGltV1]
+ Server: [nginx]
+ Strict-Transport-Security: [max-age=63072000; includeSubDomains; preload]
+ Stripe-Version: ['2019-08-14']
+ Vary: [Origin]
+ X-Content-Type-Options: [nosniff]
+ X-Stripe-Routing-Context-Priority-Tier: [api-testmode]
+ status: {code: 200, message: OK}
+- request:
+ body: owner[email]=donor%40example.com&redirect[return_url]=http%3A%2F%2Flocalhost%2Fdonor%2Fgiving%2Fpay%2Fstripe%2Fcomplete&token=tok_1PDQiWFk4eGpfLOCiSX7NtF1&type=card&usage=reusable
+ headers: {}
+ method: POST
+ uri: https://api.stripe.com/v1/sources
+ response:
+ body: {string: "{\n \"id\": \"src_1PDQiXFk4eGpfLOCf3u2MlSq\",\n \"object\":\
+ \ \"source\",\n \"amount\": null,\n \"card\": {\n \"address_line1_check\"\
+ : null,\n \"address_zip_check\": null,\n \"brand\": \"Visa\",\n \"\
+ country\": \"CN\",\n \"cvc_check\": \"unchecked\",\n \"dynamic_last4\"\
+ : null,\n \"exp_month\": 5,\n \"exp_year\": 2025,\n \"fingerprint\"\
+ : \"tK1E0SQqkJE83bzR\",\n \"funding\": \"credit\",\n \"last4\": \"0002\"\
+ ,\n \"name\": null,\n \"three_d_secure\": \"optional\",\n \"tokenization_method\"\
+ : null\n },\n \"client_secret\": \"src_client_secret_e1CCxgxIyFytmuYuPHGXHwuL\"\
+ ,\n \"created\": 1714998749,\n \"currency\": null,\n \"flow\": \"none\"\
+ ,\n \"livemode\": false,\n \"metadata\": {},\n \"owner\": {\n \"address\"\
+ : null,\n \"email\": \"donor@example.com\",\n \"name\": null,\n \"\
+ phone\": null,\n \"verified_address\": null,\n \"verified_email\": null,\n\
+ \ \"verified_name\": null,\n \"verified_phone\": null\n },\n \"statement_descriptor\"\
+ : null,\n \"status\": \"chargeable\",\n \"type\": \"card\",\n \"usage\"\
+ : \"reusable\"\n}"}
+ headers:
+ Access-Control-Allow-Credentials: ['true']
+ Access-Control-Allow-Methods: ['GET,HEAD,PUT,PATCH,POST,DELETE']
+ Access-Control-Allow-Origin: ['*']
+ Access-Control-Expose-Headers: ['Request-Id, Stripe-Manage-Version, Stripe-Should-Retry,
+ X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required']
+ Access-Control-Max-Age: ['300']
+ Cache-Control: ['no-cache, no-store']
+ Connection: [keep-alive]
+ Content-Length: ['961']
+ Content-Security-Policy: ['report-uri https://q.stripe.com/csp-report?p=v1%2Fsources;
+ block-all-mixed-content; default-src ''none''; base-uri ''none''; form-action
+ ''none''; frame-ancestors ''none''; img-src ''self''; script-src ''self''
+ ''report-sample''; style-src ''self''']
+ Content-Type: [application/json]
+ Cross-Origin-Opener-Policy-Report-Only: [same-origin; report-to="coop"]
+ Date: ['Mon, 06 May 2024 12:32:29 GMT']
+ Idempotency-Key: [create_source_from_tok_1PDQiWFk4eGpfLOCiSX7NtF1]
+ Original-Request: [req_SGJ5HzinsgHaO1]
+ Report-To: ['{"group":"coop","max_age":8640,"endpoints":[{"url":"https://q.stripe.com/coop-report"}],"include_subdomains":true}']
+ Reporting-Endpoints: ['coop="https://q.stripe.com/coop-report"']
+ Request-Id: [req_SGJ5HzinsgHaO1]
+ Server: [nginx]
+ Strict-Transport-Security: [max-age=63072000; includeSubDomains; preload]
+ Stripe-Should-Retry: ['false']
+ Stripe-Version: ['2019-08-14']
+ Vary: [Origin]
+ X-Content-Type-Options: [nosniff]
+ X-Stripe-Routing-Context-Priority-Tier: [api-testmode]
+ status: {code: 200, message: OK}
+- request:
+ body: email=donor%40example.com&source=src_1PDQiXFk4eGpfLOCf3u2MlSq
+ headers: {}
+ method: POST
+ uri: https://api.stripe.com/v1/customers
+ response:
+ body: {string: "{\n \"id\": \"cus_Q3XoBRX8tjftwS\",\n \"object\": \"customer\"\
+ ,\n \"account_balance\": 0,\n \"address\": null,\n \"balance\": 0,\n \"\
+ created\": 1714998749,\n \"currency\": null,\n \"default_currency\": null,\n\
+ \ \"default_source\": \"src_1PDQiXFk4eGpfLOCf3u2MlSq\",\n \"delinquent\"\
+ : false,\n \"description\": null,\n \"discount\": null,\n \"email\": \"\
+ donor@example.com\",\n \"invoice_prefix\": \"644AECDD\",\n \"invoice_settings\"\
+ : {\n \"custom_fields\": null,\n \"default_payment_method\": null,\n\
+ \ \"footer\": null,\n \"rendering_options\": null\n },\n \"livemode\"\
+ : false,\n \"metadata\": {},\n \"name\": null,\n \"next_invoice_sequence\"\
+ : 1,\n \"phone\": null,\n \"preferred_locales\": [],\n \"shipping\": null,\n\
+ \ \"sources\": {\n \"object\": \"list\",\n \"data\": [\n {\n \
+ \ \"id\": \"src_1PDQiXFk4eGpfLOCf3u2MlSq\",\n \"object\": \"\
+ source\",\n \"amount\": null,\n \"card\": {\n \"address_line1_check\"\
+ : null,\n \"address_zip_check\": null,\n \"brand\": \"Visa\"\
+ ,\n \"country\": \"CN\",\n \"cvc_check\": \"pass\",\n \
+ \ \"dynamic_last4\": null,\n \"exp_month\": 5,\n \
+ \ \"exp_year\": 2025,\n \"fingerprint\": \"tK1E0SQqkJE83bzR\",\n\
+ \ \"funding\": \"credit\",\n \"last4\": \"0002\",\n \
+ \ \"name\": null,\n \"three_d_secure\": \"optional\",\n \
+ \ \"tokenization_method\": null\n },\n \"client_secret\"\
+ : \"src_client_secret_e1CCxgxIyFytmuYuPHGXHwuL\",\n \"created\": 1714998749,\n\
+ \ \"currency\": null,\n \"customer\": \"cus_Q3XoBRX8tjftwS\"\
+ ,\n \"flow\": \"none\",\n \"livemode\": false,\n \"metadata\"\
+ : {},\n \"owner\": {\n \"address\": null,\n \"email\"\
+ : \"donor@example.com\",\n \"name\": null,\n \"phone\":\
+ \ null,\n \"verified_address\": null,\n \"verified_email\"\
+ : null,\n \"verified_name\": null,\n \"verified_phone\"\
+ : null\n },\n \"statement_descriptor\": null,\n \"status\"\
+ : \"chargeable\",\n \"type\": \"card\",\n \"usage\": \"reusable\"\
+ \n }\n ],\n \"has_more\": false,\n \"total_count\": 1,\n \
+ \ \"url\": \"/v1/customers/cus_Q3XoBRX8tjftwS/sources\"\n },\n \"subscriptions\"\
+ : {\n \"object\": \"list\",\n \"data\": [],\n \"has_more\": false,\n\
+ \ \"total_count\": 0,\n \"url\": \"/v1/customers/cus_Q3XoBRX8tjftwS/subscriptions\"\
+ \n },\n \"tax_exempt\": \"none\",\n \"tax_ids\": {\n \"object\": \"\
+ list\",\n \"data\": [],\n \"has_more\": false,\n \"total_count\"\
+ : 0,\n \"url\": \"/v1/customers/cus_Q3XoBRX8tjftwS/tax_ids\"\n },\n \"\
+ tax_info\": null,\n \"tax_info_verification\": null,\n \"test_clock\": null\n\
+ }"}
+ headers:
+ Access-Control-Allow-Credentials: ['true']
+ Access-Control-Allow-Methods: ['GET,HEAD,PUT,PATCH,POST,DELETE']
+ Access-Control-Allow-Origin: ['*']
+ Access-Control-Expose-Headers: ['Request-Id, Stripe-Manage-Version, Stripe-Should-Retry,
+ X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required']
+ Access-Control-Max-Age: ['300']
+ Cache-Control: ['no-cache, no-store']
+ Connection: [keep-alive]
+ Content-Length: ['2493']
+ Content-Security-Policy: ['report-uri https://q.stripe.com/csp-report?p=v1%2Fcustomers;
+ block-all-mixed-content; default-src ''none''; base-uri ''none''; form-action
+ ''none''; frame-ancestors ''none''; img-src ''self''; script-src ''self''
+ ''report-sample''; style-src ''self''']
+ Content-Type: [application/json]
+ Cross-Origin-Opener-Policy-Report-Only: [same-origin; report-to="coop"]
+ Date: ['Mon, 06 May 2024 12:32:30 GMT']
+ Idempotency-Key: [create_customer_for_participant_2310_with_src_1PDQiXFk4eGpfLOCf3u2MlSq]
+ Original-Request: [req_TgAUzyWrG6GmIa]
+ Report-To: ['{"group":"coop","max_age":8640,"endpoints":[{"url":"https://q.stripe.com/coop-report"}],"include_subdomains":true}']
+ Reporting-Endpoints: ['coop="https://q.stripe.com/coop-report"']
+ Request-Id: [req_TgAUzyWrG6GmIa]
+ Server: [nginx]
+ Strict-Transport-Security: [max-age=63072000; includeSubDomains; preload]
+ Stripe-Should-Retry: ['false']
+ Stripe-Version: ['2019-08-14']
+ Vary: [Origin]
+ X-Content-Type-Options: [nosniff]
+ X-Stripe-Routing-Context-Priority-Tier: [api-testmode]
+ status: {code: 200, message: OK}
version: 1
diff --git a/tests/py/test_donating.py b/tests/py/test_donating.py
index 1053c489b..03d60e2bc 100644
--- a/tests/py/test_donating.py
+++ b/tests/py/test_donating.py
@@ -42,7 +42,7 @@ def test_donation_form_v2_for_paypal_only_recipient(self):
)
self.add_payment_account(creator, 'paypal')
assert creator.payment_providers == 2
- assert creator.recipient_settings.patron_visibilities == 0
+ assert creator.recipient_settings.patron_visibilities is None
r = self.client.GET('/creator/donate')
assert r.code == 200
assert "This donation won't be secret, " in r.text, r.text
diff --git a/tests/py/test_payins.py b/tests/py/test_payins.py
index f9caaa2c1..135f323dc 100644
--- a/tests/py/test_payins.py
+++ b/tests/py/test_payins.py
@@ -8,7 +8,7 @@
from liberapay.billing.payday import Payday
from liberapay.constants import DONATION_LIMITS, EPOCH, PAYIN_AMOUNTS, STANDARD_TIPS
-from liberapay.exceptions import MissingPaymentAccount, NoSelfTipping
+from liberapay.exceptions import MissingPaymentAccount, NoSelfTipping, ProhibitedSourceCountry
from liberapay.models.exchange_route import ExchangeRoute
from liberapay.payin.common import resolve_amounts, resolve_team_donation
from liberapay.payin.cron import execute_reviewed_payins
@@ -765,6 +765,84 @@ def test_payin_paypal_invalid_email(self):
assert payin.error
assert 'debug_id' in payin.error
+ def test_payin_paypal_with_failing_origin_country_check(self):
+ self.add_payment_account(self.creator_2, 'paypal', 'US')
+ self.creator_2.update_recipient_settings(patron_countries='-FI')
+ tip = self.donor.set_tip_to(self.creator_2, EUR('0.01'))
+
+ # 1st request: initiate the payment
+ form_data = {
+ 'amount': '10.00',
+ 'currency': 'EUR',
+ 'tips': str(tip['id'])
+ }
+ r = self.client.PxST('/donor/giving/pay/paypal', form_data, auth_as=self.donor)
+ assert r.code == 200, r.text
+ assert r.headers[b'Refresh'] == b'0;url=/donor/giving/pay/paypal/1'
+ payin = self.db.one("SELECT * FROM payins")
+ assert payin.status == 'pre'
+ assert payin.amount == EUR('10.00')
+ pt = self.db.one("SELECT * FROM payin_transfers")
+ assert pt.status == 'pre'
+ assert pt.amount == EUR('10.00')
+
+ # 2nd request: redirect to PayPal
+ r = self.client.GxT(
+ '/donor/giving/pay/paypal/1', HTTP_ACCEPT_LANGUAGE=b'en-US',
+ auth_as=self.donor,
+ )
+ assert r.code == 302, r.text
+ assert r.headers[b'Location'].startswith(b'https://www.sandbox.paypal.com/')
+ payin = self.db.one("SELECT * FROM payins")
+ assert payin.status == 'awaiting_payer_action'
+
+ # 3rd request: execute the payment
+ qs = '?token=FFFFFFFFFFFFFFFFF&PayerID=6C9EQBCEQY4MA'
+ r = self.client.GET('/donor/giving/pay/paypal/1' + qs, auth_as=self.donor)
+ assert r.code == 200, r.text
+ payin = self.db.one("SELECT * FROM payins")
+ assert payin.status == 'failed'
+ assert payin.error.startswith("creator_2 does not accept donations from Finland. ")
+
+ def test_payin_paypal_with_passing_origin_country_check(self):
+ self.add_payment_account(self.creator_2, 'paypal', 'US')
+ self.creator_2.update_recipient_settings(patron_countries='-FI')
+ tip = self.donor.set_tip_to(self.creator_2, EUR('0.01'))
+
+ # 1st request: initiate the payment
+ form_data = {
+ 'amount': '10.00',
+ 'currency': 'EUR',
+ 'tips': str(tip['id'])
+ }
+ r = self.client.PxST('/donor/giving/pay/paypal', form_data, auth_as=self.donor)
+ assert r.code == 200, r.text
+ assert r.headers[b'Refresh'] == b'0;url=/donor/giving/pay/paypal/1'
+ payin = self.db.one("SELECT * FROM payins")
+ assert payin.status == 'pre'
+ assert payin.amount == EUR('10.00')
+ pt = self.db.one("SELECT * FROM payin_transfers")
+ assert pt.status == 'pre'
+ assert pt.amount == EUR('10.00')
+
+ # 2nd request: redirect to PayPal
+ r = self.client.GxT(
+ '/donor/giving/pay/paypal/1', HTTP_ACCEPT_LANGUAGE=b'en-US',
+ auth_as=self.donor,
+ )
+ assert r.code == 302, r.text
+ assert r.headers[b'Location'].startswith(b'https://www.sandbox.paypal.com/')
+ payin = self.db.one("SELECT * FROM payins")
+ assert payin.status == 'awaiting_payer_action'
+
+ # 3rd request: execute the payment
+ qs = '?token=8UK19239YC952053V&PayerID=6C9EQBCEQY4MA'
+ r = self.client.GET('/donor/giving/pay/paypal/1' + qs, auth_as=self.donor)
+ assert r.code == 200, r.text
+ payin = self.db.one("SELECT * FROM payins")
+ assert payin.status == 'succeeded'
+ assert payin.error is None
+
class TestPayinsStripe(Harness):
@@ -1175,6 +1253,7 @@ def test_05_payin_intent_stripe_card_one_to_many(self):
self.add_payment_account(self.creator_1, 'stripe', id=self.acct_switzerland.id)
self.add_payment_account(self.creator_3, 'stripe')
self.add_payment_account(self.creator_3, 'paypal')
+ self.creator_3.update_recipient_settings(patron_countries='-FI')
tip1 = self.donor.set_tip_to(self.creator_1, EUR('12.50'))
tip3 = self.donor.set_tip_to(self.creator_3, EUR('12.50'))
@@ -1674,6 +1753,35 @@ def test_09_payin_stripe_sdd_fraud_review(self):
assert pt.status == 'failed'
assert pt.error == "canceled because the destination account is blocked"
+ def test_10_payin_stripe_failing_origin_country_check(self):
+ self.db.run("ALTER SEQUENCE payins_id_seq RESTART WITH %s", (self.offset,))
+ self.db.run("ALTER SEQUENCE payin_transfers_id_seq RESTART WITH %s", (self.offset,))
+ self.add_payment_account(self.creator_1, 'stripe')
+ self.creator_1.update_recipient_settings(patron_countries='-CN')
+ tip = self.donor.set_tip_to(self.creator_1, EUR('0.06'))
+
+ # 1st request: test getting the payment page
+ r = self.client.GET(
+ '/donor/giving/pay/stripe?method=card&beneficiary=%i' % self.creator_1.id,
+ auth_as=self.donor
+ )
+ assert r.code == 200, r.text
+
+ # 2nd request: try to prepare the payment
+ form_data = {
+ 'amount': '6.66',
+ 'currency': 'EUR',
+ 'keep': 'true',
+ 'tips': str(tip['id']),
+ 'token': 'tok_cn',
+ }
+ r = self.client.PxST('/donor/giving/pay/stripe', form_data, auth_as=self.donor)
+ assert isinstance(r, ProhibitedSourceCountry)
+ payin = self.db.one("SELECT * FROM payins")
+ assert payin is None
+ pt = self.db.one("SELECT * FROM payin_transfers")
+ assert pt is None
+
class TestRefundsStripe(EmailHarness):
diff --git a/tests/py/test_settings.py b/tests/py/test_settings.py
index fbe808d2b..f42ddc096 100644
--- a/tests/py/test_settings.py
+++ b/tests/py/test_settings.py
@@ -195,7 +195,7 @@ class TestRecipientSettings(Harness):
def test_enabling_and_disabling_non_secret_donations(self):
alice = self.make_participant('alice')
- assert alice.recipient_settings.patron_visibilities == 0
+ assert alice.recipient_settings.patron_visibilities is None
# Check that the donation form isn't proposing the visibility options
r = self.client.GET('/alice/donate')
assert r.code == 200
diff --git a/www/%username/edit/countries.spt b/www/%username/edit/countries.spt
new file mode 100644
index 000000000..b7734a4d9
--- /dev/null
+++ b/www/%username/edit/countries.spt
@@ -0,0 +1,57 @@
+from liberapay.utils import form_post_success, get_participant
+
+[---]
+participant = get_participant(state, restrict=True, allow_member=False)
+
+if request.method == 'POST':
+ accepted_countries, rejected_countries = [], []
+ for country_code in locale.countries:
+ if request.body.get(country_code) == '1':
+ 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 = _("Countries")
+
+[---] text/html
+% from "templates/macros/icons.html" import icon with context
+
+% extends "templates/layouts/profile-edit.html"
+
+% block form
+
+
+
+% endblock
diff --git a/www/%username/giving/pay/paypal/%payin_id.spt b/www/%username/giving/pay/paypal/%payin_id.spt
index 4e5ca1808..c2d96b40b 100644
--- a/www/%username/giving/pay/paypal/%payin_id.spt
+++ b/www/%username/giving/pay/paypal/%payin_id.spt
@@ -145,10 +145,10 @@ title = _("Funding your donations")
% elif status == 'failed'
{{ _("Failure") }}
-
{{ _(
- "The payment processor {name} returned an error: “{error_message}”.",
- name='PayPal', error_message=payin.error
- ) }}
+
{{
+ _("PayPal status code: {0}", payin.error) if payin.error.isupper() else
+ _("error message: {0}", payin.error)
+ }}
{{ _("Try again") }}
% elif status == 'pending'
diff --git a/www/%username/index.html.spt b/www/%username/index.html.spt
index 1b266666b..ffbda241e 100644
--- a/www/%username/index.html.spt
+++ b/www/%username/index.html.spt
@@ -28,7 +28,7 @@ communities = () # participant.get_communities()
langs = participant.get_statement_langs(include_conversions=True)
-patron_visibilities = participant.recipient_settings.patron_visibilities
+patron_visibilities = participant.recipient_settings.patron_visibilities or 0
if patron_visibilities > 1:
public_patrons = website.db.all("""
SELECT tipper_p.id
diff --git a/www/%username/ledger/index.spt b/www/%username/ledger/index.spt
index e51db160c..d14d2c847 100644
--- a/www/%username/ledger/index.spt
+++ b/www/%username/ledger/index.spt
@@ -112,7 +112,7 @@ if participant.join_time:
% endif
% set show_id = user_is_admin
-% set show_patrons = participant.recipient_settings.patron_visibilities > 1
+% set show_patrons = (participant.recipient_settings.patron_visibilities or 0) > 1
% if events
@@ -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']) }}
diff --git a/www/%username/patrons/export.spt b/www/%username/patrons/export.spt
index b0be400aa..482dfdb2b 100644
--- a/www/%username/patrons/export.spt
+++ b/www/%username/patrons/export.spt
@@ -5,7 +5,7 @@ from liberapay.utils import get_participant
[---]
participant = get_participant(state, restrict=True, allow_member=True)
-if user != participant and user.recipient_settings.patron_visibilities < 2:
+if user != participant and (user.recipient_settings.patron_visibilities or 0) < 2:
if not user.is_acting_as('admin'):
raise response.error(403, "You haven't opted-in to see who your patrons are.")
diff --git a/www/%username/patrons/index.spt b/www/%username/patrons/index.spt
index 973acd8b4..f2357975b 100644
--- a/www/%username/patrons/index.spt
+++ b/www/%username/patrons/index.spt
@@ -3,7 +3,7 @@ from liberapay.utils import form_post_success, get_participant
[---]
participant = get_participant(state, restrict=True, allow_member=True)
-if user != participant and user.recipient_settings.patron_visibilities < 2:
+if user != participant and (user.recipient_settings.patron_visibilities or 0) < 2:
if not user.is_acting_as('admin'):
response.redirect(user.path('patrons/'))
@@ -111,7 +111,7 @@ subhead = _("Patrons")
{{ _("Save") }}
% endif
-% if patron_visibilities > 1
+% if (patron_visibilities or 0) > 1
{{ _("Data export") }}
{{ icon('download') }} {{
_("Download the list of currently active patrons who chose to make their donations public")