Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement money quarantine #42

Merged
merged 5 commits into from
Nov 10, 2015
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 99 additions & 24 deletions liberapay/billing/exchanges.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@
from mangopaysdk.types.money import Money

from liberapay.billing import mangoapi, PayInExecutionDetailsDirect, PayInPaymentDetailsCard, PayOutPaymentDetailsBankWire
from liberapay.exceptions import LazyResponse, NegativeBalance, TransactionFeeTooHigh, UserIsSuspicious
from liberapay.constants import QUARANTINE
from liberapay.exceptions import (
LazyResponse, NegativeBalance, NotEnoughWithdrawableMoney,
TransactionFeeTooHigh, UserIsSuspicious
)
from liberapay.models import check_db
from liberapay.models.participant import Participant
from liberapay.models.exchange_route import ExchangeRoute
Expand All @@ -32,6 +36,8 @@
MC MT NL NO PL PT RO SE SI SK
""".split())

QUARANTINE = '%s days' % QUARANTINE.days


def upcharge(amount):
"""Given an amount, return a higher amount and the difference.
Expand Down Expand Up @@ -203,20 +209,20 @@ def record_exchange(db, route, amount, fee, participant, status, error=None):

with db.get_cursor() as cursor:

exchange_id = cursor.one("""
e = cursor.one("""
INSERT INTO exchanges
(amount, fee, participant, status, route, note)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
RETURNING *
""", (amount, fee, participant.id, status, route.id, error))

if status == 'failed':
propagate_exchange(cursor, participant, route, error, 0)
propagate_exchange(cursor, participant, e, route, error, 0)
elif amount < 0:
amount -= fee
propagate_exchange(cursor, participant, route, '', amount)
propagate_exchange(cursor, participant, e, route, '', amount)

return exchange_id
return e.id


def record_exchange_result(db, exchange_id, status, error, participant):
Expand All @@ -229,7 +235,7 @@ def record_exchange_result(db, exchange_id, status, error, participant):
, note=%(error)s
WHERE id=%(exchange_id)s
AND status <> %(status)s
RETURNING id, amount, fee, participant, recorder, note, status
RETURNING id, amount, fee, participant, recorder, note, status, timestamp
, ( SELECT r.*::exchange_routes
FROM exchange_routes r
WHERE r.id = e.route
Expand All @@ -242,21 +248,20 @@ def record_exchange_result(db, exchange_id, status, error, participant):

amount = e.amount
if amount < 0:
amount -= e.fee
amount = amount if status == 'failed' else 0
propagate_exchange(cursor, participant, e.route, error, -amount)
amount = -amount + e.fee if status == 'failed' else 0
else:
amount = amount if status == 'succeeded' else 0
propagate_exchange(cursor, participant, e.route, error, amount)
propagate_exchange(cursor, participant, e, e.route, error, amount)

return e


def propagate_exchange(cursor, participant, route, error, amount):
def propagate_exchange(cursor, participant, exchange, route, error, amount):
"""Propagates an exchange's result to the participant's balance and the
route's status.
"""
route.update_error(error or '')

new_balance = cursor.one("""
UPDATE participants
SET balance=(balance + %s)
Expand All @@ -267,6 +272,40 @@ def propagate_exchange(cursor, participant, route, error, amount):
if amount < 0 and new_balance < 0:
raise NegativeBalance

if amount < 0:
bundles = cursor.all("""
LOCK TABLE cash_bundles IN EXCLUSIVE MODE;
SELECT *
FROM cash_bundles
WHERE owner = %s
AND ts < now() - INTERVAL %s
ORDER BY ts
""", (participant.id, QUARANTINE))
withdrawable = sum(b.amount for b in bundles)
x = -amount
if x > withdrawable:
raise NotEnoughWithdrawableMoney(Money(withdrawable, 'EUR'))
for b in bundles:
if x >= b.amount:
cursor.run("DELETE FROM cash_bundles WHERE id = %s", (b.id,))
x -= b.amount
if x == 0:
break
else:
assert x > 0
cursor.run("""
UPDATE cash_bundles
SET amount = (amount - %s)
WHERE id = %s
""", (x, b.id))
break
elif amount > 0:
cursor.run("""
INSERT INTO cash_bundles
(owner, origin, amount, ts)
VALUES (%s, %s, %s, %s)
""", (participant.id, exchange.id, amount, exchange.timestamp))

participant.set_attributes(balance=new_balance)

if amount != 0:
Expand All @@ -292,15 +331,24 @@ def transfer(db, tipper, tippee, amount, context, **kw):
tr.Fees = Money(0, 'EUR')
tr.Tag = str(t_id)
tr = mangoapi.transfers.Create(tr)
return record_transfer_result(db, t_id, tipper, tippee, amount, tr)
return record_transfer_result(db, t_id, tr)


def record_transfer_result(db, t_id, tipper, tippee, amount, tr):
def record_transfer_result(db, t_id, tr):
error = repr_error(tr)
status = tr.Status.lower()
assert (not error) ^ (status == 'failed')
return _record_transfer_result(db, t_id, status)


def _record_transfer_result(db, t_id, status):
with db.get_cursor() as c:
c.run("UPDATE transfers SET status = %s WHERE id = %s", (status, t_id))
tipper, tippee, amount = c.one("""
UPDATE transfers
SET status = %s
WHERE id = %s
RETURNING tipper, tippee, amount
""", (status, t_id))
if status == 'succeeded':
balance = c.one("""

Expand All @@ -317,6 +365,35 @@ def record_transfer_result(db, t_id, tipper, tippee, amount, tr):
""", locals())
if balance is None:
raise NegativeBalance
bundles = c.all("""
LOCK TABLE cash_bundles IN EXCLUSIVE MODE;
SELECT *
FROM cash_bundles
WHERE owner = %s
ORDER BY ts
""", (tipper,))
x = amount
for b in bundles:
if x >= b.amount:
c.run("""
UPDATE cash_bundles
SET owner = %s
WHERE id = %s
""", (tippee, b.id))
x -= b.amount
if x == 0:
break
else:
c.run("""
UPDATE cash_bundles
SET amount = (amount - %s)
WHERE id = %s;

INSERT INTO cash_bundles
(owner, origin, amount, ts)
VALUES (%s, %s, %s, %s);
""", (x, b.id, tippee, b.origin, x, b.ts))
break
return balance
raise LazyResponse(500, lambda _: _("Transfering the money failed, please try again."))

Expand All @@ -340,15 +417,13 @@ def sync_with_mangopay(db):
assert (not error) ^ (status == 'failed')
record_exchange_result(db, e.id, status, error, p)
else:
# The exchange didn't happen, remove it
db.run("DELETE FROM exchanges WHERE id=%s", (e.id,))
# and restore the participant's balance if it was a credit
# The exchange didn't happen
if e.amount < 0:
db.run("""
UPDATE participants
SET balance=(balance + %s)
WHERE id=%s
""", (-e.amount + e.fee, p.id))
# Mark it as failed if it was a credit
record_exchange_result(db, e.id, 'failed', 'interrupted', p)
else:
# Otherwise forget about it
db.run("DELETE FROM exchanges WHERE id=%s", (e.id,))

transfers = db.all("SELECT * FROM transfers WHERE status = 'pre'")
for t in transfers:
Expand All @@ -357,7 +432,7 @@ def sync_with_mangopay(db):
transactions = [x for x in transactions if x.Type == 'TRANSFER' and x.Tag == str(t.id)]
assert len(transactions) < 2
if transactions:
record_transfer_result(db, t.id, t.tipper, t.tippee, t.amount, transactions[0])
record_transfer_result(db, t.id, transactions[0])
else:
# The transfer didn't happen, remove it
db.run("DELETE FROM transfers WHERE id = %s", (t.id,))
Expand Down
2 changes: 2 additions & 0 deletions liberapay/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
MAX_TIP = Decimal('100.00')
MIN_TIP = Decimal('0.01')

QUARANTINE = timedelta(weeks=4)

PASSWORD_MIN_SIZE = 8
PASSWORD_MAX_SIZE = 150

Expand Down
5 changes: 5 additions & 0 deletions liberapay/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ def msg(self, _):
return _("There isn't enough money in your wallet.")


class NotEnoughWithdrawableMoney(LazyResponse400):
def msg(self, _):
return _("You can't withdraw more than {0} at this time.", *self.args)


class UserIsSuspicious(Exception): pass


Expand Down
17 changes: 17 additions & 0 deletions liberapay/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def check_db(cursor):
"""
_check_balances(cursor)
_check_tips(cursor)
_check_bundles(cursor)


def _check_tips(cursor):
Expand Down Expand Up @@ -94,3 +95,19 @@ def _check_balances(cursor):
where expected <> p.balance
""")
assert len(b) == 0, "conflicting balances: {}".format(b)


