from collections import defaultdict, namedtuple, OrderedDict
from datetime import date, datetime, timedelta
from decimal import Decimal, ROUND_FLOOR, ROUND_HALF_UP, ROUND_UP
import re

from babel.numbers import get_currency_precision
from mangopay.utils import Money
from markupsafe import Markup
from pando.utils import utc


def ordered_set(keys):
    return OrderedDict((k, None) for k in keys)


def check_bits(bits):
    assert len(set(bits)) == len(bits)  # no duplicates
    assert not [b for b in bits if '{0:b}'.format(b).count('1') != 1]  # single bit


Event = namedtuple('Event', 'name bit title')


class Fees(namedtuple('Fees', ('var', 'fix'))):
    VAT = Decimal('0.17')  # 17% (Luxembourg rate)
    VAT_1 = VAT + 1

    @property
    def with_vat(self):
        r = (self.var * self.VAT_1 * 100, self.fix * self.VAT_1)
        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):
    """Round `x` to keep only `precision` of its most significant digits.

    >>> to_precision(Decimal('0.0086820'), 2)
    Decimal('0.0087')
    >>> to_precision(Decimal('13567.89'), 3)
    Decimal('13600')
    >>> to_precision(Decimal('0.000'), 4)
    Decimal('0')
    """
    if x == 0:
        return Decimal(0)
    log10 = x.log10().to_integral(ROUND_FLOOR)
    # round
    factor = Decimal(10) ** (log10 + 1)
    r = (x / factor).quantize(Decimal(10) ** -precision, rounding=rounding) * factor
    # remove trailing zeros
    r = r.quantize(Decimal(10) ** (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)]
    minimum = Money.MINIMUMS[target_currency].amount
    return max(
        to_precision(amount * rate, precision, rounding).quantize(minimum, rounding),
        minimum
    )


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')


_ = lambda a: a

ASCII_ALLOWED_IN_USERNAME = set("0123456789"
                                "abcdefghijklmnopqrstuvwxyz"
                                "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
                                "-_.")

AVATAR_QUERY = '?s=160&d=404'
AVATAR_SOURCES = (
    'libravatar bitbucket facebook github gitlab google mastodon pleroma twitch twitter youtube'
).split()

BASE64URL_CHARS = set('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_')

BIRTHDAY = date(2015, 5, 22)

CARD_BRANDS = {
    'amex': 'American Express',
    'diners': 'Diners Club',
    'discover': 'Discover',
    'jcb': 'JCB',
    'mastercard': 'Mastercard',
    'unionpay': 'UnionPay',
    'visa': 'Visa',
    'unknown': '',
}

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_MAX = Decimal('999999999999.99')
D_ZERO = Decimal('0.00')

class _DonationLimits(defaultdict):
    def __missing__(self, currency):
        minimum = Money.MINIMUMS[currency].amount
        eur_weekly_amounts = DONATION_LIMITS_EUR_USD['weekly']
        converted_weekly_amounts = (
            convert_symbolic_amount(eur_weekly_amounts[0], currency),
            convert_symbolic_amount(eur_weekly_amounts[1], currency)
        )
        r = {
            'weekly': tuple(Money(x, currency) for x in converted_weekly_amounts),
            'monthly': tuple(
                Money((x * Decimal(52) / Decimal(12)).quantize(minimum, rounding=ROUND_UP), currency)
                for x in converted_weekly_amounts
            ),
            'yearly': tuple(Money(x * Decimal(52), currency) for x in converted_weekly_amounts),
        }
        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,
    'monthly': tuple((x * Decimal(52) / Decimal(12)).quantize(D_CENT, rounding=ROUND_UP)
                     for x in DONATION_LIMITS_WEEKLY_EUR_USD),
    'yearly': tuple(x * Decimal(52) for x in DONATION_LIMITS_WEEKLY_EUR_USD),
}
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'''
    ^
    ([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+
    [a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?
    $
''', re.VERBOSE)

ELSEWHERE_ACTIONS = {'connect', 'lock', 'unlock'}

EMAIL_VERIFICATION_TIMEOUT = timedelta(hours=24)
EMAIL_RE = re.compile(r'''
    # This is the regexp used by MangoPay (as of February 2017).
    # It rejects some valid but exotic addresses.
    # https://en.wikipedia.org/wiki/Email_address
    ^
    [a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*
    @
    ([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?
    $
''', re.VERBOSE)

EPOCH = datetime(1970, 1, 1, 0, 0, 0, 0, utc)

EUROZONE = set("AT BE CY DE EE ES FI FR GR IE IT LT LU LV MT NL PT SI SK".split())
SEPA = EUROZONE | set("AD BG CH CZ DK GB GI HR HU IS LI MC NO PL RO SE VA".split())

EVENTS = [
    Event('income', 1, _("Every week as long as I am receiving donations")),
    Event('donate_reminder', 2, _("When it's time to renew my donations")),
    Event('pledgee_joined', 16, _("When someone I pledge to joins Liberapay")),
    Event('team_invite', 32, _("When someone invites me to join a team")),
    Event('payin_failed', 2**11, _("When a payment I initiated fails")),
    Event('payin_succeeded', 2**12, _("When a payment I initiated succeeds")),
    Event('payin_refund_initiated', 2**13, _("When money is being refunded back to me")),
    Event('upcoming_debit', 2**14, _("When an automatic donation renewal payment is upcoming")),
    Event('missing_route', 2**15, _("When I no longer have any valid payment instrument")),
    Event('renewal_aborted', 2**16, _("When a donation renewal payment has been aborted")),
]
check_bits([e.bit for e in EVENTS])
EVENTS = OrderedDict((e.name, e) for e in EVENTS)
EVENTS_S = ' '.join(EVENTS.keys())

# https://www.mangopay.com/pricing/
FEE_PAYIN_BANK_WIRE = Fees(Decimal('0.005'), 0)  # 0.5%
FEE_PAYIN_CARD = {
    'EUR': Fees(Decimal('0.018'), Money('0.18', 'EUR')),  # 1.8% + €0.18
    'USD': Fees(Decimal('0.025'), Money('0.30', 'USD')),  # 2.5% + $0.30
}
FEE_PAYIN_DIRECT_DEBIT = {
    'EUR': Fees(0, Money('0.50', 'EUR')),  # €0.50
    'GBP': Fees(0, Money('0.50', 'GBP')),  # £0.50
}
FEE_PAYOUT = {
    'EUR': {
        'domestic': (SEPA, Fees(0, 0)),
        'foreign': Fees(0, 0),
    },
    'GBP': {
        'domestic': ({'GB'}, Fees(0, Money('0.45', 'GBP'))),
        'foreign': Fees(0, Money('1.90', 'GBP')),
    },
    'USD': {
        '*': Fees(0, Money('3.00', 'USD')),
    },
}
FEE_PAYOUT_WARN = Decimal('0.03')  # warn user when fee exceeds 3%

