"""Revenue share tracking and Stripe Connect payouts backed by PostgreSQL. Records every plugin installation as a revenue event and facilitates 70 % / 30 % payouts to developers via Stripe Connect. Data is persisted in the ``revenue_events`` table. Module-level singleton:: from app.marketplace.revenue_share import revenue_share """ from __future__ import annotations import logging from datetime import datetime, timezone from typing import Any import stripe as stripe_lib from sqlalchemy import extract, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.config.settings import settings from app.marketplace.plugin_registry import registry from app.models import Plugin, RevenueEvent logger = logging.getLogger(__name__) # ── Revenue split constants ─────────────────────────────────────────── DEVELOPER_SHARE: float = 0.70 PLATFORM_SHARE: float = 0.30 class RevenueShare: """Records installation revenue events and coordinates developer payouts. Stripe Connect calls are gracefully stubbed when ``STRIPE_SECRET_KEY`` is not configured, consistent with the rest of the billing layer. """ # ── Helpers ────────────────────────────────────────────────────── @staticmethod def _stripe_configured() -> bool: return bool(settings.STRIPE_SECRET_KEY) @staticmethod def _stripe() -> Any: stripe_lib.api_key = settings.STRIPE_SECRET_KEY return stripe_lib # ── Core operations ────────────────────────────────────────────── async def record_install( self, db: AsyncSession, plugin_id: str, user_id: str, amount_cents: int, ) -> None: """Record a plugin installation and trigger a Stripe Connect charge if paid. For free plugins (``amount_cents == 0``) no payment is initiated but the event is still recorded for analytics. For paid plugins the developer receives 70 % via a Stripe Connect destination charge. If Stripe is not configured or the charge fails the installation still succeeds (the event is recorded and the install count is incremented) — a warning is logged for monitoring. """ developer_share_cents = int(amount_cents * DEVELOPER_SHARE) stripe_transfer_id: str | None = None if amount_cents > 0 and self._stripe_configured(): # Look up the plugin's author Stripe account from the DB result = await db.execute(select(Plugin).where(Plugin.id == plugin_id)) plugin_row = result.scalar_one_or_none() developer_stripe_account: str | None = None if plugin_row and plugin_row.author_id: # Future: look up user.stripe_connect_account_id developer_stripe_account = None # no real account yet if developer_stripe_account: try: s = self._stripe() transfer = s.Transfer.create( amount=developer_share_cents, currency="eur", destination=developer_stripe_account, description=f"Revenue share for plugin {plugin_id}", metadata={"plugin_id": plugin_id, "user_id": user_id}, ) stripe_transfer_id = transfer["id"] except Exception as exc: logger.warning( "Stripe Connect transfer failed for plugin %s: %s", plugin_id, exc, ) else: logger.debug( "No Stripe account on file for plugin %s developer; " "skipping transfer.", plugin_id, ) event = RevenueEvent( plugin_id=plugin_id, user_id=user_id, amount_cents=amount_cents, developer_share_cents=developer_share_cents, stripe_transfer_id=stripe_transfer_id, ) db.add(event) await db.commit() await registry.record_install(db, plugin_id) async def get_earnings( self, db: AsyncSession, developer_id: str, period: str | None = None, ) -> dict[str, Any]: """Return aggregated earnings for *developer_id*. ``period`` is an optional ``YYYY-MM`` string to restrict the window. Returns:: { "developer_id": str, "period": str | None, "total_installs": int, "total_revenue_cents": int, "developer_share_cents": int, } """ # Find plugin ids belonging to this developer (by author_name match) plugin_q = select(Plugin.id).where(Plugin.author_name == developer_id) plugin_result = await db.execute(plugin_q) developer_plugin_ids = [row[0] for row in plugin_result.all()] if not developer_plugin_ids: return { "developer_id": developer_id, "period": period, "total_installs": 0, "total_revenue_cents": 0, "developer_share_cents": 0, } query = select( func.count().label("total_installs"), func.coalesce(func.sum(RevenueEvent.amount_cents), 0).label("total_revenue"), func.coalesce(func.sum(RevenueEvent.developer_share_cents), 0).label("dev_share"), ).where(RevenueEvent.plugin_id.in_(developer_plugin_ids)) if period: # Filter by YYYY-MM: extract year and month from created_at try: year, month = period.split("-") query = query.where( extract("year", RevenueEvent.created_at) == int(year), extract("month", RevenueEvent.created_at) == int(month), ) except ValueError: pass # invalid period format — return all result = await db.execute(query) row = result.one() return { "developer_id": developer_id, "period": period, "total_installs": row.total_installs, "total_revenue_cents": row.total_revenue, "developer_share_cents": row.dev_share, } async def payout_developer(self, db: AsyncSession, plugin_id: str, period: str) -> None: """Aggregate unpaid revenue for *period* and issue a Stripe Transfer. Marks processed events with ``paid_at`` timestamp. Stubs gracefully when Stripe is not configured. """ try: year, month = period.split("-") year_int, month_int = int(year), int(month) except ValueError: logger.warning("Invalid period format: %s", period) return result = await db.execute( select(RevenueEvent).where( RevenueEvent.plugin_id == plugin_id, RevenueEvent.paid_at.is_(None), extract("year", RevenueEvent.created_at) == year_int, extract("month", RevenueEvent.created_at) == month_int, ) ) unpaid = list(result.scalars().all()) total_dev_share = sum(e.developer_share_cents for e in unpaid) if total_dev_share <= 0 or not unpaid: logger.debug("Nothing to pay out for plugin %s in period %s", plugin_id, period) return if self._stripe_configured(): plugin_result = await db.execute(select(Plugin).where(Plugin.id == plugin_id)) plugin_row = plugin_result.scalar_one_or_none() developer_stripe_account: str | None = None # Future: fetch from DB if plugin_row and developer_stripe_account: try: s = self._stripe() s.Transfer.create( amount=total_dev_share, currency="eur", destination=developer_stripe_account, description=f"Payout for plugin {plugin_id} period {period}", ) except Exception as exc: logger.warning("Payout transfer failed for plugin %s: %s", plugin_id, exc) return paid_ts = datetime.now(timezone.utc) for event in unpaid: event.paid_at = paid_ts await db.commit() # Module-level singleton revenue_share = RevenueShare()