We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
[Feature Request] Always put your primary email address at the top
I first added the "@qq.com" email address, and then added the "@protonmail.com" email address and set it as the new primary email address.
But it was ranked second. Obviously, this order is in chronological order of adding. But the primary email address is already the "primary" address, so why shouldn't it be ranked first?

![screenshot](https://github.com/liberapay/liberapay.com/assets/42690037/c63a283d-fdfc-441a-bd48-e1ae3509a7de)


--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `liberapay/payin/paypal.py`
Content:
```
1 from datetime import timedelta
2 import logging
3 import re
4 from time import sleep
5 
6 import requests
7 from pando.utils import utcnow
8 
9 from ..exceptions import PaymentError
10 from ..i18n.currencies import Money
11 from ..website import website
12 from .common import (
13     abort_payin, update_payin, update_payin_transfer, record_payin_refund,
14     record_payin_transfer_reversal,
15 )
16 
17 
18 logger = logging.getLogger('paypal')
19 
20 session = requests.Session()
21 
22 
23 def _extract_error_message(response):
24     try:
25         error = response.json()
26         message = error['message']
27         assert message
28         details = error.get('details')
29         if details and isinstance(details, list):
30             message = ' | '.join(
31                 ('%(issue)s: %(description)s' % d if d.get('issue') else d['description'])
32                 for d in details if d.get('description')
33             ) or message
34         debug_id = error.get('debug_id')
35         if debug_id:
36             message += " | PayPal debug_id: " + debug_id
37         return message
38     except Exception:
39         error = response.text  # for Sentry
40         logger.debug(error)
41         raise PaymentError('PayPal')
42 
43 
44 def _init_session():
45     # TODO switch to bearer tokens to reduce the risk of exposing the long-lived secret
46     if 'Authentication' in session.headers:
47         return session
48     from base64 import b64encode
49     session.headers.update({
50         'Authorization': 'Basic ' + b64encode((
51             '%s:%s' % (website.app_conf.paypal_id, website.app_conf.paypal_secret)
52         ).encode('ascii')).decode('ascii'),
53     })
54     return session
55 
56 
57 # Version 2
58 # =========
59 
60 CAPTURE_STATUSES_MAP = {
61     'COMPLETED': 'succeeded',
62     'DECLINED': 'failed',
63     'PARTIALLY_REFUNDED': 'succeeded',
64     'PENDING': 'pending',
65     'REFUNDED': 'succeeded',
66 }
67 ORDER_STATUSES_MAP = {
68     'APPROVED': 'pending',
69     'COMPLETED': 'succeeded',
70     'CREATED': 'awaiting_payer_action',
71     'SAVED': 'pending',
72     'VOIDED': 'failed',
73 }
74 REFUND_STATUSES_MAP = {
75     'CANCELLED': 'failed',
76     'COMPLETED': 'succeeded',
77     'FAILED': 'failed',
78     'PENDING': 'pending',
79 }
80 
81 locale_re = re.compile("^[a-z]{2}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}))?$")
82 
83 
84 def create_order(db, payin, payer, return_url, cancel_url, state):
85     """Create an Order.
86 
87     Doc: https://developer.paypal.com/docs/api/orders/v2/#orders_create
88 
89     Note: even though the API expects a list of purchase_units it rejects the
90     request if the list contains more than one of them.
91     """
92     transfers = db.all("""
93         SELECT pt.*
94              , recipient.username AS recipient_username
95              , team.username AS team_name
96              , a.id AS merchant_id
97           FROM payin_transfers pt
98           JOIN participants recipient ON recipient.id = pt.recipient
99      LEFT JOIN participants team ON team.id = pt.team
100           JOIN payment_accounts a ON a.pk = pt.destination
101          WHERE pt.payin = %s
102       ORDER BY pt.id
103     """, (payin.id,))
104     assert transfers
105     locale, _, ngettext = state['locale'], state['_'], state['ngettext']
106     # PayPal processes BCP47 tags in a case-sensitive way, and completely rejects
107     # requests containing "improperly" cased values.
108     locale_tag = (
109         locale.language +
110         (f'-{locale.script}' if locale.script else '') +
111         (f'-{locale.territory}' if locale.territory else '')
112     )
113     if not locale_re.match(locale_tag):
114         website.tell_sentry(Warning(
115             f"the locale tag `{locale_tag}` doesn't match the format expected by PayPal; "
116             f"falling back to `{locale.language}`"
117         ))
118         locale_tag = locale.language
119     data = {
120         "intent": "CAPTURE",
121         "application_context": {
122             "brand_name": "Liberapay",
123             "cancel_url": cancel_url,
124             "locale": locale_tag,
125             "landing_page": "BILLING",
126             "shipping_preference": "NO_SHIPPING",
127             "user_action": "PAY_NOW",
128             "return_url": return_url,
129         },
130         "purchase_units": [{
131             "amount": {
132                 "value": str(pt.amount.amount),
133                 "currency_code": pt.amount.currency
134             },
135             "custom_id": str(pt.id),
136             "description": (
137                 _("Liberapay donation to {username} (team {team_name})",
138                   username=pt.recipient_username, team_name=pt.team_name)
139                 if pt.team_name else
140                 _("Liberapay donation to {username}", username=pt.recipient_username)
141             ) + ' | ' + (ngettext(
142                 "{n} week of {money_amount}",
143                 "{n} weeks of {money_amount}",
144                 n=pt.n_units, money_amount=pt.unit_amount
145             ) if pt.period == 'weekly' else ngettext(
146                 "{n} month of {money_amount}",
147                 "{n} months of {money_amount}",
148                 n=pt.n_units, money_amount=pt.unit_amount
149             ) if pt.period == 'monthly' else ngettext(
150                 "{n} year of {money_amount}",
151                 "{n} years of {money_amount}",
152                 n=pt.n_units, money_amount=pt.unit_amount
153             )),
154             "payee": {
155                 "email_address": pt.merchant_id,
156             },
157             "reference_id": str(pt.id),
158             "soft_descriptor": "Liberapay",
159         } for pt in transfers],
160     }
161     url = 'https://api.%s/v2/checkout/orders' % website.app_conf.paypal_domain
162     headers = {
163         'PayPal-Request-Id': 'payin_%i' % payin.id
164     }
165     response = _init_session().post(url, json=data, headers=headers)
166     if response.status_code not in (200, 201):
167         error = _extract_error_message(response)
168         return abort_payin(db, payin, error)
169     order = response.json()
170     status = ORDER_STATUSES_MAP[order['status']]
171     error = order['status'] if status == 'failed' else None
172     payin = update_payin(db, payin.id, order['id'], status, error)
173     if payin.status == 'awaiting_payer_action':
174         redirect_url = [l['href'] for l in order['links'] if l['rel'] == 'approve'][0]
175         raise state['response'].redirect(redirect_url)
176     return payin
177 
178 
179 def capture_order(db, payin):
180     """Capture a previously approved payment for an order.
181 
182     Doc: https://developer.paypal.com/docs/api/orders/v2/#orders_capture
183     """
184     url = 'https://api.%s/v2/checkout/orders/%s/capture' % (
185         website.app_conf.paypal_domain, payin.remote_id
186     )
187     headers = {
188         'PayPal-Request-Id': 'capture_order_%i' % payin.id,
189         'Prefer': 'return=representation',
190     }
191     response = _init_session().post(url, json={}, headers=headers)
192     if response.status_code not in (200, 201):
193         error = _extract_error_message(response)
194         return abort_payin(db, payin, error)
195     order = response.json()
196     return record_order_result(db, payin, order)
197 
198 
199 def record_order_result(db, payin, order):
200     """Update the status of a payin and its transfers in our database.
201     """
202     # Update the payin
203     status = ORDER_STATUSES_MAP[order['status']]
204     if status == 'awaiting_payer_action' and payin.status == 'failed':
205         # This payin has already been aborted, don't reset it.
206         return payin
207     error = order['status'] if status == 'failed' else None
208     refunded_amount = sum(
209         sum(
210             Money(refund['amount']['value'], refund['amount']['currency_code'])
211             for refund in pu.get('payments', {}).get('refunds', ())
212         )
213         for pu in order['purchase_units']
214     ) or None
215     payin = update_payin(
216         db, payin.id, order['id'], status, error, refunded_amount=refunded_amount
217     )
218 
219     # Update the payin transfers
220     for pu in order['purchase_units']:
221         pt_id = pu['reference_id']
222         reversed_amount = payin.amount.zero()
223         for refund in pu.get('payments', {}).get('refunds', ()):
224             refund_amount = refund['amount']
225             refund_amount = Money(refund_amount['value'], refund_amount['currency_code'])
226             reversed_amount += refund_amount
227             refund_description = refund['note_to_payer']
228             refund_status = REFUND_STATUSES_MAP[refund['status']]
229             refund_error = refund.get('status_details', {}).get('reason')
230             payin_refund = record_payin_refund(
231                 db, payin.id, refund['id'], refund_amount, None, refund_description,
232                 refund_status, refund_error, refund['create_time'], notify=False,
233 
234             )
235             record_payin_transfer_reversal(
236                 db, pt_id, refund['id'], payin_refund.id, refund['create_time']
237             )
238         if reversed_amount == 0:
239             reversed_amount = None
240         for capture in pu.get('payments', {}).get('captures', ()):
241             pt_remote_id = capture['id']
242             pt_status = CAPTURE_STATUSES_MAP[capture['status']]
243             pt_error = capture.get('status_details', {}).get('reason')
244             breakdown = capture.get('seller_receivable_breakdown')
245             if breakdown and breakdown.get('paypal_fee'):
246                 pt_fee = breakdown['paypal_fee']
247                 pt_fee = Money(pt_fee['value'], pt_fee['currency_code'])
248                 net_amount = breakdown['net_amount']
249             else:
250                 pt_fee = None
251                 net_amount = capture['amount']
252             net_amount = Money(net_amount['value'], net_amount['currency_code'])
253             update_payin_transfer(
254                 db, pt_id, pt_remote_id, pt_status, pt_error,
255                 amount=net_amount, fee=pt_fee, reversed_amount=reversed_amount
256             )
257 
258     return payin
259 
260 
261 def sync_order(db, payin):
262     """Fetch the order's data and update our database.
263 
264     Doc: https://developer.paypal.com/docs/api/orders/v2/#orders_get
265     """
266     url = 'https://api.%s/v2/checkout/orders/%s' % (
267         website.app_conf.paypal_domain, payin.remote_id
268     )
269     response = _init_session().get(url)
270     if response.status_code != 200:
271         if payin.status == 'failed':
272             return payin
273         try:
274             error = response.json()
275         except Exception:
276             error = {}
277         expired = response.status_code == 404 and (
278             error.get('message') == "The specified resource does not exist." or
279             payin.ctime < (utcnow() - timedelta(days=30))
280         )
281         if expired:
282             return abort_payin(db, payin, "abandoned by payer")
283         error = response.text  # for Sentry
284         logger.debug(error)
285         raise PaymentError('PayPal')
286     order = response.json()
287     return record_order_result(db, payin, order)
288 
289 
290 def sync_all_pending_payments(db=None):
291     """Calls `sync_order` for every pending payment.
292     """
293     db = db or website.db
294     payins = db.all("""
295         SELECT DISTINCT ON (pi.id) pi.*
296           FROM payin_transfers pt
297           JOIN payins pi ON pi.id = pt.payin
298           JOIN exchange_routes r ON r.id = pi.route
299          WHERE pt.status = 'pending'
300            AND r.network = 'paypal'
301       ORDER BY pi.id
302     """)
303     print("Syncing %i pending PayPal payments..." % len(payins))
304     for payin in payins:
305         try:
306             sync_order(db, payin)
307         except Exception as e:
308             website.tell_sentry(e)
309         sleep(0.2)
310
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/liberapay/payin/paypal.py b/liberapay/payin/paypal.py
--- a/liberapay/payin/paypal.py
+++ b/liberapay/payin/paypal.py
@@ -224,7 +224,7 @@
             refund_amount = refund['amount']
             refund_amount = Money(refund_amount['value'], refund_amount['currency_code'])
             reversed_amount += refund_amount
-            refund_description = refund['note_to_payer']
+            refund_description = refund.get('note_to_payer')
             refund_status = REFUND_STATUSES_MAP[refund['status']]
             refund_error = refund.get('status_details', {}).get('reason')
             payin_refund = record_payin_refund(

{"golden_diff": "diff --git a/liberapay/payin/paypal.py b/liberapay/payin/paypal.py\n--- a/liberapay/payin/paypal.py\n+++ b/liberapay/payin/paypal.py\n@@ -224,7 +224,7 @@\n             refund_amount = refund['amount']\n             refund_amount = Money(refund_amount['value'], refund_amount['currency_code'])\n             reversed_amount += refund_amount\n-            refund_description = refund['note_to_payer']\n+            refund_description = refund.get('note_to_payer')\n             refund_status = REFUND_STATUSES_MAP[refund['status']]\n             refund_error = refund.get('status_details', {}).get('reason')\n             payin_refund = record_payin_refund(\n", "issue": "[Feature Request] Always put your primary email address at the top\nI first added the \"@qq.com\" email address, and then added the \"@protonmail.com\" email address and set it as the new primary email address.\r\nBut it was ranked second. Obviously, this order is in chronological order of adding. But the primary email address is already the \"primary\" address, so why shouldn't it be ranked first?\r\n\r\n![screenshot](https://github.com/liberapay/liberapay.com/assets/42690037/c63a283d-fdfc-441a-bd48-e1ae3509a7de)\r\n\n", "before_files": [{"content": "from datetime import timedelta\nimport logging\nimport re\nfrom time import sleep\n\nimport requests\nfrom pando.utils import utcnow\n\nfrom ..exceptions import PaymentError\nfrom ..i18n.currencies import Money\nfrom ..website import website\nfrom .common import (\n    abort_payin, update_payin, update_payin_transfer, record_payin_refund,\n    record_payin_transfer_reversal,\n)\n\n\nlogger = logging.getLogger('paypal')\n\nsession = requests.Session()\n\n\ndef _extract_error_message(response):\n    try:\n        error = response.json()\n        message = error['message']\n        assert message\n        details = error.get('details')\n        if details and isinstance(details, list):\n            message = ' | '.join(\n                ('%(issue)s: %(description)s' % d if d.get('issue') else d['description'])\n                for d in details if d.get('description')\n            ) or message\n        debug_id = error.get('debug_id')\n        if debug_id:\n            message += \" | PayPal debug_id: \" + debug_id\n        return message\n    except Exception:\n        error = response.text  # for Sentry\n        logger.debug(error)\n        raise PaymentError('PayPal')\n\n\ndef _init_session():\n    # TODO switch to bearer tokens to reduce the risk of exposing the long-lived secret\n    if 'Authentication' in session.headers:\n        return session\n    from base64 import b64encode\n    session.headers.update({\n        'Authorization': 'Basic ' + b64encode((\n            '%s:%s' % (website.app_conf.paypal_id, website.app_conf.paypal_secret)\n        ).encode('ascii')).decode('ascii'),\n    })\n    return session\n\n\n# Version 2\n# =========\n\nCAPTURE_STATUSES_MAP = {\n    'COMPLETED': 'succeeded',\n    'DECLINED': 'failed',\n    'PARTIALLY_REFUNDED': 'succeeded',\n    'PENDING': 'pending',\n    'REFUNDED': 'succeeded',\n}\nORDER_STATUSES_MAP = {\n    'APPROVED': 'pending',\n    'COMPLETED': 'succeeded',\n    'CREATED': 'awaiting_payer_action',\n    'SAVED': 'pending',\n    'VOIDED': 'failed',\n}\nREFUND_STATUSES_MAP = {\n    'CANCELLED': 'failed',\n    'COMPLETED': 'succeeded',\n    'FAILED': 'failed',\n    'PENDING': 'pending',\n}\n\nlocale_re = re.compile(\"^[a-z]{2}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}))?$\")\n\n\ndef create_order(db, payin, payer, return_url, cancel_url, state):\n    \"\"\"Create an Order.\n\n    Doc: https://developer.paypal.com/docs/api/orders/v2/#orders_create\n\n    Note: even though the API expects a list of purchase_units it rejects the\n    request if the list contains more than one of them.\n    \"\"\"\n    transfers = db.all(\"\"\"\n        SELECT pt.*\n             , recipient.username AS recipient_username\n             , team.username AS team_name\n             , a.id AS merchant_id\n          FROM payin_transfers pt\n          JOIN participants recipient ON recipient.id = pt.recipient\n     LEFT JOIN participants team ON team.id = pt.team\n          JOIN payment_accounts a ON a.pk = pt.destination\n         WHERE pt.payin = %s\n      ORDER BY pt.id\n    \"\"\", (payin.id,))\n    assert transfers\n    locale, _, ngettext = state['locale'], state['_'], state['ngettext']\n    # PayPal processes BCP47 tags in a case-sensitive way, and completely rejects\n    # requests containing \"improperly\" cased values.\n    locale_tag = (\n        locale.language +\n        (f'-{locale.script}' if locale.script else '') +\n        (f'-{locale.territory}' if locale.territory else '')\n    )\n    if not locale_re.match(locale_tag):\n        website.tell_sentry(Warning(\n            f\"the locale tag `{locale_tag}` doesn't match the format expected by PayPal; \"\n            f\"falling back to `{locale.language}`\"\n        ))\n        locale_tag = locale.language\n    data = {\n        \"intent\": \"CAPTURE\",\n        \"application_context\": {\n            \"brand_name\": \"Liberapay\",\n            \"cancel_url\": cancel_url,\n            \"locale\": locale_tag,\n            \"landing_page\": \"BILLING\",\n            \"shipping_preference\": \"NO_SHIPPING\",\n            \"user_action\": \"PAY_NOW\",\n            \"return_url\": return_url,\n        },\n        \"purchase_units\": [{\n            \"amount\": {\n                \"value\": str(pt.amount.amount),\n                \"currency_code\": pt.amount.currency\n            },\n            \"custom_id\": str(pt.id),\n            \"description\": (\n                _(\"Liberapay donation to {username} (team {team_name})\",\n                  username=pt.recipient_username, team_name=pt.team_name)\n                if pt.team_name else\n                _(\"Liberapay donation to {username}\", username=pt.recipient_username)\n            ) + ' | ' + (ngettext(\n                \"{n} week of {money_amount}\",\n                \"{n} weeks of {money_amount}\",\n                n=pt.n_units, money_amount=pt.unit_amount\n            ) if pt.period == 'weekly' else ngettext(\n                \"{n} month of {money_amount}\",\n                \"{n} months of {money_amount}\",\n                n=pt.n_units, money_amount=pt.unit_amount\n            ) if pt.period == 'monthly' else ngettext(\n                \"{n} year of {money_amount}\",\n                \"{n} years of {money_amount}\",\n                n=pt.n_units, money_amount=pt.unit_amount\n            )),\n            \"payee\": {\n                \"email_address\": pt.merchant_id,\n            },\n            \"reference_id\": str(pt.id),\n            \"soft_descriptor\": \"Liberapay\",\n        } for pt in transfers],\n    }\n    url = 'https://api.%s/v2/checkout/orders' % website.app_conf.paypal_domain\n    headers = {\n        'PayPal-Request-Id': 'payin_%i' % payin.id\n    }\n    response = _init_session().post(url, json=data, headers=headers)\n    if response.status_code not in (200, 201):\n        error = _extract_error_message(response)\n        return abort_payin(db, payin, error)\n    order = response.json()\n    status = ORDER_STATUSES_MAP[order['status']]\n    error = order['status'] if status == 'failed' else None\n    payin = update_payin(db, payin.id, order['id'], status, error)\n    if payin.status == 'awaiting_payer_action':\n        redirect_url = [l['href'] for l in order['links'] if l['rel'] == 'approve'][0]\n        raise state['response'].redirect(redirect_url)\n    return payin\n\n\ndef capture_order(db, payin):\n    \"\"\"Capture a previously approved payment for an order.\n\n    Doc: https://developer.paypal.com/docs/api/orders/v2/#orders_capture\n    \"\"\"\n    url = 'https://api.%s/v2/checkout/orders/%s/capture' % (\n        website.app_conf.paypal_domain, payin.remote_id\n    )\n    headers = {\n        'PayPal-Request-Id': 'capture_order_%i' % payin.id,\n        'Prefer': 'return=representation',\n    }\n    response = _init_session().post(url, json={}, headers=headers)\n    if response.status_code not in (200, 201):\n        error = _extract_error_message(response)\n        return abort_payin(db, payin, error)\n    order = response.json()\n    return record_order_result(db, payin, order)\n\n\ndef record_order_result(db, payin, order):\n    \"\"\"Update the status of a payin and its transfers in our database.\n    \"\"\"\n    # Update the payin\n    status = ORDER_STATUSES_MAP[order['status']]\n    if status == 'awaiting_payer_action' and payin.status == 'failed':\n        # This payin has already been aborted, don't reset it.\n        return payin\n    error = order['status'] if status == 'failed' else None\n    refunded_amount = sum(\n        sum(\n            Money(refund['amount']['value'], refund['amount']['currency_code'])\n            for refund in pu.get('payments', {}).get('refunds', ())\n        )\n        for pu in order['purchase_units']\n    ) or None\n    payin = update_payin(\n        db, payin.id, order['id'], status, error, refunded_amount=refunded_amount\n    )\n\n    # Update the payin transfers\n    for pu in order['purchase_units']:\n        pt_id = pu['reference_id']\n        reversed_amount = payin.amount.zero()\n        for refund in pu.get('payments', {}).get('refunds', ()):\n            refund_amount = refund['amount']\n            refund_amount = Money(refund_amount['value'], refund_amount['currency_code'])\n            reversed_amount += refund_amount\n            refund_description = refund['note_to_payer']\n            refund_status = REFUND_STATUSES_MAP[refund['status']]\n            refund_error = refund.get('status_details', {}).get('reason')\n            payin_refund = record_payin_refund(\n                db, payin.id, refund['id'], refund_amount, None, refund_description,\n                refund_status, refund_error, refund['create_time'], notify=False,\n\n            )\n            record_payin_transfer_reversal(\n                db, pt_id, refund['id'], payin_refund.id, refund['create_time']\n            )\n        if reversed_amount == 0:\n            reversed_amount = None\n        for capture in pu.get('payments', {}).get('captures', ()):\n            pt_remote_id = capture['id']\n            pt_status = CAPTURE_STATUSES_MAP[capture['status']]\n            pt_error = capture.get('status_details', {}).get('reason')\n            breakdown = capture.get('seller_receivable_breakdown')\n            if breakdown and breakdown.get('paypal_fee'):\n                pt_fee = breakdown['paypal_fee']\n                pt_fee = Money(pt_fee['value'], pt_fee['currency_code'])\n                net_amount = breakdown['net_amount']\n            else:\n                pt_fee = None\n                net_amount = capture['amount']\n            net_amount = Money(net_amount['value'], net_amount['currency_code'])\n            update_payin_transfer(\n                db, pt_id, pt_remote_id, pt_status, pt_error,\n                amount=net_amount, fee=pt_fee, reversed_amount=reversed_amount\n            )\n\n    return payin\n\n\ndef sync_order(db, payin):\n    \"\"\"Fetch the order's data and update our database.\n\n    Doc: https://developer.paypal.com/docs/api/orders/v2/#orders_get\n    \"\"\"\n    url = 'https://api.%s/v2/checkout/orders/%s' % (\n        website.app_conf.paypal_domain, payin.remote_id\n    )\n    response = _init_session().get(url)\n    if response.status_code != 200:\n        if payin.status == 'failed':\n            return payin\n        try:\n            error = response.json()\n        except Exception:\n            error = {}\n        expired = response.status_code == 404 and (\n            error.get('message') == \"The specified resource does not exist.\" or\n            payin.ctime < (utcnow() - timedelta(days=30))\n        )\n        if expired:\n            return abort_payin(db, payin, \"abandoned by payer\")\n        error = response.text  # for Sentry\n        logger.debug(error)\n        raise PaymentError('PayPal')\n    order = response.json()\n    return record_order_result(db, payin, order)\n\n\ndef sync_all_pending_payments(db=None):\n    \"\"\"Calls `sync_order` for every pending payment.\n    \"\"\"\n    db = db or website.db\n    payins = db.all(\"\"\"\n        SELECT DISTINCT ON (pi.id) pi.*\n          FROM payin_transfers pt\n          JOIN payins pi ON pi.id = pt.payin\n          JOIN exchange_routes r ON r.id = pi.route\n         WHERE pt.status = 'pending'\n           AND r.network = 'paypal'\n      ORDER BY pi.id\n    \"\"\")\n    print(\"Syncing %i pending PayPal payments...\" % len(payins))\n    for payin in payins:\n        try:\n            sync_order(db, payin)\n        except Exception as e:\n            website.tell_sentry(e)\n        sleep(0.2)\n", "path": "liberapay/payin/paypal.py"}], "after_files": [{"content": "from datetime import timedelta\nimport logging\nimport re\nfrom time import sleep\n\nimport requests\nfrom pando.utils import utcnow\n\nfrom ..exceptions import PaymentError\nfrom ..i18n.currencies import Money\nfrom ..website import website\nfrom .common import (\n    abort_payin, update_payin, update_payin_transfer, record_payin_refund,\n    record_payin_transfer_reversal,\n)\n\n\nlogger = logging.getLogger('paypal')\n\nsession = requests.Session()\n\n\ndef _extract_error_message(response):\n    try:\n        error = response.json()\n        message = error['message']\n        assert message\n        details = error.get('details')\n        if details and isinstance(details, list):\n            message = ' | '.join(\n                ('%(issue)s: %(description)s' % d if d.get('issue') else d['description'])\n                for d in details if d.get('description')\n            ) or message\n        debug_id = error.get('debug_id')\n        if debug_id:\n            message += \" | PayPal debug_id: \" + debug_id\n        return message\n    except Exception:\n        error = response.text  # for Sentry\n        logger.debug(error)\n        raise PaymentError('PayPal')\n\n\ndef _init_session():\n    # TODO switch to bearer tokens to reduce the risk of exposing the long-lived secret\n    if 'Authentication' in session.headers:\n        return session\n    from base64 import b64encode\n    session.headers.update({\n        'Authorization': 'Basic ' + b64encode((\n            '%s:%s' % (website.app_conf.paypal_id, website.app_conf.paypal_secret)\n        ).encode('ascii')).decode('ascii'),\n    })\n    return session\n\n\n# Version 2\n# =========\n\nCAPTURE_STATUSES_MAP = {\n    'COMPLETED': 'succeeded',\n    'DECLINED': 'failed',\n    'PARTIALLY_REFUNDED': 'succeeded',\n    'PENDING': 'pending',\n    'REFUNDED': 'succeeded',\n}\nORDER_STATUSES_MAP = {\n    'APPROVED': 'pending',\n    'COMPLETED': 'succeeded',\n    'CREATED': 'awaiting_payer_action',\n    'SAVED': 'pending',\n    'VOIDED': 'failed',\n}\nREFUND_STATUSES_MAP = {\n    'CANCELLED': 'failed',\n    'COMPLETED': 'succeeded',\n    'FAILED': 'failed',\n    'PENDING': 'pending',\n}\n\nlocale_re = re.compile(\"^[a-z]{2}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}))?$\")\n\n\ndef create_order(db, payin, payer, return_url, cancel_url, state):\n    \"\"\"Create an Order.\n\n    Doc: https://developer.paypal.com/docs/api/orders/v2/#orders_create\n\n    Note: even though the API expects a list of purchase_units it rejects the\n    request if the list contains more than one of them.\n    \"\"\"\n    transfers = db.all(\"\"\"\n        SELECT pt.*\n             , recipient.username AS recipient_username\n             , team.username AS team_name\n             , a.id AS merchant_id\n          FROM payin_transfers pt\n          JOIN participants recipient ON recipient.id = pt.recipient\n     LEFT JOIN participants team ON team.id = pt.team\n          JOIN payment_accounts a ON a.pk = pt.destination\n         WHERE pt.payin = %s\n      ORDER BY pt.id\n    \"\"\", (payin.id,))\n    assert transfers\n    locale, _, ngettext = state['locale'], state['_'], state['ngettext']\n    # PayPal processes BCP47 tags in a case-sensitive way, and completely rejects\n    # requests containing \"improperly\" cased values.\n    locale_tag = (\n        locale.language +\n        (f'-{locale.script}' if locale.script else '') +\n        (f'-{locale.territory}' if locale.territory else '')\n    )\n    if not locale_re.match(locale_tag):\n        website.tell_sentry(Warning(\n            f\"the locale tag `{locale_tag}` doesn't match the format expected by PayPal; \"\n            f\"falling back to `{locale.language}`\"\n        ))\n        locale_tag = locale.language\n    data = {\n        \"intent\": \"CAPTURE\",\n        \"application_context\": {\n            \"brand_name\": \"Liberapay\",\n            \"cancel_url\": cancel_url,\n            \"locale\": locale_tag,\n            \"landing_page\": \"BILLING\",\n            \"shipping_preference\": \"NO_SHIPPING\",\n            \"user_action\": \"PAY_NOW\",\n            \"return_url\": return_url,\n        },\n        \"purchase_units\": [{\n            \"amount\": {\n                \"value\": str(pt.amount.amount),\n                \"currency_code\": pt.amount.currency\n            },\n            \"custom_id\": str(pt.id),\n            \"description\": (\n                _(\"Liberapay donation to {username} (team {team_name})\",\n                  username=pt.recipient_username, team_name=pt.team_name)\n                if pt.team_name else\n                _(\"Liberapay donation to {username}\", username=pt.recipient_username)\n            ) + ' | ' + (ngettext(\n                \"{n} week of {money_amount}\",\n                \"{n} weeks of {money_amount}\",\n                n=pt.n_units, money_amount=pt.unit_amount\n            ) if pt.period == 'weekly' else ngettext(\n                \"{n} month of {money_amount}\",\n                \"{n} months of {money_amount}\",\n                n=pt.n_units, money_amount=pt.unit_amount\n            ) if pt.period == 'monthly' else ngettext(\n                \"{n} year of {money_amount}\",\n                \"{n} years of {money_amount}\",\n                n=pt.n_units, money_amount=pt.unit_amount\n            )),\n            \"payee\": {\n                \"email_address\": pt.merchant_id,\n            },\n            \"reference_id\": str(pt.id),\n            \"soft_descriptor\": \"Liberapay\",\n        } for pt in transfers],\n    }\n    url = 'https://api.%s/v2/checkout/orders' % website.app_conf.paypal_domain\n    headers = {\n        'PayPal-Request-Id': 'payin_%i' % payin.id\n    }\n    response = _init_session().post(url, json=data, headers=headers)\n    if response.status_code not in (200, 201):\n        error = _extract_error_message(response)\n        return abort_payin(db, payin, error)\n    order = response.json()\n    status = ORDER_STATUSES_MAP[order['status']]\n    error = order['status'] if status == 'failed' else None\n    payin = update_payin(db, payin.id, order['id'], status, error)\n    if payin.status == 'awaiting_payer_action':\n        redirect_url = [l['href'] for l in order['links'] if l['rel'] == 'approve'][0]\n        raise state['response'].redirect(redirect_url)\n    return payin\n\n\ndef capture_order(db, payin):\n    \"\"\"Capture a previously approved payment for an order.\n\n    Doc: https://developer.paypal.com/docs/api/orders/v2/#orders_capture\n    \"\"\"\n    url = 'https://api.%s/v2/checkout/orders/%s/capture' % (\n        website.app_conf.paypal_domain, payin.remote_id\n    )\n    headers = {\n        'PayPal-Request-Id': 'capture_order_%i' % payin.id,\n        'Prefer': 'return=representation',\n    }\n    response = _init_session().post(url, json={}, headers=headers)\n    if response.status_code not in (200, 201):\n        error = _extract_error_message(response)\n        return abort_payin(db, payin, error)\n    order = response.json()\n    return record_order_result(db, payin, order)\n\n\ndef record_order_result(db, payin, order):\n    \"\"\"Update the status of a payin and its transfers in our database.\n    \"\"\"\n    # Update the payin\n    status = ORDER_STATUSES_MAP[order['status']]\n    if status == 'awaiting_payer_action' and payin.status == 'failed':\n        # This payin has already been aborted, don't reset it.\n        return payin\n    error = order['status'] if status == 'failed' else None\n    refunded_amount = sum(\n        sum(\n            Money(refund['amount']['value'], refund['amount']['currency_code'])\n            for refund in pu.get('payments', {}).get('refunds', ())\n        )\n        for pu in order['purchase_units']\n    ) or None\n    payin = update_payin(\n        db, payin.id, order['id'], status, error, refunded_amount=refunded_amount\n    )\n\n    # Update the payin transfers\n    for pu in order['purchase_units']:\n        pt_id = pu['reference_id']\n        reversed_amount = payin.amount.zero()\n        for refund in pu.get('payments', {}).get('refunds', ()):\n            refund_amount = refund['amount']\n            refund_amount = Money(refund_amount['value'], refund_amount['currency_code'])\n            reversed_amount += refund_amount\n            refund_description = refund.get('note_to_payer')\n            refund_status = REFUND_STATUSES_MAP[refund['status']]\n            refund_error = refund.get('status_details', {}).get('reason')\n            payin_refund = record_payin_refund(\n                db, payin.id, refund['id'], refund_amount, None, refund_description,\n                refund_status, refund_error, refund['create_time'], notify=False,\n\n            )\n            record_payin_transfer_reversal(\n                db, pt_id, refund['id'], payin_refund.id, refund['create_time']\n            )\n        if reversed_amount == 0:\n            reversed_amount = None\n        for capture in pu.get('payments', {}).get('captures', ()):\n            pt_remote_id = capture['id']\n            pt_status = CAPTURE_STATUSES_MAP[capture['status']]\n            pt_error = capture.get('status_details', {}).get('reason')\n            breakdown = capture.get('seller_receivable_breakdown')\n            if breakdown and breakdown.get('paypal_fee'):\n                pt_fee = breakdown['paypal_fee']\n                pt_fee = Money(pt_fee['value'], pt_fee['currency_code'])\n                net_amount = breakdown['net_amount']\n            else:\n                pt_fee = None\n                net_amount = capture['amount']\n            net_amount = Money(net_amount['value'], net_amount['currency_code'])\n            update_payin_transfer(\n                db, pt_id, pt_remote_id, pt_status, pt_error,\n                amount=net_amount, fee=pt_fee, reversed_amount=reversed_amount\n            )\n\n    return payin\n\n\ndef sync_order(db, payin):\n    \"\"\"Fetch the order's data and update our database.\n\n    Doc: https://developer.paypal.com/docs/api/orders/v2/#orders_get\n    \"\"\"\n    url = 'https://api.%s/v2/checkout/orders/%s' % (\n        website.app_conf.paypal_domain, payin.remote_id\n    )\n    response = _init_session().get(url)\n    if response.status_code != 200:\n        if payin.status == 'failed':\n            return payin\n        try:\n            error = response.json()\n        except Exception:\n            error = {}\n        expired = response.status_code == 404 and (\n            error.get('message') == \"The specified resource does not exist.\" or\n            payin.ctime < (utcnow() - timedelta(days=30))\n        )\n        if expired:\n            return abort_payin(db, payin, \"abandoned by payer\")\n        error = response.text  # for Sentry\n        logger.debug(error)\n        raise PaymentError('PayPal')\n    order = response.json()\n    return record_order_result(db, payin, order)\n\n\ndef sync_all_pending_payments(db=None):\n    \"\"\"Calls `sync_order` for every pending payment.\n    \"\"\"\n    db = db or website.db\n    payins = db.all(\"\"\"\n        SELECT DISTINCT ON (pi.id) pi.*\n          FROM payin_transfers pt\n          JOIN payins pi ON pi.id = pt.payin\n          JOIN exchange_routes r ON r.id = pi.route\n         WHERE pt.status = 'pending'\n           AND r.network = 'paypal'\n      ORDER BY pi.id\n    \"\"\")\n    print(\"Syncing %i pending PayPal payments...\" % len(payins))\n    for payin in payins:\n        try:\n            sync_order(db, payin)\n        except Exception as e:\n            website.tell_sentry(e)\n        sleep(0.2)\n", "path": "liberapay/payin/paypal.py"}]}