HTML_A = Markup('<a href="%s">%s</a>')

IDENTITY_FIELDS = set("""
    birthdate headquarters_address name nationality occupation organization_name
    postal_address
""".split())

INVOICE_DOC_MAX_SIZE = 5000000
INVOICE_DOCS_EXTS = ['pdf', 'jpeg', 'jpg', 'png']
INVOICE_DOCS_LIMIT = 25

INVOICE_NATURES = {
    'expense': _("Expense Report"),
}

INVOICE_STATUSES = {
    'pre': _("Draft"),
    'new': _("Sent (awaiting approval)"),
    'retracted': _("Retracted"),
    'accepted': _("Accepted (awaiting payment)"),
    'paid': _("Paid"),
    'rejected': _("Rejected"),
}

# https://docs.mangopay.com/api-references/kyc-rules/
KYC_DOC_MAX_SIZE = 7000000
KYC_DOC_MAX_SIZE_MB = int(KYC_DOC_MAX_SIZE / 1000000)
KYC_DOCS_EXTS = ['pdf', 'jpeg', 'jpg', 'gif', 'png']
KYC_DOCS_EXTS_STR = ', '.join(KYC_DOCS_EXTS)
KYC_INCOME_THRESHOLDS = [(i, Money(a, 'EUR')) for i, a in (
    (1, 18000),
    (2, 30000),
    (3, 50000),
    (4, 80000),
    (5, 120000),
    (6, 120000),
)]
KYC_PAYIN_YEARLY_THRESHOLD = Money('2500', 'EUR')
KYC_PAYOUT_YEARLY_THRESHOLD = Money('1000', 'EUR')

LAUNCH_TIME = datetime(2016, 2, 3, 12, 50, 0, 0, utc)

PARTICIPANT_KINDS = {
    'individual': _("Individual"),
    'organization': _("Organization"),
    'group': _("Team"),
}

PASSWORD_MIN_SIZE = 8
PASSWORD_MAX_SIZE = 150

PAYIN_BANK_WIRE_MIN = {k: Money('2.00', k) for k in ('EUR', 'USD')}  # fee ≈ 0.99%
PAYIN_BANK_WIRE_TARGET = {k: Money('5.00', k) for k in ('EUR', 'USD')}  # fee ≈ 0.6%
PAYIN_BANK_WIRE_MAX = {k: Money('2500.00', k) for k in ('EUR', 'USD')}
PAYIN_CARD_MIN = {
    'EUR': Money('15.00', 'EUR'),  # fee ≈ 3.5%
    'USD': Money('20.00', 'USD'),  # fee ≈ 4.58%
}
PAYIN_CARD_TARGET = {
    'EUR': Money('92.00', 'EUR'),  # fee ≈ 2.33%
    'USD': Money('95.00', 'USD'),  # fee ≈ 3.27%
}
PAYIN_CARD_MAX = {k: Money('2500.00', k) for k in ('EUR', 'USD')}
PAYIN_DIRECT_DEBIT_COUNTRIES = {
    # https://support.gocardless.com/hc/en-gb/articles/115005758445
    'EUR': EUROZONE | set("MC SM".split()),
}
PAYIN_DIRECT_DEBIT_MIN_EUR_GBP = Decimal('15.00')  # fee ≈ 3.78%
PAYIN_DIRECT_DEBIT_MIN = {
    'EUR': Money(PAYIN_DIRECT_DEBIT_MIN_EUR_GBP, 'EUR'),
    'GBP': Money(PAYIN_DIRECT_DEBIT_MIN_EUR_GBP, 'GBP'),
}
PAYIN_DIRECT_DEBIT_TARGET_EUR_GBP = Decimal('99.00')  # fee ≈ 0.59%
PAYIN_DIRECT_DEBIT_TARGET = {
    'EUR': Money(PAYIN_DIRECT_DEBIT_TARGET_EUR_GBP, 'EUR'),
    'GBP': Money(PAYIN_DIRECT_DEBIT_TARGET_EUR_GBP, 'GBP'),
}
PAYIN_DIRECT_DEBIT_MAX = {k: Money('2500.00', k) for k in ('EUR', 'USD')}

PAYIN_AMOUNTS = {
    'paypal': {
        'min_acceptable': MoneyAutoConvertDict({  # fee > 10%
            'EUR': Money('2.00', 'EUR'),
            'USD': Money('2.00', 'USD'),
        }),
        'min_recommended': MoneyAutoConvertDict({  # fee < 8%
            'EUR': Money('10.00', 'EUR'),
            'USD': Money('12.00', 'USD'),
        }),
        'low_fee': MoneyAutoConvertDict({  # fee < 6%
            'EUR': Money('40.00', 'EUR'),
            'USD': Money('48.00', 'USD'),
        }),
        'max_acceptable': MoneyAutoConvertDict({
            'EUR': Money('5000.00', 'EUR'),
            'USD': Money('5000.00', 'USD'),
        }),
    },
    'stripe': {
        'min_acceptable': MoneyAutoConvertDict({  # fee > 10%
            'EUR': Money('2.00', 'EUR'),
            'USD': Money('2.00', 'USD'),
        }),
        'min_recommended': MoneyAutoConvertDict({  # fee < 8%
            'EUR': Money('10.00', 'EUR'),
            'USD': Money('12.00', 'USD'),
        }),
        'low_fee': MoneyAutoConvertDict({  # fee < 6%
            'EUR': Money('40.00', 'EUR'),
            'USD': Money('48.00', 'USD'),
        }),
        'max_acceptable': MoneyAutoConvertDict({
            'EUR': Money('5000.00', 'EUR'),
            'USD': Money('5000.00', 'USD'),
        }),
    },
}

