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
14 changes: 11 additions & 3 deletions boxoffice/extapi/razorpay.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
from baseframe import __
from boxoffice import app


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

__all__ = ['RAZORPAY_PAYMENT_STATUS', 'capture_payment']

Expand All @@ -29,7 +30,7 @@ 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 +41,14 @@ 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()
16 changes: 15 additions & 1 deletion boxoffice/models/line_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ class LINE_ITEM_STATUS(LabeledEnum):
#: a line item. Eg: a discount no longer applicable on a line item as a result of a cancellation
VOID = (3, __("Void"))

LINE_ITEM_STATUS.TRANSACTION = [LINE_ITEM_STATUS.CONFIRMED, LINE_ITEM_STATUS.VOID, LINE_ITEM_STATUS.CANCELLED]
Copy link
Member

Choose a reason for hiding this comment

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

This is a kludge. I'm adding a fix to Coaster to support this directly inside the class.



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

Expand Down Expand Up @@ -66,7 +68,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 +81,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 +334,11 @@ def get_confirmed_line_items(self):
Order.get_confirmed_line_items = property(get_confirmed_line_items)


def get_initial_line_items(self):
return LineItem.query.filter(LineItem.order == self, LineItem.previous == None, LineItem.status.in_(LINE_ITEM_STATUS.TRANSACTION))
Order.get_initial_line_items = property(get_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.

If it's a property it shouldn't have the get_ prefix. It'll be used as order.initial_line_items.all(), similar to how a lazy relationship will be.



def get_from_item(cls, item, qty, coupon_codes=[]):
"""
Returns a list of (discount_policy, discount_coupon) tuples
Expand Down
73 changes: 72 additions & 1 deletion boxoffice/models/payment.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
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

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)


def get_refund_transactions(self):
Expand Down Expand Up @@ -167,3 +169,72 @@ 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

def get_settled_transactions(date_range):
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']
Copy link
Member

Choose a reason for hiding this comment

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

Long line needs wrapping.

rows = []
Copy link
Member

Choose a reason for hiding this comment

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

Add comment indicating content.

external_transaction_msg = u"Transaction external to Boxoffice. Credited directly to Razorpay?"
Copy link
Member

Choose a reason for hiding this comment

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

PG-specific message.


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

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

Choose a reason for hiding this comment

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

PG reference shouldn't be here.

'transaction_date': localize_timezone(order.paid_at, 'Asia/Kolkata'),
Copy link
Member

Choose a reason for hiding this comment

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

Does Razorpay do IST as a documented feature of their API? Link to docs will help.

'credit': settled_transaction['credit'],
'buyer_fullname': order.buyer_fullname,
'item_collection': order.item_collection.title
})
for line_item in order.get_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(settled_transactionid=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'],
'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)
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
27 changes: 27 additions & 0 deletions migrations/versions/171fcb171759_add_previous_to_line_item.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""add_previous_to_line_item

Revision ID: 171fcb171759
Revises: 81f30d00706f
Create Date: 2017-10-24 18:40:39.183620

"""

# revision identifiers, used by Alembic.
revision = '171fcb171759'
down_revision = '81f30d00706f'

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
import sqlalchemy_utils

def upgrade():
op.add_column('line_item', sa.Column('previous_id', sqlalchemy_utils.types.uuid.UUIDType(), nullable=True))
op.create_index(op.f('ix_line_item_previous_id'), 'line_item', ['previous_id'], unique=True)
op.create_foreign_key('line_item_id_fkey', 'line_item', 'line_item', ['previous_id'], ['id'])


def downgrade():
op.drop_constraint('line_item_id_fkey', 'line_item', type_='foreignkey')
op.drop_index(op.f('ix_line_item_previous_id'), table_name='line_item')
op.drop_column('line_item', 'previous_id')
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""retroactively_migrate_previous_id_for_line_item

Revision ID: 66b67130c901
Revises: 171fcb171759
Create Date: 2017-10-26 14:50:18.859247

"""

# revision identifiers, used by Alembic.
revision = '66b67130c901'
down_revision = '171fcb171759'

from collections import OrderedDict
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy.sql import table, column
import sqlalchemy_utils
from boxoffice.models.order import ORDER_STATUS
from boxoffice.models.line_item import LINE_ITEM_STATUS



def find_nearest_timestamp(lst, timestamp):
nearest_ts = None
for ts in lst:
diff = abs(timestamp - ts).total_seconds()
if diff < 1:
if nearest_ts and diff > abs(timestamp - nearest_ts).total_seconds():
# there's a timestamp that's closer
pass
else:
nearest_ts = ts
return nearest_ts
Copy link
Member

Choose a reason for hiding this comment

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

This could be simplified to (source):

nearest_ts = min(lst, key=lambda x: abs(x - timestamp).total_seconds())
if abs(nearest_ts - timestamp).total_seconds() < 1:
    return nearest_ts


def set_previous_keys_for_line_items(line_items):
timestamped_line_items = OrderedDict()

# Assemble the `timestamped_line_items` dictionary with the timestamp at which the line items were created
# as the key, and the line items that were created at that time as the value (as a list)
# Some line items may have been created a few milliseconds later, so the nearest timestamp
# with a tolerance level of one second is searched for
Copy link
Member

Choose a reason for hiding this comment

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

This depends on whether we used datetime.utcnow() or func.utcnow(). The latter won't have this problem as it uses the transaction's timestamp. Maybe add a comment explaining that this may be caused by having used datetime.utcnow() in the past.

for line_item in line_items:
ts_key = find_nearest_timestamp(timestamped_line_items.keys(), line_item.created_at) or line_item.created_at
if not timestamped_line_items.get(ts_key):
timestamped_line_items[ts_key] = []
timestamped_line_items[ts_key].append({
'id': line_item.id,
'status': line_item.status,
'item_id': line_item.item_id,
'previous_id': None
})

# The previous line item for a line item, is a line item that has an earlier timestamp with a void status
# with the same item_id. Find it and set it
used_line_item_ids = set()
for idx, (timestamp, line_item_dicts) in enumerate(timestamped_line_items.items()):
# 0th timestamps are root line items
if idx > 0:
Copy link
Member

Choose a reason for hiding this comment

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

Why not just use enumerate(timestamped_line_items.items())[1:] and skip the if?

Copy link
Member

Choose a reason for hiding this comment

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

Also, I'm unclear on what timestamped_line_items contains and why the first element can be ignored.

Copy link
Contributor Author

@shreyas-satish shreyas-satish Oct 27, 2017

Choose a reason for hiding this comment

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

The 0th group of line_item_dicts is the group of line items that comprised the initial transaction. Hence their previous_id will remain None. It's only the subsequent groups that will need their previous_id updated.

for li_dict in timestamped_line_items[timestamp]:
previous_li_dict = [previous_li_dict
for previous_li_dict in timestamped_line_items[timestamped_line_items.keys()[idx-1]]
if previous_li_dict['item_id'] == li_dict['item_id']
and previous_li_dict['id'] not in used_line_item_ids
and previous_li_dict['status'] == LINE_ITEM_STATUS.VOID][0]
Copy link
Member

Choose a reason for hiding this comment

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

Indent the two and lines and move the closing ][0] to its own line. This took a while to parse.

li_dict['previous_id'] = previous_li_dict['id']
used_line_item_ids.add(previous_li_dict['id'])

return [li_dict for li_dicts in timestamped_line_items.values() for li_dict in li_dicts]

order_table = table('customer_order',
column('id', sqlalchemy_utils.types.uuid.UUIDType()),
column('status', sa.Integer()))

line_item_table = table('line_item',
column('id', sqlalchemy_utils.types.uuid.UUIDType()),
column('customer_order_id', sqlalchemy_utils.types.uuid.UUIDType()),
column('item_id', sqlalchemy_utils.types.uuid.UUIDType()),
column('previous_id', sqlalchemy_utils.types.uuid.UUIDType()),
column('status', sa.Integer()),
column('created_at', sa.Boolean()))

def upgrade():
conn = op.get_bind()
orders = conn.execute(sa.select([order_table.c.id]).where(order_table.c.status.in_(ORDER_STATUS.TRANSACTION)).select_from(order_table))
for order_id in [order.id for order in orders]:
line_items = conn.execute(sa.select([line_item_table.c.id,
line_item_table.c.item_id,
line_item_table.c.status,
line_item_table.c.created_at
]).where(line_item_table.c.customer_order_id == order_id).order_by("created_at").select_from(line_item_table))
updated_line_item_dicts = set_previous_keys_for_line_items(line_items)
for updated_line_item_dict in updated_line_item_dicts:
if updated_line_item_dict['previous_id']:
conn.execute(sa.update(line_item_table).where(
line_item_table.c.id == updated_line_item_dict['id']
).values(previous_id=updated_line_item_dict['previous_id']))


def downgrade():
conn = op.get_bind()
orders = conn.execute(sa.select([order_table.c.id]).where(order_table.c.status.in_(ORDER_STATUS.TRANSACTION)).select_from(order_table))
for order_id in [order.id for order in orders]:
line_items = conn.execute(sa.select([line_item_table.c.id]).where(
line_item_table.c.customer_order_id == order_id).order_by("created_at").select_from(line_item_table))
for line_item in line_items:
conn.execute(sa.update(line_item_table).where(
line_item_table.c.id == line_item.id
).values(previous_id=None))
22 changes: 22 additions & 0 deletions migrations/versions/81f30d00706f_add_pg_refundid_to_transaction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""add_pg_refundid_to_transaction

Revision ID: 81f30d00706f
Revises: 1a22f5035244
Create Date: 2017-10-19 03:39:48.608087

"""

# revision identifiers, used by Alembic.
revision = '81f30d00706f'
down_revision = '1a22f5035244'

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

def upgrade():
op.add_column('payment_transaction', sa.Column('pg_refundid', sa.Unicode(length=80), nullable=True))


def downgrade():
op.drop_column('payment_transaction', 'pg_refundid')