Skip to content

Commit

Permalink
Merge pull request #2315 from liberapay/various
Browse files Browse the repository at this point in the history
  • Loading branch information
Changaco authored Feb 6, 2024
2 parents 07a732c + 5263b37 commit 2e25797
Show file tree
Hide file tree
Showing 13 changed files with 137 additions and 71 deletions.
8 changes: 8 additions & 0 deletions liberapay/models/participant.py
Original file line number Diff line number Diff line change
Expand Up @@ -3705,6 +3705,14 @@ def _guessed_country(self):
locale, request = state['locale'], state['request']
return locale.territory or request.source_country

@property
def can_attempt_payment(self):
return (
not self.is_suspended and
self.status == 'active' and
bool(self.get_email_address())
)


class NeedConfirmation(Exception):
"""Represent the case where we need user confirmation during a merge.
Expand Down
6 changes: 4 additions & 2 deletions liberapay/payin/cron.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ def send_donation_reminder_notifications():
today = utcnow().date()
next_payday = compute_next_payday_date()
for payer, payins in rows:
if payer.is_suspended or payer.status != 'active':
if not payer.can_attempt_payment:
continue
payins.sort(key=itemgetter('execution_date'))
_check_scheduled_payins(db, payer, payins, automatic=False)
Expand Down Expand Up @@ -193,7 +193,7 @@ def send_upcoming_debit_notifications():
ORDER BY sp.payer, (sp.amount).currency
""")
for payer, payins in rows:
if payer.is_suspended or payer.status != 'active' or not payer.get_email_address():
if not payer.can_attempt_payment:
continue
_check_scheduled_payins(db, payer, payins, automatic=True)
if not payins:
Expand Down Expand Up @@ -459,6 +459,8 @@ def _check_scheduled_payins(db, payer, payins, automatic):
def _filter_transfers(payer, transfers, automatic):
"""Splits scheduled transfers into 4 lists: okay, canceled, impossible, actionable.
"""
if not payer.can_attempt_payment:
return [], list(transfers), [], []
canceled_transfers = []
impossible_transfers = []
actionable_transfers = []
Expand Down
17 changes: 12 additions & 5 deletions liberapay/payin/stripe.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,13 @@ def charge(db, payin, payer, route, update_donor=True):
JOIN participants p On p.id = pt.recipient
WHERE pt.payin = %(payin)s
""", dict(payin=payin.id))
payer_state = (
'blocked' if payer.is_suspended else
'invalid' if payer.status != 'active' or not payer.get_email_address() else
'okay'
)
new_status = None
if payer.is_suspended:
if payer_state != 'okay':
new_status = 'failed'
elif route.network == 'stripe-sdd':
for pt in transfers:
Expand All @@ -113,9 +118,11 @@ def charge(db, payin, payer, route, update_donor=True):
payin = update_payin(db, payin.id, None, new_status, new_payin_error)
for i, pt in enumerate(transfers, 1):
new_transfer_error = (
"canceled because payer account is blocked"
if payer.is_suspended else
"canceled because destination account is blocked"
"canceled because the payer's account is blocked"
if payer_state == 'blocked' else
"canceled because the payer's account is in an invalid state"
if payer_state == 'invalid' else
"canceled because the destination account is blocked"
if pt.recipient_marked_as in ('fraud', 'spam') else
"canceled because another destination account is blocked"
) if new_status == 'failed' else None
Expand Down Expand Up @@ -460,7 +467,7 @@ def settle_charge_and_transfers(
payer = db.Participant.from_id(payin.payer)
undeliverable_amount = amount_settled.zero()
for i, pt in enumerate(payin_transfers):
if payer.is_suspended:
if payer.is_suspended or not payer.get_email_address():
if pt.status not in ('failed', 'succeeded'):
pt = update_payin_transfer(
db, pt.id, None, 'suspended', None,
Expand Down
18 changes: 9 additions & 9 deletions requirements_base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -327,21 +327,21 @@ python-dateutil==2.8.1 \
--hash=sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c \
--hash=sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a

boto3==1.29.6 \
--hash=sha256:d1d0d979a70bf9b0b13ae3b017f8523708ad953f62d16f39a602d67ee9b25554 \
--hash=sha256:f4d19e01d176c3a5a05e4af733185ff1891b08a3c38d4a439800fa132aa6e9be
boto3==1.34.29 \
--hash=sha256:34b2b404bd2bec869ec2fc6aeeeeb884c72a9b895e2c7716b95381ab8deb9069 \
--hash=sha256:50776db195a73533f4f2c9d000c69f9d0e9fb9810f9f81584adc283e9516ad0d

botocore==1.32.6 \
--hash=sha256:4454f967a4d1a01e3e6205c070455bc4e8fd53b5b0753221581ae679c55a9dfd \
--hash=sha256:ecec876103783b5efe6099762dda60c2af67e45f7c0ab4568e8265d11c6c449b
botocore==1.34.29 \
--hash=sha256:34223fdb8ebd47d1fce5724bb1bcb164e81853ea9ca532b50639c749fc347458 \
--hash=sha256:44d918b91a1c1085d99266f8bf6ecc087c8a73a8efbb8e829f0c2dcd1ddf9963

jmespath==1.0.1 \
--hash=sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980 \
--hash=sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe

s3transfer==0.7.0 \
--hash=sha256:10d6923c6359175f264811ef4bf6161a3156ce8e350e705396a7557d6293c33a \
--hash=sha256:fd3889a66f5fe17299fe75b82eae6cf722554edca744ca5d5fe308b104883d2e
s3transfer==0.10.0 \
--hash=sha256:3cdb40f5cfa6966e812209d0994f2a4709b561c88e90cf00c2696d2df4e56b2e \
--hash=sha256:d0c8bbf672d5eebbe4e57945e23b972d963f07d82f661cabf678a5c88831595b

cached-property==1.3.1 \
--hash=sha256:6562f0be134957547421dda11640e8cadfa7c23238fc4e0821ab69efdb1095f3 \
Expand Down
4 changes: 4 additions & 0 deletions style/base/lists.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
list-style-type: '';
padding-left: 2ex;
}
.hooked-right-pointing-arrows {
list-style-type: '';
padding-left: 2ex;
}

// Status lists

Expand Down
2 changes: 1 addition & 1 deletion templates/layouts/explore.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

% block main
<div class="content-with-nav tabs-left">
% if request.path.raw != '/explore/'
% if request.path.raw not in ('/explore', '/explore/')
% from "templates/macros/nav.html" import nav_explore with context
<nav class="nav-col">
<input id="subnav-explore" class="subnav-toggler sr-only" type="checkbox" />
Expand Down
2 changes: 1 addition & 1 deletion tests/py/test_payins.py
Original file line number Diff line number Diff line change
Expand Up @@ -1669,7 +1669,7 @@ def test_09_payin_stripe_sdd_fraud_review(self):
assert payin.fee is None
pt = self.db.one("SELECT * FROM payin_transfers")
assert pt.status == 'failed'
assert pt.error == "canceled because destination account is blocked"
assert pt.error == "canceled because the destination account is blocked"


class TestRefundsStripe(EmailHarness):
Expand Down
61 changes: 33 additions & 28 deletions www/%username/giving/pay/stripe/%payin_id.spt
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ del currency

payment_type = request.qs.get('method', 'card')

error = None
if request.method == 'POST':
website.check_payin_allowed(request, user, 'stripe-' + payment_type)

Expand Down Expand Up @@ -104,44 +105,44 @@ if request.method == 'POST':
route.set_as_default_for(set_as_default_for)

except stripe.error.StripeError as e:
raise response.error(e.http_status or 500, _(
route = None
error = _(
"The payment processor {name} returned an error: “{error_message}”.",
name='Stripe', error_message=repr_stripe_error(e)
))

if not route:
raise response.error(400, _(
"The payment instrument is invalid, please select or add another one."
))

try:
if postal_address and not (route.get_postal_address() or {}).get('line1'):
route.set_postal_address(postal_address)
except stripe.error.StripeError as e:
website.tell_sentry(e)
)

if route:
try:
if postal_address and not (route.get_postal_address() or {}).get('line1'):
route.set_postal_address(postal_address)
except stripe.error.StripeError as e:
website.tell_sentry(e)

transfer_amounts = resolve_amounts(
payin_amount, {tip.id: tip.amount for tip in tips}
)
payer_country = route.country or payer.guessed_country
proto_transfers = []
sepa_only = len(tips) > 1
for tip in tips:
proto_transfers.extend(resolve_tip(
website.db, tip, tip.tippee_p, 'stripe', payer, payer_country,
transfer_amounts[tip.id], sepa_only=sepa_only,
))
payin = prepare_payin(website.db, payer, payin_amount, route, proto_transfers)[0]

transfer_amounts = resolve_amounts(
payin_amount, {tip.id: tip.amount for tip in tips}
)
payer_country = route.country or payer.guessed_country
proto_transfers = []
sepa_only = len(tips) > 1
for tip in tips:
proto_transfers.extend(resolve_tip(
website.db, tip, tip.tippee_p, 'stripe', payer, payer_country,
transfer_amounts[tip.id], sepa_only=sepa_only,
))
payin = prepare_payin(website.db, payer, payin_amount, route, proto_transfers)[0]
msg = _("Request in progress, please wait…")
response.refresh(state, url=payer.path('giving/pay/stripe/%i' % payin.id), msg=msg)

msg = _("Request in progress, please wait…")
response.refresh(state, url=payer.path('giving/pay/stripe/%i' % payin.id), msg=msg)
elif not error:
error = _("The payment instrument is invalid, please select or add another one.")

payin_id = request.path['payin_id']

if payin_id == 'complete':
payin = website.db.one("""
SELECT pi.*
FROM payin pi
FROM payins pi
JOIN exchange_routes r ON r.id = pi.route
WHERE pi.participant = %s
AND r.network = 'stripe'
Expand Down Expand Up @@ -356,6 +357,10 @@ title = _("Funding your donations")