PAYMENT_METHODS = {
    'mango-ba': _("Direct Debit"),
    'mango-bw': _("Bank Wire"),
    'mango-cc': _("Credit Card"),
    'paypal': "PayPal",
    'stripe-card': _("Credit/Debit Card"),
    'stripe-sdd': _("Direct Debit"),
}
PAYMENT_SLUGS = {
    'mango-ba': 'direct-debit',
    'mango-bw': 'bankwire',
    'mango-cc': 'card',
}

PAYOUT_COUNTRIES = {
    'paypal': set("""
        AD AE AG AI AL AM AN AO AR AT AU AW AZ BA BB BE BF BG BH BI BJ BM BN BO
        BR BS BT BW BY BZ C2 CA CD CG CH CI CK CL CM CO CR CV CY CZ DE DJ DK DM
        DO DZ EC EE EG ER ES ET FI FJ FK FM FO FR GA GD GE GF GI GL GM GN GP GR
        GT GW GY HK HN HR HU ID IE IL IN IS IT JM JO JP KE KG KH KI KM KN KR KW
        KY KZ LA LC LI LK LS LT LU LV MA MC MD ME MG MH MK ML MN MQ MR MS MT MU
        MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PL
        PM PN PT PW PY QA RE RO RS RU RW SA SB SC SE SG SH SI SJ SK SL SM SN SO
        SR ST SV SZ TC TD TG TH TJ TM TN TO TT TT TT TT TV TW TZ UA UG GB US UY
        VA VC VE VG VN VU WF WS YE YT ZA ZM ZW
        PR
    """.split()),  # https://www.paypal.com/us/webapps/mpp/country-worldwide

    'stripe': set("""
        AT AU BE BG CA CH CY CZ DE DK EE ES FI FR GB GR HK IE IT JP LT LU LV MT
        MX MY NL NO NZ PL PT RO SE SG SI SK US
        PR
    """.split()),  # https://stripe.com/global
}

# https://developer.paypal.com/docs/api/reference/currency-codes/
PAYPAL_CURRENCIES = set("""
    AUD CAD CHF CZK DKK EUR GBP HKD HUF ILS JPY MXN NOK NZD PHP PLN RUB SEK SGD
    THB TWD USD
""".split())

PERIOD_CONVERSION_MAP = {
    ('weekly', 'weekly'): Decimal(1),
    ('monthly', 'weekly'): Decimal(12) / Decimal(52),
    ('yearly', 'weekly'): Decimal(1) / Decimal(52),
    ('weekly', 'monthly'): Decimal(52) / Decimal(12),
    ('monthly', 'monthly'): Decimal(1),
    ('yearly', 'monthly'): Decimal(1) / Decimal(12),
    ('weekly', 'yearly'): Decimal(52),
    ('monthly', 'yearly'): Decimal(12),
    ('yearly', 'yearly'): Decimal(1),
}

PERIOD_CONVERSION_RATES = {
    'weekly': Decimal(1),
    'monthly': Decimal(12) / Decimal(52),
    'yearly': Decimal(1) / Decimal(52),
}

POSTAL_ADDRESS_KEYS = (
    'AddressLine1', 'AddressLine2', 'City', 'Region', 'PostalCode', 'Country'
)
POSTAL_ADDRESS_KEYS_LIBERAPAY = (
    'country', 'region', 'city', 'postal_code', 'local_address'
)
POSTAL_ADDRESS_KEYS_STRIPE = (
    'line1', 'line2', 'city', 'state', 'postal_code', 'country'
)

PRIVACY_FIELDS = OrderedDict([
    ('hide_giving', (_("Hide total giving from others."), False)),
    ('hide_receiving', (_("Hide total receiving from others."), False)),
    ('hide_from_search', (_("Hide this profile from search results on Liberapay."), True)),
    ('profile_noindex', (_("Tell web search engines not to index this profile."), True)),
    ('hide_from_lists', (_("Prevent this profile from being listed on Liberapay."), True)),
])
PRIVACY_FIELDS_S = ' '.join(PRIVACY_FIELDS.keys())

PRIVILEGES = dict(admin=1, run_payday=2)
check_bits(list(PRIVILEGES.values()))

PROFILE_VISIBILITY_ATTRS = ('profile_noindex', 'hide_from_lists', 'hide_from_search')

PUBLIC_NAME_MAX_SIZE = 64

QUARANTINE = timedelta(weeks=0)

RATE_LIMITS = {
    'add_email.source': (5, 60*60*24),  # 5 per day
    'add_email.target': (2, 60*60*24),  # 2 per day
    'admin.http-unsafe': (10, 60*60*24),  # 10 per day
    'change_currency': (4, 60*60*24*7),  # 4 per week
    'change_password': (7, 60*60*24*7),  # 7 per week
    'change_username': (7, 60*60*24*7),  # 7 per week
    'check_password': (25, 60*60*24*7),  # 25 per week
    'elsewhere-lookup.ip-addr': (5, 20),  # 5 per 20 seconds
    'email.bypass_error': (2, 60*60*24*7),  # 2 per week
    'email.unblacklist.source': (5, 60*60*24*7),  # 5 per week
    'email.unblacklist.target': (3, 60*60*24*7),  # 3 per week
    'http-query.ip-addr': (10, 10),  # 10 per 10 seconds
    'http-query.user': (10, 10),  # 10 per 10 seconds
    'http-unsafe.ip-addr': (10, 10),  # 10 per 10 seconds
    'http-unsafe.user': (10, 10),  # 10 per 10 seconds
    'insert_identity': (7, 60*60*24*7),  # 7 per week
    'log-in.country': (10, 60),  # 10 per minute per country
    'log-in.email': (10, 60*60*24),  # 10 per day
    'log-in.email.not-verified': (2, 60*60*24),  # 2 per day
    'log-in.email.verified': (10, 60*60*24),  # 10 per day
    'log-in.ip-addr': (5, 5*60),  # 5 per 5 minutes per IP address
    'log-in.password': (3, 60*60),  # 3 per hour
    'make_team': (5, 60*60*24*7),  # 5 per week
    'payin.from-user': (15, 60*60*24*7),  # 15 per week
    'payin.from-ip-addr': (15, 60*60*24*7),  # 15 per week
    'refetch_elsewhere_data': (1, 60*60*24*7),  # retry after one week
    'refetch_repos': (1, 60*60*24),  # retry after one day
    'sign-up.email': (1, 5*60),  # this is used to detect near-simultaneous requests,
                                 # so 5 minutes should be plenty enough
    'sign-up.ip-addr': (5, 60*60),  # 5 per hour per IP address
    'sign-up.ip-net': (15, 60*60),  # 15 per hour per IP network
    'sign-up.country': (5, 5*60),  # 5 per 5 minutes per country
    'sign-up.ip-version': (15, 5*60),  # 15 per 5 minutes per IP version
}

