Skip to content

Commit

Permalink
implement per-currency payment instrument defaults (#2271)
Browse files Browse the repository at this point in the history
closes #2269
  • Loading branch information
Changaco committed Aug 27, 2023
1 parent f5948c1 commit 025853c
Show file tree
Hide file tree
Showing 6 changed files with 90 additions and 24 deletions.
16 changes: 16 additions & 0 deletions liberapay/models/exchange_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,22 @@ def set_as_default(self):
id=self.id, network=self.network
))

def set_as_default_for(self, currency):
with self.db.get_cursor() as cursor:
cursor.run("""
UPDATE exchange_routes
SET is_default_for = NULL
WHERE participant = %(p_id)s
AND is_default_for = %(currency)s;
UPDATE exchange_routes
SET is_default_for = %(currency)s
WHERE participant = %(p_id)s
AND id = %(route_id)s
""", dict(p_id=self.participant.id, route_id=self.id, currency=currency))
self.participant.add_event(cursor, 'set_default_route_for', dict(
id=self.id, network=self.network, currency=currency,
))

def set_mandate(self, mandate_id):
self.db.run("""
UPDATE exchange_routes
Expand Down
10 changes: 10 additions & 0 deletions liberapay/models/participant.py
Original file line number Diff line number Diff line change
Expand Up @@ -2028,6 +2028,16 @@ def get_currencies_for(tippee, tip):
fallback_currency = 'USD'
return fallback_currency, accepted

@cached_property
def donates_in_multiple_currencies(self):
return self.db.one("""
SELECT count(DISTINCT amount::currency) > 1
FROM current_tips
WHERE tipper = %s
AND amount > 0
AND renewal_mode > 0
""", (self.id,))


# More Random Stuff
# =================
Expand Down
3 changes: 2 additions & 1 deletion liberapay/payin/cron.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,8 @@ def execute_scheduled_payins():
AND r.status = 'chargeable'
AND r.network::text LIKE 'stripe-%%'
AND ( sp.amount::currency = 'EUR' OR r.network <> 'stripe-sdd' )
ORDER BY r.is_default NULLS LAST
ORDER BY r.is_default_for = sp.amount::currency DESC NULLS LAST
, r.is_default DESC NULLS LAST
, r.ctime DESC
LIMIT 1
) r ON true
Expand Down
1 change: 1 addition & 0 deletions sql/branch.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE exchange_routes ADD COLUMN is_default_for currency;
52 changes: 30 additions & 22 deletions www/%username/giving/pay/stripe/%payin_id.spt
Original file line number Diff line number Diff line change
Expand Up @@ -87,19 +87,20 @@ if request.method == 'POST':
body.word('token'), one_off, payin_amount, owner_info, return_url
)
route = ExchangeRoute.attach_stripe_source(payer, source, one_off)
if body.parse_boolean('set_as_default', default=False):
route.set_as_default()
elif 'stripe_pm_id' in body:
one_off = body.get('keep') != 'true'
pm = stripe.PaymentMethod.retrieve(body.word('stripe_pm_id'))
route = ExchangeRoute.attach_stripe_payment_method(payer, pm, one_off)
if body.parse_boolean('set_as_default', default=False):
route.set_as_default()
else:
route = ExchangeRoute.from_id(payer, body.get_int('route'), _raise=False)
if route is None:
raise response.invalid_input(body.get('route'), 'route', 'body')
route.sync_status()
if body.parse_boolean('set_as_default', default=False):
route.set_as_default()
set_as_default_for = body.get_currency('set_as_default_for', None)
if set_as_default_for:
route.set_as_default_for(set_as_default_for)

except stripe.error.StripeError as e:
raise response.error(e.http_status or 500, _(
Expand Down Expand Up @@ -188,18 +189,32 @@ elif payin_id:
""", (payin.id,)) > 0
)