def _check_bundles(cursor):
"""Check that balances and cash bundles are coherent.
"""
b = cursor.all("""
SELECT bundles_total, balance
FROM (
SELECT owner, sum(amount) AS bundles_total
FROM cash_bundles b
GROUP BY owner
) foo
JOIN participants p ON p.id = owner
WHERE bundles_total <> balance
""")
assert len(b) == 0, "bundles are out of whack: {}".format(b)
10 changes: 10 additions & 0 deletions liberapay/models/participant.py
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,16 @@ def get_bank_account_error(self):
def get_credit_card_error(self):
return getattr(ExchangeRoute.from_network(self, 'mango-cc'), 'error', None)

@property
def withdrawable_balance(self):
from liberapay.billing.exchanges import QUARANTINE
return self.db.one("""
SELECT COALESCE(sum(amount), 0)
FROM cash_bundles
WHERE owner = %s
AND ts < now() - INTERVAL %s
""", (self.id, QUARANTINE))


# Random Junk
# ===========
Expand Down
19 changes: 18 additions & 1 deletion liberapay/testing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@
from aspen import resources, Response
from aspen.utils import utcnow
from aspen.testing.client import Client
from liberapay.billing.exchanges import record_exchange, record_exchange_result
from liberapay.billing import exchanges
from liberapay.billing.exchanges import (
record_exchange, record_exchange_result, _record_transfer_result
)
from liberapay.constants import SESSION
from liberapay.elsewhere import UserInfo
from liberapay.main import website
Expand Down Expand Up @@ -64,6 +67,7 @@ def decode_body(self):