SAFE_METHODS = {'GET', 'HEAD', 'OPTIONS'}

SESSION = 'session'
SESSION_REFRESH = timedelta(hours=1)
SESSION_TIMEOUT = timedelta(hours=6)


def make_standard_tip(label, weekly, currency):
    precision = get_currency_precision(currency)
    minimum = D_CENT if precision == 2 else Decimal(10) ** (-precision)
    return StandardTip(
        label,
        Money(weekly, currency),
        Money((weekly / PERIOD_CONVERSION_RATES['monthly']).quantize(minimum), currency),
        Money((weekly / PERIOD_CONVERSION_RATES['yearly']).quantize(minimum), currency),
    )


class _StandardTips(defaultdict):
    def __missing__(self, currency):
        r = [
            make_standard_tip(
                label, convert_symbolic_amount(weekly, currency), 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')),
    (_("Medium"), Decimal('1.00')),
    (_("Large"), Decimal('5.00')),
    (_("Maximum"), DONATION_LIMITS_EUR_USD['weekly'][1]),
)
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

TAKE_THROTTLING_THRESHOLD = MoneyAutoConvertDict(
    {k: Money('1.00', k) for k in ('EUR', 'USD')}
)

USERNAME_MAX_SIZE = 32
USERNAME_SUFFIX_BLACKLIST = set('.txt .html .htm .json .xml'.split())

del _

liberapay/constants.py
from collections import defaultdict, namedtuple, OrderedDict
from datetime import date, datetime, timedelta
from decimal import Decimal, ROUND_FLOOR, ROUND_HALF_UP, ROUND_UP
import re

from babel.numbers import get_currency_precision
from mangopay.utils import Money
from markupsafe import Markup
from pando.utils import utc


def ordered_set(keys):
    return OrderedDict((k, None) for k in keys)


def check_bits(bits):
    assert len(set(bits)) == len(bits)  # no duplicates
    assert not [b for b in bits if '{0:b}'.format(b).count('1') != 1]  # single bit


Event = namedtuple('Event', 'name bit title')


class Fees(namedtuple('Fees', ('var', 'fix'))):
    VAT = Decimal('0.17')  # 17% (Luxembourg rate)
    VAT_1 = VAT + 1

    @property
    def with_vat(self):
        r = (self.var * self.VAT_1 * 100, self.fix * self.VAT_1)
        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):
    """Round `x` to keep only `precision` of its most significant digits.

    >>> to_precision(Decimal('0.0086820'), 2)
    Decimal('0.0087')
    >>> to_precision(Decimal('13567.89'), 3)
    Decimal('13600')
    >>> to_precision(Decimal('0.000'), 4)
    Decimal('0')
    """
    if x == 0:
        return Decimal(0)
    log10 = x.log10().to_integral(ROUND_FLOOR)
    # round
    factor = Decimal(10) ** (log10 + 1)
    r = (x / factor).quantize(Decimal(10) ** -precision, rounding=rounding) * factor
    # remove trailing zeros
    r = r.quantize(Decimal(10) ** (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)]
    minimum = Money.MINIMUMS[target_currency].amount
    return max(
        to_precision(amount * rate, precision, rounding).quantize(minimum, rounding),
        minimum
    )


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')


_ = lambda a: a

ASCII_ALLOWED_IN_USERNAME = set("0123456789"
                                "abcdefghijklmnopqrstuvwxyz"
                                "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
                                "-_.")

AVATAR_QUERY = '?s=160&d=404'
AVATAR_SOURCES = (
    'libravatar bitbucket facebook github gitlab google mastodon pleroma twitch twitter youtube'
).split()

BASE64URL_CHARS = set('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_')

BIRTHDAY = date(2015, 5, 22)

CARD_BRANDS = {
    'amex': 'American Express',
    'diners': 'Diners Club',
    'discover': 'Discover',
    'jcb': 'JCB',
    'mastercard': 'Mastercard',
    'unionpay': 'UnionPay',
    'visa': 'Visa',
    'unknown': '',
}

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_MAX = Decimal('999999999999.99')
D_ZERO = Decimal('0.00')

class _DonationLimits(defaultdict):
    def __missing__(self, currency):
        minimum = Money.MINIMUMS[currency].amount
        eur_weekly_amounts = DONATION_LIMITS_EUR_USD['weekly']
        converted_weekly_amounts = (
            convert_symbolic_amount(eur_weekly_amounts[0], currency),
            convert_symbolic_amount(eur_weekly_amounts[1], currency)
        )
        r = {
            'weekly': tuple(Money(x, currency) for x in converted_weekly_amounts),
            'monthly': tuple(
                Money((x * Decimal(52) / Decimal(12)).quantize(minimum, rounding=ROUND_UP), currency)
                for x in converted_weekly_amounts
            ),
            'yearly': tuple(Money(x * Decimal(52), currency) for x in converted_weekly_amounts),
        }
        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,
    'monthly': tuple((x * Decimal(52) / Decimal(12)).quantize(D_CENT, rounding=ROUND_UP)
                     for x in DONATION_LIMITS_WEEKLY_EUR_USD),
    'yearly': tuple(x * Decimal(52) for x in DONATION_LIMITS_WEEKLY_EUR_USD),
}
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'''
    ^
    ([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+
    [a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?
    $
''', re.VERBOSE)

ELSEWHERE_ACTIONS = {'connect', 'lock', 'unlock'}

EMAIL_VERIFICATION_TIMEOUT = timedelta(hours=24)
EMAIL_RE = re.compile(r'''
    # This is the regexp used by MangoPay (as of February 2017).
    # It rejects some valid but exotic addresses.
    # https://en.wikipedia.org/wiki/Email_address
    ^
    [a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*
    @
    ([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?
    $
''', re.VERBOSE)

EPOCH = datetime(1970, 1, 1, 0, 0, 0, 0, utc)

EUROZONE = set("AT BE CY DE EE ES FI FR GR IE IT LT LU LV MT NL PT SI SK".split())
SEPA = EUROZONE | set("AD BG CH CZ DK GB GI HR HU IS LI MC NO PL RO SE VA".split())

