Step 12 - completed

This commit is contained in:
2026-03-03 14:53:34 +01:00
parent 5d485b3665
commit d0b303e745
13 changed files with 950 additions and 487 deletions

View File

@@ -1,8 +1,8 @@
"""Revenue share tracking and Stripe Connect payouts.
"""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. Storage is
in-memory until Step 12 migrates to the ``revenue_events`` table.
70 % / 30 % payouts to developers via Stripe Connect. Data is persisted
in the ``revenue_events`` table.
Module-level singleton::
@@ -12,13 +12,16 @@ Module-level singleton::
from __future__ import annotations
import logging
import time
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__)
@@ -35,10 +38,6 @@ class RevenueShare:
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
@@ -54,6 +53,7 @@ class RevenueShare:
async def record_install(
self,
db: AsyncSession,
plugin_id: str,
user_id: str,
amount_cents: int,
@@ -72,11 +72,12 @@ class RevenueShare:
stripe_transfer_id: str | None = None
if amount_cents > 0 and self._stripe_configured():
plugin_entry = registry._catalog.get(plugin_id)
# 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_entry:
# Step 12: look up developer's Stripe account from DB
# For now, the author field is used as a placeholder key.
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:
@@ -103,22 +104,21 @@ class RevenueShare:
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()),
}
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(plugin_id)
await registry.record_install(db, plugin_id)
async def get_earnings(
self,
db: AsyncSession,
developer_id: str,
period: str | None = None,
) -> dict[str, Any]:
@@ -136,54 +136,81 @@ class RevenueShare:
"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
}
# 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()]
events = [e for e in self._events if e["plugin_id"] in developer_plugin_ids]
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 prefix of the created_at timestamp
events = [
e
for e in events
if time.strftime("%Y-%m", time.gmtime(e["created_at"])) == 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": 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),
"total_installs": row.total_installs,
"total_revenue_cents": row.total_revenue,
"developer_share_cents": row.dev_share,
}
async def payout_developer(self, plugin_id: str, period: str) -> None:
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.
"""
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
]
try:
year, month = period.split("-")
year_int, month_int = int(year), int(month)
except ValueError:
logger.warning("Invalid period format: %s", period)
return
total_dev_share = sum(e["developer_share_cents"] for e in unpaid)
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_entry = registry._catalog.get(plugin_id)
developer_stripe_account: str | None = None # Step 12: fetch from DB
if plugin_entry and developer_stripe_account:
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(
@@ -196,9 +223,10 @@ class RevenueShare:
logger.warning("Payout transfer failed for plugin %s: %s", plugin_id, exc)
return
paid_ts = int(time.time())
paid_ts = datetime.now(timezone.utc)
for event in unpaid:
event["paid_at"] = paid_ts
event.paid_at = paid_ts
await db.commit()
# Module-level singleton