"""Revenue share tracking and Stripe Connect payouts. Records every plugin installation as a revenue event and facilitates 70 % / 30 % payouts to developers via Stripe Connect. Storage is in-memory until Step 12 migrates to the ``revenue_events`` table. Module-level singleton:: from app.marketplace.revenue_share import revenue_share """ from __future__ import annotations import logging import time from typing import Any import stripe as stripe_lib from app.config.settings import settings from app.marketplace.plugin_registry import registry 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. """ def __init__(self) -> None: # Step 12 replaces with revenue_events DB table self._events: list[dict[str, Any]] = [] # ── 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, 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(): plugin_entry = registry._catalog.get(plugin_id) developer_stripe_account: str | None = None if plugin_entry: # Step 12: look up developer's Stripe account from DB # For now, the author field is used as a placeholder key. 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, ) self._events.append( { "plugin_id": plugin_id, "user_id": user_id, "amount_cents": amount_cents, "developer_share_cents": developer_share_cents, "stripe_transfer_id": stripe_transfer_id, "paid_at": None, "created_at": int(time.time()), } ) await registry.record_install(plugin_id) async def get_earnings( self, 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 developer_plugin_ids: set[str] = { pid for pid, entry in registry._catalog.items() if entry["manifest"].author == developer_id } events = [e for e in self._events if e["plugin_id"] in developer_plugin_ids] if period: # Filter by YYYY-MM prefix of the created_at timestamp events = [ e for e in events if time.strftime("%Y-%m", time.gmtime(e["created_at"])) == period ] return { "developer_id": developer_id, "period": period, "total_installs": len(events), "total_revenue_cents": sum(e["amount_cents"] for e in events), "developer_share_cents": sum(e["developer_share_cents"] for e in events), } async def payout_developer(self, 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. """ unpaid = [ e for e in self._events if e["plugin_id"] == plugin_id and e["paid_at"] is None and time.strftime("%Y-%m", time.gmtime(e["created_at"])) == period ] 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_entry = registry._catalog.get(plugin_id) developer_stripe_account: str | None = None # Step 12: fetch from DB if plugin_entry 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 = int(time.time()) for event in unpaid: event["paid_at"] = paid_ts # Module-level singleton revenue_share = RevenueShare()