EVENTS = [
    Event('income', 1, _("Every week as long as I am receiving donations")),
    Event('donate_reminder', 2, _("When it's time to renew my donations")),
    Event('pledgee_joined', 16, _("When someone I pledge to joins Liberapay")),
    Event('team_invite', 32, _("When someone invites me to join a team")),
    Event('payin_failed', 2**11, _("When a payment I initiated fails")),
    Event('payin_succeeded', 2**12, _("When a payment I initiated succeeds")),
    Event('payin_refund_initiated', 2**13, _("When money is being refunded back to me")),
    Event('upcoming_debit', 2**14, _("When an automatic donation renewal payment is upcoming")),
    Event('missing_route', 2**15, _("When I no longer have any valid payment instrument")),
    Event('renewal_aborted', 2**16, _("When a donation renewal payment has been aborted")),
]
check_bits([e.bit for e in EVENTS])
EVENTS = OrderedDict((e.name, e) for e in EVENTS)
EVENTS_S = ' '.join(EVENTS.keys())

# https://www.mangopay.com/pricing/
FEE_PAYIN_BANK_WIRE = Fees(Decimal('0.005'), 0)  # 0.5%
FEE_PAYIN_CARD = {
    'EUR': Fees(Decimal('0.018'), Money('0.18', 'EUR')),  # 1.8% + €0.18
    'USD': Fees(Decimal('0.025'), Money('0.30', 'USD')),  # 2.5% + $0.30
}
FEE_PAYIN_DIRECT_DEBIT = {
    'EUR': Fees(0, Money('0.50', 'EUR')),  # €0.50
    'GBP': Fees(0, Money('0.50', 'GBP')),  # £0.50
}
FEE_PAYOUT = {
    'EUR': {
        'domestic': (SEPA, Fees(0, 0)),
        'foreign': Fees(0, 0),
    },
    'GBP': {
        'domestic': ({'GB'}, Fees(0, Money('0.45', 'GBP'))),
        'foreign': Fees(0, Money('1.90', 'GBP')),
    },
    'USD': {
        '*': Fees(0, Money('3.00', 'USD')),
    },
}
FEE_PAYOUT_WARN = Decimal('0.03')  # warn user when fee exceeds 3%

HTML_A = Markup('<a href="%s">%s</a>')

IDENTITY_FIELDS = set("""
    birthdate headquarters_address name nationality occupation organization_name
    postal_address
""".split())

INVOICE_DOC_MAX_SIZE = 5000000
INVOICE_DOCS_EXTS = ['pdf', 'jpeg', 'jpg', 'png']
INVOICE_DOCS_LIMIT = 25

INVOICE_NATURES = {
    'expense': _("Expense Report"),
}

INVOICE_STATUSES = {
    'pre': _("Draft"),
    'new': _("Sent (awaiting approval)"),
    'retracted': _("Retracted"),
    'accepted': _("Accepted (awaiting payment)"),
    'paid': _("Paid"),
    'rejected': _("Rejected"),
}

# https://docs.mangopay.com/api-references/kyc-rules/
KYC_DOC_MAX_SIZE = 7000000
KYC_DOC_MAX_SIZE_MB = int(KYC_DOC_MAX_SIZE / 1000000)
KYC_DOCS_EXTS = ['pdf', 'jpeg', 'jpg', 'gif', 'png']
KYC_DOCS_EXTS_STR = ', '.join(KYC_DOCS_EXTS)
KYC_INCOME_THRESHOLDS = [(i, Money(a, 'EUR')) for i, a in (
    (1, 18000),
    (2, 30000),
    (3, 50000),
    (4, 80000),
    (5, 120000),
    (6, 120000),
)]
KYC_PAYIN_YEARLY_THRESHOLD = Money('2500', 'EUR')
KYC_PAYOUT_YEARLY_THRESHOLD = Money('1000', 'EUR')

LAUNCH_TIME = datetime(2016, 2, 3, 12, 50, 0, 0, utc)

PARTICIPANT_KINDS = {
    'individual': _("Individual"),
    'organization': _("Organization"),
    'group': _("Team"),
}

PASSWORD_MIN_SIZE = 8
PASSWORD_MAX_SIZE = 150

PAYIN_BANK_WIRE_MIN = {k: Money('2.00', k) for k in ('EUR', 'USD')}  # fee ≈ 0.99%
PAYIN_BANK_WIRE_TARGET = {k: Money('5.00', k) for k in ('EUR', 'USD')}  # fee ≈ 0.6%
PAYIN_BANK_WIRE_MAX = {k: Money('2500.00', k) for k in ('EUR', 'USD')}
PAYIN_CARD_MIN = {
    'EUR': Money('15.00', 'EUR'),  # fee ≈ 3.5%
    'USD': Money('20.00', 'USD'),  # fee ≈ 4.58%
}
PAYIN_CARD_TARGET = {
    'EUR': Money('92.00', 'EUR'),  # fee ≈ 2.33%
    'USD': Money('95.00', 'USD'),  # fee ≈ 3.27%
}
PAYIN_CARD_MAX = {k: Money('2500.00', k) for k in ('EUR', 'USD')}
PAYIN_DIRECT_DEBIT_COUNTRIES = {
    # https://support.gocardless.com/hc/en-gb/articles/115005758445
    'EUR': EUROZONE | set("MC SM".split()),
}
PAYIN_DIRECT_DEBIT_MIN_EUR_GBP = Decimal('15.00')  # fee ≈ 3.78%
PAYIN_DIRECT_DEBIT_MIN = {
    'EUR': Money(PAYIN_DIRECT_DEBIT_MIN_EUR_GBP, 'EUR'),
    'GBP': Money(PAYIN_DIRECT_DEBIT_MIN_EUR_GBP, 'GBP'),
}
PAYIN_DIRECT_DEBIT_TARGET_EUR_GBP = Decimal('99.00')  # fee ≈ 0.59%
PAYIN_DIRECT_DEBIT_TARGET = {
    'EUR': Money(PAYIN_DIRECT_DEBIT_TARGET_EUR_GBP, 'EUR'),
    'GBP': Money(PAYIN_DIRECT_DEBIT_TARGET_EUR_GBP, 'GBP'),
}
PAYIN_DIRECT_DEBIT_MAX = {k: Money('2500.00', k) for k in ('EUR', 'USD')}

