SentinelScan-WSS / backend /billing.py
grateful790's picture
Add Razorpay checkout with platform fee and INR/USD pricing
335a9ba
Raw History Blame Contribute Delete
27.6 kB
"""
Razorpay billing blueprint for LarShield.
Checkout model
--------------
One-time payment (mode: order). Each successful purchase unlocks
SCANS_PER_PURCHASE scans for the buyer's organization.
Pricing model
-------------
The customer pays the plan price *plus* a platform fee:
platform_fee = round(plan_price * PLATFORM_FEE_PERCENT / 100)
gst = round(platform_fee * PLATFORM_FEE_GST_PERCENT / 100) # INR only
total = plan_price + platform_fee + gst
All amounts are handled in minor units (paise for INR, cents for USD) so
there is no floating point drift. The client never supplies an amount -
every figure is recalculated server-side from the tier table.
Endpoints
---------
GET /api/billing/config -> gateway status + fee config
GET /api/billing/tiers -> public price list (USD + INR)
POST /api/billing/quote -> public order breakdown
POST /api/billing/create-order -> auth, creates Razorpay order
POST /api/billing/verify-payment -> auth, verifies signature + fulfils
POST /api/billing/webhook -> Razorpay server-to-server webhook
GET /api/billing/history -> auth, customer payment history
PUT /api/billing/tiers/<tier_id> -> super_admin price update
"""
import hashlib
import hmac
import json
import os
import uuid
from datetime import datetime, timezone
from decimal import Decimal, ROUND_HALF_UP
from flask import Blueprint, current_app, jsonify, request
from sqlalchemy import inspect, text
from sqlalchemy.exc import IntegrityError
try:
import razorpay
except ImportError: # pragma: no cover - requirements.txt installs it
razorpay = None
from .extensions import db, limiter
from .models import (
AuditLog,
Organization,
OrganizationScanQuota,
Payment,
SubscriptionTier,
User,
)
from .routes import token_required
billing_bp = Blueprint('billing', __name__)
SUPPORTED_CURRENCIES = ('INR', 'USD')
SCANS_PER_PURCHASE = 3
# Tier id -> scan_type used by OrganizationScanQuota
TIER_SCAN_TYPE = {
'quick': 'Quick',
'advanced': 'Advanced',
'deep': 'Deep',
}
DEFAULT_TIERS = [
{
'id': 'free',
'name': 'Free Tier Plan',
'monthly_price': 0,
'yearly_price': 0,
'monthly_price_inr': 0,
'yearly_price_inr': 0,
},
{
'id': 'quick',
'name': 'Quick Scan',
'monthly_price': 499, # $4.99
'yearly_price': 4990,
'monthly_price_inr': 49900, # Rs.499
'yearly_price_inr': 499000,
},
{
'id': 'advanced',
'name': 'Advanced Scan',
'monthly_price': 4499, # $44.99
'yearly_price': 44990,
'monthly_price_inr': 399900, # Rs.3,999
'yearly_price_inr': 3999000,
},
{
'id': 'deep',
'name': 'Deep Scan',
'monthly_price': 9999, # $99.99
'yearly_price': 99990,
'monthly_price_inr': 899900, # Rs.8,999
'yearly_price_inr': 8999000,
},
{
'id': 'enterprise',
'name': 'Custom Solutions',
'monthly_price': 0,
'yearly_price': 0,
'monthly_price_inr': 0,
'yearly_price_inr': 0,
},
]
_TIER_ORDER = {'quick': 0, 'advanced': 1, 'deep': 2, 'enterprise': 3}
# ---------------------------------------------------------------------------
# Configuration helpers (read lazily so HF secrets / .env changes are picked up)
# ---------------------------------------------------------------------------
def _key_id() -> str:
return (os.getenv('RAZORPAY_KEY_ID') or '').strip()
def _key_secret() -> str:
return (os.getenv('RAZORPAY_KEY_SECRET') or '').strip()
def _webhook_secret() -> str:
return (os.getenv('RAZORPAY_WEBHOOK_SECRET') or '').strip()
def _fee_percent() -> Decimal:
try:
return Decimal(str(os.getenv('PLATFORM_FEE_PERCENT', '2')))
except Exception:
return Decimal('2')
def _gst_percent() -> Decimal:
try:
return Decimal(str(os.getenv('PLATFORM_FEE_GST_PERCENT', '18')))
except Exception:
return Decimal('18')
def _usd_to_inr_rate() -> Decimal:
try:
return Decimal(str(os.getenv('USD_TO_INR_RATE', '88')))
except Exception:
return Decimal('88')
def _client():
if razorpay is None:
raise RuntimeError('The "razorpay" Python package is not installed.')
if not _key_id() or not _key_secret():
raise RuntimeError('RAZORPAY_KEY_ID / RAZORPAY_KEY_SECRET are not configured.')
return razorpay.Client(auth=(_key_id(), _key_secret()))
def _minor_to_major(minor) -> str:
return f"{(int(minor or 0) / 100):.2f}"
def _round_minor(value: Decimal) -> int:
return int(value.quantize(Decimal('1'), rounding=ROUND_HALF_UP))
def _fee_breakdown(base_minor: int, currency: str) -> dict:
"""Return the platform-fee breakdown for a plan price, in minor units."""
currency = currency.upper()
base = Decimal(int(base_minor or 0))
fee = _round_minor(base * _fee_percent() / Decimal('100'))
# GST is charged on the platform fee for domestic (INR) invoices only.
gst = _round_minor(Decimal(fee) * _gst_percent() / Decimal('100')) if currency == 'INR' else 0
total = int(base) + fee + gst
return {
'currency': currency,
'base_amount': int(base),
'platform_fee': fee,
'tax_amount': gst,
'total_amount': total,
'fee_percent': float(_fee_percent()),
'gst_percent': float(_gst_percent()) if currency == 'INR' else 0.0,
'base_display': _minor_to_major(base),
'platform_fee_display': _minor_to_major(fee),
'tax_display': _minor_to_major(gst),
'total_display': _minor_to_major(total),
'scans_included': SCANS_PER_PURCHASE,
}
def _tier_price_minor(tier: SubscriptionTier, currency: str) -> int:
"""Plan price in minor units for the requested currency."""
if currency == 'INR':
if tier.monthly_price_inr and tier.monthly_price_inr > 0:
return int(tier.monthly_price_inr)
# Legacy row without an INR price: convert from USD cents using the
# configured rate (1 USD cent -> `rate` paise).
return _round_minor(Decimal(int(tier.monthly_price or 0)) * _usd_to_inr_rate())
return int(tier.monthly_price or 0)
def _get_tier(tier_id: str):
return db.session.get(SubscriptionTier, (tier_id or '').strip().lower())
def _payment_dict(payment: Payment) -> dict:
return {
'id': payment.id,
'tier_id': payment.tier_id,
'currency': payment.currency,
'amount': payment.amount,
'amount_display': _minor_to_major(payment.amount),
'base_amount': payment.base_amount,
'platform_fee': payment.platform_fee,
'tax_amount': payment.tax_amount,
'status': payment.status,
'razorpay_order_id': payment.razorpay_order_id,
'razorpay_payment_id': payment.razorpay_payment_id,
'created_at': payment.created_at.isoformat() + 'Z' if payment.created_at else None,
}
# ---------------------------------------------------------------------------
# Schema upgrade + seed (safe to call on every boot)
# ---------------------------------------------------------------------------
def ensure_billing_schema():
"""Add billing columns that were introduced after the first deployment.
`db.create_all()` only creates *missing tables*, never missing columns,
so existing Cloud SQL databases need this tiny in-place migration.
Works on both PostgreSQL and SQLite.
"""
inspector = inspect(db.engine)
try:
table_names = set(inspector.get_table_names())
except Exception as exc: # pragma: no cover - DB unreachable
current_app.logger.warning(f"[Billing] Could not inspect schema: {exc}")
return
statements = []
if 'subscription_tiers' in table_names:
cols = {c['name'] for c in inspector.get_columns('subscription_tiers')}
if 'monthly_price_inr' not in cols:
statements.append("ALTER TABLE subscription_tiers ADD COLUMN monthly_price_inr INTEGER DEFAULT 0")
if 'yearly_price_inr' not in cols:
statements.append("ALTER TABLE subscription_tiers ADD COLUMN yearly_price_inr INTEGER DEFAULT 0")
if 'payments' in table_names:
cols = {c['name'] for c in inspector.get_columns('payments')}
if 'base_amount' not in cols:
statements.append("ALTER TABLE payments ADD COLUMN base_amount INTEGER")
if 'platform_fee' not in cols:
statements.append("ALTER TABLE payments ADD COLUMN platform_fee INTEGER")
if 'tax_amount' not in cols:
statements.append("ALTER TABLE payments ADD COLUMN tax_amount INTEGER")
for stmt in statements:
try:
db.session.execute(text(stmt))
db.session.commit()
current_app.logger.info(f"[Billing] Applied migration: {stmt}")
except Exception as exc:
db.session.rollback()
current_app.logger.warning(f"[Billing] Migration skipped ({stmt}): {exc}")
def seed_billing_tiers():
"""Create the four plans the pricing page sells, if they are missing."""
changed = False
for spec in DEFAULT_TIERS:
tier = db.session.get(SubscriptionTier, spec['id'])
if tier is None:
db.session.add(SubscriptionTier(**spec))
changed = True
continue
# Fill in newer columns for rows created by an older seed script.
if not tier.monthly_price_inr and spec['monthly_price_inr']:
tier.monthly_price_inr = spec['monthly_price_inr']
changed = True
if not tier.yearly_price_inr and spec['yearly_price_inr']:
tier.yearly_price_inr = spec['yearly_price_inr']
changed = True
if changed:
try:
db.session.commit()
current_app.logger.info("[Billing] Seeded subscription tiers.")
except Exception as exc:
db.session.rollback()
current_app.logger.warning(f"[Billing] Tier seed failed: {exc}")
# ---------------------------------------------------------------------------
# Public endpoints
# ---------------------------------------------------------------------------
@billing_bp.route('/config', methods=['GET'])
def billing_config():
return jsonify({
'provider': 'razorpay',
'configured': bool(_key_id() and _key_secret()),
'webhook_configured': bool(_webhook_secret()),
'currencies': list(SUPPORTED_CURRENCIES),
'platform_fee_percent': float(_fee_percent()),
'gst_percent': float(_gst_percent()),
'gst_applies_to': ['INR'],
'scans_per_purchase': SCANS_PER_PURCHASE,
}), 200
@billing_bp.route('/tiers', methods=['GET'])
def list_tiers():
tiers = [t for t in SubscriptionTier.query.all() if t.id in _TIER_ORDER]
tiers.sort(key=lambda t: _TIER_ORDER.get(t.id, 99))
payload = []
for tier in tiers:
payload.append({
'id': tier.id,
'name': tier.name,
# Minor units. USD in cents, INR in paise.
'monthly_price': int(tier.monthly_price or 0),
'yearly_price': int(tier.yearly_price or 0),
'monthly_price_inr': int(_tier_price_minor(tier, 'INR')),
'yearly_price_inr': int(tier.yearly_price_inr or 0),
'purchasable': tier.id in TIER_SCAN_TYPE,
'scans_included': SCANS_PER_PURCHASE,
})
return jsonify(payload), 200
@billing_bp.route('/quote', methods=['POST'])
@limiter.limit("120 per minute")
def quote():
data = request.get_json(silent=True) or {}
tier_id = str(data.get('tier_id') or '').strip().lower()
currency = str(data.get('currency') or 'INR').strip().upper()
if currency not in SUPPORTED_CURRENCIES:
return jsonify({'message': f'Unsupported currency. Use one of {SUPPORTED_CURRENCIES}.'}), 400
tier = _get_tier(tier_id)
if tier is None or tier.id not in TIER_SCAN_TYPE:
return jsonify({'message': 'Unknown or non-purchasable plan.'}), 400
base = _tier_price_minor(tier, currency)
if base <= 0:
return jsonify({'message': 'This plan is not available for online checkout.'}), 400
breakdown = _fee_breakdown(base, currency)
breakdown.update({
'tier_id': tier.id,
'tier_name': tier.name,
})
return jsonify(breakdown), 200
# ---------------------------------------------------------------------------
# Authenticated checkout
# ---------------------------------------------------------------------------
@billing_bp.route('/create-order', methods=['POST'])
@token_required
@limiter.limit("20 per minute")
def create_order(current_user):
data = request.get_json(silent=True) or {}
tier_id = str(data.get('tier_id') or data.get('price_id') or '').strip().lower()
currency = str(data.get('currency') or 'INR').strip().upper()
if currency not in SUPPORTED_CURRENCIES:
return jsonify({'message': f'Unsupported currency. Use one of {SUPPORTED_CURRENCIES}.'}), 400
tier = _get_tier(tier_id)
if tier is None or tier.id not in TIER_SCAN_TYPE:
return jsonify({'message': 'Unknown or non-purchasable plan.'}), 400
if not current_user.org_id:
return jsonify({'message': 'Your account is not linked to an organization. Please contact support.'}), 400
base = _tier_price_minor(tier, currency)
if base <= 0:
return jsonify({'message': 'This plan is not available for online checkout.'}), 400
breakdown = _fee_breakdown(base, currency)
if breakdown['total_amount'] < 100:
# Razorpay rejects amounts below 1.00 unit for most methods.
return jsonify({'message': 'Order amount is too small for Razorpay.'}), 400
try:
client = _client()
except RuntimeError as exc:
current_app.logger.error(f"[Billing] Razorpay not configured: {exc}")
return jsonify({'message': 'Payment gateway is not configured yet. Please contact support.'}), 503
receipt = f"larshield_{uuid.uuid4().hex[:24]}"
notes = {
'user_id': current_user.id,
'org_id': current_user.org_id,
'tier_id': tier.id,
'base_amount': str(breakdown['base_amount']),
'platform_fee': str(breakdown['platform_fee']),
'tax_amount': str(breakdown['tax_amount']),
'total_amount': str(breakdown['total_amount']),
'scans': str(SCANS_PER_PURCHASE),
}
try:
order = client.order.create({
'amount': int(breakdown['total_amount']),
'currency': currency,
'receipt': receipt,
'notes': notes,
})
except Exception as exc:
current_app.logger.error(f"[Billing] Razorpay order creation failed: {exc}")
return jsonify({'message': 'Could not start the payment. Please try again in a moment.'}), 502
payment = Payment(
user_id=current_user.id,
org_id=current_user.org_id,
tier_id=tier.id,
amount=int(breakdown['total_amount']),
base_amount=int(breakdown['base_amount']),
platform_fee=int(breakdown['platform_fee']),
tax_amount=int(breakdown['tax_amount']),
currency=currency,
status='created',
razorpay_order_id=order.get('id'),
)
db.session.add(payment)
db.session.commit()
return jsonify({
'order_id': order.get('id'),
'key_id': _key_id(),
'amount': int(breakdown['total_amount']),
'currency': currency,
'breakdown': breakdown,
'tier': {
'id': tier.id,
'name': tier.name,
'scans_included': SCANS_PER_PURCHASE,
},
'description': f"{tier.name} - {SCANS_PER_PURCHASE} scans for 1 target website",
'prefill': {
'name': f"{current_user.first_name or ''} {current_user.last_name or ''}".strip(),
'email': current_user.email,
},
'payment_id': payment.id,
}), 200
def _fulfil(payment: Payment, razorpay_payment_id: str) -> Payment:
"""Mark a payment successful and grant the purchased scans (idempotent)."""
if payment.status == 'successful' and payment.razorpay_payment_id:
return payment
user = db.session.get(User, payment.user_id)
org = db.session.get(Organization, payment.org_id) if payment.org_id else None
if org is None and user is not None:
org = user.organization
if org is None:
raise ValueError('No organization linked to this payment.')
payment.status = 'successful'
payment.razorpay_payment_id = razorpay_payment_id
if not payment.created_at:
payment.created_at = datetime.now(timezone.utc)
# Upgrade the tenant tier. Tier ids are seeded into subscription_tiers,
# so the organizations.subscription_tier foreign key is satisfied.
org.subscription_tier = payment.tier_id
scan_type = TIER_SCAN_TYPE.get(payment.tier_id, payment.tier_id.capitalize())
quota = OrganizationScanQuota.query.filter_by(org_id=org.id, scan_type=scan_type).first()
if quota is None:
db.session.add(OrganizationScanQuota(
org_id=org.id,
scan_type=scan_type,
allocated_count=SCANS_PER_PURCHASE,
used_count=0,
))
else:
quota.allocated_count = int(quota.allocated_count or 0) + SCANS_PER_PURCHASE
db.session.add(AuditLog(
admin_id=payment.user_id,
action=(
f"Razorpay payment successful: {payment.tier_id} plan "
f"({payment.currency} {_minor_to_major(payment.amount)}) - "
f"+{SCANS_PER_PURCHASE} {scan_type} scans"
),
target_id=org.id,
))
try:
db.session.commit()
except IntegrityError:
# Another worker (webhook vs redirect verify) already fulfilled it.
db.session.rollback()
existing = Payment.query.filter_by(razorpay_payment_id=razorpay_payment_id).first()
if existing is not None:
return existing
raise
return payment
@billing_bp.route('/verify-payment', methods=['POST'])
@token_required
@limiter.limit("30 per minute")
def verify_payment(current_user):
data = request.get_json(silent=True) or {}
order_id = str(data.get('razorpay_order_id') or '').strip()
payment_id = str(data.get('razorpay_payment_id') or '').strip()
signature = str(data.get('razorpay_signature') or '').strip()
if not order_id or not payment_id or not signature:
return jsonify({'message': 'razorpay_order_id, razorpay_payment_id and razorpay_signature are required.'}), 400
secret = _key_secret()
if not secret:
return jsonify({'message': 'Payment gateway is not configured yet.'}), 503
expected = hmac.new(
secret.encode('utf-8'),
f"{order_id}|{payment_id}".encode('utf-8'),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
current_app.logger.warning(f"[Billing] Signature mismatch for order {order_id}")
return jsonify({'message': 'Invalid payment signature.'}), 400
payment = Payment.query.filter_by(razorpay_order_id=order_id).first()
if payment is None:
return jsonify({'message': 'Payment record not found.'}), 404
if payment.user_id != current_user.id and current_user.role not in ('super_admin', 'admin'):
return jsonify({'message': 'This payment belongs to another account.'}), 403
if payment.status == 'successful':
return jsonify({'status': 'success', 'message': 'Payment already verified.', 'payment': _payment_dict(payment)}), 200
# Server-side confirmation with Razorpay. The signature proves the payment
# is genuine, but we also confirm the captured amount/currency/order.
try:
rp_payment = _client().payment.fetch(payment_id)
except Exception as exc:
current_app.logger.error(f"[Billing] Could not fetch payment {payment_id}: {exc}")
return jsonify({'message': 'Could not confirm the payment with the gateway. Please contact support.'}), 502
if str(rp_payment.get('order_id') or '') != order_id:
return jsonify({'message': 'Payment does not belong to this order.'}), 400
if str(rp_payment.get('status') or '').lower() not in ('captured', 'authorized'):
return jsonify({'message': f"Payment is not completed (status: {rp_payment.get('status')})."}), 402
if int(rp_payment.get('amount') or 0) != int(payment.amount):
current_app.logger.error(
f"[Billing] Amount mismatch order {order_id}: gateway={rp_payment.get('amount')} expected={payment.amount}"
)
return jsonify({'message': 'Paid amount does not match the order.'}), 400
if str(rp_payment.get('currency') or '').upper() != str(payment.currency).upper():
return jsonify({'message': 'Paid currency does not match the order.'}), 400
try:
payment = _fulfil(payment, payment_id)
except ValueError as exc:
db.session.rollback()
return jsonify({'message': str(exc)}), 400
except Exception as exc:
db.session.rollback()
current_app.logger.exception(f"[Billing] Fulfilment failed for order {order_id}: {exc}")
return jsonify({'message': 'Payment succeeded but activation failed. Please contact support with your order id.'}), 500
return jsonify({
'status': 'success',
'message': 'Payment verified and plan activated.',
'payment': _payment_dict(payment),
}), 200
# ---------------------------------------------------------------------------
# Webhook (source of truth if the user closes the tab before redirect)
# ---------------------------------------------------------------------------
@billing_bp.route('/webhook', methods=['POST'])
@limiter.exempt
def razorpay_webhook():
secret = _webhook_secret()
if not secret:
current_app.logger.warning("[Billing] Webhook received but RAZORPAY_WEBHOOK_SECRET is not set.")
return jsonify({'status': 'ignored'}), 200
raw_body = request.get_data() or b''
signature = request.headers.get('X-Razorpay-Signature', '')
expected = hmac.new(secret.encode('utf-8'), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
current_app.logger.warning("[Billing] Webhook signature verification failed.")
return jsonify({'status': 'invalid signature'}), 401
try:
payload = json.loads(raw_body.decode('utf-8') or '{}')
except (json.JSONDecodeError, UnicodeDecodeError):
return jsonify({'status': 'invalid payload'}), 400
event = payload.get('event') or ''
body = payload.get('payload') or {}
payment_entity = (body.get('payment') or {}).get('entity') or {}
order_entity = (body.get('order') or {}).get('entity') or {}
order_id = payment_entity.get('order_id') or order_entity.get('id')
payment_id = payment_entity.get('id')
if event in ('payment.captured', 'order.paid') and order_id:
row = Payment.query.filter_by(razorpay_order_id=order_id).first()
if row is None:
current_app.logger.warning(f"[Billing] Webhook for unknown order {order_id}")
return jsonify({'status': 'ok'}), 200
if row.status == 'successful':
return jsonify({'status': 'ok'}), 200
if payment_entity.get('amount') and int(payment_entity['amount']) != int(row.amount):
current_app.logger.error(
f"[Billing] Webhook amount mismatch for {order_id}: "
f"gateway={payment_entity.get('amount')} expected={row.amount}"
)
return jsonify({'status': 'amount mismatch'}), 200
if not payment_id:
current_app.logger.warning(f"[Billing] Webhook {event} for {order_id} has no payment id yet; skipping.")
return jsonify({'status': 'ok'}), 200
try:
_fulfil(row, payment_id)
except Exception as exc:
db.session.rollback()
current_app.logger.exception(f"[Billing] Webhook fulfilment failed for {order_id}: {exc}")
return jsonify({'status': 'retry'}), 500
elif event == 'payment.failed' and order_id:
row = Payment.query.filter_by(razorpay_order_id=order_id).first()
if row is not None and row.status == 'created':
row.status = 'failed'
db.session.commit()
current_app.logger.info(f"[Billing] Payment failed for order {order_id}")
return jsonify({'status': 'ok'}), 200
# ---------------------------------------------------------------------------
# Customer payment history (Settings -> Billing)
# ---------------------------------------------------------------------------
@billing_bp.route('/history', methods=['GET'])
@token_required
def billing_history(current_user):
if not current_user.org_id:
return jsonify({'history': []}), 200
payments = (
Payment.query
.filter_by(org_id=current_user.org_id)
.order_by(Payment.created_at.desc())
.limit(100)
.all()
)
history = []
for p in payments:
history.append({
'id': p.id,
'tier_id': p.tier_id,
# Minor units - the frontend divides by 100 for display.
'amount': int(p.amount or 0),
'base_amount': p.base_amount,
'platform_fee': p.platform_fee,
'tax_amount': p.tax_amount,
'currency': p.currency or 'USD',
# Historical UI expects "success" rather than "successful".
'status': 'success' if p.status == 'successful' else p.status,
'created_at': p.created_at.isoformat() + 'Z' if p.created_at else None,
})
return jsonify({'history': history}), 200
# ---------------------------------------------------------------------------
# Admin
# ---------------------------------------------------------------------------
@billing_bp.route('/tiers/<tier_id>', methods=['PUT'])
@token_required
def update_tier(current_user, tier_id):
if current_user.role != 'super_admin':
return jsonify({'message': 'Admin privileges required!'}), 403
tier = _get_tier(tier_id)
if tier is None:
return jsonify({'message': 'Tier not found.'}), 404
data = request.get_json(silent=True) or {}
fields = ('monthly_price', 'yearly_price', 'monthly_price_inr', 'yearly_price_inr')
updated = []
for field in fields:
if field in data:
try:
value = int(data[field])
except (TypeError, ValueError):
return jsonify({'message': f'{field} must be an integer (minor units).'}), 400
if value < 0:
return jsonify({'message': f'{field} cannot be negative.'}), 400
setattr(tier, field, value)
updated.append(field)
if updated:
db.session.add(AuditLog(
admin_id=current_user.id,
action=f"Updated billing tier {tier.id}: {', '.join(updated)}",
target_id=tier.id,
))
db.session.commit()
return jsonify({
'message': 'Tier updated successfully.',
'tier': {
'id': tier.id,
'name': tier.name,
'monthly_price': tier.monthly_price,
'yearly_price': tier.yearly_price,
'monthly_price_inr': tier.monthly_price_inr,
'yearly_price_inr': tier.yearly_price_inr,
},
}), 200