Skip to content

Commit

Permalink
Merge pull request #561 from liberapay/expenses-2
Browse files Browse the repository at this point in the history
Implement Invoices - Part 2
  • Loading branch information
Changaco authored Apr 5, 2017
2 parents a0b3bf2 + 3c07072 commit 2c1a6c0
Show file tree
Hide file tree
Showing 12 changed files with 336 additions and 24 deletions.
16 changes: 16 additions & 0 deletions emails/invoice_accepted.spt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{{ _("Your invoice to {0} has been accepted - Liberapay", addressee_name) }}

[---] text/html
<p>{{ _(
"Your request for a payment of {amount} from {addressee_name} has been accepted."
, addressee_name=addressee_name, amount=Money(invoice.amount, 'EUR')
) }}</p>

<p>{{ _(
"Unfortunately the wallet of {0} does not contain enough money to pay you right "
"now, but as soon as it does you will be paid automatically."
, addressee_name
) }}</p>

<p><a href="{{ participant.url('invoices/%s' % invoice.id) }}"
>{{ _("View the invoice") }}</a></p>
20 changes: 20 additions & 0 deletions emails/invoice_paid.spt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{{ _("Your invoice to {0} has been accepted and paid - Liberapay", addressee_name) }}

[---] text/html
<p>{{ _(
"Your request for a payment of {amount} from {addressee_name} has been accepted, "
"and the money has been transferred to your wallet."
, addressee_name=addressee_name, amount=Money(invoice.amount, 'EUR')
) }}</p>

<p>
% if withdrawable
<a href="{{ participant.url('wallet/payout/') }}"
style="{{ button_style('primary') }}">{{ _("Withdraw the money") }}</a>
% else
{{ _("However you cannot withdraw the money yet as it is still in quarantine.") }}
% endif
</p>

<p><a href="{{ participant.url('invoices/%s' % invoice.id) }}"
>{{ _("View the invoice") }}</a></p>
11 changes: 11 additions & 0 deletions emails/invoice_rejected.spt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{{ _("Your invoice to {0} has been rejected - Liberapay", addressee_name) }}

[---] text/html
<p>{{ _(
"Your request for a payment of {amount} from {addressee_name} has been rejected."
, addressee_name=addressee_name, amount=Money(invoice.amount, 'EUR')
) }}</p>

<p>{{ _("Reason: “{0}”", rejection_message) }}</p>

<p><a href="{{ participant.url('invoices/%s' % invoice.id) }}">{{ _("View the invoice") }}</a></p>
6 changes: 3 additions & 3 deletions liberapay/billing/exchanges.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,10 +418,10 @@ def propagate_exchange(cursor, participant, exchange, route, error, amount):
def transfer(db, tipper, tippee, amount, context, **kw):
t_id = db.one("""
INSERT INTO transfers
(tipper, tippee, amount, context, team, status)
VALUES (%s, %s, %s, %s, %s, 'pre')
(tipper, tippee, amount, context, team, invoice, status)
VALUES (%s, %s, %s, %s, %s, %s, 'pre')
RETURNING id
""", (tipper, tippee, amount, context, kw.get('team')))
""", (tipper, tippee, amount, context, kw.get('team'), kw.get('invoice')))
get = lambda id, col: db.one("SELECT {0} FROM participants WHERE id = %s".format(col), (id,))
tr = Transfer()
tr.AuthorId = kw.get('tipper_mango_id') or get(tipper, 'mangopay_user_id')
Expand Down
55 changes: 48 additions & 7 deletions liberapay/billing/payday.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,13 +204,14 @@ def prepare(cursor, ts_start):
, amount numeric(35,2)
, context transfer_context
, team bigint
, invoice int
, UNIQUE (tipper, tippee, context, team)
) ON COMMIT DROP;
-- Prepare a statement that makes and records a transfer
CREATE OR REPLACE FUNCTION transfer(bigint, bigint, numeric, transfer_context, bigint)
CREATE OR REPLACE FUNCTION transfer(bigint, bigint, numeric, transfer_context, bigint, int)
RETURNS void AS $$
BEGIN
IF ($3 = 0) THEN RETURN; END IF;
Expand All @@ -223,8 +224,8 @@ def prepare(cursor, ts_start):
WHERE id = $2;
IF (NOT FOUND) THEN RAISE 'tippee %% not found', $2; END IF;
INSERT INTO payday_transfers
(tipper, tippee, amount, context, team)
VALUES ($1, $2, $3, $4, $5);
(tipper, tippee, amount, context, team, invoice)
VALUES ($1, $2, $3, $4, $5, $6);
END;
$$ LANGUAGE plpgsql;
Expand All @@ -241,7 +242,7 @@ def prepare(cursor, ts_start):
WHERE id = NEW.tipper
);
IF (NEW.amount <= tipper.new_balance) THEN
EXECUTE transfer(NEW.tipper, NEW.tippee, NEW.amount, 'tip', NULL);
EXECUTE transfer(NEW.tipper, NEW.tippee, NEW.amount, 'tip', NULL, NULL);
RETURN NEW;
END IF;
RETURN NULL;
Expand Down Expand Up @@ -331,7 +332,7 @@ def prepare(cursor, ts_start):
CONTINUE;
END IF;
transfer_amount := min(tip.amount, take.amount);
EXECUTE transfer(tip.tipper, take.member, transfer_amount, 'take', team_id);
EXECUTE transfer(tip.tipper, take.member, transfer_amount, 'take', team_id, NULL);
tip.amount := tip.amount - transfer_amount;
UPDATE our_takes t
SET amount = take.amount - transfer_amount
Expand All @@ -343,6 +344,45 @@ def prepare(cursor, ts_start):
END;
$$ LANGUAGE plpgsql;
-- Create a function to pay invoices
CREATE OR REPLACE FUNCTION pay_invoices() RETURNS void AS $$
DECLARE
invoices_cursor CURSOR FOR
SELECT i.*
FROM invoices i
WHERE i.status = 'accepted'
AND ( SELECT ie.ts
FROM invoice_events ie
WHERE ie.invoice = i.id
ORDER BY ts DESC
LIMIT 1
) < %(ts_start)s;
payer_balance numeric(35,2);
BEGIN
FOR i IN invoices_cursor LOOP
payer_balance := (
SELECT p.new_balance
FROM payday_participants p
WHERE id = i.addressee
);
IF (payer_balance < i.amount) THEN
CONTINUE;
END IF;
EXECUTE transfer(i.addressee, i.sender, i.amount,
i.nature::text::transfer_context,
NULL, i.id);
UPDATE invoices
SET status = 'paid'
WHERE id = i.id;
INSERT INTO invoice_events
(invoice, participant, status)
VALUES (i.id, i.addressee, 'paid');
END LOOP;
END;
$$ LANGUAGE plpgsql;
""", dict(ts_start=ts_start))
log("Prepared the DB.")