PAYIN_AMOUNTS = {
    'paypal': {
        'min_acceptable': MoneyAutoConvertDict({  # fee > 10%
            'EUR': Money('2.00', 'EUR'),
            'USD': Money('2.00', 'USD'),
        }),
        'min_recommended': MoneyAutoConvertDict({  # fee < 8%
            'EUR': Money('10.00', 'EUR'),
            'USD': Money('12.00', 'USD'),
        }),
        'low_fee': MoneyAutoConvertDict({  # fee < 6%
            'EUR': Money('40.00', 'EUR'),
            'USD': Money('48.00', 'USD'),
        }),
        'max_acceptable': MoneyAutoConvertDict({
            'EUR': Money('5000.00', 'EUR'),
            'USD': Money('5000.00', 'USD'),
        }),
    },
    'stripe': {
        'min_acceptable': MoneyAutoConvertDict({  # fee > 10%
            'EUR': Money('2.00', 'EUR'),
            'USD': Money('2.00', 'USD'),
        }),
        'min_recommended': MoneyAutoConvertDict({  # fee < 8%
            'EUR': Money('10.00', 'EUR'),
            'USD': Money('12.00', 'USD'),
        }),
        'low_fee': MoneyAutoConvertDict({  # fee < 6%
            'EUR': Money('40.00', 'EUR'),
            'USD': Money('48.00', 'USD'),
        }),
        'max_acceptable': MoneyAutoConvertDict({
            'EUR': Money('5000.00', 'EUR'),
            'USD': Money('5000.00', 'USD'),
        }),
    },
}

PAYIN_SETTLEMENT_DELAYS = {
    'stripe-sdd': timedelta(days=6),
}

PAYMENT_METHODS = {
    'mango-ba': _("Direct Debit"),
    'mango-bw': _("Bank Wire"),
    'mango-cc': _("Credit Card"),
    'paypal': "PayPal",
    'stripe-card': _("Credit/Debit Card"),
    'stripe-sdd': _("Direct Debit"),
}
PAYMENT_SLUGS = {
    'mango-ba': 'direct-debit',
    'mango-bw': 'bankwire',
    'mango-cc': 'card',
}

PAYOUT_COUNTRIES = {
    'paypal': set("""
        AD AE AG AI AL AM AN AO AR AT AU AW AZ BA BB BE BF BG BH BI BJ BM BN BO
        BR BS BT BW BY BZ C2 CA CD CG CH CI CK CL CM CO CR CV CY CZ DE DJ DK DM
        DO DZ EC EE EG ER ES ET FI FJ FK FM FO FR GA GD GE GF GI GL GM GN GP GR
        GT GW GY HK HN HR HU ID IE IL IN IS IT JM JO JP KE KG KH KI KM KN KR KW
        KY KZ LA LC LI LK LS LT LU LV MA MC MD ME MG MH MK ML MN MQ MR MS MT MU
        MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PL
        PM PN PT PW PY QA RE RO RS RU RW SA SB SC SE SG SH SI SJ SK SL SM SN SO
        SR ST SV SZ TC TD TG TH TJ TM TN TO TT TT TT TT TV TW TZ UA UG GB US UY
        VA VC VE VG VN VU WF WS YE YT ZA ZM ZW
        PR
    """.split()),  # https://www.paypal.com/us/webapps/mpp/country-worldwide

    'stripe': set("""
        AT AU BE BG CA CH CY CZ DE DK EE ES FI FR GB GR HK IE IT JP LT LU LV MT
        MX MY NL NO NZ PL PT RO SE SG SI SK US
        PR
    """.split()),  # https://stripe.com/global
}

# https://developer.paypal.com/docs/api/reference/currency-codes/
PAYPAL_CURRENCIES = set("""
    AUD CAD CHF CZK DKK EUR GBP HKD HUF ILS JPY MXN NOK NZD PHP PLN RUB SEK SGD
    THB TWD USD
""".split())

PERIOD_CONVERSION_MAP = {
    ('weekly', 'weekly'): Decimal(1),
    ('monthly', 'weekly'): Decimal(12) / Decimal(52),
    ('yearly', 'weekly'): Decimal(1) / Decimal(52),
    ('weekly', 'monthly'): Decimal(52) / Decimal(12),
    ('monthly', 'monthly'): Decimal(1),
    ('yearly', 'monthly'): Decimal(1) / Decimal(12),
    ('weekly', 'yearly'): Decimal(52),
    ('monthly', 'yearly'): Decimal(12),
    ('yearly', 'yearly'): Decimal(1),
}

PERIOD_CONVERSION_RATES = {
    'weekly': Decimal(1),
    'monthly': Decimal(12) / Decimal(52),
    'yearly': Decimal(1) / Decimal(52),
}

POSTAL_ADDRESS_KEYS = (
    'AddressLine1', 'AddressLine2', 'City', 'Region', 'PostalCode', 'Country'
)
POSTAL_ADDRESS_KEYS_LIBERAPAY = (
    'country', 'region', 'city', 'postal_code', 'local_address'
)
POSTAL_ADDRESS_KEYS_STRIPE = (
    'line1', 'line2', 'city', 'state', 'postal_code', 'country'
)

PRIVACY_FIELDS = OrderedDict([
    ('hide_giving', (_("Hide total giving from others."), False)),
    ('hide_receiving', (_("Hide total receiving from others."), False)),
    ('hide_from_search', (_("Hide this profile from search results on Liberapay."), True)),
    ('profile_noindex', (_("Tell web search engines not to index this profile."), True)),
    ('hide_from_lists', (_("Prevent this profile from being listed on Liberapay."), True)),
])
PRIVACY_FIELDS_S = ' '.join(PRIVACY_FIELDS.keys())

PRIVILEGES = dict(admin=1, run_payday=2)
check_bits(list(PRIVILEGES.values()))

PROFILE_VISIBILITY_ATTRS = ('profile_noindex', 'hide_from_lists', 'hide_from_search')

PUBLIC_NAME_MAX_SIZE = 64

QUARANTINE = timedelta(weeks=0)

