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
This commit is contained in:
Leo Mouyna 2019-10-19 11:25:28 +02:00
parent eea50b9b2c
commit a1ff32f6ad
12 changed files with 280 additions and 14 deletions

View file

@ -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]

View file

@ -11,6 +11,7 @@ from ihatemoney.api.common import (
ProjectStatsHandler,
MembersHandler,
BillHandler,
PaymentHandler,
BillsHandler,
)
@ -29,6 +30,7 @@ restful_api.add_resource(
MemberHandler, "/projects/<string:project_id>/members/<int:member_id>"
)
restful_api.add_resource(BillsHandler, "/projects/<string:project_id>/bills")
restful_api.add_resource(PaymentHandler, "/projects/<string:project_id>/payments")
restful_api.add_resource(
BillHandler, "/projects/<string:project_id>/bills/<int:bill_id>"
)

View file

@ -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

View file

@ -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 ###

View file

@ -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 "<Bill of %s from %s for %s>" % (
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)

View file

@ -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,18 +309,22 @@ 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;
}
.table-actions > .payment {
background: url("../images/payment.png") no-repeat right;
}
#bill_table , #monthly_stats {
margin-top: 30px;
margin-bottom: 30px;

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 B

View file

@ -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) }}
<div class="form-group row">
@ -122,6 +123,7 @@
</ul>
</div>
</div>
{% endif %}
</fieldset>
<div class="actions">
{{ form.submit(class="btn btn-primary") }}

View file

@ -131,7 +131,7 @@
{{ bill.owers|join(', ', 'name') }}
{%- endif %}</td>
<td>{{ "%0.2f"|format(bill.amount) }} ({{ "%0.2f"|format(bill.pay_each()) }} {{ _("each") }})</td>
<td class="bill-actions">
<td class="table-actions">
<a class="edit" href="{{ url_for(".edit_bill", bill_id=bill.id) }}" title="{{ _("edit") }}">{{ _('edit') }}</a>
<a class="delete" href="{{ url_for(".delete_bill", bill_id=bill.id) }}" title="{{ _("delete") }}">{{ _('delete') }}</a>
{% if bill.external_link %}

View file

@ -19,16 +19,41 @@
{% block content %}
<table id="bill_table" class="split_bills table table-striped">
<thead><tr><th>{{ _("Who pays?") }}</th><th>{{ _("To whom?") }}</th><th>{{ _("How much?") }}</th></tr></thead>
<thead><tr><th>{{ _("Who pays?") }}</th><th>{{ _("To whom?") }}</th><th>{{ _("How much?") }}</th><th>{{ _("Actions") }}</th></tr></thead>
<tbody>
{% for bill in bills %}
<tr receiver={{bill.receiver.id}}>
<td>{{ bill.ower }}</td>
<td>{{ bill.receiver }}</td>
<td>{{ "%0.2f"|format(bill.amount) }}</td>
<td class="table-actions">
<a
class="payment"
href="{{ url_for(".add_payment", receiver=bill.receiver.id) }}"
title="{{ _("pay") }}" data-toggle="modal"
data-target="#payment-form-{{bill.ower.id}}-{{bill.receiver.id}}"
>
{{ _('pay') }}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% for form in bill_forms %}
<div id="payment-form-{{form.payer.data}}-{{form.payed_for.data[0]}}" class="modal fade show" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">{{ form.title }}</h3>
<a href="#" class="close" data-dismiss="modal">&times;</a>
</div>
<form action="{{ url_for(".add_payment", receiver=form.payed_for.data[0]) }}" method="post" class="modal-body container">
{{ forms.add_bill(form, title=False, edit=True) }}
</form>
</div>
</div>
</div>
{% endfor %}
{% endblock %}

View file

@ -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 = '<td class="d-md-none">'
indent = "\n "
@ -970,6 +991,38 @@ class BudgetTestCase(IhatemoneyTestCase):
response.data.decode("utf-8"),
)
balance_first_cell = '<td class="balance-name">'
balance_indent = "\n "
positive_balance = '<td class="balance-value positive">'
negative_balance = '<td class="balance-value negative">'
self.assertIn(
balance_first_cell
+ "alexis</td>"
+ balance_indent
+ positive_balance
+ indent
+ "+6.33",
response.data.decode("utf-8"),
)
self.assertIn(
balance_first_cell
+ "fred</td>"
+ balance_indent
+ negative_balance
+ indent
+ "-3.83",
response.data.decode("utf-8"),
)
self.assertIn(
balance_first_cell
+ "tata</td>"
+ 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(

View file

@ -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("/<project_id>/payment/add/<int:receiver>", 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("/<project_id>/statistics")