206 lines
7.3 KiB
Python
206 lines
7.3 KiB
Python
"""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()
|