% else

% if error is defined
<output class="alert alert-danger">{{ error }}</output>
% endif

<noscript><div class="alert alert-danger">{{ _("JavaScript is required") }}</div></noscript>

<form action="javascript:" method="POST" id="stripe"
Expand Down
6 changes: 5 additions & 1 deletion www/%username/ledger/index.spt
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,11 @@ if participant.join_time:
<td class="fees">{{ locale.format_money(event['fee']) if event['fee'] else '' }}</td>
<td class="method">{{ payment_method_icon(event['payin_method']) }}</td>
% if show_id
<td class="id">{{ event['id'] }}{% if event['remote_id'] %} <a href="https://dashboard.stripe.com/payments/{{ event['remote_id'] }}">{{ fontawesome('external-link-square') }}</a>{% endif %}</td>
<td class="id">{{ event['id'] }}
{%- if event['remote_id'] and event['payin_method'].startswith('stripe-') %}
<a href="https://dashboard.stripe.com/payments/{{ event['remote_id'] }}">{{ fontawesome('external-link-square') }}</a>
{%- endif -%}
</td>
% endif
</tr>
% elif event['kind'] == 'payin_transfer'
Expand Down
3 changes: 3 additions & 0 deletions www/%username/routes/add.spt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import stripe

from liberapay.exceptions import AccountSuspended
from liberapay.models.exchange_route import ExchangeRoute
from liberapay.payin.stripe import create_source_from_token, repr_stripe_error
from liberapay.utils import check_address_v2, form_post_success, get_participant
Expand All @@ -10,6 +11,8 @@ participant = get_participant(state, restrict=True)
identity = participant.get_current_identity() or {}

