Skip to content

Commit

Permalink
Settlement report (#184)
Browse files Browse the repository at this point in the history
* backend for settlments

* fixed mistaken edit

* single quote

* moved get_settled_transactions to razorpay since it is payment gateway specific

* cleanup

* cleanup comments

* cleanup

* added UI for settlement report. cleanup. added date validation

* cleanup

* remove merchant flag, use siteadmin
  • Loading branch information
shreyas-satish authored Nov 7, 2017
1 parent f7d6c3e commit 2ddea22
Show file tree
Hide file tree
Showing 16 changed files with 353 additions and 39 deletions.
1 change: 1 addition & 0 deletions boxoffice/extapi/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-

from .razorpay import *
from .razorpay_status import *
109 changes: 89 additions & 20 deletions boxoffice/extapi/razorpay.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,20 @@
# -*- coding: utf-8 -*-

import requests
from coaster.utils import LabeledEnum
from baseframe import __
from baseframe import __, localize_timezone
from boxoffice import app
from boxoffice.models import OnlinePayment, PaymentTransaction, TRANSACTION_TYPE

# Don't use a trailing slash
base_url = 'https://api.razorpay.com/v1/payments'

__all__ = ['RAZORPAY_PAYMENT_STATUS', 'capture_payment']


class RAZORPAY_PAYMENT_STATUS(LabeledEnum):
"""
Reflects payment statuses as specified in
https://docs.razorpay.com/docs/return-objects
"""
CREATED = (0, __("Created"))
AUTHORIZED = (1, __("Authorized"))
CAPTURED = (2, __("Captured"))
#: Only fully refunded payments.
REFUNDED = (3, __("Refunded"))
FAILED = (4, __("Failed"))
base_url = 'https://api.razorpay.com/v1'


def capture_payment(paymentid, amount):
"""
Attempts to capture the payment, from Razorpay
"""
verify_https = False if app.config.get('VERIFY_RAZORPAY_HTTPS') is False else True
url = '{base_url}/{paymentid}/capture'.format(base_url=base_url, paymentid=paymentid)
url = '{base_url}/payments/{paymentid}/capture'.format(base_url=base_url, paymentid=paymentid)
# Razorpay requires the amount to be in paisa and of type integer
resp = requests.post(url, data={'amount': int(amount * 100)},
auth=(app.config['RAZORPAY_KEY_ID'], app.config['RAZORPAY_KEY_SECRET']), verify=verify_https)
Expand All @@ -40,7 +25,91 @@ def refund_payment(paymentid, amount):
"""
Sends a POST request to Razorpay, to initiate a refund
"""
url = '{base_url}/{paymentid}/refund'.format(base_url=base_url, paymentid=paymentid)
url = '{base_url}/payments/{paymentid}/refund'.format(base_url=base_url, paymentid=paymentid)
# Razorpay requires the amount to be in paisa and of type integer
resp = requests.post(url, data={'amount': int(amount * 100)}, auth=(app.config['RAZORPAY_KEY_ID'], app.config['RAZORPAY_KEY_SECRET']))
return resp

def get_settlements(date_range):
url = '{base_url}/settlements/report/combined'.format(base_url=base_url)
resp = requests.get(url,
params={'year': date_range['year'], 'month': date_range['month']},
auth=(app.config['RAZORPAY_KEY_ID'], app.config['RAZORPAY_KEY_SECRET']))
return resp.json()

def get_settled_transactions(date_range, tz=None):
if not tz:
tz = app.config['TIMEZONE']
settled_transactions = get_settlements(date_range)
headers = ['settlement_id', 'transaction_type', 'order_id', 'payment_id', 'refund_id',
'item_collection', 'description', 'base_amount', 'discounted_amount',
'final_amount', 'order_paid_amount', 'transaction_date', 'settled_at',
'razorpay_fees', 'order_amount', 'credit', 'debit',
'receivable_amount', 'settlement_amount', 'buyer_fullname']
# Nested list of dictionaries consisting of transaction details
rows = []
external_transaction_msg = u"Transaction external to Boxoffice. Credited directly to Razorpay?"

for settled_transaction in settled_transactions:
if settled_transaction['type'] == 'settlement':
rows.append({
'settlement_id': settled_transaction['entity_id'],
'settlement_amount': settled_transaction['amount'],
'settled_at': settled_transaction['settled_at'],
'transaction_type': settled_transaction['type']
})
elif settled_transaction['type'] == 'payment':
payment = OnlinePayment.query.filter_by(pg_paymentid=settled_transaction['entity_id']).one_or_none()
if payment:
order = payment.order
rows.append({
'settlement_id': settled_transaction['settlement_id'],
'transaction_type': settled_transaction['type'],
'order_id': order.id,
'payment_id': settled_transaction['entity_id'],
'razorpay_fees': settled_transaction['fee'],
'transaction_date': localize_timezone(order.paid_at, tz),
'credit': settled_transaction['credit'],
'buyer_fullname': order.buyer_fullname,
'item_collection': order.item_collection.title
})
for line_item in order.initial_line_items:
rows.append({
'settlement_id': settled_transaction['settlement_id'],
'payment_id': settled_transaction['entity_id'],
'order_id': order.id,
'item_collection': order.item_collection.title,
'description': line_item.item.title,
'base_amount': line_item.base_amount,
'discounted_amount': line_item.discounted_amount,
'final_amount': line_item.final_amount
})
else:
# Transaction outside of Boxoffice
rows.append({
'settlement_id': settled_transaction['settlement_id'],
'payment_id': settled_transaction['entity_id'],
'credit': settled_transaction['credit'],
'description': external_transaction_msg
})
elif settled_transaction['type'] == 'refund':
payment = OnlinePayment.query.filter_by(pg_paymentid=settled_transaction['payment_id']).one()
refund = PaymentTransaction.query.filter(PaymentTransaction.online_payment == payment,
PaymentTransaction.transaction_type == TRANSACTION_TYPE.REFUND,
PaymentTransaction.pg_refundid == settled_transaction['entity_id']
).one()
order = refund.order
rows.append({
'settlement_id': settled_transaction['settlement_id'],
'refund_id': settled_transaction['entity_id'],
'payment_id': settled_transaction['payment_id'],
'transaction_type': settled_transaction['type'],
'order_id': order.id,
'razorpay_fees': settled_transaction['fee'],
'debit': settled_transaction['debit'],
'buyer_fullname': order.buyer_fullname,
'description': refund.refund_description,
'amount': refund.amount,
'item_collection': order.item_collection.title
})
return (headers, rows)
18 changes: 18 additions & 0 deletions boxoffice/extapi/razorpay_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-

from baseframe import __
from coaster.utils import LabeledEnum

__all__ = ['RAZORPAY_PAYMENT_STATUS']

class RAZORPAY_PAYMENT_STATUS(LabeledEnum):
"""
Reflects payment statuses as specified in
https://docs.razorpay.com/docs/return-objects
"""
CREATED = (0, __("Created"))
AUTHORIZED = (1, __("Authorized"))
CAPTURED = (2, __("Captured"))
#: Only fully refunded payments.
REFUNDED = (3, __("Refunded"))
FAILED = (4, __("Failed"))
16 changes: 14 additions & 2 deletions boxoffice/models/line_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class LINE_ITEM_STATUS(LabeledEnum):
#: A line item can be made void by the system to invalidate
#: a line item. Eg: a discount no longer applicable on a line item as a result of a cancellation
VOID = (3, __("Void"))

TRANSACTION = {CONFIRMED, VOID, CANCELLED}

LineItemTuple = namedtuple('LineItemTuple', ['item_id', 'id', 'base_amount', 'discount_policy_id', 'discount_coupon_id', 'discounted_amount', 'final_amount'])

Expand Down Expand Up @@ -66,7 +66,8 @@ class LineItem(BaseMixin, db.Model):
"""
__tablename__ = 'line_item'
__uuid_primary_key__ = True
__table_args__ = (db.UniqueConstraint('customer_order_id', 'line_item_seq'),)
__table_args__ = (db.UniqueConstraint('customer_order_id', 'line_item_seq'),
db.UniqueConstraint('previous_id'))

# line_item_seq is the relative number of the line item per order.
line_item_seq = db.Column(db.Integer, nullable=False)
Expand All @@ -78,6 +79,12 @@ class LineItem(BaseMixin, db.Model):
item_id = db.Column(None, db.ForeignKey('item.id'), nullable=False, index=True, unique=False)
item = db.relationship(Item, backref=db.backref('line_items', cascade='all, delete-orphan'))

previous_id = db.Column(None, db.ForeignKey('line_item.id'), nullable=True, index=True, unique=True)
previous = db.relationship('LineItem',
primaryjoin='line_item.c.id==line_item.c.previous_id',
backref=db.backref('revision', uselist=False),
remote_side='LineItem.id')

discount_policy_id = db.Column(None, db.ForeignKey('discount_policy.id'), nullable=True, index=True, unique=False)
discount_policy = db.relationship('DiscountPolicy', backref=db.backref('line_items'))

Expand Down Expand Up @@ -325,6 +332,11 @@ def get_confirmed_line_items(self):
Order.get_confirmed_line_items = property(get_confirmed_line_items)


def initial_line_items(self):
return LineItem.query.filter(LineItem.order == self, LineItem.previous == None, LineItem.status.in_(LINE_ITEM_STATUS.TRANSACTION))
Order.initial_line_items = property(initial_line_items)


def get_from_item(cls, item, qty, coupon_codes=[]):
"""
Returns a list of (discount_policy, discount_coupon) tuples
Expand Down
8 changes: 5 additions & 3 deletions boxoffice/models/payment.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
from decimal import Decimal
from coaster.utils import LabeledEnum, isoweek_datetime
from isoweek import Week
from baseframe import __
from baseframe import __, localize_timezone
from boxoffice.models import db, BaseMixin, Order, ORDER_STATUS, MarkdownColumn, ItemCollection
from ..extapi import RAZORPAY_PAYMENT_STATUS
from ..extapi.razorpay_status import RAZORPAY_PAYMENT_STATUS

__all__ = ['OnlinePayment', 'PaymentTransaction', 'CURRENCY', 'CURRENCY_SYMBOL', 'TRANSACTION_TYPE']

Expand Down Expand Up @@ -35,7 +35,7 @@ class OnlinePayment(BaseMixin, db.Model):
order = db.relationship(Order, backref=db.backref('online_payments', cascade='all, delete-orphan'))

# Payment id issued by the payment gateway
pg_paymentid = db.Column(db.Unicode(80), nullable=False)
pg_paymentid = db.Column(db.Unicode(80), nullable=False, unique=True)
# Payment status issued by the payment gateway
pg_payment_status = db.Column(db.Integer, nullable=False)
confirmed_at = db.Column(db.DateTime, nullable=True)
Expand Down Expand Up @@ -74,6 +74,8 @@ class PaymentTransaction(BaseMixin, db.Model):
internal_note = db.Column(db.Unicode(250), nullable=True)
refund_description = db.Column(db.Unicode(250), nullable=True)
note_to_user = MarkdownColumn('note_to_user', nullable=True)
# Refund id issued by the payment gateway
pg_refundid = db.Column(db.Unicode(80), nullable=True, unique=True)


def get_refund_transactions(self):
Expand Down
4 changes: 4 additions & 0 deletions boxoffice/static/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,10 @@ input.icon-placeholder {
display: inline;
}
}
p.settlements-month-widget {
margin-top: 10px;
}

