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 + +
+ + +

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

+ +

{{ icon('info-sign') }} {{ _( + "We recommend limiting the origins of donations only if you are required to by law." + ) }}

+ + + +
+ +
+ +% 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") % 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")