Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Download backend/billing.py from larxius/SentinelScan-WSS: direct link, hf CLI and curl.
- Browser
- Download file 27.6 kB
-
https://huggingface.co/spaces/larxius/SentinelScan-WSS/resolve/main/backend/billing.py
- Command line
-
hf download hf://spaces/larxius/SentinelScan-WSS/backend/billing.py
-
curl -L -o billing.py https://huggingface.co/spaces/larxius/SentinelScan-WSS/resolve/main/backend/billing.py
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 | |
| # --------------------------------------------------------------------------- | |
| 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 | |
| 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 | |
| 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 | |
| # --------------------------------------------------------------------------- | |
| 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 | |
| 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) | |
| # --------------------------------------------------------------------------- | |
| 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) | |
| # --------------------------------------------------------------------------- | |
| 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 | |
| # --------------------------------------------------------------------------- | |
| 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 | |