.org-header {
text-align: center;
margin: 0 auto 15px;
Expand Down
2 changes: 1 addition & 1 deletion boxoffice/static/css/dist/admin_bundle.css

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions boxoffice/static/js/dist/admin_bundle.js

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions boxoffice/static/js/templates/admin_org_report.html.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,15 @@ export const OrgReportTemplate = `
<p class="field-title filled">Report type</p>
<select name="report-type" value="{{ reportType }}">
<option value="invoices" selected="selected">Invoices</option>
{{#if siteadmin}}
<option value="settlements">Settlements</option>
{{/if}}
</select>
{{#if reportType == "settlements"}}
<p class='settlements-month-widget'>
<input id="month" type="month" value="{{monthYear}}">
</p>
{{/if}}
</div>
<div class="btn-wrapper">
<a href="{{ reportsUrl() }}" download="{{ reportsFilename() }}" class="boxoffice-button boxoffice-button-action">Download</a>
Expand Down
23 changes: 20 additions & 3 deletions boxoffice/static/js/views/admin_org_report.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,43 @@ export const OrgReportView = {
render: function({org_name}={}) {
fetch({
url: urlFor('index', {resource: 'reports', scope_ns: 'o', scope_id: org_name, root: true})
}).done(({org_title}) => {
}).done(({org_title, siteadmin}) => {
// Initial render
let currentDate = new Date();
let currentYear = currentDate.getFullYear();
// month starts from 0
let currentMonth = currentDate.getMonth() + 1;
let reportComponent = new Ractive({
el: '#main-content-area',
template: OrgReportTemplate,
data: {
orgTitle: org_title,
reportType: "invoices",
monthYear: `${currentYear}-${currentMonth}`,
siteadmin: siteadmin,
reportsUrl: function() {
let reportType = this.get('reportType');
return urlFor('index', {
let url = urlFor('index', {
resource: reportType,
scope_ns: 'o',
scope_id: org_name,
ext: 'csv',
root: true
});
if (reportType === 'settlements') {
let year, month;
[year, month] = this.get('monthYear').split('-');
return `${url}?year=${year}&month=${month}`;
} else {
return url;
}
},
reportsFilename: function() {
return org_name + '_' + this.get('reportType') + '.csv';
if (this.get('reportType') === 'settlements'){
return `${org_name}_${this.get('reportType')}_${this.get('monthYear')}.csv`;
} else {
return `${org_name}_${this.get('reportType')}.csv`;
}
}
}
});
Expand Down
3 changes: 3 additions & 0 deletions boxoffice/static/sass/_layout.sass
Original file line number Diff line number Diff line change
Expand Up @@ -567,3 +567,6 @@ input.icon-placeholder
border-bottom: 2px solid
padding-bottom: 5px
display: inline

p.settlements-month-widget
margin-top: 10px
28 changes: 23 additions & 5 deletions boxoffice/views/admin_report.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
# -*- coding: utf-8 -*-

from flask import jsonify
from flask import jsonify, request, g, abort
from .. import app, lastuser
from coaster.views import load_models, render_with
from baseframe import localize_timezone, get_locale
from boxoffice.models import Organization, ItemCollection, LineItem, INVOICE_STATUS
from boxoffice.views.utils import check_api_access, csv_response
from boxoffice.views.utils import check_api_access, csv_response, api_error
from babel.dates import format_datetime
from datetime import datetime
from datetime import datetime, date
from ..extapi.razorpay import get_settled_transactions


def jsonify_report(data_dict):
Expand All @@ -28,7 +29,7 @@ def admin_report(item_collection):


def jsonify_org_report(data_dict):
return jsonify(org_title=data_dict['organization'].title)
return jsonify(org_title=data_dict['organization'].title, siteadmin=data_dict['siteadmin'])


@app.route('/admin/o/<org_name>/reports')
Expand All @@ -38,7 +39,7 @@ def jsonify_org_report(data_dict):
(Organization, {'name': 'org_name'}, 'organization'),
permission='org_admin')
def admin_org_report(organization):
return dict(organization=organization)
return dict(organization=organization, siteadmin=lastuser.has_permission('siteadmin'))


@app.route('/admin/ic/<ic_id>/tickets.csv')
Expand Down Expand Up @@ -169,3 +170,20 @@ def row_handler(row):
return dict_row

return csv_response(headers, rows, row_type='dict', row_handler=row_handler)


@app.route('/admin/o/<org_name>/settlements.csv')
@lastuser.requires_permission('siteadmin')
@load_models(
(Organization, {'name': 'org_name'}, 'organization'))
def settled_transactions(organization):
year = int(request.args.get('year'))
month = int(request.args.get('month'))
try:
date(year, month, 1)
except (ValueError, TypeError):
return api_error(message='Invalid year/month',
status_code=403,
errors=['invalid_date'])
headers, rows = get_settled_transactions({'year': year, 'month': month}, g.user.timezone)
return csv_response(headers, rows, row_type='dict')
8 changes: 5 additions & 3 deletions boxoffice/views/order.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,7 @@ def regenerate_line_item(order, original_line_item, updated_line_item_tup, line_
else:
coupon = None

return LineItem(order=order, item=item, discount_policy=policy,
return LineItem(order=order, item=item, discount_policy=policy, previous=original_line_item,
status=LINE_ITEM_STATUS.CONFIRMED,
line_item_seq=line_item_seq,
discount_coupon=coupon,
Expand Down Expand Up @@ -507,8 +507,9 @@ def process_line_item_cancellation(line_item):
payment = OnlinePayment.query.filter_by(order=line_item.order, pg_payment_status=RAZORPAY_PAYMENT_STATUS.CAPTURED).one()
rp_resp = razorpay.refund_payment(payment.pg_paymentid, refund_amount)
if rp_resp.status_code == 200:
rp_refund = rp_resp.json()
db.session.add(PaymentTransaction(order=order, transaction_type=TRANSACTION_TYPE.REFUND,
online_payment=payment, amount=refund_amount, currency=CURRENCY.INR, refunded_at=func.utcnow(),
pg_refundid=rp_refund['id'], online_payment=payment, amount=refund_amount, currency=CURRENCY.INR, refunded_at=func.utcnow(),
refund_description='Refund: {line_item_title}'.format(line_item_title=line_item.item.title)))
else:
raise PaymentGatewayError("Cancellation failed for order - {order} with the following details - {msg}".format(order=order.id,
Expand Down Expand Up @@ -557,8 +558,9 @@ def process_partial_refund_for_order(order, form_dict):
pg_payment_status=RAZORPAY_PAYMENT_STATUS.CAPTURED).one()
rp_resp = razorpay.refund_payment(payment.pg_paymentid, requested_refund_amount)
if rp_resp.status_code == 200:
rp_refund = rp_resp.json()
transaction = PaymentTransaction(order=order, transaction_type=TRANSACTION_TYPE.REFUND,
online_payment=payment, currency=CURRENCY.INR,
online_payment=payment, currency=CURRENCY.INR, pg_refundid=rp_refund['id'],
refunded_at=func.utcnow())
form.populate_obj(transaction)
db.session.add(transaction)
Expand Down
Loading

0 comments on commit 2ddea22

Please sign in to comment.