if request.method == 'POST':
if not participant.can_attempt_payment:
raise AccountSuspended()
body = request.body
one_off = body.get('one_off') == 'true'
return_url = participant.url('routes/')
Expand Down
2 changes: 1 addition & 1 deletion www/%username/widgets/%type.spt
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ if participant.hide_giving and t_is_giving or \
participant.hide_receiving and not t_is_giving:
raise response.error(403)

response.headers[b'Cache-Control'] = b'public, max-age=3600'
response.headers[b'Cache-Control'] = b'public, max-age=3600, stale-while-revalidate=900'
if request.hostname == website.canonical_host:
response.headers[b'Vary'] = b'Accept-Language'

Expand Down
2 changes: 1 addition & 1 deletion www/%username/widgets/button.js.spt
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ ICON = include_svg(ICON, 16, 16)

participant = get_participant(state, restrict=False, redirect_canon=False)

response.headers[b'Cache-Control'] = b'public, max-age=86400'
response.headers[b'Cache-Control'] = b'public, max-age=86400, stale-while-revalidate=3600'
if request.hostname == website.canonical_host:
response.headers[b'Vary'] = b'Accept-Language'

Expand Down
77 changes: 55 additions & 22 deletions www/admin/payments.spt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from liberapay.i18n.base import LOCALE_EN as locale
from liberapay.utils import render_postal_address
from operator import itemgetter