class Harness(unittest.TestCase):

QUARANTINE = exchanges.QUARANTINE
client = ClientWithAuth(www_root=WWW_ROOT, project_root=PROJECT_ROOT)
db = client.website.db
platforms = client.website.platforms
Expand All @@ -82,6 +86,7 @@ def setUpClass(cls):
cls.db.run("ALTER SEQUENCE exchanges_id_seq RESTART WITH %s", (cls_id,))
cls.db.run("ALTER SEQUENCE transfers_id_seq RESTART WITH %s", (cls_id,))
cls.setUpVCR()
exchanges.QUARANTINE = '0 seconds'


@classmethod
Expand All @@ -103,6 +108,7 @@ def setUpVCR(cls):
@classmethod
def tearDownClass(cls):
cls.vcr_cassette.__exit__(None, None, None)
exchanges.QUARANTINE = cls.QUARANTINE


def setUp(self):
Expand Down Expand Up @@ -219,4 +225,15 @@ def make_exchange(self, route, amount, fee, participant, status='succeeded', err
return e_id


def make_transfer(self, tipper, tippee, amount, context='tip', team=None, status='succeeded'):
t_id = self.db.one("""
INSERT INTO transfers
(tipper, tippee, amount, context, team, status)
VALUES (%s, %s, %s, %s, %s, 'pre')
RETURNING id
""", (tipper, tippee, amount, context, team))
_record_transfer_result(self.db, t_id, status)
return t_id


class Foobar(Exception): pass
13 changes: 13 additions & 0 deletions sql/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -389,3 +389,16 @@ CREATE TABLE balances_at
, balance numeric(35,2) NOT NULL
, UNIQUE (participant, at)
);


-- all the money currently in the system, grouped by origin and current owner

CREATE TABLE cash_bundles
( id bigserial PRIMARY KEY
, owner bigint NOT NULL REFERENCES participants
, origin bigint NOT NULL REFERENCES exchanges
, amount numeric(35,2) NOT NULL CHECK (amount > 0)
, ts timestamptz NOT NULL
);

CREATE INDEX cash_bundles_owner_idx ON cash_bundles (owner);
Loading