Skip to content

Commit

Permalink
adapt fees to the currency
Browse files Browse the repository at this point in the history
  • Loading branch information
Changaco committed Oct 29, 2017
1 parent f6376ee commit d945bca
Show file tree
Hide file tree
Showing 11 changed files with 165 additions and 124 deletions.
40 changes: 22 additions & 18 deletions liberapay/billing/fees.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,23 @@
"""
from __future__ import division, print_function, unicode_literals

from decimal import Decimal, ROUND_UP

from pando.utils import typecheck
from mangopay.utils import Money

from liberapay.constants import (
D_CENT,
PAYIN_CARD_MIN, FEE_PAYIN_CARD,
FEE_PAYIN_BANK_WIRE, PAYIN_BANK_WIRE_MIN,
FEE_PAYIN_DIRECT_DEBIT, PAYIN_DIRECT_DEBIT_MIN,
FEE_PAYOUT, FEE_PAYOUT_OUTSIDE_SEPA, SEPA,
FEE_VAT,
FEE_PAYOUT,
Fees,
)


def upcharge(amount, fees, min_amount):
"""Given an amount, return a higher amount and the difference.
"""
typecheck(amount, Decimal)
assert isinstance(amount, Money), type(amount)

fees = fees if isinstance(fees, Fees) else fees[amount.currency]

if amount < min_amount:
amount = min_amount
Expand All @@ -30,14 +29,14 @@ def upcharge(amount, fees, min_amount):
fee = charge_amount - amount

# + VAT
vat = fee * FEE_VAT
vat = fee * Fees.VAT
charge_amount += vat
fee += vat

# Round
charge_amount = charge_amount.quantize(D_CENT, rounding=ROUND_UP)
fee = fee.quantize(D_CENT, rounding=ROUND_UP)
vat = vat.quantize(D_CENT, rounding=ROUND_UP)
charge_amount = charge_amount.round_up()
fee = fee.round_up()
vat = vat.round_up()

return charge_amount, fee, vat

Expand All @@ -51,10 +50,10 @@ def skim_amount(amount, fees):
"""Given a nominal amount, compute the fees, taxes, and the actual amount.
"""
fee = amount * fees.var + fees.fix
vat = fee * FEE_VAT
vat = fee * Fees.VAT
fee += vat
fee = fee.quantize(D_CENT, rounding=ROUND_UP)
vat = vat.quantize(D_CENT, rounding=ROUND_UP)
fee = fee.round_up()
vat = vat.round_up()
return amount - fee, fee, vat


Expand All @@ -76,10 +75,15 @@ def skim_credit(amount, ba):
The returned amount can be negative, look out for that.
"""
typecheck(amount, Decimal)
assert isinstance(amount, Money), type(amount)
fees = FEE_PAYOUT[amount.currency]
country = get_bank_account_country(ba)
if country in SEPA:
fee = FEE_PAYOUT
if 'domestic' in fees:
countries, domestic_fee = fees['domestic']
if country in countries:
fee = domestic_fee
else:
fee = fees['foreign']
else:
fee = FEE_PAYOUT_OUTSIDE_SEPA
fee = fees['*']
return skim_amount(amount, fee)
41 changes: 25 additions & 16 deletions liberapay/billing/transactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
SettlementTransfer, Transfer, User, Wallet,
)
from mangopay.utils import Money
from pando.utils import typecheck