Expand All @@ -353,6 +393,7 @@ def transfer_virtually(cursor):
SELECT resolve_takes(id) FROM payday_participants WHERE kind = 'group';
SELECT settle_tip_graph();
UPDATE payday_tips SET is_funded = false WHERE is_funded IS NULL;
SELECT pay_invoices();
""")

@staticmethod
Expand Down Expand Up @@ -390,7 +431,7 @@ def clean_up(self):
self.db.run("""
DROP FUNCTION process_tip();
DROP FUNCTION settle_tip_graph();
DROP FUNCTION transfer(bigint, bigint, numeric, transfer_context, bigint);
DROP FUNCTION transfer(bigint, bigint, numeric, transfer_context, bigint, int);
DROP FUNCTION resolve_takes(bigint);
""")

Expand Down Expand Up @@ -628,7 +669,7 @@ def notify_participants(self):
FROM transfers t
WHERE "timestamp" > %s
AND "timestamp" <= %s
AND context <> 'refund'
AND context NOT IN ('refund', 'expense')
GROUP BY tippee
""", (previous_ts_end, self.ts_end))
for tippee_id, transfers in r:
Expand Down
15 changes: 15 additions & 0 deletions liberapay/models/participant.py
Original file line number Diff line number Diff line change
Expand Up @@ -1194,6 +1194,21 @@ def update_invoice_status(self, invoice_id, new_status, message=None):
""", (invoice_id, self.id, new_status, message))
return True

def pay_invoice(self, invoice):
assert self.id == invoice.addressee
if self.balance < invoice.amount:
return False
from liberapay.billing.exchanges import transfer
balance = transfer(
self.db, self.id, invoice.sender, invoice.amount, invoice.nature,
invoice=invoice.id,
tipper_mango_id=self.mangopay_user_id,
tipper_wallet_id=self.mangopay_wallet_id,
)
self.update_invoice_status(invoice.id, 'paid')
self.set_attributes(balance=balance)
return True


# More Random Stuff
# =================
Expand Down
43 changes: 43 additions & 0 deletions liberapay/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from datetime import date, datetime, timedelta
import errno
import fnmatch
from hashlib import sha256
import hmac
import os
import pickle
import re
Expand Down Expand Up @@ -317,3 +319,44 @@ def pid_exists(pid):
raise
else:
return True


def build_s3_object_url(key):
now = utcnow()
timestamp = now.strftime('%Y%m%dT%H%M%SZ')
today = timestamp.split('T', 1)[0]
region = website.app_conf.s3_region
access_key = website.app_conf.s3_public_access_key
endpoint = website.app_conf.s3_endpoint
assert endpoint.startswith('https://')
host = endpoint[8:]
querystring = (
"X-Amz-Algorithm=AWS4-HMAC-SHA256&"
"X-Amz-Credential={access_key}%2F{today}%2F{region}%2Fs3%2Faws4_request&"
"X-Amz-Date={timestamp}&"
"X-Amz-Expires=86400&"
"X-Amz-SignedHeaders=host"
).format(**locals())
canonical_request = (
"GET\n"
"/{key}\n"
"{querystring}\n"
"host:{host}\n"
"\n"
"host\n"
"UNSIGNED-PAYLOAD"
).format(**locals()).encode()
canonical_request_hash = sha256(canonical_request).hexdigest()
string_to_sign = (
"AWS4-HMAC-SHA256\n"
"{timestamp}\n"
"{today}/{region}/s3/aws4_request\n"
"{canonical_request_hash}"
).format(**locals()).encode()
aws4_secret_key = b"AWS4" + website.app_conf.s3_secret_key.encode()
sig_key = hmac.new(aws4_secret_key, today.encode(), sha256).digest()
sig_key = hmac.new(sig_key, region.encode(), sha256).digest()
sig_key = hmac.new(sig_key, b"s3", sha256).digest()
sig_key = hmac.new(sig_key, b"aws4_request", sha256).digest()
signature = hmac.new(sig_key, string_to_sign, sha256).hexdigest()
return endpoint + "/" + key + "?" + querystring + "&X-Amz-Signature=" + signature
3 changes: 1 addition & 2 deletions liberapay/utils/emails.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from __future__ import unicode_literals

from aspen.simplates.pagination import parse_specline, split_and_escape
from aspen.simplates.simplate import _decode
from aspen_jinja2_renderer import SimplateLoader
from jinja2 import Environment

Expand All @@ -27,7 +26,7 @@
def compile_email_spt(fpath):
r = {}
with open(fpath, 'rb') as f:
pages = list(split_and_escape(_decode(f.read())))
pages = list(split_and_escape(f.read().decode('utf8')))
for i, page in enumerate(pages, 1):
tmpl = '\n' * page.offset + page.content
content_type, renderer = parse_specline(page.header)
Expand Down
50 changes: 50 additions & 0 deletions tests/py/test_payday.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import absolute_import, division, print_function, unicode_literals

from decimal import Decimal as D
import json
import os

import mock
Expand Down Expand Up @@ -423,6 +424,55 @@ def test_unfunded_tip_to_team_doesnt_cause_NegativeBalance(self):
}
assert d == expected

def make_invoice(self, sender, addressee, amount, status):
invoice_data = {
'nature': 'expense',
'amount': amount,
'description': 'lorem ipsum',
'details': '',
}
r = self.client.PxST(
'/~%s/invoices/new' % addressee.id, auth_as=sender,
data=invoice_data, xhr=True,
)
assert r.code == 200, r.text
invoice_id = json.loads(r.text)['invoice_id']
if status == 'pre':
return invoice_id
r = self.client.PxST(
'/~%s/invoices/%s' % (addressee.id, invoice_id), auth_as=sender,
data={'action': 'send'},
)
assert r.code == 302, r.text
if status == 'new':
return invoice_id
r = self.client.PxST(
'/~%s/invoices/%s' % (addressee.id, invoice_id), auth_as=addressee,
data={'action': status[:-2], 'message': 'a message'},
)
assert r.code == 302, r.text
return invoice_id

def test_it_handles_invoices_correctly(self):
org = self.make_participant('org', kind='organization', allow_invoices=True)
self.make_exchange('mango-cc', 60, 0, self.janet)
self.janet.set_tip_to(org, '50.00')
self.db.run("UPDATE participants SET allow_invoices = true WHERE id = %s",
(self.janet.id,))
self.make_invoice(self.janet, org, '40.02', 'accepted')
self.make_invoice(self.janet, org, '80.04', 'accepted')
self.make_invoice(self.janet, org, '5.16', 'rejected')
self.make_invoice(self.janet, org, '3.77', 'new')
self.make_invoice(self.janet, org, '1.23', 'pre')
Payday.start().run()
expense_transfers = self.db.all("SELECT * FROM transfers WHERE context = 'expense'")
assert len(expense_transfers) == 1
d = dict(self.db.all("SELECT username, balance FROM participants WHERE balance <> 0"))
assert d == {
'org': D('9.98'),
'janet': D('50.02'),
}

def test_it_notifies_participants(self):
self.make_exchange('mango-cc', 10, 0, self.janet)
self.janet.set_tip_to(self.david, '4.50')
Expand Down
Loading

0 comments on commit 2c1a6c0

Please sign in to comment.