Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Settlement report #184

Merged
merged 11 commits into from
Nov 7, 2017
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']
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you want the timezone object, it's app.config['tz']. Our convention is to use timezone and TIMEZONE for the string and tz for the object.

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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both this and the other function could be lazy relationships with elaborate primaryjoin conditions. Seems cleaner.



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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This call does not specify the organization it's being called for. Since the RazorPay API key is global to the app, there is no reason to drag the organization into this. Just use Lastuser's siteadmin permission with a @lastuser.requires_permission('siteadmin') on this view. Or use a new permission name if siteadmin is too much to give away.

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