pounce/backend/app/api/yield_webhooks.py
Yves Gugger bb7ce97330
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
Deploy: referral rewards antifraud + legal contact updates
2025-12-15 13:56:43 +01:00

564 lines
19 KiB
Python

"""
Webhook endpoints for Yield affiliate partner callbacks.
Partners call these endpoints to report:
- Clicks (redirect happened)
- Leads (form submitted, signup, etc.)
- Sales (purchase completed)
Each partner may have different authentication methods:
- HMAC signature verification
- API key in header
- IP whitelist
"""
import hashlib
import hmac
import json
import logging
from datetime import datetime
from decimal import Decimal
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db
from app.config import get_settings
from app.models.yield_domain import YieldDomain, YieldTransaction, AffiliatePartner
from app.services.telemetry import track_event
logger = logging.getLogger(__name__)
settings = get_settings()
router = APIRouter(prefix="/yield-webhooks", tags=["yield-webhooks"])
# Revenue split: User gets 70%, Pounce keeps 30%
USER_REVENUE_SHARE = Decimal("0.70")
# ============================================================================
# Schemas
# ============================================================================
class PartnerEvent(BaseModel):
"""Generic partner event payload."""
event_type: str = Field(..., description="click, lead, or sale")
domain: str = Field(..., description="The yield domain that generated this event")
transaction_id: Optional[str] = Field(None, description="Partner's transaction ID")
click_id: Optional[str] = Field(None, description="Pounce click_id for attribution (UUID hex)")
amount: Optional[float] = Field(None, description="Gross commission amount")
currency: Optional[str] = Field("CHF", description="Currency code")
# Optional attribution data
geo_country: Optional[str] = None
referrer: Optional[str] = None
user_agent: Optional[str] = None
# Optional metadata
metadata: Optional[dict] = None
class WebhookResponse(BaseModel):
"""Response for webhook calls."""
success: bool
transaction_id: Optional[int] = None
message: str
# ============================================================================
# Signature Verification Helpers
# ============================================================================
def verify_hmac_signature(
payload: bytes,
signature: str,
secret: str,
algorithm: str = "sha256"
) -> bool:
"""Verify HMAC signature for webhook payload."""
expected = hmac.new(
secret.encode(),
payload,
hashlib.sha256 if algorithm == "sha256" else hashlib.sha1
).hexdigest()
return hmac.compare_digest(signature, expected)
def hash_ip(ip: str) -> str:
"""Hash IP address for privacy-compliant storage."""
return hashlib.sha256(f"{ip}|{settings.secret_key}".encode()).hexdigest()[:32]
def _get_webhook_secret(partner_slug: str) -> Optional[str]:
"""
Webhook secrets are configured via environment:
- YIELD_WEBHOOK_SECRET (global default)
- YIELD_WEBHOOK_SECRET_<PARTNER_SLUG_UPPER> (partner-specific override)
"""
import os
specific = os.getenv(f"YIELD_WEBHOOK_SECRET_{partner_slug.upper()}")
if specific:
return specific
return os.getenv("YIELD_WEBHOOK_SECRET") or None
# ============================================================================
# Generic Webhook Endpoint
# ============================================================================
@router.post("/{partner_slug}", response_model=WebhookResponse)
async def receive_partner_webhook(
partner_slug: str,
event: PartnerEvent,
request: Request,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
x_webhook_signature: Optional[str] = Header(None),
x_api_key: Optional[str] = Header(None),
):
"""
Receive webhook callback from affiliate partner.
Partners POST events here when clicks, leads, or sales occur.
"""
# 1. Find partner
partner = (
await db.execute(
select(AffiliatePartner).where(
and_(
AffiliatePartner.slug == partner_slug,
AffiliatePartner.is_active == True,
)
)
)
).scalar_one_or_none()
if not partner:
logger.warning(f"Webhook from unknown partner: {partner_slug}")
raise HTTPException(status_code=404, detail="Unknown partner")
# 2. Verify authentication (strict)
secret = _get_webhook_secret(partner_slug)
if not secret:
raise HTTPException(status_code=503, detail="Webhook secret not configured on server.")
if not x_webhook_signature:
raise HTTPException(status_code=401, detail="Missing webhook signature.")
raw = await request.body()
if not verify_hmac_signature(raw, x_webhook_signature, secret):
raise HTTPException(status_code=401, detail="Invalid webhook signature.")
# 3. Find yield domain (must be active)
yield_domain = (
await db.execute(
select(YieldDomain).where(
and_(
YieldDomain.domain == event.domain.lower(),
YieldDomain.status == "active",
)
)
)
).scalar_one_or_none()
if not yield_domain:
logger.warning(f"Webhook for unknown/inactive domain: {event.domain}")
raise HTTPException(status_code=404, detail="Domain not found or inactive")
# 4. Calculate amounts
gross_amount = Decimal(str(event.amount)) if event.amount else Decimal("0")
net_amount = gross_amount * USER_REVENUE_SHARE
# 5. Get client IP for hashing
client_ip = request.client.host if request.client else None
ip_hash = hash_ip(client_ip) if client_ip else None
# 6. Create transaction
transaction = YieldTransaction(
yield_domain_id=yield_domain.id,
event_type=event.event_type,
partner_slug=partner_slug,
partner_transaction_id=event.transaction_id,
click_id=(event.click_id[:64] if event.click_id else None),
gross_amount=gross_amount,
net_amount=net_amount,
currency=event.currency or "CHF",
referrer=event.referrer,
user_agent=event.user_agent,
geo_country=event.geo_country,
ip_hash=ip_hash,
status="pending" if event.event_type in ["lead", "sale"] else "confirmed",
confirmed_at=datetime.utcnow() if event.event_type == "click" else None,
)
db.add(transaction)
# Optional: attribute to an existing click transaction (same yield_domain + click_id)
if event.click_id:
click_tx = (
await db.execute(
select(YieldTransaction).where(
and_(
YieldTransaction.yield_domain_id == yield_domain.id,
YieldTransaction.event_type == "click",
YieldTransaction.click_id == event.click_id[:64],
)
)
)
).scalar_one_or_none()
if not click_tx:
logger.warning(
f"Webhook received click_id but no matching click found: partner={partner_slug} "
f"domain={yield_domain.domain} click_id={event.click_id[:64]}"
)
# 7. Update domain aggregates
if event.event_type == "click":
yield_domain.total_clicks += 1
yield_domain.last_click_at = datetime.utcnow()
elif event.event_type in ["lead", "sale"]:
yield_domain.total_conversions += 1
yield_domain.last_conversion_at = datetime.utcnow()
# Add revenue when confirmed
if transaction.status == "confirmed":
yield_domain.total_revenue += net_amount
await track_event(
db,
event_name="yield_conversion",
request=request,
user_id=yield_domain.user_id,
is_authenticated=None,
source="webhook",
domain=yield_domain.domain,
yield_domain_id=yield_domain.id,
click_id=event.click_id,
metadata={
"partner": partner_slug,
"event_type": event.event_type,
"status": transaction.status,
"currency": transaction.currency,
"net_amount": float(net_amount),
"partner_transaction_id": event.transaction_id,
},
)
await db.commit()
await db.refresh(transaction)
logger.info(
f"Webhook processed: {partner_slug} -> {event.domain} "
f"({event.event_type}, gross={gross_amount}, net={net_amount})"
)
return WebhookResponse(
success=True,
transaction_id=transaction.id,
message=f"Event {event.event_type} recorded successfully"
)
# ============================================================================
# Awin-Specific Webhook
# ============================================================================
class AwinEvent(BaseModel):
"""Awin network postback format."""
clickRef: str # Our yield domain ID or domain name
transactionId: str
commission: float
commissionCurrency: str = "CHF"
status: str # "pending", "approved", "declined"
transactionType: str # "sale", "lead"
@router.post("/awin/postback", response_model=WebhookResponse)
async def receive_awin_postback(
event: AwinEvent,
request: Request,
db: AsyncSession = Depends(get_db),
x_awin_signature: Optional[str] = Header(None),
):
"""
Receive postback from Awin affiliate network.
Awin sends postbacks for tracked conversions.
"""
# Verify authentication (strict)
secret = _get_webhook_secret("awin")
if not secret:
raise HTTPException(status_code=503, detail="Webhook secret not configured on server.")
if not x_awin_signature:
raise HTTPException(status_code=401, detail="Missing webhook signature.")
raw = await request.body()
if not verify_hmac_signature(raw, x_awin_signature, secret):
raise HTTPException(status_code=401, detail="Invalid webhook signature.")
# Find domain by click reference
yield_domain = (
await db.execute(select(YieldDomain).where(YieldDomain.domain == event.clickRef.lower()))
).scalar_one_or_none()
if not yield_domain:
# Try to find by ID if clickRef is numeric
try:
domain_id = int(event.clickRef)
yield_domain = (
await db.execute(select(YieldDomain).where(YieldDomain.id == domain_id))
).scalar_one_or_none()
except ValueError:
pass
if not yield_domain:
logger.warning(f"Awin postback for unknown domain: {event.clickRef}")
raise HTTPException(status_code=404, detail="Domain not found")
# Calculate amounts
gross_amount = Decimal(str(event.commission))
net_amount = gross_amount * USER_REVENUE_SHARE
# Map Awin status to our status
status_map = {
"pending": "pending",
"approved": "confirmed",
"declined": "rejected",
}
status = status_map.get(event.status.lower(), "pending")
# Create or update transaction
existing_tx = (
await db.execute(
select(YieldTransaction).where(
and_(
YieldTransaction.partner_transaction_id == event.transactionId,
YieldTransaction.partner_slug.ilike("awin%"),
)
)
)
).scalar_one_or_none()
if existing_tx:
# Update existing transaction
existing_tx.status = status
if status == "confirmed":
existing_tx.confirmed_at = datetime.utcnow()
yield_domain.total_revenue += net_amount
transaction_id = existing_tx.id
else:
# Create new transaction
transaction = YieldTransaction(
yield_domain_id=yield_domain.id,
event_type="lead" if event.transactionType.lower() == "lead" else "sale",
partner_slug=f"awin_{yield_domain.active_route or 'unknown'}",
partner_transaction_id=event.transactionId,
gross_amount=gross_amount,
net_amount=net_amount,
currency=event.commissionCurrency,
status=status,
confirmed_at=datetime.utcnow() if status == "confirmed" else None,
)
db.add(transaction)
# Update domain stats
yield_domain.total_conversions += 1
yield_domain.last_conversion_at = datetime.utcnow()
if status == "confirmed":
yield_domain.total_revenue += net_amount
await db.flush()
transaction_id = transaction.id
await db.commit()
logger.info(f"Awin postback processed: {event.transactionId} -> {status}")
return WebhookResponse(
success=True,
transaction_id=transaction_id,
message=f"Awin event processed ({status})"
)
# ============================================================================
# Transaction Confirmation Endpoint (Admin/Internal)
# ============================================================================
@router.post("/confirm/{transaction_id}", response_model=WebhookResponse)
async def confirm_transaction(
transaction_id: int,
db: AsyncSession = Depends(get_db),
x_internal_key: Optional[str] = Header(None),
):
"""
Manually confirm a pending transaction.
Internal endpoint for admin use or automated confirmation.
"""
internal_key = (settings.internal_api_key or "").strip()
if not internal_key:
raise HTTPException(status_code=503, detail="internal_api_key is not configured on server.")
if x_internal_key != internal_key:
raise HTTPException(status_code=401, detail="Unauthorized")
transaction = (
await db.execute(
select(YieldTransaction).where(
and_(
YieldTransaction.id == transaction_id,
YieldTransaction.status == "pending",
)
)
)
).scalar_one_or_none()
if not transaction:
raise HTTPException(status_code=404, detail="Transaction not found or not pending")
# Confirm transaction
transaction.status = "confirmed"
transaction.confirmed_at = datetime.utcnow()
# Update domain revenue
yield_domain = (
await db.execute(select(YieldDomain).where(YieldDomain.id == transaction.yield_domain_id))
).scalar_one_or_none()
if yield_domain:
yield_domain.total_revenue += transaction.net_amount
await db.commit()
logger.info(f"Transaction {transaction_id} confirmed manually")
return WebhookResponse(
success=True,
transaction_id=transaction_id,
message="Transaction confirmed"
)
# ============================================================================
# Batch Transaction Import (for reconciliation)
# ============================================================================
class BatchTransactionItem(BaseModel):
"""Single transaction in batch import."""
domain: str
event_type: str
partner_slug: str
transaction_id: str
click_id: Optional[str] = None
gross_amount: float
currency: str = "CHF"
status: str = "confirmed"
created_at: Optional[str] = None
class BatchImportRequest(BaseModel):
"""Batch transaction import request."""
transactions: list[BatchTransactionItem]
class BatchImportResponse(BaseModel):
"""Batch import response."""
success: bool
imported: int
skipped: int
errors: list[str]
@router.post("/batch-import", response_model=BatchImportResponse)
async def batch_import_transactions(
request_data: BatchImportRequest,
db: AsyncSession = Depends(get_db),
x_internal_key: Optional[str] = Header(None),
):
"""
Batch import transactions for reconciliation.
Internal endpoint for importing partner reports.
"""
internal_key = (settings.internal_api_key or "").strip()
if not internal_key:
raise HTTPException(status_code=503, detail="internal_api_key is not configured on server.")
if x_internal_key != internal_key:
raise HTTPException(status_code=401, detail="Unauthorized")
imported = 0
skipped = 0
errors = []
for item in request_data.transactions:
try:
# Find domain
yield_domain = (
await db.execute(select(YieldDomain).where(YieldDomain.domain == item.domain.lower()))
).scalar_one_or_none()
if not yield_domain:
errors.append(f"Domain not found: {item.domain}")
skipped += 1
continue
# Check for duplicate
existing = (
await db.execute(
select(YieldTransaction).where(
and_(
YieldTransaction.partner_transaction_id == item.transaction_id,
YieldTransaction.partner_slug == item.partner_slug,
)
)
)
).scalar_one_or_none()
if existing:
skipped += 1
continue
# Create transaction
gross = Decimal(str(item.gross_amount))
net = gross * USER_REVENUE_SHARE
tx = YieldTransaction(
yield_domain_id=yield_domain.id,
event_type=item.event_type,
partner_slug=item.partner_slug,
partner_transaction_id=item.transaction_id,
click_id=(item.click_id[:64] if item.click_id else None),
gross_amount=gross,
net_amount=net,
currency=item.currency,
status=item.status,
confirmed_at=datetime.utcnow() if item.status == "confirmed" else None,
)
db.add(tx)
# Update domain stats
if item.event_type == "click":
yield_domain.total_clicks += 1
else:
yield_domain.total_conversions += 1
if item.status == "confirmed":
yield_domain.total_revenue += net
imported += 1
except Exception as e:
errors.append(f"Error importing {item.domain}/{item.transaction_id}: {str(e)}")
skipped += 1
await db.commit()
return BatchImportResponse(
success=len(errors) == 0,
imported=imported,
skipped=skipped,
errors=errors[:10] # Limit error messages
)