mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-05-06 05:01:48 +02:00
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:
parent
eea50b9b2c
commit
a1ff32f6ad
12 changed files with 280 additions and 14 deletions
|
@ -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]
|
||||
|
||||
|
|
|
@ -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>"
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
26
ihatemoney/migrations/versions/52903c4b2459_.py
Normal file
26
ihatemoney/migrations/versions/52903c4b2459_.py
Normal 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 ###
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
BIN
ihatemoney/static/images/payment.png
Normal file
BIN
ihatemoney/static/images/payment.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 490 B |
|
@ -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") }}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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">×</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 %}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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")
|
||||
|
|
Loading…
Reference in a new issue