234 lines
8.7 KiB
Python
234 lines
8.7 KiB
Python
"""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()
|