Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
133 lines
4.1 KiB
Python
133 lines
4.1 KiB
Python
"""
|
|
Yield payout generation helpers (ledger).
|
|
|
|
Used by:
|
|
- Admin endpoints (manual ops)
|
|
- Scheduler (automatic monthly preparation)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta
|
|
from decimal import Decimal
|
|
|
|
from sqlalchemy import and_, func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.yield_domain import YieldDomain, YieldPayout, YieldTransaction
|
|
|
|
|
|
async def generate_payouts_for_period(
|
|
db: AsyncSession,
|
|
*,
|
|
period_start: datetime,
|
|
period_end: datetime,
|
|
) -> tuple[int, int]:
|
|
"""
|
|
Create payouts for confirmed, unpaid transactions and assign payout_id.
|
|
|
|
Returns: (created_count, skipped_existing_count)
|
|
"""
|
|
if period_end <= period_start:
|
|
raise ValueError("period_end must be after period_start")
|
|
|
|
aggregates = (
|
|
await db.execute(
|
|
select(
|
|
YieldDomain.user_id.label("user_id"),
|
|
YieldTransaction.currency.label("currency"),
|
|
func.count(YieldTransaction.id).label("tx_count"),
|
|
func.coalesce(func.sum(YieldTransaction.net_amount), 0).label("amount"),
|
|
)
|
|
.join(YieldDomain, YieldDomain.id == YieldTransaction.yield_domain_id)
|
|
.where(
|
|
and_(
|
|
YieldTransaction.status == "confirmed",
|
|
YieldTransaction.payout_id.is_(None),
|
|
YieldTransaction.created_at >= period_start,
|
|
YieldTransaction.created_at < period_end,
|
|
)
|
|
)
|
|
.group_by(YieldDomain.user_id, YieldTransaction.currency)
|
|
)
|
|
).all()
|
|
|
|
created = 0
|
|
skipped = 0
|
|
|
|
for row in aggregates:
|
|
user_id = int(row.user_id)
|
|
currency = (row.currency or "CHF").upper()
|
|
tx_count = int(row.tx_count or 0)
|
|
amount = Decimal(str(row.amount or 0))
|
|
|
|
if tx_count <= 0 or amount <= 0:
|
|
continue
|
|
|
|
existing = (
|
|
await db.execute(
|
|
select(YieldPayout).where(
|
|
and_(
|
|
YieldPayout.user_id == user_id,
|
|
YieldPayout.currency == currency,
|
|
YieldPayout.period_start == period_start,
|
|
YieldPayout.period_end == period_end,
|
|
)
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
if existing:
|
|
skipped += 1
|
|
continue
|
|
|
|
payout = YieldPayout(
|
|
user_id=user_id,
|
|
amount=amount,
|
|
currency=currency,
|
|
period_start=period_start,
|
|
period_end=period_end,
|
|
transaction_count=tx_count,
|
|
status="pending",
|
|
payment_method=None,
|
|
payment_reference=None,
|
|
)
|
|
db.add(payout)
|
|
await db.flush()
|
|
|
|
tx_ids = (
|
|
await db.execute(
|
|
select(YieldTransaction.id)
|
|
.join(YieldDomain, YieldDomain.id == YieldTransaction.yield_domain_id)
|
|
.where(
|
|
and_(
|
|
YieldDomain.user_id == user_id,
|
|
YieldTransaction.currency == currency,
|
|
YieldTransaction.status == "confirmed",
|
|
YieldTransaction.payout_id.is_(None),
|
|
YieldTransaction.created_at >= period_start,
|
|
YieldTransaction.created_at < period_end,
|
|
)
|
|
)
|
|
)
|
|
).scalars().all()
|
|
|
|
for tx_id in tx_ids:
|
|
tx = (
|
|
await db.execute(select(YieldTransaction).where(YieldTransaction.id == tx_id))
|
|
).scalar_one()
|
|
tx.payout_id = payout.id
|
|
|
|
created += 1
|
|
|
|
await db.commit()
|
|
return created, skipped
|
|
|
|
|
|
async def generate_payouts_for_previous_month(db: AsyncSession) -> tuple[int, int]:
|
|
now = datetime.utcnow()
|
|
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
prev_month_end = month_start
|
|
prev_month_start = (month_start - timedelta(days=1)).replace(day=1)
|
|
return await generate_payouts_for_period(db, period_start=prev_month_start, period_end=prev_month_end)
|
|
|