from liberapay.billing.fees import (
skim_bank_wire, skim_credit, upcharge_card, upcharge_direct_debit
Expand Down Expand Up @@ -84,6 +83,7 @@ def payout(db, route, amount, ignore_high_fee=False):
ba = BankAccount.get(route.address, user_id=participant.mangopay_user_id)

# Do final calculations
amount = Money(amount, 'EUR') if isinstance(amount, Decimal) else amount
credit_amount, fee, vat = skim_credit(amount, ba)
if credit_amount <= 0 and fee > 0:
raise FeeExceedsAmount
Expand All @@ -95,9 +95,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 = Money(int(amount * 100), 'EUR')
payout.DebitedFunds = amount.int()
payout.DebitedWalletId = participant.mangopay_wallet_id
payout.Fees = Money(int(fee * 100), 'EUR')
payout.Fees = fee.int()
payout.BankAccountId = route.address
payout.BankWireRef = str(e_id)
payout.Tag = str(e_id)
Expand All @@ -119,12 +119,13 @@ def charge(db, route, amount, return_url):
and add it to amount to end up with charge_amount.
"""
typecheck(amount, Decimal)
assert isinstance(amount, (Decimal, Money)), type(amount)
assert route
assert route.network == 'mango-cc'

participant = route.participant

amount = Money(amount, 'EUR') if isinstance(amount, Decimal) else amount
charge_amount, fee, vat = upcharge_card(amount)
amount = charge_amount - fee

Expand All @@ -135,10 +136,10 @@ def charge(db, route, amount, return_url):
payin = DirectPayIn()
payin.AuthorId = participant.mangopay_user_id
payin.CreditedWalletId = participant.mangopay_wallet_id
payin.DebitedFunds = Money(int(charge_amount * 100), 'EUR')
payin.DebitedFunds = charge_amount.int()
payin.CardId = route.address
payin.SecureModeReturnURL = return_url
payin.Fees = Money(int(fee * 100), 'EUR')
payin.Fees = fee.int()
payin.Tag = str(e_id)
try:
test_hook()
Expand All @@ -158,12 +159,13 @@ def charge(db, route, amount, return_url):
def prepare_direct_debit(db, route, amount):
"""Prepare to debit a bank account.
"""
typecheck(amount, Decimal)
assert isinstance(amount, (Decimal, Money)), type(amount)

assert route.network == 'mango-ba'

participant = route.participant

amount = Money(amount, 'EUR') if isinstance(amount, Decimal) else amount
debit_amount, fee, vat = upcharge_direct_debit(amount)
amount = debit_amount - fee

Expand Down Expand Up @@ -197,16 +199,16 @@ def execute_direct_debit(db, exchange, route):

assert exchange.status == 'pre'

amount, fee = exchange.amount, exchange.fee
amount, fee = Money(exchange.amount, 'EUR'), Money(exchange.fee, 'EUR')
debit_amount = amount + fee

e_id = exchange.id
payin = DirectDebitDirectPayIn()
payin.AuthorId = participant.mangopay_user_id
payin.CreditedWalletId = participant.mangopay_wallet_id
payin.DebitedFunds = Money(int(debit_amount * 100), 'EUR')
payin.DebitedFunds = debit_amount.int()
payin.MandateId = route.mandate
payin.Fees = Money(int(fee * 100), 'EUR')
payin.Fees = fee.int()
payin.Tag = str(e_id)
try:
test_hook()
Expand All @@ -229,6 +231,8 @@ def payin_bank_wire(db, participant, debit_amount):

route = ExchangeRoute.upsert_bankwire_route(participant)

if not isinstance(debit_amount, Money):
debit_amount = Money(debit_amount, 'EUR')
amount, fee, vat = skim_bank_wire(debit_amount)

if not participant.mangopay_wallet_id:
Expand All @@ -238,8 +242,8 @@ def payin_bank_wire(db, participant, debit_amount):
payin = BankWirePayIn()
payin.AuthorId = participant.mangopay_user_id
payin.CreditedWalletId = participant.mangopay_wallet_id
payin.DeclaredDebitedFunds = Money(int(debit_amount * 100), 'EUR')
payin.DeclaredFees = Money(int(fee * 100), 'EUR')
payin.DeclaredDebitedFunds = debit_amount.int()
payin.DeclaredFees = fee.int()
payin.Tag = str(e_id)
try:
test_hook()
Expand All @@ -258,19 +262,20 @@ def record_unexpected_payin(db, payin):
"""Record an unexpected bank wire payin.
"""
assert payin.PaymentType == 'BANK_WIRE'
amount = Decimal(payin.DebitedFunds.Amount) / Decimal(100)
paid_fee = Decimal(payin.Fees.Amount) / Decimal(100)
vat = skim_bank_wire(amount)[2]
debited_amount = payin.DebitedFunds / Decimal(100)
paid_fee = payin.Fees / Decimal(100)
vat = skim_bank_wire(debited_amount)[2].amount
wallet_id = payin.CreditedWalletId
participant = Participant.from_mangopay_user_id(payin.AuthorId)
assert participant.mangopay_wallet_id == wallet_id
route = ExchangeRoute.upsert_bankwire_route(participant)
amount = (debited_amount - paid_fee).amount
return db.one("""
INSERT INTO exchanges
(amount, fee, vat, participant, status, route, note, remote_id, wallet_id)
VALUES (%s, %s, %s, %s, 'created', %s, NULL, %s, %s)
RETURNING id
""", (amount, paid_fee, vat, participant.id, route.id, payin.Id, wallet_id))
""", (amount, paid_fee.amount, vat, participant.id, route.id, payin.Id, wallet_id))


def record_payout_refund(db, payout_refund):
Expand Down Expand Up @@ -314,6 +319,10 @@ def record_exchange(db, route, amount, fee, vat, participant, status, error=None
if participant.is_suspended:
raise AccountSuspended()

amount = getattr(amount, 'amount', amount)
fee = getattr(fee, 'amount', fee)
vat = getattr(vat, 'amount', vat)

with db.get_cursor() as cursor:

wallet_id = participant.mangopay_wallet_id
Expand Down
48 changes: 36 additions & 12 deletions liberapay/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,16 @@ def check_bits(bits):

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

Fees = namedtuple('Fees', ('var', 'fix'))

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] if not r[0] else r


StandardTip = namedtuple('StandardTip', 'label weekly monthly yearly')

Expand Down Expand Up @@ -109,13 +118,33 @@ def check_bits(bits):
EVENTS_S = ' '.join(EVENTS.keys())

