diff --git a/emails/invoice_accepted.spt b/emails/invoice_accepted.spt new file mode 100644 index 0000000000..5b96de6351 --- /dev/null +++ b/emails/invoice_accepted.spt @@ -0,0 +1,16 @@ +{{ _("Your invoice to {0} has been accepted - Liberapay", addressee_name) }} + +[---] text/html +
{{ _( + "Your request for a payment of {amount} from {addressee_name} has been accepted." + , addressee_name=addressee_name, amount=Money(invoice.amount, 'EUR') +) }}
+ +{{ _( + "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 +) }}
+ + diff --git a/emails/invoice_paid.spt b/emails/invoice_paid.spt new file mode 100644 index 0000000000..c5307c7362 --- /dev/null +++ b/emails/invoice_paid.spt @@ -0,0 +1,20 @@ +{{ _("Your invoice to {0} has been accepted and paid - Liberapay", addressee_name) }} + +[---] text/html +{{ _( + "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') +) }}
+ ++ % if withdrawable + {{ _("Withdraw the money") }} + % else + {{ _("However you cannot withdraw the money yet as it is still in quarantine.") }} + % endif +
+ + diff --git a/emails/invoice_rejected.spt b/emails/invoice_rejected.spt new file mode 100644 index 0000000000..0a50af0678 --- /dev/null +++ b/emails/invoice_rejected.spt @@ -0,0 +1,11 @@ +{{ _("Your invoice to {0} has been rejected - Liberapay", addressee_name) }} + +[---] text/html +{{ _( + "Your request for a payment of {amount} from {addressee_name} has been rejected." + , addressee_name=addressee_name, amount=Money(invoice.amount, 'EUR') +) }}
+ +{{ _("Reason: “{0}”", rejection_message) }}
+ + diff --git a/liberapay/billing/exchanges.py b/liberapay/billing/exchanges.py index a5a2406ed4..d4c90ac37c 100644 --- a/liberapay/billing/exchanges.py +++ b/liberapay/billing/exchanges.py @@ -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') diff --git a/liberapay/billing/payday.py b/liberapay/billing/payday.py index 157ee2cc2d..443f03c785 100644 --- a/liberapay/billing/payday.py +++ b/liberapay/billing/payday.py @@ -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; @@ -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; @@ -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; @@ -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 @@ -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.") @@ -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 @@ -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); """) @@ -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: diff --git a/liberapay/models/participant.py b/liberapay/models/participant.py index 0fee1bfb22..346c788af6 100644 --- a/liberapay/models/participant.py +++ b/liberapay/models/participant.py @@ -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 # ================= diff --git a/liberapay/utils/__init__.py b/liberapay/utils/__init__.py index 908bc2bc5b..33c3024c7f 100644 --- a/liberapay/utils/__init__.py +++ b/liberapay/utils/__init__.py @@ -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 @@ -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 diff --git a/liberapay/utils/emails.py b/liberapay/utils/emails.py index 599c8cfc5a..a89f6e5cc2 100644 --- a/liberapay/utils/emails.py +++ b/liberapay/utils/emails.py @@ -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 @@ -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) diff --git a/tests/py/test_payday.py b/tests/py/test_payday.py index 8b1820bcdf..42c522980d 100644 --- a/tests/py/test_payday.py +++ b/tests/py/test_payday.py @@ -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 @@ -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') diff --git a/www/%username/invoices/%invoice_id.spt b/www/%username/invoices/%invoice_id.spt index e9fe6dc9a2..862b2467f3 100644 --- a/www/%username/invoices/%invoice_id.spt +++ b/www/%username/invoices/%invoice_id.spt @@ -1,7 +1,8 @@ +# coding: utf8 from liberapay.exceptions import LoginRequired from liberapay.models.participant import Participant -from liberapay.utils import get_participant, markdown +from liberapay.utils import build_s3_object_url, get_participant, markdown [---] @@ -34,18 +35,36 @@ if invoice.status == 'pre' and user != sender and not user.is_admin: raise response.error(404) if request.method == 'POST': + if user.ANON: + raise LoginRequired action = request.body['action'] - if action == 'send': - new_status = 'new' - elif action == 'cancel': - new_status = 'canceled' + if action == 'download-file': + if user not in (sender, addressee) and not user.is_admin: + raise response.error(403, _("Invoice documents are private.")) + name = request.body['name'] + if name not in invoice.documents: + raise response.error(400, "bad `name` value") + key = 'invoice_docs/%i/%s' % (invoice.id, name) + response.redirect(build_s3_object_url(key)) + if action in ('send', 'cancel'): + new_status = 'new' if action == 'send' else action + 'ed' + allowed_user_id = invoice.sender + elif action in ('accept', 'reject'): + new_status = action + 'ed' + allowed_user_id = invoice.addressee else: response.error(400, "bad `action` value") - if user.id != invoice.sender: + if user.id != allowed_user_id: raise response.error(403) - r = sender.update_invoice_status(invoice.id, new_status) + if action in ('reject', 'retract'): + msg = request.body['message'] + if msg and len(msg) > 256: + raise response.error(400, _("The message is too long.")) + else: + msg = None + r = user.update_invoice_status(invoice.id, new_status, message=msg) if not r: - raise response.error(409, "This invoice has already been submitted or canceled.") + raise response.error(409, "The status of this invoice has already been modified.") if action == 'send': addressee.notify( 'new_invoice', @@ -53,7 +72,26 @@ if request.method == 'POST': sender_name=sender.username, force_email=True, ) - response.redirect(request.line.uri) + elif action == 'accept': + paid = addressee.pay_invoice(invoice) + sender.notify( + 'invoice_paid' if paid else 'invoice_accepted', + invoice=invoice, + addressee_name=addressee.username, + accepted_by=user.username, + withdrawable=(participant.withdrawable_balance >= invoice.amount), + force_email=True, + ) + elif action == 'reject': + sender.notify( + 'invoice_rejected', + invoice=invoice, + addressee_name=addressee.username, + rejected_by=user.username, + rejection_message=msg, + force_email=True, + ) + response.redirect(participant.path('invoices/%s' % invoice.id)) invoice_events = website.db.all(""" SELECT * @@ -71,8 +109,28 @@ title = _("Invoice #{0}", i_id) % block content +% if request.qs.get('action') == 'reject' +{{ _("The invoice is ready, please check that everything is correct, then click the button below to send it.") }}
+% elif invoice.status == 'paid' +{{ _("This invoice has been paid.") }}
+% elif invoice.status == 'rejected' +{{ _("This invoice has been rejected.") }}
% endif{{ _("Documents:") }}
+ % endif % if invoice.status == 'pre' @@ -105,12 +168,22 @@ title = _("Invoice #{0}", i_id) -% elif invoice_events +% elif invoice.status == 'new' and user == addressee +{{ format_datetime(e.ts) }} — - {{ _(constants.INVOICE_STATUSES[invoice.status]) }} + {{ _(constants.INVOICE_STATUSES[e.status]) }} {% if e.message %} — {{ _("Message: {0}", e.message) }}{% endif %}
% endfor{{ format_datetime(invoice.ctime) }} — {{ _("Created") }}
@@ -118,4 +191,6 @@ title = _("Invoice #{0}", i_id){{ _("Status: {0}", _(constants.INVOICE_STATUSES[invoice.status])) }}
% endif +% endif + % endblock diff --git a/www/%username/invoices/index.spt b/www/%username/invoices/index.spt new file mode 100644 index 0000000000..cdcf13c8b6 --- /dev/null +++ b/www/%username/invoices/index.spt @@ -0,0 +1,40 @@ +# coding: utf8 + +from liberapay.utils import get_participant + +[---] + +participant = get_participant(state, restrict=False) + +invoices = website.db.all(""" + SELECT i.* + , ( SELECT ts + FROM invoice_events ie + WHERE ie.invoice = i.id + ORDER BY ts DESC + LIMIT 1 + ) AS mtime + FROM invoices i + WHERE i.addressee = %s + AND i.status NOT IN ('pre', 'canceled') + ORDER BY i.ctime DESC + LIMIT 100 +""", (participant.id,)) + +title = _("Invoices - {username}", username=participant.username) + +[---] text/html +% extends "templates/base.html" + +% block content + +% for i in invoices ++ {{ i.description }} + — {{ _(constants.INVOICE_STATUSES[i.status]) }} ({{ format_date(i.mtime, 'long') }}) +
+% else +{{ _("Nothing to show.") }}
+% endfor + +% endblock diff --git a/www/%username/invoices/new.spt b/www/%username/invoices/new.spt index 1bf69fccf1..46637aa954 100644 --- a/www/%username/invoices/new.spt +++ b/www/%username/invoices/new.spt @@ -49,6 +49,8 @@ if request.method == 'POST': if invoice_nature != 'expense': raise response.error(400, "bad `nature` value (in POST data)") amount = parse_decimal(body['amount']) + if amount <= 0: + raise response.error(400, "`amount` must be greater than 0") description = body['description'].strip() if len(description) < 5: raise response.error(400, _("The description is too short."))