RATE_LIMITS = {
    'add_email.source': (5, 60*60*24),  # 5 per day
    'add_email.target': (2, 60*60*24),  # 2 per day
    'admin.http-unsafe': (10, 60*60*24),  # 10 per day
    'change_currency': (4, 60*60*24*7),  # 4 per week
    'change_password': (7, 60*60*24*7),  # 7 per week
    'change_username': (7, 60*60*24*7),  # 7 per week
    'check_password': (25, 60*60*24*7),  # 25 per week
    'elsewhere-lookup.ip-addr': (5, 20),  # 5 per 20 seconds
    'email.bypass_error': (2, 60*60*24*7),  # 2 per week
    'email.unblacklist.source': (5, 60*60*24*7),  # 5 per week
    'email.unblacklist.target': (3, 60*60*24*7),  # 3 per week
    'http-query.ip-addr': (10, 10),  # 10 per 10 seconds
    'http-query.user': (10, 10),  # 10 per 10 seconds
    'http-unsafe.ip-addr': (10, 10),  # 10 per 10 seconds
    'http-unsafe.user': (10, 10),  # 10 per 10 seconds
    'insert_identity': (7, 60*60*24*7),  # 7 per week
    'log-in.country': (10, 60),  # 10 per minute per country
    'log-in.email': (10, 60*60*24),  # 10 per day
    'log-in.email.not-verified': (2, 60*60*24),  # 2 per day
    'log-in.email.verified': (10, 60*60*24),  # 10 per day
    'log-in.ip-addr': (5, 5*60),  # 5 per 5 minutes per IP address
    'log-in.password': (3, 60*60),  # 3 per hour
    'make_team': (5, 60*60*24*7),  # 5 per week
    'payin.from-user': (15, 60*60*24*7),  # 15 per week
    'payin.from-ip-addr': (15, 60*60*24*7),  # 15 per week
    'refetch_elsewhere_data': (1, 60*60*24*7),  # retry after one week
    'refetch_repos': (1, 60*60*24),  # retry after one day
    'sign-up.email': (1, 5*60),  # this is used to detect near-simultaneous requests,
                                 # so 5 minutes should be plenty enough
    'sign-up.ip-addr': (5, 60*60),  # 5 per hour per IP address
    'sign-up.ip-net': (15, 60*60),  # 15 per hour per IP network
    'sign-up.country': (5, 5*60),  # 5 per 5 minutes per country
    'sign-up.ip-version': (15, 5*60),  # 15 per 5 minutes per IP version
}

SAFE_METHODS = {'GET', 'HEAD', 'OPTIONS'}

SESSION = 'session'
SESSION_REFRESH = timedelta(hours=1)
SESSION_TIMEOUT = timedelta(hours=6)


def make_standard_tip(label, weekly, currency):
    precision = get_currency_precision(currency)
    minimum = D_CENT if precision == 2 else Decimal(10) ** (-precision)
    return StandardTip(
        label,
        Money(weekly, currency),
        Money((weekly / PERIOD_CONVERSION_RATES['monthly']).quantize(minimum), currency),
        Money((weekly / PERIOD_CONVERSION_RATES['yearly']).quantize(minimum), currency),
    )


class _StandardTips(defaultdict):
    def __missing__(self, currency):
        r = [
            make_standard_tip(
                label, convert_symbolic_amount(weekly, currency), 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')),
    (_("Medium"), Decimal('1.00')),
    (_("Large"), Decimal('5.00')),
    (_("Maximum"), DONATION_LIMITS_EUR_USD['weekly'][1]),
)
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

TAKE_THROTTLING_THRESHOLD = MoneyAutoConvertDict(
    {k: Money('1.00', k) for k in ('EUR', 'USD')}
)

USERNAME_MAX_SIZE = 32
USERNAME_SUFFIX_BLACKLIST = set('.txt .html .htm .json .xml'.split())

del _

liberapay/constants.py
diff --git a/emails/payin_sdd_created.spt b/emails/payin_sdd_created.spt
index 0df2053df7..712ae3ffbc 100644
--- a/emails/payin_sdd_created.spt
+++ b/emails/payin_sdd_created.spt
@@ -28,3 +28,8 @@
     descriptor=statement_descriptor
 ) }}</p>
 % endif
+
+<p>{{ _(
+    "Processing this kind of payment takes {timedelta} on average.",
+    timedelta=constants.PAYIN_SETTLEMENT_DELAYS['stripe-sdd']
+) }}</p>
diff --git a/liberapay/constants.py b/liberapay/constants.py
index d12b4176dd..bdc77897ff 100644
--- a/liberapay/constants.py
+++ b/liberapay/constants.py
@@ -330,6 +330,10 @@ def __missing__(self, currency):
     },
 }
 