# https://www.mangopay.com/pricing/
FEE_PAYIN_BANK_WIRE = Fees(Decimal('0.005'), Decimal(0)) # 0.5%
FEE_PAYIN_CARD = Fees(Decimal('0.018'), Decimal('0.18')) # 1.8% + €0.18
FEE_PAYIN_DIRECT_DEBIT = Fees(Decimal(0), Decimal('0.80')) # €0.80
FEE_PAYOUT = Fees(Decimal(0), Decimal(0))
FEE_PAYOUT_OUTSIDE_SEPA = Fees(Decimal(0), Decimal('2.5'))
SEPA = set("""
AT BE BG CH CY CZ DE DK EE ES ES FI FR GB GI GR HR HU IE IS IT LI LT LU LV
MC MT NL NO PL PT RO SE SI SK
""".split())
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.80', 'EUR')), # €0.80
'GBP': Fees(0, Money('0.80', 'GBP')), # £0.80
}
FEE_PAYOUT = {
'EUR': {
'domestic': (SEPA, Fees(0, 0)),
'foreign': Fees(0, Money('2.50', 'EUR')),
},
'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%
FEE_VAT = Decimal('0.17') # 17% (Luxembourg rate)

INVOICE_DOC_MAX_SIZE = 5000000
INVOICE_DOCS_EXTS = ['pdf', 'jpeg', 'jpg', 'png']
Expand Down Expand Up @@ -211,11 +240,6 @@ def check_bits(bits):
'sign-up.ip-version': (15, 15*60), # 15 per 15 minutes per IP version
}

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

SESSION = str('session') # bytes in python2, unicode in python3
SESSION_REFRESH = timedelta(hours=1)
SESSION_TIMEOUT = timedelta(hours=6)
Expand Down
34 changes: 17 additions & 17 deletions tests/py/test_payio.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ class TestPayinBankWire(MangopayHarness):

def test_payin_bank_wire_creation(self):
path = b'/janet/wallet/payin/bankwire/'
data = {'amount': str(upcharge_bank_wire(D('10.00'))[0])}
data = {'amount': str(upcharge_bank_wire(EUR('10.00'))[0].amount)}

r = self.client.PxST(path, data, auth_as=self.janet)
assert r.code == 403 # rejected because janet has no donations set up
Expand Down Expand Up @@ -301,44 +301,44 @@ def test_direct_debit_failure(self, save):
class TestFees(MangopayHarness):

def test_upcharge_basically_works(self):
actual = upcharge_card(D('20.00'))
expected = (D('20.65'), D('0.65'), D('0.10'))
actual = upcharge_card(EUR('20.00'))
expected = (EUR('20.65'), EUR('0.65'), EUR('0.10'))
assert actual == expected

def test_upcharge_full_in_rounded_case(self):
actual = upcharge_card(D('5.00'))
expected = upcharge_card(PAYIN_CARD_MIN)
actual = upcharge_card(EUR('5.00'))
expected = upcharge_card(EUR(PAYIN_CARD_MIN))
assert actual == expected

def test_upcharge_at_min(self):
actual = upcharge_card(PAYIN_CARD_MIN)
expected = (D('15.54'), D('0.54'), D('0.08'))
actual = upcharge_card(EUR(PAYIN_CARD_MIN))
expected = (EUR('15.54'), EUR('0.54'), EUR('0.08'))
assert actual == expected
assert actual[1] / actual[0] < D('0.035') # less than 3.5% fee

def test_upcharge_at_target(self):
actual = upcharge_card(PAYIN_CARD_TARGET)
expected = (D('94.19'), D('2.19'), D('0.32'))
actual = upcharge_card(EUR(PAYIN_CARD_TARGET))
expected = (EUR('94.19'), EUR('2.19'), EUR('0.32'))
assert actual == expected
assert actual[1] / actual[0] < D('0.024') # less than 2.4% fee

def test_upcharge_at_one_cent(self):
actual = upcharge_card(D('0.01'))
expected = upcharge_card(PAYIN_CARD_MIN)
actual = upcharge_card(EUR('0.01'))
expected = upcharge_card(EUR(PAYIN_CARD_MIN))
assert actual == expected

def test_upcharge_at_min_minus_one_cent(self):
actual = upcharge_card(PAYIN_CARD_MIN - D('0.01'))
expected = upcharge_card(PAYIN_CARD_MIN)
actual = upcharge_card(EUR(PAYIN_CARD_MIN) - EUR('0.01'))
expected = upcharge_card(EUR(PAYIN_CARD_MIN))
assert actual == expected

def test_skim_credit(self):
actual = skim_credit(D('10.00'), self.bank_account)
assert actual == (D('10.00'), D('0.00'), D('0.00'))
actual = skim_credit(EUR('10.00'), self.bank_account)
assert actual == (EUR('10.00'), EUR('0.00'), EUR('0.00'))

def test_skim_credit_outside_sepa(self):
actual = skim_credit(D('10.00'), self.bank_account_outside_sepa)
assert actual == (D('7.07'), D('2.93'), D('0.43'))
actual = skim_credit(EUR('10.00'), self.bank_account_outside_sepa)
assert actual == (EUR('7.07'), EUR('2.93'), EUR('0.43'))


class TestRecordExchange(MangopayHarness):
Expand Down
Loading

0 comments on commit d945bca

Please sign in to comment.