else:
tippees = request.qs.parse_list('beneficiary', int, default=None)
if tippees:
tips = [
tip for tip in payer.get_tips_to(tippees)
if tip.tippee_p.payment_providers & STRIPE_BIT > 0
]
if len(set(tip.amount.currency for tip in tips)) != 1:
raise response.invalid_input(tippees, 'beneficiary', 'querystring')
payment = PayinProspect(payer, tips, 'stripe')
for tip in tips:
if payment.currency not in tip.tippee_p.accepted_currencies_set:
raise response.redirect(payer.path(
'giving/pay?redirect_reason=unaccepted_currency'
))
routes = website.db.all("""
SELECT r
FROM exchange_routes r
WHERE r.participant = %s
AND r.status = 'chargeable'
AND r.network::text LIKE %s
AND (r.one_off IS FALSE OR r.ctime > (current_timestamp - interval '6 hours'))
ORDER BY r.is_default NULLS LAST
ORDER BY r.is_default_for = %s DESC NULLS LAST
, r.is_default DESC NULLS LAST
, r.network = 'stripe-sdd' DESC
, r.id DESC
""", (payer.id, 'stripe-' + (payment_type or '%')))
""", (payer.id, 'stripe-' + (payment_type or '%'), payment.currency))
if routes:
route = None
while routes:
Expand All @@ -212,21 +227,6 @@ else:
del route
if not payment_type:
response.redirect(payer.path('giving/pay'))

tippees = request.qs.parse_list('beneficiary', int, default=None)
if tippees:
tips = [
tip for tip in payer.get_tips_to(tippees)
if tip.tippee_p.payment_providers & STRIPE_BIT > 0
]
if len(set(tip.amount.currency for tip in tips)) != 1:
raise response.invalid_input(tippees, 'beneficiary', 'querystring')
payment = PayinProspect(payer, tips, 'stripe')
for tip in tips:
if payment.currency not in tip.tippee_p.accepted_currencies_set:
raise response.redirect(payer.path(
'giving/pay?redirect_reason=unaccepted_currency'
))
ask_for_postal_address = (
payment_type == 'sdd' or
len(tips) == 1 and
Expand Down Expand Up @@ -509,10 +509,18 @@ title = _("Funding your donations")
<input type="checkbox" name="keep" value="true" checked />
{{ _("Remember the card number for next time") }}
</label><br>
% if payer.donates_in_multiple_currencies
<label>
<input type="checkbox" name="set_as_default_for" value="{{ payment.currency }}" checked />
{{ _("Use this payment instrument by default for future payments in {currency}",
currency=Currency(payment.currency)) }}
</label>
% else
<label>
<input type="checkbox" name="set_as_default" value="true" checked />
{{ _("Use this payment instrument by default for future payments") }}
</label>
% endif
<br><br>
</fieldset>
% elif payment_type == 'sdd'
Expand Down
32 changes: 31 additions & 1 deletion www/%username/routes/index.spt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ if request.method == 'POST':
route = ExchangeRoute.from_id(participant, request.body.get_int('set_as_default'), _raise=False)
if route:
route.set_as_default()
elif 'set_as_default_for' in request.body:
try:
route_id, currency = request.body['set_as_default_for'].split(':')
route_id = int(route_id)
if currency not in constants.CURRENCIES:
raise ValueError(currency)
except ValueError:
raise response.invalid_input(request.body['set_as_default_for'], 'set_as_default_for', 'body')
route = ExchangeRoute.from_id(participant, route_id, _raise=False)
if route:
route.set_as_default_for(currency)
else:
raise response.error(400, "no known action found in request body")
form_post_success(state)
Expand Down Expand Up @@ -100,7 +111,16 @@ title = _("Payment Instruments")
<span class="text-muted">({{ locale.currencies.get(route.currency, route.currency) }})</span>
% endif
% if route.is_default
&nbsp; <span class="label label-primary">{{ _("default") }}</span>
&nbsp; <span class="label label-primary" title="{{ _(
'This instrument is used by default.'
) }}">{{ _("default") }}</span>
% elif route.is_default_for
&nbsp; <span class="label label-primary" title="{{ _(
'This instrument is used by default for payments in {currency}.',
currency=Currency(route.is_default_for)
) }}">{{ _(
"default for {currency}", currency=route.is_default_for
) }}</span>
% endif
% if route.status == 'pending'
&nbsp; <span class="label label-warning">{{ _("pending") }}</span>
Expand Down Expand Up @@ -159,6 +179,16 @@ title = _("Payment Instruments")
<button class="btn btn-danger btn-xs" name="remove" value="{{ route.id }}">{{ _("Remove") }}</button>
% if not route.is_default
<button class="btn btn-primary btn-xs" name="set_as_default" value="{{ route.id }}">{{ _("Set as default") }}</button>
% if last_payin and participant.donates_in_multiple_currencies
<button class="btn btn-primary btn-xs"
name="set_as_default_for" value="{{ route.id }}:{{ last_payin.amount.currency }}"
title="{{ _(
'Use this instrument by default for payments in {currency}.',
currency=Currency(last_payin.amount.currency)
) }}">{{ _(
"Set as default for {currency}", currency=last_payin.amount.currency
) }}</button>
% endif
% endif
</span>
</div>
Expand Down

0 comments on commit 025853c

Please sign in to comment.