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

Choose a reason for hiding this comment

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

Brackets not required. It's confusing as it appears as a tuple until you notice it ends with ), not ,).

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
10 changes: 7 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 Expand Up @@ -167,3 +169,5 @@ def calculate_weekly_refunds(item_collection_ids, user_tz, year):
ordered_week_refunds[int(week_refund.sales_week)] = week_refund.sum

return ordered_week_refunds


Copy link
Member

Choose a reason for hiding this comment

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

Extra blank lines

3 changes: 3 additions & 0 deletions boxoffice/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ class Organization(ProfileBase, db.Model):
# logo (image url), refund_policy (html), ticket_faq (html), website (url)
details = db.Column(JsonDict, nullable=False, server_default='{}')
contact_email = db.Column(db.Unicode(254), nullable=False)
# Merchant account involves settlements and hence can access
# settlement reports
merchant = db.Column(db.Boolean, nullable=False, default=False)
Copy link
Member

Choose a reason for hiding this comment

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

What sort of organization accounts would we have that are not merchant accounts?


def permissions(self, user, inherited=None):
perms = super(Organization, self).permissions(user, inherited)
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
Loading