Step 12 - completed
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user