From a1ff32f6ad2fd50768dbc0a30980721154087a60 Mon Sep 17 00:00:00 2001 From: Leo Mouyna Date: Sat, 19 Oct 2019 11:25:28 +0200 Subject: [PATCH] feat: add the ability to refund. Create a new form based on the bill one with autocompleted fields. Add an action button at the end of each row on settle page. Add API payments entry. Implement refund tests related. See issue: #137 --- ihatemoney/api/common.py | 19 +++ ihatemoney/api/v1/resources.py | 2 + ihatemoney/forms.py | 8 ++ .../migrations/versions/52903c4b2459_.py | 26 +++++ ihatemoney/models.py | 25 +++- ihatemoney/static/css/main.css | 21 ++-- ihatemoney/static/images/payment.png | Bin 0 -> 490 bytes ihatemoney/templates/forms.html | 2 + ihatemoney/templates/list_bills.html | 2 +- ihatemoney/templates/settle_bills.html | 27 ++++- ihatemoney/tests/tests.py | 110 +++++++++++++++++- ihatemoney/web.py | 52 ++++++++- 12 files changed, 280 insertions(+), 14 deletions(-) create mode 100644 ihatemoney/migrations/versions/52903c4b2459_.py create mode 100644 ihatemoney/static/images/payment.png 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 0000000000000000000000000000000000000000..6f017a85cdce2dcff36c63ee091cd413bac3ea38 GIT binary patch literal 490 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!60wlNoGJgf6SkfJR9T^xl_H+M9WCijSl0AZa z85pY67#JE_7#My5g&JNkFq9fFFuY1&V6d9Oz#v{QXIG#NP=YDR+uenMVO6iP5s=4O z;1OBOz`%C|gc+x5^GO2**-JcqUD+RV35uHVDyFlt0flyWx;TbtoUff|%XP>>pw&KB z@Ss^%vU&v9wy~nktr7;_3`dz8vzBElE9J~k5f`x-8wREGs_9INM}bK zb?86v!Ha5o04xB40rMNK7f3y1p|2yx`Zku!Sq45I#7M7b=&(Al`RJ*w~ z)W*-v&S=|Ce!)rg_j5iS@b8b?X6GEbog;iV&=0C5t`Q|Ei6yC4$wjF^iowXh&{EgX zK-bVb#K_Rf)X2)vSlhtB%D|xBC{G(jLvDUbW?Cg~4Sf06rUNxdf@}!RPb(=;EJ|f4 jFE7{2%*!rLPAo{(%P&fw{mw=TsEEPS)z4*}Q$iB}8q1xO literal 0 HcmV?d00001 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")