from liberapay.i18n.base import LOCALE_EN as locale, MoneyBasket
from liberapay.utils import group_by, render_postal_address

PAGE_SIZE = 50
STATUS_MAP = {
Expand Down Expand Up @@ -47,6 +49,29 @@ payins = website.db.all("""
LIMIT %s
""", (before, status, PAGE_SIZE))

for pi in payins:
for pt in pi.transfers:
pt['amount'] = Money(**pt['amount'])
if pt['unit_amount']:
pt['unit_amount'] = Money(**pt['unit_amount'])
if pt['reversed_amount']:
pt['reversed_amount'] = Money(**pt['reversed_amount'])
pt['status'] = (
'refunded' if pt['reversed_amount'] == pt['amount'] else
'partially_refunded' if pt['reversed_amount'] else
pt['status']
)
grouped_transfers = group_by(pi.transfers, itemgetter('team'))
pi.transfers = grouped_transfers.pop(None, [])
for group in grouped_transfers.values():
pi.transfers.append({
'sum': MoneyBasket(pt['amount'] for pt in group),
'team_id': group[0]['team'],
'team_name': group[0]['team_name'],
'transfers': group,
})
del grouped_transfers

title = "Payments Admin"

[---] text/html
Expand All @@ -56,6 +81,22 @@ title = "Payments Admin"

% extends "templates/layouts/admin.html"

% macro render_payin_transfer(pt, pi)
<li>
{{ locale.format_money(pt.amount) }}
to <a href="/~{{ pt.recipient }}/">{{ pt.recipient_name }}</a>
% if pt.period
({{ locale.format_money(pt.unit_amount) }}/{{ pt.period[:-2] }} × {{ pt.n_units }})
% endif
% if pt.status != pi.status
‒ <span class="text-{{ STATUS_MAP.get(pt.status, 'info') }}">{{ pt.status }}</span>
% if pt.error
‒ error: <code>{{ pt.error }}</code>
% endif
% endif
</li>
% endmacro

% block content

<ul class="nav nav-pills">{{ querystring_nav('status', [
Expand Down Expand Up @@ -120,28 +161,20 @@ title = "Payments Admin"
<td>
% if pi.transfers
<ul class="right-pointing-arrows">
% for pt in pi.transfers
% for item in pi.transfers
% if 'transfers' in item
<li>
% do pt.__setitem__('status', (
'refunded' if pt.reversed_amount == pt.amount else
'partially_refunded' if pt.reversed_amount else
pt.status
))
{{ locale.format_money(Money(**pt.amount)) }}
to <a href="/~{{ pt.recipient }}/">{{ pt.recipient_name }}</a>
% if pt.team
through team <a href="/~{{ pt.team }}/">{{ pt.team_name }}</a>
% endif
% if pt.period
({{ locale.format_money(Money(**pt.unit_amount)) }}/{{ pt.period[:-2] }} × {{ pt.n_units }})
% endif
% if pt.status != pi.status
‒ <span class="text-{{ STATUS_MAP.get(pt.status, 'info') }}">{{ pt.status }}</span>
% if pt.error
‒ error: <code>{{ pt.error }}</code>
% endif
% endif
{{ locale.format_money_basket(item['sum']) }}
through team <a href="/~{{ item['team_id'] }}/">{{ item['team_name'] }}</a>
<ul class="hooked-right-pointing-arrows">
% for pt in item['transfers']
{{ render_payin_transfer(pt, pi) }}
% endfor
</ul>
</li>
% else
{{ render_payin_transfer(item, pi) }}
% endif
% endfor
</ul>
% else
Expand Down

0 comments on commit 2e25797

Please sign in to comment.