diff --git a/ihatemoney/api/common.py b/ihatemoney/api/common.py index 728d2a8e..f0277205 100644 --- a/ihatemoney/api/common.py +++ b/ihatemoney/api/common.py @@ -154,6 +154,25 @@ class BillsHandler(Resource): return form.errors, 400 +class PaymentHandler(Resource): + method_decorators = [need_auth] + + def get(self, project): + return project.get_bills().filter(Bill.type == "settlement").all() + + def post(self, project): + form = get_billform_for( + project, True, meta={"csrf": False}, bill_type="settlement" + ) + if form.validate(): + bill = Bill() + form.save(bill, project) + db.session.add(bill) + db.session.commit() + return bill.id, 201 + return form.errors, 400 + + class BillHandler(Resource): method_decorators = [need_auth] diff --git a/ihatemoney/api/v1/resources.py b/ihatemoney/api/v1/resources.py index 821ba2bd..87e4c0f8 100644 --- a/ihatemoney/api/v1/resources.py +++ b/ihatemoney/api/v1/resources.py @@ -11,6 +11,7 @@ from ihatemoney.api.common import ( ProjectStatsHandler, MembersHandler, BillHandler, + PaymentHandler, BillsHandler, ) @@ -29,6 +30,7 @@ restful_api.add_resource( MemberHandler, "/projects//members/" ) restful_api.add_resource(BillsHandler, "/projects//bills") +restful_api.add_resource(PaymentHandler, "/projects//payments") restful_api.add_resource( BillHandler, "/projects//bills/" ) diff --git a/ihatemoney/forms.py b/ihatemoney/forms.py index 88afd296..0f878fde 100644 --- a/ihatemoney/forms.py +++ b/ihatemoney/forms.py @@ -182,6 +182,11 @@ class BillForm(FlaskForm): submit = SubmitField(_("Submit")) submit2 = SubmitField(_("Submit and add a new one")) + def __init__(self, bill_type="loan", *args, **kwargs): + super(BillForm, self).__init__(*args, **kwargs) + self.bill_type = bill_type + self.title = _("Bill form") + def save(self, bill, project): bill.payer_id = self.payer.data bill.amount = self.amount.data @@ -189,6 +194,7 @@ class BillForm(FlaskForm): bill.external_link = self.external_link.data bill.date = self.date.data bill.owers = [Person.query.get(ower, project) for ower in self.payed_for.data] + bill.type = self.bill_type return bill def fake_form(self, bill, project): @@ -198,6 +204,7 @@ class BillForm(FlaskForm): bill.external_link = "" bill.date = self.date bill.owers = [Person.query.get(ower, project) for ower in self.payed_for] + bill.type = self.bill_type return bill @@ -208,6 +215,7 @@ class BillForm(FlaskForm): self.external_link.data = bill.external_link self.date.data = bill.date self.payed_for.data = [int(ower.id) for ower in bill.owers] + self.bill_type = bill.type def set_default(self): self.payed_for.data = self.payed_for.default diff --git a/ihatemoney/migrations/versions/52903c4b2459_.py b/ihatemoney/migrations/versions/52903c4b2459_.py new file mode 100644 index 00000000..438aae28 --- /dev/null +++ b/ihatemoney/migrations/versions/52903c4b2459_.py @@ -0,0 +1,26 @@ +"""empty message + +Revision ID: 52903c4b2459 +Revises: 6c6fb2b7f229 +Create Date: 2019-10-20 20:31:49.848058 + +""" + +# revision identifiers, used by Alembic. +revision = "52903c4b2459" +down_revision = "6c6fb2b7f229" + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("bill", sa.Column("type", sa.UnicodeText(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("bill", "type") + # ### end Alembic commands ### diff --git a/ihatemoney/models.py b/ihatemoney/models.py index 4d32fd97..f3f6f088 100644 --- a/ihatemoney/models.py +++ b/ihatemoney/models.py @@ -3,6 +3,7 @@ from collections import defaultdict from datetime import datetime from flask_sqlalchemy import SQLAlchemy, BaseQuery from flask import g, current_app +from flask_babel import lazy_gettext as _ from debts import settle from sqlalchemy import orm @@ -86,13 +87,17 @@ class Project(db.Model): { "member": member, "paid": sum( - [bill.amount for bill in self.get_member_bills(member.id).all()] + [ + bill.amount + for bill in self.get_member_bills(member.id).all() + if bill.type != "settlement" + ] ), "spent": sum( [ bill.pay_each() * member.weight for bill in self.get_bills().all() - if member in bill.owers + if member in bill.owers and bill.type != "settlement" ] ), "balance": self.balance[member.id], @@ -374,6 +379,7 @@ class Bill(db.Model): date = db.Column(db.Date, default=datetime.now) creation_date = db.Column(db.Date, default=datetime.now) what = db.Column(db.UnicodeText) + type = db.Column(db.UnicodeText, default="loan") external_link = db.Column(db.UnicodeText) archive = db.Column(db.Integer, db.ForeignKey("archive.id")) @@ -389,6 +395,7 @@ class Bill(db.Model): "creation_date": self.creation_date, "what": self.what, "external_link": self.external_link, + "type": self.type, } def pay_each(self): @@ -403,6 +410,12 @@ class Bill(db.Model): else: return 0 + def is_loan(self): + return self.type == "loan" + + def is_settlement(self): + return self.type == "settlement" + def __repr__(self): return "" % ( self.amount, @@ -410,6 +423,14 @@ class Bill(db.Model): ", ".join([o.name for o in self.owers]), ) + def from_settlement(self, settlement_bill): + self.payer_id = settlement_bill["ower"].id + self.amount = settlement_bill["amount"] + self.what = _("Refund") + self.date = datetime.now() + self.owers = [settlement_bill["receiver"]] + return self + class Archive(db.Model): id = db.Column(db.Integer, primary_key=True) diff --git a/ihatemoney/static/css/main.css b/ihatemoney/static/css/main.css index 135a6840..7cbef949 100644 --- a/ihatemoney/static/css/main.css +++ b/ihatemoney/static/css/main.css @@ -291,14 +291,15 @@ footer .footer-left { color: red; } -.bill-actions { +.table-actions { padding-top: 10px; text-align: center; } -.bill-actions > .delete, -.bill-actions > .edit, -.bill-actions > .see { +.table-actions > .delete, +.table-actions > .edit, +.table-actions > .see, +.table-actions > .payment { font-size: 0px; display: block; width: 16px; @@ -308,19 +309,23 @@ footer .footer-left { float: left; } -.bill-actions > .delete { +.table-actions > .delete { background: url("../images/delete.png") no-repeat right; } -.bill-actions > .edit { +.table-actions > .edit { background: url("../images/edit.png") no-repeat right; } -.bill-actions > .see { +.table-actions > .see { background: url("../images/see.png") no-repeat right; } -#bill_table, #monthly_stats { +.table-actions > .payment { + background: url("../images/payment.png") no-repeat right; +} + +#bill_table , #monthly_stats { margin-top: 30px; margin-bottom: 30px; } diff --git a/ihatemoney/static/images/payment.png b/ihatemoney/static/images/payment.png new file mode 100644 index 00000000..6f017a85 Binary files /dev/null and b/ihatemoney/static/images/payment.png differ diff --git a/ihatemoney/templates/forms.html b/ihatemoney/templates/forms.html index bec70184..ee03e620 100644 --- a/ihatemoney/templates/forms.html +++ b/ihatemoney/templates/forms.html @@ -104,6 +104,7 @@ {{ input(form.what, inline=True) }} {{ input(form.payer, inline=True, class="form-control custom-select") }} {{ input(form.amount, inline=True) }} + {% if form.bill_type == "loan" %} {{ input(form.external_link, inline=True) }}
@@ -122,6 +123,7 @@
+ {% endif %}
{{ form.submit(class="btn btn-primary") }} diff --git a/ihatemoney/templates/list_bills.html b/ihatemoney/templates/list_bills.html index 0f2a68a5..0b42c1ff 100644 --- a/ihatemoney/templates/list_bills.html +++ b/ihatemoney/templates/list_bills.html @@ -131,7 +131,7 @@ {{ bill.owers|join(', ', 'name') }} {%- endif %} {{ "%0.2f"|format(bill.amount) }} ({{ "%0.2f"|format(bill.pay_each()) }} {{ _("each") }}) - + {{ _('edit') }} {{ _('delete') }} {% if bill.external_link %} diff --git a/ihatemoney/templates/settle_bills.html b/ihatemoney/templates/settle_bills.html index 7ec5e290..9c3f0c75 100644 --- a/ihatemoney/templates/settle_bills.html +++ b/ihatemoney/templates/settle_bills.html @@ -19,16 +19,41 @@ {% block content %} - + {% for bill in bills %} + {% endfor %}
{{ _("Who pays?") }}{{ _("To whom?") }}{{ _("How much?") }}
{{ _("Who pays?") }}{{ _("To whom?") }}{{ _("How much?") }}{{ _("Actions") }}
{{ bill.ower }} {{ bill.receiver }} {{ "%0.2f"|format(bill.amount) }} + + {{ _('pay') }} + +
+ {% for form in bill_forms %} + + {% endfor %} {% endblock %} diff --git a/ihatemoney/tests/tests.py b/ihatemoney/tests/tests.py index c4b1585c..fc6f7e90 100644 --- a/ihatemoney/tests/tests.py +++ b/ihatemoney/tests/tests.py @@ -141,7 +141,8 @@ class BudgetTestCase(IhatemoneyTestCase): self.post_project("raclette") self.client.post( - "/raclette/invite", data={"emails": "alexis@notmyidea.org"} + "/raclette/invite", + data={"emails": ["alexis@notmyidea.org", "raclette@notmyidea.org"]}, ) self.assertEqual(len(outbox), 2) @@ -675,6 +676,20 @@ class BudgetTestCase(IhatemoneyTestCase): balance = models.Project.query.get("raclette").balance self.assertEqual(set(balance.values()), set([19.0, -19.0])) + # Refund from member 0 to member 1 + self.client.post( + "raclette/payment/add/{}".format(members_ids[1]), + data={ + "date": "2011-08-11", + "payer": members_ids[0], + "what": "Refund", + "amount": 18, + }, + ) + + balance = models.Project.query.get("raclette").balance + self.assertEqual(set(balance.values()), set([1.0, -1.0])) + # Bill with negative amount self.client.post( "/raclette/add", @@ -930,6 +945,12 @@ class BudgetTestCase(IhatemoneyTestCase): }, ) + # Refund should only affect balance + self.client.post( + "raclette/payment/add/2", + data={"date": "2011-08-11", "payer": 1, "what": "Refund", "amount": 18}, + ) + response = self.client.get("/raclette/statistics") first_cell = '' indent = "\n " @@ -970,6 +991,38 @@ class BudgetTestCase(IhatemoneyTestCase): response.data.decode("utf-8"), ) + balance_first_cell = '' + balance_indent = "\n " + positive_balance = '' + negative_balance = '' + self.assertIn( + balance_first_cell + + "alexis" + + balance_indent + + positive_balance + + indent + + "+6.33", + response.data.decode("utf-8"), + ) + self.assertIn( + balance_first_cell + + "fred" + + balance_indent + + negative_balance + + indent + + "-3.83", + response.data.decode("utf-8"), + ) + self.assertIn( + balance_first_cell + + "tata" + + balance_indent + + negative_balance + + indent + + "-2.5", + response.data.decode("utf-8"), + ) + def test_settle_page(self): self.post_project("raclette") response = self.client.get("/raclette/settle_bills") @@ -1776,6 +1829,7 @@ class APITestCase(IhatemoneyTestCase): "date": "2011-08-10", "id": 1, "external_link": "https://raclette.fr", + "type": "loan", } got = json.loads(req.data.decode("utf-8")) @@ -1845,6 +1899,7 @@ class APITestCase(IhatemoneyTestCase): "date": "2011-09-10", "external_link": "https://raclette.fr", "id": 1, + "type": "loan", } got = json.loads(req.data.decode("utf-8")) @@ -1855,6 +1910,57 @@ class APITestCase(IhatemoneyTestCase): del got["creation_date"] self.assertDictEqual(expected, got) + # add a refound + req = self.client.post( + "/api/projects/raclette/payments", + data={ + "date": "2011-08-15", + "what": "refund", + "payer": "2", + "payed_for": ["1"], + "amount": "10", + "type": "settlement", + }, + headers=self.get_auth("raclette"), + ) + + # should return the id + self.assertStatus(201, req) + self.assertEqual(req.data.decode("utf-8"), "2\n") + + # get the refund details + req = self.client.get( + "/api/projects/raclette/bills/2", headers=self.get_auth("raclette") + ) + + # compare with the added info + self.assertStatus(200, req) + expected = { + "what": "refund", + "payer_id": 2, + "owers": [{"activated": True, "id": 1, "name": "alexis", "weight": 1},], + "amount": 10.0, + "date": "2011-08-15", + "external_link": "", + "id": 2, + "type": "settlement", + } + + got = json.loads(req.data.decode("utf-8")) + self.assertEqual( + datetime.date.today(), + datetime.datetime.strptime(got["creation_date"], "%Y-%m-%d").date(), + ) + del got["creation_date"] + self.assertDictEqual(expected, got) + + # get only refund bills + req = self.client.get( + "/api/projects/raclette/payments", headers=self.get_auth("raclette") + ) + self.assertStatus(200, req) + self.assertEqual(1, len(json.loads(req.data.decode("utf-8")))) + # delete a bill req = self.client.delete( "/api/projects/raclette/bills/1", headers=self.get_auth("raclette") @@ -1921,6 +2027,7 @@ class APITestCase(IhatemoneyTestCase): "date": "2011-08-10", "id": id, "external_link": "", + "type": "loan", } got = json.loads(req.data.decode("utf-8")) @@ -2063,6 +2170,7 @@ class APITestCase(IhatemoneyTestCase): "date": "2011-08-10", "id": 1, "external_link": "", + "type": "loan", } got = json.loads(req.data.decode("utf-8")) self.assertEqual( diff --git a/ihatemoney/web.py b/ihatemoney/web.py index 1b80ab62..2ee03cc7 100644 --- a/ihatemoney/web.py +++ b/ihatemoney/web.py @@ -737,7 +737,57 @@ def change_lang(lang): def settle_bill(): """Compute the sum each one have to pay to each other and display it""" bills = g.project.get_transactions_to_settle_bill() - return render_template("settle_bills.html", bills=bills, current_view="settle_bill") + bill_forms = [build_payment_form(bill) for bill in bills] + + return render_template( + "settle_bills.html", + bills=bills, + bill_forms=bill_forms, + current_view="settle_bill", + ) + + +def build_payment_form(settlement_bill): + form = get_billform_for(g.project, set_default=False, bill_type="settlement") + bill = Bill().from_settlement(settlement_bill) + + form.fill(bill) + receiver = settlement_bill["receiver"] + form.payer.choices = [ + choice for choice in form.payer.choices if choice[0] != receiver.id + ] + title = _("Refund for {}".format(receiver)) + form.title = title + + return form + + +@main.route("//payment/add/", methods=["POST"]) +def add_payment(receiver): + receiver = Person.query.get(receiver, g.project) + if not receiver: + raise NotFound("Person with id: {} not found".format(receiver)) + + form = get_billform_for(g.project, bill_type="settlement") + if request.method == "POST": + form.payed_for.data = [receiver.id] + if form.validate(): + + bill = Bill() + db.session.add(form.save(bill, g.project)) + db.session.commit() + + flash( + _( + "The payment of {} to {} has been added".format( + form.amount.data, receiver.name + ) + ) + ) + + return redirect(url_for(".settle_bill")) + + return render_template("add_bill.html", form=form, edit=True) @main.route("//statistics")