+PAYIN_SETTLEMENT_DELAYS = {
+    'stripe-sdd': timedelta(days=6),
+}
+
 PAYMENT_METHODS = {
     'mango-ba': _("Direct Debit"),
     'mango-bw': _("Bank Wire"),
diff --git a/simplates/confirm.spt b/simplates/confirm.spt
index 56b4be70be..4050c6e6ce 100644
--- a/simplates/confirm.spt
+++ b/simplates/confirm.spt
@@ -7,8 +7,9 @@
     <form action="" method="POST">
         % include "templates/form-repost.html"
         <input type="hidden" name="confirmed" value="true" />
-        <div class="alert alert-{{ cls }}">{{ msg }}</div>
+        <div class="alert alert-{{ cls }}">{{ escape(msg).replace('\n', '<br>'|safe) }}</div>
         <a class="btn btn-default" href="{{ response.sanitize_untrusted_url(back_to) if back_to else '' }}">{{ _("Cancel") }}</a>
+        &nbsp;&nbsp;
         <button class="btn btn-{{ cls }}">{{ _("Confirm") }}</button>
     </form>
 % endblock
diff --git a/tests/py/test_settings.py b/tests/py/test_settings.py
index ac19e23c21..8b4e31dac4 100644
--- a/tests/py/test_settings.py
+++ b/tests/py/test_settings.py
@@ -52,12 +52,26 @@ def test_team_participant_doesnt_show_up_on_explore_teams(self):
 
 class TestUsername(Harness):
 
+    def test_participant_can_set_username(self):
+        alice = self.make_participant(None)
+        r = self.client.POST(
+            f'/~{alice.id}/edit/username', {'username': 'alice'},
+            auth_as=alice, raise_immediately=False
+        )
+        assert r.code == 302
+        assert r.headers[b'Location'].startswith(b'/alice/edit/username')
+        alice = alice.refetch()
+        assert alice.username == 'alice'
+
     def change_username(self, new_username, auth_as='alice'):
         if auth_as:
             auth_as = self.make_participant(auth_as)
 
-        r = self.client.POST('/alice/edit/username', {'username': new_username},
-                             auth_as=auth_as, raise_immediately=False)
+        r = self.client.POST(
+            '/alice/edit/username',
+            {'username': new_username, 'confirmed': 'true'},
+            auth_as=auth_as, raise_immediately=False,
+        )
         return r
 
     def test_participant_can_change_their_username(self):
@@ -111,6 +125,10 @@ def test_change_team_name(self):
         team.add_member(bob)
         r = self.client.POST('/team/edit/username', {'username': 'Team'},
                              auth_as=alice, raise_immediately=False)
+        assert r.code == 200
+        assert ">Confirm</button>" in r.text
+        r = self.client.POST('/team/edit/username', {'username': 'Team', 'confirmed': 'true'},
+                             auth_as=alice, raise_immediately=False)
         assert r.code == 302
         assert r.headers[b'Location'].startswith(b'/Team/edit/username')
         team = team.refetch()
diff --git a/www/%username/edit/username.spt b/www/%username/edit/username.spt
index b94515d2b8..e979a48f92 100644
--- a/www/%username/edit/username.spt
+++ b/www/%username/edit/username.spt
@@ -8,6 +8,18 @@ errors = []
 if request.method == 'POST':
     new_username = request.body['username'].strip()
     if new_username != participant.username:
+        if participant.username[0] != '~' and request.body.get('confirmed') != 'true':
+            msg = _(
+                "When you modify your username, the web address of your profile "
+                "changes accordingly. A redirect from the old URL to the new one "
+                "is put in place, but any user can remove it by claiming your old "
+                "username, so you should update all the links to your profile that "
+                "you've placed on other websites, unless those links contain your "
+                "immutable account ID ({account_id}) instead of your username.\n\n"
+                "Are you sure you want to change your username?"
+                , account_id=participant.id
+            )
+            raise response.render('simplates/confirm.spt', state, cls='warning', msg=msg)
         try:
             participant.change_username(new_username, recorder=user)
         except UsernameError as r:
@@ -52,13 +64,8 @@ subhead = _("Username")
                 _("Allowed characters: latin alphanumerics, dots (.), dashes (-), and underscores (_).")
             }}</p>
         </div>
-        % if _username
-        <div class="alert alert-warning">{{
-            _("Have you linked to your Liberapay profile from other websites? Be sure to update those links!")
-        }}</div>
-        % endif
 
-        <h4 class="no-margin-top">{{ _("Name (optional)") }}</h4>
+        <h4>{{ _("Name (optional)") }}</h4>
         <p>{{ _(
             "A name to show alongside your username on your public profile page. "
             "It doesn't have to be your legal name."
diff --git a/www/%username/giving/pay/stripe/%payin_id.spt b/www/%username/giving/pay/stripe/%payin_id.spt
index 1d68c6a195..31d5a0736b 100644
--- a/www/%username/giving/pay/stripe/%payin_id.spt
+++ b/www/%username/giving/pay/stripe/%payin_id.spt
@@ -245,6 +245,10 @@ title = _("Funding your donations")
         <p><a class="btn btn-primary btn-lg" href="{{ payer.path('giving/pay') }}?retry={{ payin.id }}">{{ _("Try again") }}</a></p>
     % elif status == 'pending'
         <div class="alert alert-info">{{ _("The payment has been initiated.") }}</div>
+        <p>{{ _(
+            "Processing this kind of payment takes {timedelta} on average.",
+            timedelta=constants.PAYIN_SETTLEMENT_DELAYS['stripe-sdd']
+        ) }}</p>
         <p>{{ _(
             "A receipt will be available once the payment has been successfully processed. You can see all your payments and receipts in {link_start}your account's ledger{link_end}.",
             link_start='<a href="%s">'|safe % payer.path('ledger/'),
diff --git a/www/about/contact.spt b/www/about/contact.spt
index 07f448df09..3843232c47 100644
--- a/www/about/contact.spt
+++ b/www/about/contact.spt
@@ -4,11 +4,20 @@ title = _("Contact")
 % extends "templates/layouts/about.html"
 % block content
 
-    <h4>{{ _("To contact us privately:") }}</h4>
+    <h4>{{ _("To contact the Liberapay team privately:") }}</h4>
     <p><a class="btn btn-primary" data-email="support&#64;liberapay.com"
           >{{ _("Send us an email") }}</a></p>
-    <p>{{ _("We are currently able to answer in English and French.") }}</p>
+    <p>{{ _(
+        "If your message isn't written in English or French, then it will be translated "
+        "automatically by a machine. Our reply will be translated to your language "
+        "the same way."
+    ) }}</p>
     <p>{{ _("We currently don't have a phone number.") }}</p>
+    <p class="text-warning">{{ glyphicon("minus-sign") }} {{ _(
+        "Do not contact us if you are trying to reach one of our users. We do not "
+        "relay messages, and we cannot reveal the identity or contact details of "
+        "our users unless you have a valid court order."
+    ) }}</p>
 
     <br>
     <h4>{{ _("To report a problem or make a suggestion in English:") }}</h4>
diff --git a/www/about/privacy.spt b/www/about/privacy.spt
index 468b4693a8..7d094537c9 100644
--- a/www/about/privacy.spt
+++ b/www/about/privacy.spt
@@ -12,7 +12,14 @@ title = _("Privacy")
     "actually need, and we don't sell it to anyone."
 ) }}</p>
 
-<h3>{{ _("Cookies") }}</h3>
+<h3>{{ _("Cookies") }}
+    <small title="{{ _(
+               'A cookie is a piece of information sent by a website to your browser, '
+               'stored on your machine, and resent by your browser to that same '
+               'website in every subsequent request.'
+           ) }}"
+           data-toggle="tooltip" data-placement="bottom">{{ glyphicon("question-sign") }}</small>
+</h3>
 
 <p>{{ _(
     "The liberapay.com website only sets technical cookies which are required to "

The username change warning isn't clear enough
<https://mastodonten.de/@scroom/100724362756380264>:

> [@Liberapay](https://mastodon.xyz/@Liberapay) I have a question about existing links to donation accounts: I changed my name and then I got a hint that I had to change the donation links. So far the old link still works. Will it be turned off at some point?
>
> https://liberapay.com/~32819/donate
