""" 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/ -> 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/', 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