diff --git a/liberapay/billing/payday.py b/liberapay/billing/payday.py index 6013bd402d..c7fd03174a 100644 --- a/liberapay/billing/payday.py +++ b/liberapay/billing/payday.py @@ -175,7 +175,8 @@ def prepare(cursor, ts_start): , username , join_time , ( COALESCE((eur_w.balance).amount, '0.00'), - COALESCE((usd_w.balance).amount, '0.00') + COALESCE((usd_w.balance).amount, '0.00'), + NULL )::currency_basket AS balances , goal , kind @@ -423,7 +424,10 @@ def resolve_takes(tips, takes, ref_currency): take.amount = (take.amount * takes_ratio).round_up() if take.paid_in_advance is None: take.paid_in_advance = take.amount.zero() - take.accepted_currencies = take.accepted_currencies.split(',') + if take.accepted_currencies is None: + take.accepted_currencies = constants.CURRENCIES + else: + take.accepted_currencies = take.accepted_currencies.split(',') for accepted in take.accepted_currencies: skip = ( accepted == take.main_currency or @@ -586,7 +590,7 @@ def check_balances(cursor): , p2.balances FROM payday_participants p2 JOIN participants p ON p.id = p2.id - WHERE (p2.balances).EUR < 0 OR (p2.balances).USD < 0 + WHERE p2.balances->'EUR' < 0 OR p2.balances->'USD' < 0 LIMIT 1 """) if oops: diff --git a/liberapay/billing/transactions.py b/liberapay/billing/transactions.py index d198852736..543b8cbda1 100644 --- a/liberapay/billing/transactions.py +++ b/liberapay/billing/transactions.py @@ -30,6 +30,12 @@ QUARANTINE = '%s days' % QUARANTINE.days +def Money_to_cents(m): + r = Money(currency=m.currency) + r.amount = int(m.amount * 100) + return r + + def repr_error(o): r = o.ResultCode if r == '000000': @@ -98,9 +104,9 @@ def payout(db, route, amount, ignore_high_fee=False): e_id = record_exchange(db, route, -credit_amount, fee, vat, participant, 'pre').id payout = BankWirePayOut() payout.AuthorId = participant.mangopay_user_id - payout.DebitedFunds = amount.int() + payout.DebitedFunds = Money_to_cents(amount) payout.DebitedWalletId = participant.get_current_wallet(amount.currency).remote_id - payout.Fees = fee.int() + payout.Fees = Money_to_cents(fee) payout.BankAccountId = route.address payout.BankWireRef = str(e_id) payout.Tag = str(e_id) @@ -139,11 +145,11 @@ def charge(db, route, amount, return_url, billing_address=None): if billing_address: payin.Billing = {'Address': billing_address} payin.CreditedWalletId = wallet.remote_id - payin.DebitedFunds = charge_amount.int() + payin.DebitedFunds = Money_to_cents(charge_amount) payin.CardId = route.address payin.SecureMode = 'FORCE' payin.SecureModeReturnURL = return_url - payin.Fees = fee.int() + payin.Fees = Money_to_cents(fee) payin.Tag = str(e_id) try: test_hook() @@ -207,9 +213,9 @@ def execute_direct_debit(db, exchange, route): payin = DirectDebitDirectPayIn() payin.AuthorId = participant.mangopay_user_id payin.CreditedWalletId = exchange.wallet_id - payin.DebitedFunds = debit_amount.int() + payin.DebitedFunds = Money_to_cents(debit_amount) payin.MandateId = route.mandate - payin.Fees = fee.int() + payin.Fees = Money_to_cents(fee) payin.Tag = str(e_id) try: test_hook() @@ -241,8 +247,8 @@ def payin_bank_wire(db, participant, debit_amount): payin = BankWirePayIn() payin.AuthorId = participant.mangopay_user_id payin.CreditedWalletId = wallet.remote_id - payin.DeclaredDebitedFunds = debit_amount.int() - payin.DeclaredFees = fee.int() + payin.DeclaredDebitedFunds = Money_to_cents(debit_amount) + payin.DeclaredFees = Money_to_cents(fee) payin.Tag = str(e_id) try: test_hook() @@ -505,7 +511,7 @@ def transfer(db, tipper, tippee, amount, context, **kw): tr.AuthorId = tipper_wallet.remote_owner_id tr.CreditedUserId = tippee_wallet.remote_owner_id tr.CreditedWalletId = wallet_to - tr.DebitedFunds = amount.int() + tr.DebitedFunds = Money_to_cents(amount) tr.DebitedWalletId = wallet_from tr.Fees = Money(0, amount.currency) tr.Tag = str(t_id) @@ -611,7 +617,7 @@ def initiate_transfer(db, t_id): tr.AuthorId = tipper_wallet.remote_owner_id tr.CreditedUserId = tippee_wallet.remote_owner_id tr.CreditedWalletId = tippee_wallet.remote_id - tr.DebitedFunds = amount.int() + tr.DebitedFunds = Money_to_cents(amount) tr.DebitedWalletId = tipper_wallet.remote_id tr.Fees = Money(0, amount.currency) tr.Tag = str(t_id) @@ -774,8 +780,8 @@ def refund_payin(db, exchange, amount, participant): m_refund = PayInRefund(payin_id=exchange.remote_id) m_refund.AuthorId = wallet.remote_owner_id m_refund.Tag = str(e_refund.id) - m_refund.DebitedFunds = amount.int() - m_refund.Fees = -fee.int() + m_refund.DebitedFunds = Money_to_cents(amount) + m_refund.Fees = -Money_to_cents(fee) try: m_refund.save() except Exception as e: @@ -926,8 +932,8 @@ def refund_disputed_payin(db, exchange, create_debts=False, refund_fee=False, dr m_refund = PayInRefund(payin_id=exchange.remote_id) m_refund.AuthorId = wallet.remote_owner_id m_refund.Tag = str(e_refund.id) - m_refund.DebitedFunds = amount.int() - m_refund.Fees = -fee.int() + m_refund.DebitedFunds = Money_to_cents(amount) + m_refund.Fees = -Money_to_cents(fee) try: m_refund.save() except Exception as e: @@ -1020,7 +1026,7 @@ def recover_lost_funds(db, exchange, lost_amount, repudiation_id): tr.AuthorId = original_owner.mangopay_user_id tr.CreditedUserId = chargebacks_account.mangopay_user_id tr.CreditedWalletId = to_wallet - tr.DebitedFunds = exchange.amount.int() + tr.DebitedFunds = Money_to_cents(exchange.amount) tr.DebitedWalletId = from_wallet tr.Fees = Money(0, currency) tr.RepudiationId = repudiation_id diff --git a/liberapay/constants.py b/liberapay/constants.py index 3d4a588bbb..44219d8149 100644 --- a/liberapay/constants.py +++ b/liberapay/constants.py @@ -1,9 +1,9 @@ # coding: utf8 from __future__ import print_function, unicode_literals -from collections import namedtuple, OrderedDict +from collections import defaultdict, namedtuple, OrderedDict from datetime import date, datetime, timedelta -from decimal import Decimal, ROUND_UP +from decimal import Decimal, ROUND_FLOOR, ROUND_HALF_UP, ROUND_UP import re from jinja2 import StrictUndefined @@ -47,6 +47,34 @@ def with_vat(self): return r[0] if not r[1] else r[1].round_up() if not r[0] else r +def to_precision(x, precision, rounding=ROUND_HALF_UP): + if not x: + return x + # round + factor = Decimal(10) ** (x.log10().to_integral(ROUND_FLOOR) + 1) + r = (x / factor).quantize(Decimal(10) ** -precision, rounding=rounding) * factor + # remove trailing zeros + r = r.quantize(Decimal(10) ** (int(x.log10()) - precision + 1)) + return r + + +def convert_symbolic_amount(amount, target_currency, precision=2, rounding=ROUND_HALF_UP): + from liberapay.website import website + rate = website.currency_exchange_rates[('EUR', target_currency)] + return to_precision(amount * rate, precision, rounding) + + +class MoneyAutoConvertDict(defaultdict): + + def __init__(self, *args, **kw): + super(MoneyAutoConvertDict, self).__init__(None, *args, **kw) + + def __missing__(self, currency): + r = Money(convert_symbolic_amount(self['EUR'].amount, currency, 1), currency) + self[currency] = r + return r + + StandardTip = namedtuple('StandardTip', 'label weekly monthly yearly') @@ -64,14 +92,28 @@ def with_vat(self): BIRTHDAY = date(2015, 5, 22) -CURRENCIES = ordered_set(['EUR', 'USD']) +CURRENCIES = ordered_set([ + 'EUR', 'USD', + 'AUD', 'BGN', 'BRL', 'CAD', 'CHF', 'CNY', 'CZK', 'DKK', 'GBP', 'HKD', 'HRK', + 'HUF', 'IDR', 'ILS', 'INR', 'ISK', 'JPY', 'KRW', 'MXN', 'MYR', 'NOK', 'NZD', + 'PHP', 'PLN', 'RON', 'RUB', 'SEK', 'SGD', 'THB', 'TRY', 'ZAR' +]) D_CENT = Decimal('0.01') -D_INF = Decimal('inf') D_MAX = Decimal('999999999999.99') -D_UNIT = Decimal('1.00') D_ZERO = Decimal('0.00') +class _DonationLimits(defaultdict): + def __missing__(self, currency): + r = { + period: ( + Money(convert_symbolic_amount(eur_amounts[0], currency, rounding=ROUND_UP), currency), + Money(convert_symbolic_amount(eur_amounts[1], currency, rounding=ROUND_UP), currency) + ) for period, eur_amounts in DONATION_LIMITS_EUR_USD.items() + } + self[currency] = r + return r + DONATION_LIMITS_WEEKLY_EUR_USD = (Decimal('0.01'), Decimal('100.00')) DONATION_LIMITS_EUR_USD = { 'weekly': DONATION_LIMITS_WEEKLY_EUR_USD, @@ -80,10 +122,10 @@ def with_vat(self): 'yearly': tuple((x * Decimal(52)).quantize(D_CENT) for x in DONATION_LIMITS_WEEKLY_EUR_USD), } -DONATION_LIMITS = { +DONATION_LIMITS = _DonationLimits(None, { 'EUR': {k: (Money(v[0], 'EUR'), Money(v[1], 'EUR')) for k, v in DONATION_LIMITS_EUR_USD.items()}, 'USD': {k: (Money(v[0], 'USD'), Money(v[1], 'USD')) for k, v in DONATION_LIMITS_EUR_USD.items()}, -} +}) DOMAIN_RE = re.compile(r''' ^ @@ -233,39 +275,39 @@ def with_vat(self): } PAYIN_DIRECT_DEBIT_MAX = {k: Money('2500.00', k) for k in ('EUR', 'USD')} -PAYIN_PAYPAL_MIN_ACCEPTABLE = { # fee > 10% +PAYIN_PAYPAL_MIN_ACCEPTABLE = MoneyAutoConvertDict({ # fee > 10% 'EUR': Money('2.00', 'EUR'), 'USD': Money('2.00', 'USD'), -} -PAYIN_PAYPAL_MIN_RECOMMENDED = { # fee < 8% +}) +PAYIN_PAYPAL_MIN_RECOMMENDED = MoneyAutoConvertDict({ # fee < 8% 'EUR': Money('10.00', 'EUR'), 'USD': Money('12.00', 'USD'), -} -PAYIN_PAYPAL_LOW_FEE = { # fee < 6% +}) +PAYIN_PAYPAL_LOW_FEE = MoneyAutoConvertDict({ # fee < 6% 'EUR': Money('40.00', 'EUR'), 'USD': Money('48.00', 'USD'), -} -PAYIN_PAYPAL_MAX_ACCEPTABLE = { +}) +PAYIN_PAYPAL_MAX_ACCEPTABLE = MoneyAutoConvertDict({ 'EUR': Money('5000.00', 'EUR'), 'USD': Money('5000.00', 'USD'), -} +}) -PAYIN_STRIPE_MIN_ACCEPTABLE = { # fee > 10% +PAYIN_STRIPE_MIN_ACCEPTABLE = MoneyAutoConvertDict({ # fee > 10% 'EUR': Money('2.00', 'EUR'), 'USD': Money('2.00', 'USD'), -} -PAYIN_STRIPE_MIN_RECOMMENDED = { # fee < 8% +}) +PAYIN_STRIPE_MIN_RECOMMENDED = MoneyAutoConvertDict({ # fee < 8% 'EUR': Money('10.00', 'EUR'), 'USD': Money('12.00', 'USD'), -} -PAYIN_STRIPE_LOW_FEE = { # fee < 6% +}) +PAYIN_STRIPE_LOW_FEE = MoneyAutoConvertDict({ # fee < 6% 'EUR': Money('40.00', 'EUR'), 'USD': Money('48.00', 'USD'), -} -PAYIN_STRIPE_MAX_ACCEPTABLE = { +}) +PAYIN_STRIPE_MAX_ACCEPTABLE = MoneyAutoConvertDict({ 'EUR': Money('5000.00', 'EUR'), 'USD': Money('5000.00', 'USD'), -} +}) PAYMENT_METHODS = { 'mango-ba': _("Direct Debit"), @@ -367,6 +409,17 @@ def make_standard_tip(label, weekly, currency): ) +class _StandardTips(defaultdict): + def __missing__(self, currency): + r = [ + make_standard_tip( + label, convert_symbolic_amount(weekly, currency, rounding=ROUND_UP), currency + ) for label, weekly in STANDARD_TIPS_EUR_USD + ] + self[currency] = r + return r + + STANDARD_TIPS_EUR_USD = ( (_("Symbolic"), Decimal('0.01')), (_("Small"), Decimal('0.25')), @@ -374,10 +427,10 @@ def make_standard_tip(label, weekly, currency): (_("Large"), Decimal('5.00')), (_("Maximum"), DONATION_LIMITS_EUR_USD['weekly'][1]), ) -STANDARD_TIPS = { +STANDARD_TIPS = _StandardTips(None, { 'EUR': [make_standard_tip(label, weekly, 'EUR') for label, weekly in STANDARD_TIPS_EUR_USD], 'USD': [make_standard_tip(label, weekly, 'USD') for label, weekly in STANDARD_TIPS_EUR_USD], -} +}) SUMMARY_MAX_SIZE = 100 @@ -386,6 +439,4 @@ def make_standard_tip(label, weekly, currency): USERNAME_MAX_SIZE = 32 USERNAME_SUFFIX_BLACKLIST = set('.txt .html .htm .json .xml'.split()) -ZERO = {c: Money(D_ZERO, c) for c in ('EUR', 'USD', None)} - del _ diff --git a/liberapay/main.py b/liberapay/main.py index 9b31addb68..9a3f9fec39 100644 --- a/liberapay/main.py +++ b/liberapay/main.py @@ -12,7 +12,6 @@ import aspen import aspen.http.mapping from aspen.request_processor.dispatcher import DispatchResult, DispatchStatus -from mangopay.utils import Money import pando from pando import json from pando.algorithms.website import fill_response_with_output @@ -31,7 +30,7 @@ from liberapay.utils import ( b64decode_s, b64encode_s, erase_cookie, http_caching, i18n, set_cookie, urlquote, ) -from liberapay.utils.currencies import MoneyBasket, fetch_currency_exchange_rates +from liberapay.utils.currencies import Money, MoneyBasket, fetch_currency_exchange_rates from liberapay.utils.emails import handle_email_bounces from liberapay.utils.state_chain import ( attach_environ_to_request, create_response_object, reject_requests_bypassing_proxy, diff --git a/liberapay/models/_mixin_team.py b/liberapay/models/_mixin_team.py index c8004f9d9a..8d3e1963c3 100644 --- a/liberapay/models/_mixin_team.py +++ b/liberapay/models/_mixin_team.py @@ -7,7 +7,7 @@ from collections import OrderedDict from statistics import median -from liberapay.constants import ZERO, TAKE_THROTTLING_THRESHOLD +from liberapay.constants import TAKE_THROTTLING_THRESHOLD from liberapay.utils import NS, group_by from liberapay.utils.currencies import Money, MoneyBasket @@ -39,7 +39,7 @@ def add_member(self, member, cursor=None): raise MemberLimitReached if member.status != 'active': raise InactiveParticipantAdded - self.set_take_for(member, ZERO[member.main_currency], self, cursor=cursor) + self.set_take_for(member, Money.ZEROS[member.main_currency], self, cursor=cursor) def remove_all_members(self, cursor=None): (cursor or self.db).run(""" @@ -95,7 +95,7 @@ def compute_max_this_week(self, member_id, last_week, currency): leftover, or last week's median take, or one currency unit (e.g. €1.00). """ nonzero_last_week = [a.convert(currency).amount for a in last_week.values() if a] - member_last_week = last_week.get(member_id, ZERO[currency]).convert(currency) + member_last_week = last_week.get(member_id, Money.ZEROS[currency]).convert(currency) return max( member_last_week * 2, member_last_week + last_week.initial_leftover.fuzzy_sum(currency), @@ -289,7 +289,7 @@ def get_members(self): compute_max = self.throttle_takes and nmembers > 1 and last_week.sum members = OrderedDict() members.leftover = self.leftover - zero = ZERO[self.main_currency] + zero = Money.ZEROS[self.main_currency] for take in takes: member = {} m_id = member['id'] = take['member_id'] diff --git a/liberapay/models/participant.py b/liberapay/models/participant.py index 7ba2ac6e64..54d8695b9d 100644 --- a/liberapay/models/participant.py +++ b/liberapay/models/participant.py @@ -2,6 +2,7 @@ from base64 import b64decode, b64encode from datetime import timedelta +from decimal import Decimal from email.utils import formataddr from hashlib import pbkdf2_hmac, md5, sha1 from os import urandom @@ -16,7 +17,6 @@ from cached_property import cached_property from html2text import html2text import mangopay -from mangopay.utils import Money from markupsafe import escape as htmlescape from pando import json from pando.utils import utcnow @@ -25,12 +25,12 @@ import requests from liberapay.constants import ( - ASCII_ALLOWED_IN_USERNAME, AVATAR_QUERY, CURRENCIES, D_UNIT, D_ZERO, + ASCII_ALLOWED_IN_USERNAME, AVATAR_QUERY, CURRENCIES, DONATION_LIMITS, EMAIL_VERIFICATION_TIMEOUT, EVENTS, HTML_A, PASSWORD_MAX_SIZE, PASSWORD_MIN_SIZE, PAYMENT_SLUGS, PERIOD_CONVERSION_RATES, PRIVILEGES, PROFILE_VISIBILITY_ATTRS, PUBLIC_NAME_MAX_SIZE, SESSION, SESSION_REFRESH, SESSION_TIMEOUT, - USERNAME_MAX_SIZE, USERNAME_SUFFIX_BLACKLIST, ZERO, + USERNAME_MAX_SIZE, USERNAME_SUFFIX_BLACKLIST, ) from liberapay.exceptions import ( BadAmount, @@ -73,7 +73,7 @@ NS, deserialize, erase_cookie, serialize, set_cookie, urlquote, emails, i18n, markdown, ) -from liberapay.utils.currencies import MoneyBasket +from liberapay.utils.currencies import Money, MoneyBasket from liberapay.utils.emails import check_email_blacklist, normalize_email_address from liberapay.website import website @@ -90,10 +90,6 @@ class Participant(Model, MixinTeam): session = None - def __init__(self, record): - super(Participant, self).__init__(record) - self.__dict__['_accepted_currencies'] = self.__dict__.pop('accepted_currencies') - def __eq__(self, other): if not isinstance(other, Participant): return False @@ -295,7 +291,7 @@ def get_chargebacks_account(cls, currency): ON CONFLICT (remote_id) DO UPDATE SET remote_owner_id = 'CREDIT' -- dummy update RETURNING * - """, ('CREDIT_' + currency, ZERO[currency], p.id)) + """, ('CREDIT_' + currency, Money.ZEROS[currency], p.id)) return p, wallet def refetch(self): @@ -584,7 +580,7 @@ def distribute_balances_to_donees(self, final_gift=True): ] if len(takes) == 1 and len(tip.takes) == 1 and tip.takes[0]['amount'] == 0: # Team of one with a zero take - tip.takes[0]['amount'].amount = D_UNIT + tip.takes[0]['amount'].amount = Decimal('1') tip.total_takes = MoneyBasket(*[t['amount'] for t in tip.takes]) tips = [t for t in tips if getattr(t, 'total_takes', -1) != 0] transfers = [] @@ -598,7 +594,7 @@ def distribute_balances_to_donees(self, final_gift=True): key=lambda t: (t.amount, t.ctime), reverse=True ) total = Money.sum((t.amount for t in tips_in_this_currency), currency) - distributed = ZERO[currency] + distributed = Money.ZEROS[currency] initial_balance = wallet.balance transfers_in_this_currency = [] @@ -1268,7 +1264,7 @@ def get_withdrawable_amount(self, currency): AND disputed IS NOT TRUE AND locked_for IS NULL AND (amount).currency = %s - """, (self.id, QUARANTINE, currency)) or ZERO[currency] + """, (self.id, QUARANTINE, currency)) or Money.ZEROS[currency] def can_withdraw(self, amount): return self.get_withdrawable_amount(amount.currency) >= amount @@ -1302,7 +1298,7 @@ def get_balance_in(self, currency): WHERE owner = %s AND balance::currency = %s AND is_current - """, (self.id, currency)) or ZERO[currency] + """, (self.id, currency)) or Money.ZEROS[currency] def get_balances(self): return self.db.one(""" @@ -1568,8 +1564,8 @@ def pay_invoice(self, invoice): # ========== @cached_property - def accepted_currencies(self): - v = self._accepted_currencies + def accepted_currencies_set(self): + v = self.accepted_currencies return CURRENCIES if v is None else set(v.split(',')) def change_main_currency(self, new_currency, recorder): @@ -1604,7 +1600,7 @@ def get_currencies_for(self, tippee, tip): if isinstance(tip, NS): tip = tip.__dict__ tip_currency = tip['amount'].currency - accepted = tippee.accepted_currencies + accepted = tippee.accepted_currencies_set if tip_currency in accepted: return tip_currency, accepted else: @@ -1840,7 +1836,7 @@ def get_giving_in(self, currency): AND t.amount::currency = %s AND p.status = 'active' AND (p.goal IS NULL OR p.goal >= 0) - """, (self.id, currency)) or ZERO[currency] + """, (self.id, currency)) or Money.ZEROS[currency] def get_receiving_in(self, currency, cursor=None): r = (cursor or self.db).one(""" @@ -1849,14 +1845,14 @@ def get_receiving_in(self, currency, cursor=None): WHERE t.tippee = %s AND t.amount::currency = %s AND t.is_funded - """, (self.id, currency)) or ZERO[currency] + """, (self.id, currency)) or Money.ZEROS[currency] if currency not in CURRENCIES: raise ValueError(currency) r += Money((cursor or self.db).one(""" - SELECT sum((t.actual_amount).{0}) + SELECT sum(t.actual_amount->%s) FROM current_takes t WHERE t.member = %s - """.format(currency), (self.id,)) or D_ZERO, currency) + """, (currency, self.id)) or Money.ZEROS[currency].amount, currency) return r def get_exact_receiving(self): @@ -1890,7 +1886,7 @@ def update_giving(self, cursor=None): currencies = set(t.amount.currency for t in tips) balances = {w.balance.currency: w.balance for w in self.get_current_wallets(cursor)} for currency in currencies: - fake_balance = balances.get(currency, ZERO[currency]) + fake_balance = balances.get(currency, Money.ZEROS[currency]) fake_balance += self.get_receiving_in(currency, cursor) for tip in (t for t in tips if t.amount.currency == currency): if tip.amount <= (tip.paid_in_advance or 0): @@ -1939,7 +1935,7 @@ def update_receiving(self, cursor=None): with self.db.get_cursor(cursor) as c: if self.kind == 'group': c.run("LOCK TABLE takes IN EXCLUSIVE MODE") - zero = ZERO[self.main_currency] + zero = Money.ZEROS[self.main_currency] r = c.one(""" WITH our_tips AS ( SELECT amount @@ -1998,7 +1994,7 @@ def set_tip_to(self, tippee, periodic_amount, period='weekly', raise BadAmount(periodic_amount, period, limits) if not tippee.accepts_tips: raise UserDoesntAcceptTips(tippee.username) - if amount.currency not in tippee.accepted_currencies: + if amount.currency not in tippee.accepted_currencies_set: raise BadDonationCurrency(tippee, amount.currency) # Insert tip @@ -2040,9 +2036,9 @@ def set_tip_to(self, tippee, periodic_amount, period='weekly', def _zero_tip_dict(tippee, currency=None): if not isinstance(tippee, Participant): tippee = Participant.from_id(tippee) - if not currency or currency not in tippee.accepted_currencies: + if not currency or currency not in tippee.accepted_currencies_set: currency = tippee.main_currency - zero = ZERO[currency] + zero = Money.ZEROS[currency] return dict(amount=zero, is_funded=False, tippee=tippee.id, period='weekly', periodic_amount=zero) @@ -2107,7 +2103,7 @@ def get_tip_distribution(self): tip_amounts = [] npatrons = 0 currency = self.main_currency - contributed = ZERO[currency] + contributed = Money.ZEROS[currency] for rec in recs: tip_amounts.append([ rec.amount, diff --git a/liberapay/payin/stripe.py b/liberapay/payin/stripe.py index 08f1120970..7454cfa6d3 100644 --- a/liberapay/payin/stripe.py +++ b/liberapay/payin/stripe.py @@ -1,5 +1,7 @@ from __future__ import absolute_import, division, print_function, unicode_literals +from decimal import Decimal + import stripe import stripe.error @@ -8,6 +10,25 @@ from .common import update_payin, update_payin_transfer +# https://stripe.com/docs/currencies#presentment-currencies +ZERO_DECIMAL_CURRENCIES = """ + BIF CLP DJF GNF JPY KMF KRW MGA PYG RWF UGX VND VUV XAF XOF XPF +""".split() + + +def int_to_Money(amount, currency): + currency = currency.upper() + if currency in ZERO_DECIMAL_CURRENCIES: + return Money(Decimal(amount), currency) + return Money(Decimal(amount) / 100, currency) + + +def Money_to_int(m): + if m.currency in ZERO_DECIMAL_CURRENCIES: + return int(m.amount) + return int(m.amount * 100) + + def repr_stripe_error(e): """Given a `StripeError` exception, return an error message suitable for display. """ @@ -45,7 +66,7 @@ def destination_charge(db, payin, payer, statement_descriptor): destination = {'account': destination} try: charge = stripe.Charge.create( - amount=amount.int().amount, + amount=Money_to_int(amount), currency=amount.currency.lower(), customer=route.remote_user_id, destination=destination, @@ -63,8 +84,8 @@ def destination_charge(db, payin, payer, statement_descriptor): return update_payin(db, payin.id, '', 'failed', str(e)) bt = charge.balance_transaction - amount_settled = Money(bt.amount, bt.currency.upper()) / 100 - fee = Money(bt.fee, bt.currency.upper()) / 100 + amount_settled = int_to_Money(bt.amount, bt.currency) + fee = int_to_Money(bt.fee, bt.currency) net_amount = amount_settled - fee if destination: diff --git a/liberapay/security/authentication.py b/liberapay/security/authentication.py index c6a2715648..142f17c9c4 100644 --- a/liberapay/security/authentication.py +++ b/liberapay/security/authentication.py @@ -28,7 +28,7 @@ class _ANON(object): def get_currencies_for(self, tippee, tip): if isinstance(tippee, AccountElsewhere): tippee = tippee.participant - return tip['amount'].currency, tippee.accepted_currencies + return tip['amount'].currency, tippee.accepted_currencies_set ANON = _ANON() diff --git a/liberapay/testing/__init__.py b/liberapay/testing/__init__.py index ba6449ce02..35176aa0b8 100644 --- a/liberapay/testing/__init__.py +++ b/liberapay/testing/__init__.py @@ -8,7 +8,6 @@ from os.path import dirname, join, realpath from aspen import resources -from mangopay.utils import Money from pando.utils import utcnow from pando.testing.client import Client from psycopg2 import IntegrityError, InternalError @@ -17,7 +16,7 @@ from liberapay.billing.transactions import ( record_exchange, record_exchange_result, prepare_transfer, _record_transfer_result ) -from liberapay.constants import SESSION, ZERO +from liberapay.constants import SESSION from liberapay.elsewhere._base import UserInfo from liberapay.main import website from liberapay.models.account_elsewhere import AccountElsewhere @@ -30,6 +29,7 @@ ) from liberapay.security.csrf import CSRF_TOKEN from liberapay.testing.vcr import use_cassette +from liberapay.utils.currencies import Money TOP = realpath(join(dirname(dirname(__file__)), '..')) @@ -41,6 +41,10 @@ def EUR(amount): return Money(amount, 'EUR') +def JPY(amount): + return Money(amount, 'JPY') + + def USD(amount): return Money(amount, 'USD') @@ -217,7 +221,7 @@ def make_participant(self, username, **kw): if is_person and participant.mangopay_user_id: wallet_id = kw2.get('mangopay_wallet_id', -participant.id) - zero = ZERO[participant.main_currency] + zero = Money.ZEROS[participant.main_currency] self.db.run(""" INSERT INTO wallets (remote_id, balance, owner, remote_owner_id) diff --git a/liberapay/testing/mangopay.py b/liberapay/testing/mangopay.py index 14c823fbfa..e0f4e70a7f 100644 --- a/liberapay/testing/mangopay.py +++ b/liberapay/testing/mangopay.py @@ -8,10 +8,10 @@ import mock import requests -from liberapay.constants import ZERO from liberapay.models.exchange_route import ExchangeRoute from liberapay.testing import Harness from liberapay.testing.vcr import use_cassette +from liberapay.utils.currencies import Money class MangopayHarness(Harness): @@ -46,7 +46,7 @@ def fake_transfer(tr): def fake_wallet(w): - w.Balance = ZERO[w.Currency] + w.Balance = Money.ZEROS[w.Currency] w.Id = -next(FakeTransfersHarness.wallet_id_serial) diff --git a/liberapay/utils/currencies.py b/liberapay/utils/currencies.py index 3b2090e1a0..8e405b581b 100644 --- a/liberapay/utils/currencies.py +++ b/liberapay/utils/currencies.py @@ -10,11 +10,11 @@ import requests import xmltodict -from liberapay.constants import CURRENCIES, D_CENT, D_ZERO, ZERO +from liberapay.constants import CURRENCIES, D_CENT, D_ZERO from liberapay.website import website -def _convert(self, c): +def _convert(self, c, rounding=ROUND_HALF_UP): if self.currency == c: return self if 'EUR' in (self.currency, c): @@ -25,17 +25,17 @@ def _convert(self, c): website.currency_exchange_rates[('EUR', c)] ) amount = self.amount * rate - return Money(amount.quantize(D_CENT), c) + return Money(amount, c, rounding=rounding) def _sum(cls, amounts, currency): - a = ZERO[currency].amount + a = Money.ZEROS[currency].amount for m in amounts: if m.currency != currency: raise CurrencyMismatch(m.currency, currency, 'sum') a += m.amount return cls(a, currency) -def _Money_init(self, amount=D_ZERO, currency=None): +def _Money_init(self, amount=Decimal('0'), currency=None, rounding=None): if not isinstance(amount, Decimal): amount = Decimal(str(amount)) # Why `str(amount)`? Because: @@ -43,6 +43,9 @@ def _Money_init(self, amount=D_ZERO, currency=None): # Decimal('0.2300000000000000099920072216264088638126850128173828125') # >>> Decimal(str(0.23)) # Decimal('0.23') + if rounding is not None: + minimum = Money.MINIMUMS[currency].amount + amount = amount.quantize(minimum, rounding=rounding) self.amount = amount self.currency = currency @@ -56,16 +59,22 @@ def _Money_eq(self, other): return False def _Money_round(self, rounding=ROUND_HALF_UP): - minimum = Money.minimums[self.currency] - return Money(self.amount.quantize(minimum, rounding=rounding), self.currency) + return Money(self.amount, self.currency, rounding=rounding) class _Minimums(defaultdict): def __missing__(self, currency): exponent = website.db.one("SELECT get_currency_exponent(%s)", (currency,)) - minimum = D_CENT if exponent == 2 else Decimal(10) ** (-exponent) + minimum = Money((D_CENT if exponent == 2 else Decimal(10) ** (-exponent)), currency) self[currency] = minimum return minimum +class _Zeros(defaultdict): + def __missing__(self, currency): + minimum = Money.MINIMUMS[currency].amount + zero = Money((D_ZERO if minimum is D_CENT else minimum - minimum), currency) + self[currency] = zero + return zero + Money.__init__ = _Money_init Money.__nonzero__ = Money.__bool__ @@ -75,21 +84,22 @@ def __missing__(self, currency): Money.__str__ = lambda m: '%(amount)s %(currency)s' % m.__dict__ Money.__unicode__ = Money.__str__ Money.convert = _convert -Money.int = lambda m: Money(int(m.amount * 100), m.currency) -Money.minimum = lambda m: Money.minimums[m.currency] -Money.minimums = _Minimums() +Money.minimum = lambda m: Money.MINIMUMS[m.currency] +Money.MINIMUMS = _Minimums() Money.round = _Money_round Money.round_down = lambda m: m.round(ROUND_DOWN) Money.round_up = lambda m: m.round(ROUND_UP) Money.sum = classmethod(_sum) -Money.zero = lambda m: Money(D_ZERO, m.currency) +Money.zero = lambda m: Money.ZEROS[m.currency] +Money.ZEROS = _Zeros() class MoneyBasket(object): def __init__(self, *args, **decimals): self.amounts = OrderedDict( - (currency, decimals.get(currency, D_ZERO)) for currency in CURRENCIES + (currency, decimals.get(currency, Money.ZEROS[currency].amount)) + for currency in CURRENCIES ) for arg in args: if isinstance(arg, Money): @@ -182,7 +192,7 @@ def __sub__(self, other): def __repr__(self): return '%s[%s]' % ( self.__class__.__name__, - ', '.join('%s %s' % (a, c) for c, a in self.amounts.items()) + ', '.join('%s %s' % (a, c) for c, a in self.amounts.items() if a) ) def __bool__(self): @@ -207,7 +217,7 @@ def currencies_present(self): return [k for k, v in self.amounts.items() if v > 0] def fuzzy_sum(self, currency, rounding=ROUND_UP): - a = ZERO[currency].amount + a = Money.ZEROS[currency].amount fuzzy = False for m in self: if m.currency == currency: @@ -215,7 +225,7 @@ def fuzzy_sum(self, currency, rounding=ROUND_UP): elif m.amount: a += m.amount * website.currency_exchange_rates[(m.currency, currency)] fuzzy = True - r = Money(a.quantize(D_CENT, rounding=rounding), currency) + r = Money(a, currency, rounding=rounding) r.fuzzy = fuzzy return r diff --git a/liberapay/utils/fake_data.py b/liberapay/utils/fake_data.py index 16185f0a77..5fa93f5fbe 100644 --- a/liberapay/utils/fake_data.py +++ b/liberapay/utils/fake_data.py @@ -5,7 +5,6 @@ import sys from faker import Factory -from mangopay.utils import Money from psycopg2 import IntegrityError from liberapay.billing.transactions import ( @@ -14,6 +13,7 @@ from liberapay.constants import D_CENT, DONATION_LIMITS, PERIOD_CONVERSION_RATES from liberapay.models.exchange_route import ExchangeRoute from liberapay.models import community +from liberapay.utils.currencies import Money, MoneyBasket DONATION_PERIODS = tuple(PERIOD_CONVERSION_RATES.keys()) @@ -284,7 +284,7 @@ def populate_db(website, num_participants=100, num_tips=200, num_teams=5, num_tr 'nparticipants': len(week_participants), 'ntippers': len(tippers), 'nactive': len(actives), - 'transfer_volume': '(%s,0.00)' % sum(x.amount.amount for x in week_transfers), + 'transfer_volume': MoneyBasket(x.amount for x in week_transfers), 'public_log': '', } _fake_thing(db, "paydays", **payday) diff --git a/liberapay/utils/i18n.py b/liberapay/utils/i18n.py index 70a3358d00..28aac725cc 100644 --- a/liberapay/utils/i18n.py +++ b/liberapay/utils/i18n.py @@ -20,13 +20,12 @@ format_currency, format_decimal, format_number, format_percent, ) import jinja2.ext -from mangopay.utils import Money from markupsafe import Markup from pando.utils import utcnow from liberapay.constants import CURRENCIES, D_MAX from liberapay.exceptions import AmbiguousNumber, InvalidNumber -from liberapay.utils.currencies import MoneyBasket +from liberapay.utils.currencies import Money, MoneyBasket from liberapay.website import website diff --git a/liberapay/wireup.py b/liberapay/wireup.py index 2a47e265c0..9a3091f3aa 100644 --- a/liberapay/wireup.py +++ b/liberapay/wireup.py @@ -24,7 +24,6 @@ import boto3 from environment import Environment, is_yesish from mailshake import AmazonSESMailer, DummyMailer, SMTPMailer -from mangopay.utils import Money import psycopg2 from psycopg2.extensions import adapt, AsIs, new_type, register_adapter, register_type import raven @@ -42,7 +41,7 @@ from liberapay.models.repository import Repository from liberapay.models import DB from liberapay.utils import find_files, markdown, mkdir_p, resolve, urlquote -from liberapay.utils.currencies import MoneyBasket, get_currency_exchange_rates +from liberapay.utils.currencies import Money, MoneyBasket, get_currency_exchange_rates from liberapay.utils.emails import compile_email_spt from liberapay.utils.http_caching import asset_etag from liberapay.utils.i18n import ( @@ -157,14 +156,31 @@ def cast_currency_amount(v, cursor): pass def adapt_money_basket(b): - return AsIs('(%s,%s)::currency_basket' % (b.amounts['EUR'], b.amounts['USD'])) + return AsIs( + "_wrap_amounts('%s'::jsonb)" % + json.dumps({k: str(v) for k, v in b.amounts.items() if v}).replace("'", "''") + ) register_adapter(MoneyBasket, adapt_money_basket) def cast_currency_basket(v, cursor): if v is None: return None - eur, usd = v[1:-1].split(',') - return MoneyBasket(EUR=Decimal(eur), USD=Decimal(usd)) + parts = v[1:-1].split(',', 2) + if len(parts) == 2: + eur, usd = parts + obj = None + else: + eur, usd, obj = parts + if obj: + amounts = json.loads(obj[1:-1].replace('""', '"') if obj[0] == '"' else obj) + amounts = {k: Decimal(str(v)) for k, v in amounts.items()} + else: + amounts = {} + if eur: + amounts['EUR'] = Decimal(eur) + if usd: + amounts['USD'] = Decimal(usd) + return MoneyBasket(**amounts) try: oid = db.one("SELECT 'currency_basket'::regtype::oid") register_type(new_type((oid,), _str('currency_basket'), cast_currency_basket)) @@ -691,7 +707,6 @@ def load_i18n(canonical_host, canonical_scheme, project_root, tell_sentry): # Patch the locales to look less formal locales['fr'].currency_formats['standard'] = parse_pattern('#,##0.00\u202f\xa4') - locales['fr'].currency_symbols['USD'] = '$' locales['fr'].currencies['USD'] = 'dollar états-unien' # Load the markdown files diff --git a/sql/branch.sql b/sql/branch.sql new file mode 100644 index 0000000000..531e643afa --- /dev/null +++ b/sql/branch.sql @@ -0,0 +1,175 @@ +CREATE FUNCTION _wrap_amounts(jsonb) RETURNS currency_basket AS $$ + BEGIN + IF ($1 IS NULL) THEN + RETURN (NULL::numeric,NULL::numeric); + ELSE + RETURN (($1->>'EUR')::numeric, ($1->>'USD')::numeric); + END IF; + END; +$$ LANGUAGE plpgsql IMMUTABLE; + +SELECT 'after deployment'; + +BEGIN; + + ALTER TYPE currency_basket ADD ATTRIBUTE amounts jsonb; + + CREATE OR REPLACE FUNCTION empty_currency_basket() RETURNS currency_basket AS $$ + BEGIN RETURN (NULL::numeric,NULL::numeric,jsonb_build_object()); END; + $$ LANGUAGE plpgsql; + + CREATE FUNCTION coalesce_currency_basket(currency_basket) RETURNS currency_basket AS $$ + BEGIN + IF (coalesce($1.EUR, 0) > 0 OR coalesce($1.USD, 0) > 0) THEN + IF ($1.amounts ? 'EUR' OR $1.amounts ? 'USD') THEN + RAISE 'got an hybrid currency basket: %', $1; + END IF; + RETURN _wrap_amounts( + jsonb_build_object('EUR', $1.EUR::text, 'USD', $1.USD::text) + ); + ELSIF (jsonb_typeof($1.amounts) = 'object') THEN + RETURN $1; + ELSIF ($1.amounts IS NULL OR jsonb_typeof($1.amounts) <> 'null') THEN + RETURN (NULL::numeric,NULL::numeric,jsonb_build_object()); + ELSE + RAISE 'unexpected JSON type: %', jsonb_typeof($1.amounts); + END IF; + END; + $$ LANGUAGE plpgsql IMMUTABLE; + + CREATE OR REPLACE FUNCTION _wrap_amounts(jsonb) RETURNS currency_basket AS $$ + BEGIN + IF ($1 IS NULL) THEN + RETURN (NULL::numeric,NULL::numeric,jsonb_build_object()); + ELSE + RETURN (NULL::numeric,NULL::numeric,$1); + END IF; + END; + $$ LANGUAGE plpgsql IMMUTABLE; + + CREATE OR REPLACE FUNCTION make_currency_basket(currency_amount) RETURNS currency_basket AS $$ + BEGIN RETURN (NULL::numeric,NULL::numeric,jsonb_build_object($1.currency::text, $1.amount::text)); END; + $$ LANGUAGE plpgsql IMMUTABLE STRICT; + + CREATE OR REPLACE FUNCTION currency_basket_add(currency_basket, currency_amount) + RETURNS currency_basket AS $$ + DECLARE + r currency_basket; + BEGIN + r := coalesce_currency_basket($1); + IF ($2.amount IS NULL OR $2.amount = 0 OR $2.currency IS NULL) THEN + RETURN r; + END IF; + r.amounts := jsonb_set( + r.amounts, + string_to_array($2.currency::text, ' '), + (coalesce((r.amounts->>$2.currency::text)::numeric, 0) + $2.amount)::text::jsonb + ); + RETURN r; + END; + $$ LANGUAGE plpgsql IMMUTABLE STRICT; + + CREATE OR REPLACE FUNCTION currency_basket_add(currency_basket, currency_basket) + RETURNS currency_basket AS $$ + DECLARE + amounts1 jsonb; + amounts2 jsonb; + currency text; + BEGIN + amounts1 := (coalesce_currency_basket($1)).amounts; + amounts2 := (coalesce_currency_basket($2)).amounts; + FOR currency IN SELECT * FROM jsonb_object_keys(amounts2) LOOP + amounts1 := jsonb_set( + amounts1, + string_to_array(currency, ' '), + ( coalesce((amounts1->>currency)::numeric, 0) + + coalesce((amounts2->>currency)::numeric, 0) + )::text::jsonb + ); + END LOOP; + RETURN _wrap_amounts(amounts1); + END; + $$ LANGUAGE plpgsql IMMUTABLE STRICT; + + CREATE OR REPLACE FUNCTION currency_basket_sub(currency_basket, currency_amount) + RETURNS currency_basket AS $$ + BEGIN RETURN currency_basket_add($1, -$2); END; + $$ LANGUAGE plpgsql IMMUTABLE STRICT; + + CREATE OR REPLACE FUNCTION currency_basket_sub(currency_basket, currency_basket) + RETURNS currency_basket AS $$ + DECLARE + amounts1 jsonb; + amounts2 jsonb; + currency text; + BEGIN + amounts1 := (coalesce_currency_basket($1)).amounts; + amounts2 := (coalesce_currency_basket($2)).amounts; + FOR currency IN SELECT * FROM jsonb_object_keys(amounts2) LOOP + amounts1 := jsonb_set( + amounts1, + string_to_array(currency, ' '), + ( coalesce((amounts1->>currency)::numeric, 0) - + coalesce((amounts2->>currency)::numeric, 0) + )::text::jsonb + ); + END LOOP; + RETURN _wrap_amounts(amounts1); + END; + $$ LANGUAGE plpgsql IMMUTABLE STRICT; + + CREATE OR REPLACE FUNCTION currency_basket_contains(currency_basket, currency_amount) + RETURNS boolean AS $$ + BEGIN RETURN coalesce(coalesce_currency_basket($1)->$2.currency::text, 0) >= $2.amount; END; + $$ LANGUAGE plpgsql IMMUTABLE STRICT; + + DROP AGGREGATE basket_sum(currency_amount); + CREATE AGGREGATE basket_sum(currency_amount) ( + sfunc = currency_basket_add, + stype = currency_basket, + initcond = '(,,{})' + ); + + DROP AGGREGATE sum(currency_basket); + CREATE AGGREGATE sum(currency_basket) ( + sfunc = currency_basket_add, + stype = currency_basket, + initcond = '(,,{})' + ); + + CREATE FUNCTION get_amount_from_currency_basket(currency_basket, currency) + RETURNS numeric AS $$ + BEGIN RETURN (coalesce_currency_basket($1)).amounts->>$2::text; END; + $$ LANGUAGE plpgsql IMMUTABLE STRICT; + + CREATE FUNCTION get_amount_from_currency_basket(currency_basket, text) + RETURNS numeric AS $$ + BEGIN RETURN (coalesce_currency_basket($1)).amounts->>$2; END; + $$ LANGUAGE plpgsql IMMUTABLE STRICT; + + CREATE OPERATOR -> ( + leftarg = currency_basket, + rightarg = currency, + procedure = get_amount_from_currency_basket + ); + + CREATE OPERATOR -> ( + leftarg = currency_basket, + rightarg = text, + procedure = get_amount_from_currency_basket + ); + + ALTER TABLE paydays ALTER COLUMN transfer_volume SET DEFAULT empty_currency_basket(); + ALTER TABLE paydays ALTER COLUMN take_volume SET DEFAULT empty_currency_basket(); + ALTER TABLE paydays ALTER COLUMN week_deposits SET DEFAULT empty_currency_basket(); + ALTER TABLE paydays ALTER COLUMN week_withdrawals SET DEFAULT empty_currency_basket(); + ALTER TABLE paydays ALTER COLUMN transfer_volume_refunded SET DEFAULT empty_currency_basket(); + ALTER TABLE paydays ALTER COLUMN week_deposits_refunded SET DEFAULT empty_currency_basket(); + ALTER TABLE paydays ALTER COLUMN week_withdrawals_refunded SET DEFAULT empty_currency_basket(); + + UPDATE participants + SET accepted_currencies = NULL + WHERE status = 'stub' + AND accepted_currencies IS NOT NULL; + +END; diff --git a/style/base/columns.scss b/style/base/columns.scss new file mode 100644 index 0000000000..8253a9e3c6 --- /dev/null +++ b/style/base/columns.scss @@ -0,0 +1,12 @@ +@media (min-width: $screen-sm-min) { + .columns-sm-2 { + column-count: 2; + column-gap: 10px; + } +} +@media (min-width: $screen-md-min) { + .columns-md-3 { + column-count: 3; + column-gap: 10px; + } +} diff --git a/templates/currencies.html b/templates/currencies.html index 9f7ca9d1d8..6fb6a2d96a 100644 --- a/templates/currencies.html +++ b/templates/currencies.html @@ -1,14 +1,10 @@ % macro currency_buttons(name, selected) -
{{ _( "You are currently donating {money_amount} per week to {name}, but " @@ -53,30 +55,38 @@ % else
{{ _("Please select or input an amount:") }}
% endif -{{ ngettext( + "Wrong currency? {n} other is supported:", + "Wrong currency? {n} others are supported:", + n=len(accepted_currencies) - 1, username=tippee_name + ) if pledging else ngettext( + "Wrong currency? {username} also accepts {n} other:", + "Wrong currency? {username} also accepts {n} others:", + n=len(accepted_currencies) - 1, username=tippee_name + ) }}
+ - % endif - % endfor -{{ _( + "No, {username} only accepts payments in {currency}.", + username=participant.username, currency=Currency(participant.main_currency) + ) }}
+ % endif +{{ _( - "We currently support most credit and debit cards (Visa, MasterCard, American Express). " - "SEPA direct debits will be operational soon. More options will be " - "added in the future." + "{username} hasn't configured any payment method yet, so your donation " + "cannot actually be processed right now. We will notify you when payment " + "becomes possible.", + username=participant.username + ) if participant.payment_providers == 0 else _( + "Donations to {username} can be paid using a credit or debit card " + "(Visa, MasterCard, American Express).", + username=participant.username + ) if participant.payment_providers == 1 else _( + "Donations to {username} are processed through PayPal. You can pay with " + "a credit or debit card even if you don't have a PayPal account.", + username=participant.username + ) if participant.payment_providers == 2 else _( + "Donations to {username} can be paid using a credit or debit card " + "(Visa, MasterCard, American Express), or through PayPal.", + username=participant.username ) }}
{{ _( "On Liberapay donations are funded in advance. You have control over " - "how much money you put in and when. Adding more money at once " + "how much money you put in and when. Sending more money at once " "results in a lower percentage of {0}transaction fees{1}.", ''|safe, ''|safe, ) }}
{{ _( - "When your account no longer contains enough money to fund your " - "donations we send you a notification via email so you can add " - "money again. Or you can discontinue your donation." + "When a donation needs to be renewed we send you a notification " + "via email so that you can fund it again, or discontinue it." ) }}
{{ _("Which currencies should your donors be allowed to send you, and which one do you prefer?") }}
% for c in constants.CURRENCIES | ||
@@ -63,7 +78,7 @@ subhead = _("Currencies") |
{{ _("Accepting foreign currencies can complicate things for you, but rejecting them can discourage some donors.") }}
+{{ glyphicon('info-sign') }} {{ _( + "Accepting foreign currencies can increase your income by convincing " + "people in other countries to donate to you, but international payments " + "usually result in a higher percentage of fees, and fluctuations in " + "exchange rates can lessen the stability of your income." + ) }}
+ +{{ glyphicon('info-sign') }} {{ _( + "Stripe automatically converts funds into your main currency, but by " + "default PayPal holds payments in foreign currencies until you tell it " + "what to do. If you have a Business PayPal account you can choose to " + "automatically convert all incoming payments in foreign currencies to " + "your main currency. This option is currently located in the " + "“{link_open}Preferences for receiving payments{link_close}” page.", + link_open=''|safe, + link_close=''|safe + ) }}
- +{{ _( + "Payday is a program ({0}this one{1}) that we run every Wednesday. It " + "executes donations and notifies donors and recipients.", + ''|safe, + ''|safe + ) }}
+{{ ngettext( + "", + "Liberapay is available to creators in {stripe_link_open}{n} countries " + "through Stripe{link_close} and {paypal_link_open}more than 200 countries " + "through PayPal{link_close}.", + n=len(constants.PAYOUT_COUNTRIES['stripe']), + stripe_link_open=''|safe, + paypal_link_open=''|safe, + link_close=''|safe +) }}
+ +{{ ngettext( + "", + "Donations can be in any of these {n} currencies:", + n=len(constants.CURRENCIES) +) }}
+ +{{ _( + "Each creator can select which of these currencies to accept." +) }}
+ +% endblock diff --git a/www/about/money.spt b/www/about/money.spt index 0500b18788..a5bcc1b6f3 100644 --- a/www/about/money.spt +++ b/www/about/money.spt @@ -1,5 +1,6 @@ # coding: utf8 [---] +response.redirect('/about/faq') title = _("Money") [---] text/html % extends "templates/about.html" @@ -19,13 +20,6 @@ title = _("Money") ) }} % endif -{{ _( - "If despite our fraud prevention efforts you receive money whose origin is " - "revealed to be fraudulent, it falls on you to pay it back." -) }}
-{{ _( @@ -34,28 +28,5 @@ title = _("Money") "have delegated the custody of the funds." ) }}
-{{ _( - "A user's money is held in their wallet. It's like an online bank account, " - "but more limited." -) }}
- -{{ _( - "We currently support two currencies: the euro (€) and the US dollar ($). " - "We do not handle crypto-currencies like bitcoin." -) }}
- -{{ _( - "Payday is when donations are actually executed. It's a program ({0}this " - "one{1}) that we run every Wednesday.", - ''|safe, - ''|safe -) }}
- % endblock diff --git a/www/about/stats.spt b/www/about/stats.spt index 8fd6494835..644b0d837c 100644 --- a/www/about/stats.spt +++ b/www/about/stats.spt @@ -1,7 +1,7 @@ # coding: utf8 from __future__ import division, print_function, unicode_literals -from liberapay.utils.currencies import MoneyBasket +from liberapay.utils.currencies import Money, MoneyBasket db = website.db db_qc5 = website.db_qc5 @@ -9,7 +9,7 @@ db_qc5 = website.db_qc5 [--------------------------------------------------------] title = _("Stats") -zero = constants.ZERO[currency] +zero = Money.ZEROS[currency] escrow = db_qc5.one("SELECT basket_sum(balance) FROM wallets") or MoneyBasket() nusers = db_qc5.one(""" SELECT count(*) diff --git a/www/index.html.spt b/www/index.html.spt index 8a80083a8d..ce8cea1778 100644 --- a/www/index.html.spt +++ b/www/index.html.spt @@ -269,14 +269,19 @@ recent = query_cache.one("""