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 Invoices - Part 2 #561

Merged
merged 6 commits into from
Apr 5, 2017
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
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