Initial commit - production deployment

This commit is contained in:
2026-01-21 17:17:16 +01:00
commit c23d00dd92
2289 changed files with 638440 additions and 0 deletions

View File

@@ -0,0 +1 @@
# API endpoints package

View File

@@ -0,0 +1,93 @@
"""
POS Service Analytics API Endpoints
ANALYTICS layer - Channel and sync performance analytics
"""
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from typing import Optional, Dict, Any
from uuid import UUID
from datetime import datetime
import structlog
from app.core.database import get_db
from shared.auth.decorators import get_current_user_dep
from shared.auth.access_control import require_user_role
from shared.routing import RouteBuilder
router = APIRouter()
logger = structlog.get_logger()
route_builder = RouteBuilder('pos')
@router.get(
route_builder.build_analytics_route("sync-performance"),
response_model=dict
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
async def get_sync_performance_analytics(
tenant_id: UUID = Path(...),
days: int = Query(30, ge=1, le=365),
config_id: Optional[UUID] = Query(None),
current_user: dict = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""Analyze sync performance metrics"""
try:
return {
"period_days": days,
"total_syncs": 0,
"successful_syncs": 0,
"failed_syncs": 0,
"success_rate": 0.0,
"average_duration_minutes": 0.0,
"total_transactions_synced": 0,
"total_revenue_synced": 0.0,
"sync_frequency": {
"daily_average": 0.0,
"peak_day": None,
"peak_count": 0
},
"error_analysis": {
"common_errors": [],
"error_trends": []
}
}
except Exception as e:
logger.error("Failed to get sync analytics", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to get analytics: {str(e)}")
@router.get(
route_builder.build_analytics_route("channel-performance"),
response_model=dict
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
async def get_channel_performance_analytics(
tenant_id: UUID = Path(...),
days: int = Query(30, ge=1, le=365),
pos_system: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""Analyze POS channel performance by system"""
try:
return {
"period_days": days,
"pos_system": pos_system,
"channel_metrics": {
"total_transactions": 0,
"total_revenue": 0.0,
"average_transaction_value": 0.0,
"transaction_growth_rate": 0.0
},
"system_breakdown": [],
"performance_trends": {
"daily_trends": [],
"hourly_trends": [],
"day_of_week_trends": []
},
"top_performing_channels": []
}
except Exception as e:
logger.error("Failed to get channel analytics", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to get channel analytics: {str(e)}")

View File

@@ -0,0 +1,237 @@
# services/pos/app/api/audit.py
"""
Audit Logs API - Retrieve audit trail for pos service
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
from typing import Optional, Dict, Any
from uuid import UUID
from datetime import datetime
import structlog
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import AuditLog
from shared.auth.decorators import get_current_user_dep
from shared.auth.access_control import require_user_role
from shared.routing import RouteBuilder
from shared.models.audit_log_schemas import (
AuditLogResponse,
AuditLogListResponse,
AuditLogStatsResponse
)
from app.core.database import database_manager
route_builder = RouteBuilder('pos')
router = APIRouter(tags=["audit-logs"])
logger = structlog.get_logger()
async def get_db():
"""Database session dependency"""
async with database_manager.get_session() as session:
yield session
@router.get(
route_builder.build_base_route("audit-logs"),
response_model=AuditLogListResponse
)
@require_user_role(['admin', 'owner'])
async def get_audit_logs(
tenant_id: UUID = Path(..., description="Tenant ID"),
start_date: Optional[datetime] = Query(None, description="Filter logs from this date"),
end_date: Optional[datetime] = Query(None, description="Filter logs until this date"),
user_id: Optional[UUID] = Query(None, description="Filter by user ID"),
action: Optional[str] = Query(None, description="Filter by action type"),
resource_type: Optional[str] = Query(None, description="Filter by resource type"),
severity: Optional[str] = Query(None, description="Filter by severity level"),
search: Optional[str] = Query(None, description="Search in description field"),
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
offset: int = Query(0, ge=0, description="Number of records to skip"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Get audit logs for pos service.
Requires admin or owner role.
"""
try:
logger.info(
"Retrieving audit logs",
tenant_id=tenant_id,
user_id=current_user.get("user_id"),
filters={
"start_date": start_date,
"end_date": end_date,
"action": action,
"resource_type": resource_type,
"severity": severity
}
)
# Build query filters
filters = [AuditLog.tenant_id == tenant_id]
if start_date:
filters.append(AuditLog.created_at >= start_date)
if end_date:
filters.append(AuditLog.created_at <= end_date)
if user_id:
filters.append(AuditLog.user_id == user_id)
if action:
filters.append(AuditLog.action == action)
if resource_type:
filters.append(AuditLog.resource_type == resource_type)
if severity:
filters.append(AuditLog.severity == severity)
if search:
filters.append(AuditLog.description.ilike(f"%{search}%"))
# Count total matching records
count_query = select(func.count()).select_from(AuditLog).where(and_(*filters))
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Fetch paginated results
query = (
select(AuditLog)
.where(and_(*filters))
.order_by(AuditLog.created_at.desc())
.limit(limit)
.offset(offset)
)
result = await db.execute(query)
audit_logs = result.scalars().all()
# Convert to response models
items = [AuditLogResponse.from_orm(log) for log in audit_logs]
logger.info(
"Successfully retrieved audit logs",
tenant_id=tenant_id,
total=total,
returned=len(items)
)
return AuditLogListResponse(
items=items,
total=total,
limit=limit,
offset=offset,
has_more=(offset + len(items)) < total
)
except Exception as e:
logger.error(
"Failed to retrieve audit logs",
error=str(e),
tenant_id=tenant_id
)
raise HTTPException(
status_code=500,
detail=f"Failed to retrieve audit logs: {str(e)}"
)
@router.get(
route_builder.build_base_route("audit-logs/stats"),
response_model=AuditLogStatsResponse
)
@require_user_role(['admin', 'owner'])
async def get_audit_log_stats(
tenant_id: UUID = Path(..., description="Tenant ID"),
start_date: Optional[datetime] = Query(None, description="Filter logs from this date"),
end_date: Optional[datetime] = Query(None, description="Filter logs until this date"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Get audit log statistics for pos service.
Requires admin or owner role.
"""
try:
logger.info(
"Retrieving audit log statistics",
tenant_id=tenant_id,
user_id=current_user.get("user_id")
)
# Build base filters
filters = [AuditLog.tenant_id == tenant_id]
if start_date:
filters.append(AuditLog.created_at >= start_date)
if end_date:
filters.append(AuditLog.created_at <= end_date)
# Total events
count_query = select(func.count()).select_from(AuditLog).where(and_(*filters))
total_result = await db.execute(count_query)
total_events = total_result.scalar() or 0
# Events by action
action_query = (
select(AuditLog.action, func.count().label('count'))
.where(and_(*filters))
.group_by(AuditLog.action)
)
action_result = await db.execute(action_query)
events_by_action = {row.action: row.count for row in action_result}
# Events by severity
severity_query = (
select(AuditLog.severity, func.count().label('count'))
.where(and_(*filters))
.group_by(AuditLog.severity)
)
severity_result = await db.execute(severity_query)
events_by_severity = {row.severity: row.count for row in severity_result}
# Events by resource type
resource_query = (
select(AuditLog.resource_type, func.count().label('count'))
.where(and_(*filters))
.group_by(AuditLog.resource_type)
)
resource_result = await db.execute(resource_query)
events_by_resource_type = {row.resource_type: row.count for row in resource_result}
# Date range
date_range_query = (
select(
func.min(AuditLog.created_at).label('min_date'),
func.max(AuditLog.created_at).label('max_date')
)
.where(and_(*filters))
)
date_result = await db.execute(date_range_query)
date_row = date_result.one()
logger.info(
"Successfully retrieved audit log statistics",
tenant_id=tenant_id,
total_events=total_events
)
return AuditLogStatsResponse(
total_events=total_events,
events_by_action=events_by_action,
events_by_severity=events_by_severity,
events_by_resource_type=events_by_resource_type,
date_range={
"min": date_row.min_date,
"max": date_row.max_date
}
)
except Exception as e:
logger.error(
"Failed to retrieve audit log statistics",
error=str(e),
tenant_id=tenant_id
)
raise HTTPException(
status_code=500,
detail=f"Failed to retrieve audit log statistics: {str(e)}"
)

View File

@@ -0,0 +1,241 @@
"""
POS Configuration API Endpoints
ATOMIC layer - Basic CRUD operations for POS configurations
"""
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from typing import List, Optional, Dict, Any
from uuid import UUID
import structlog
from app.core.database import get_db
from shared.auth.decorators import get_current_user_dep
from shared.auth.access_control import require_user_role, admin_role_required
from shared.routing import RouteBuilder
from shared.security import create_audit_logger, AuditSeverity, AuditAction
from app.services.pos_config_service import POSConfigurationService
from app.schemas.pos_config import POSConfigurationListResponse
from app.models import AuditLog
router = APIRouter()
logger = structlog.get_logger()
audit_logger = create_audit_logger("pos-service", AuditLog)
route_builder = RouteBuilder('pos')
@router.get(
route_builder.build_base_route("configurations"),
response_model=POSConfigurationListResponse
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
async def list_pos_configurations(
tenant_id: UUID = Path(...),
pos_system: Optional[str] = Query(None),
is_active: Optional[bool] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=100),
current_user: dict = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""List all POS configurations for a tenant"""
try:
service = POSConfigurationService()
configurations = await service.get_configurations_by_tenant(
tenant_id=tenant_id,
pos_system=pos_system,
is_active=is_active,
skip=skip,
limit=limit
)
total = await service.count_configurations_by_tenant(
tenant_id=tenant_id,
pos_system=pos_system,
is_active=is_active
)
return POSConfigurationListResponse(
configurations=configurations,
total=total,
supported_systems=["square", "toast", "lightspeed"]
)
except Exception as e:
logger.error("Failed to list POS configurations", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to list configurations: {str(e)}")
@router.post(
route_builder.build_base_route("configurations"),
response_model=dict,
status_code=201
)
@admin_role_required
async def create_pos_configuration(
configuration_data: Dict[str, Any],
tenant_id: UUID = Path(...),
current_user: dict = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""Create a new POS configuration (Admin/Owner only)"""
try:
logger.info("Creating POS configuration",
tenant_id=tenant_id,
pos_system=configuration_data.get("pos_system"),
user_id=current_user.get("user_id"))
return {
"message": "POS configuration created successfully",
"id": "placeholder",
"pos_system": configuration_data.get("pos_system")
}
except Exception as e:
logger.error("Failed to create POS configuration", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to create configuration: {str(e)}")
@router.get(
route_builder.build_resource_detail_route("configurations", "config_id"),
response_model=dict
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
async def get_pos_configuration(
tenant_id: UUID = Path(...),
config_id: UUID = Path(...),
current_user: dict = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""Get a specific POS configuration"""
try:
return {
"id": str(config_id),
"tenant_id": str(tenant_id),
"pos_system": "square",
"is_active": True
}
except Exception as e:
logger.error("Failed to get POS configuration", error=str(e),
tenant_id=tenant_id, config_id=config_id)
raise HTTPException(status_code=500, detail=f"Failed to get configuration: {str(e)}")
@router.put(
route_builder.build_resource_detail_route("configurations", "config_id"),
response_model=dict
)
@admin_role_required
async def update_pos_configuration(
configuration_data: Dict[str, Any],
tenant_id: UUID = Path(...),
config_id: UUID = Path(...),
current_user: dict = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""Update a POS configuration (Admin/Owner only)"""
try:
# Log HIGH severity audit event for configuration changes
try:
await audit_logger.log_event(
db_session=db,
tenant_id=str(tenant_id),
user_id=current_user["user_id"],
action=AuditAction.UPDATE.value,
resource_type="pos_configuration",
resource_id=str(config_id),
severity=AuditSeverity.HIGH.value,
description=f"Admin {current_user.get('email', 'unknown')} updated POS configuration",
changes={"configuration_updates": configuration_data},
endpoint=f"/configurations/{config_id}",
method="PUT"
)
except Exception as audit_error:
logger.warning("Failed to log audit event", error=str(audit_error))
logger.info("POS configuration updated",
config_id=str(config_id),
tenant_id=str(tenant_id),
user_id=current_user["user_id"])
return {"message": "Configuration updated successfully", "id": str(config_id)}
except Exception as e:
logger.error("Failed to update POS configuration", error=str(e),
tenant_id=tenant_id, config_id=config_id)
raise HTTPException(status_code=500, detail=f"Failed to update configuration: {str(e)}")
@router.delete(
route_builder.build_resource_detail_route("configurations", "config_id"),
response_model=dict
)
@require_user_role(['owner'])
async def delete_pos_configuration(
tenant_id: UUID = Path(...),
config_id: UUID = Path(...),
current_user: dict = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""Delete a POS configuration (Owner only)"""
try:
# Log CRITICAL severity audit event for configuration deletion
try:
await audit_logger.log_deletion(
db_session=db,
tenant_id=str(tenant_id),
user_id=current_user["user_id"],
resource_type="pos_configuration",
resource_id=str(config_id),
severity=AuditSeverity.CRITICAL.value,
description=f"Owner {current_user.get('email', 'unknown')} deleted POS configuration",
endpoint=f"/configurations/{config_id}",
method="DELETE"
)
except Exception as audit_error:
logger.warning("Failed to log audit event", error=str(audit_error))
logger.info("POS configuration deleted",
config_id=str(config_id),
tenant_id=str(tenant_id),
user_id=current_user["user_id"])
return {"message": "Configuration deleted successfully"}
except Exception as e:
logger.error("Failed to delete POS configuration", error=str(e),
tenant_id=tenant_id, config_id=config_id)
raise HTTPException(status_code=500, detail=f"Failed to delete configuration: {str(e)}")
# ============================================================================
# Reference Data
# ============================================================================
@router.get(
route_builder.build_global_route("supported-systems"),
response_model=dict
)
async def get_supported_pos_systems():
"""Get list of supported POS systems (no tenant context required)"""
return {
"systems": [
{
"id": "square",
"name": "Square POS",
"description": "Square Point of Sale system",
"features": ["payments", "inventory", "analytics", "webhooks"],
"supported_regions": ["US", "CA", "AU", "JP", "GB", "IE", "ES", "FR"]
},
{
"id": "toast",
"name": "Toast POS",
"description": "Toast restaurant POS system",
"features": ["orders", "payments", "menu_management", "webhooks"],
"supported_regions": ["US", "CA", "IE", "ES"]
},
{
"id": "lightspeed",
"name": "Lightspeed Restaurant",
"description": "Lightspeed restaurant management system",
"features": ["orders", "inventory", "reservations", "webhooks"],
"supported_regions": ["US", "CA", "EU", "AU"]
}
]
}

View File

@@ -0,0 +1,857 @@
"""
POS Operations API Endpoints
BUSINESS layer - Sync operations, webhooks, reconciliation, and test connection
"""
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Body, Request, Header
from typing import Optional, Dict, Any
from uuid import UUID
from datetime import datetime
import structlog
import json
from app.core.database import get_db
from shared.auth.decorators import get_current_user_dep
from shared.auth.access_control import require_user_role, admin_role_required, service_only_access
from shared.routing import RouteBuilder
from app.services.pos_transaction_service import POSTransactionService
from app.services.pos_config_service import POSConfigurationService
from app.services.pos_webhook_service import POSWebhookService
from app.services.pos_sync_service import POSSyncService
from app.services.tenant_deletion_service import POSTenantDeletionService
router = APIRouter()
logger = structlog.get_logger()
route_builder = RouteBuilder('pos')
# ============================================================================
# Sync Operations
# ============================================================================
@router.post(
route_builder.build_operations_route("sync"),
response_model=dict
)
@require_user_role(['member', 'admin', 'owner'])
async def trigger_sync(
sync_request: Dict[str, Any],
tenant_id: UUID = Path(...),
current_user: dict = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""Trigger manual synchronization with POS system (Member+)"""
try:
sync_type = sync_request.get("sync_type", "incremental")
data_types = sync_request.get("data_types", ["transactions"])
config_id = sync_request.get("config_id")
if not config_id:
raise HTTPException(status_code=400, detail="config_id is required")
# Get POS configuration to determine system type
config_service = POSConfigurationService()
configs = await config_service.get_configurations_by_tenant(tenant_id, skip=0, limit=100)
config = next((c for c in configs if str(c.id) == str(config_id)), None)
if not config:
raise HTTPException(status_code=404, detail="POS configuration not found")
# Create sync job
sync_service = POSSyncService(db)
sync_log = await sync_service.create_sync_job(
tenant_id=tenant_id,
pos_config_id=UUID(config_id),
pos_system=config.pos_system,
sync_type=sync_type,
data_types=data_types
)
logger.info("Manual sync triggered",
tenant_id=tenant_id,
config_id=config_id,
sync_id=str(sync_log.id),
sync_type=sync_type,
user_id=current_user.get("user_id"))
return {
"message": "Sync triggered successfully",
"sync_id": str(sync_log.id),
"status": "queued",
"sync_type": sync_type,
"data_types": data_types,
"estimated_duration": "5-10 minutes"
}
except HTTPException:
raise
except Exception as e:
logger.error("Failed to trigger sync", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to trigger sync: {str(e)}")
@router.get(
route_builder.build_operations_route("sync-status"),
response_model=dict
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
async def get_sync_status(
tenant_id: UUID = Path(...),
config_id: Optional[UUID] = Query(None),
limit: int = Query(10, ge=1, le=100),
current_user: dict = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""Get synchronization status and recent sync history"""
try:
transaction_service = POSTransactionService()
sync_service = POSSyncService(db)
# Get sync metrics from transaction service
sync_metrics = await transaction_service.get_sync_metrics(tenant_id)
# Get last successful sync time
sync_status = sync_metrics["sync_status"]
last_successful_sync = sync_status.get("last_sync_at")
# Calculate sync success rate
total = sync_metrics["total_transactions"]
synced = sync_status.get("synced", 0)
success_rate = (synced / total * 100) if total > 0 else 100.0
# Calculate actual average duration from sync logs
average_duration_minutes = await sync_service.calculate_average_duration(
tenant_id=tenant_id,
pos_config_id=config_id,
days=30
)
return {
"current_sync": None,
"last_successful_sync": last_successful_sync.isoformat() if last_successful_sync else None,
"recent_syncs": [], # Could be enhanced with actual sync history
"sync_health": {
"status": "healthy" if success_rate > 90 else "degraded" if success_rate > 70 else "unhealthy",
"success_rate": round(success_rate, 2),
"average_duration_minutes": average_duration_minutes,
"last_error": None,
"total_transactions": total,
"synced_count": synced,
"pending_count": sync_status.get("pending", 0),
"failed_count": sync_status.get("failed", 0)
}
}
except Exception as e:
logger.error("Failed to get sync status", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to get sync status: {str(e)}")
@router.get(
route_builder.build_operations_route("sync-logs"),
response_model=dict
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
async def get_sync_logs(
tenant_id: UUID = Path(...),
config_id: Optional[UUID] = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
status: Optional[str] = Query(None),
sync_type: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""Get detailed sync logs"""
try:
sync_service = POSSyncService(db)
logs_data = await sync_service.get_sync_logs(
tenant_id=tenant_id,
config_id=config_id,
status=status,
sync_type=sync_type,
limit=limit,
offset=offset
)
return logs_data
except Exception as e:
logger.error("Failed to get sync logs", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to get sync logs: {str(e)}")
@router.post(
route_builder.build_operations_route("resync-failed"),
response_model=dict
)
@admin_role_required
async def resync_failed_transactions(
tenant_id: UUID = Path(...),
days_back: int = Query(7, ge=1, le=90),
current_user: dict = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""Resync failed transactions from the specified time period (Admin/Owner only)"""
try:
# Get active POS configuration for tenant
config_service = POSConfigurationService()
configs = await config_service.get_configurations_by_tenant(
tenant_id=tenant_id,
is_active=True,
skip=0,
limit=1
)
if not configs:
raise HTTPException(status_code=404, detail="No active POS configuration found")
config = configs[0]
# Create resync job
sync_service = POSSyncService(db)
sync_log = await sync_service.create_sync_job(
tenant_id=tenant_id,
pos_config_id=config.id,
pos_system=config.pos_system,
sync_type="resync_failed",
data_types=["transactions"]
)
logger.info("Resync failed transactions requested",
tenant_id=tenant_id,
days_back=days_back,
sync_id=str(sync_log.id),
user_id=current_user.get("user_id"))
return {
"message": "Resync job queued successfully",
"job_id": str(sync_log.id),
"scope": f"Failed transactions from last {days_back} days",
"estimated_transactions": 0
}
except HTTPException:
raise
except Exception as e:
logger.error("Failed to queue resync job", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to queue resync job: {str(e)}")
@router.post(
route_builder.build_operations_route("test-connection"),
response_model=dict
)
@admin_role_required
async def test_pos_connection(
tenant_id: UUID = Path(...),
config_id: UUID = Query(...),
current_user: dict = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""Test connection to POS system (Admin/Owner only)"""
try:
config_service = POSConfigurationService()
# Get the configuration to verify it exists
configurations = await config_service.get_configurations_by_tenant(
tenant_id=tenant_id,
skip=0,
limit=100
)
config = next((c for c in configurations if str(c.id) == str(config_id)), None)
if not config:
raise HTTPException(status_code=404, detail="Configuration not found")
# For demo purposes, we assume connection is successful if config exists
# In production, this would actually test the POS API connection
is_connected = config.is_connected and config.is_active
return {
"success": is_connected,
"status": "success" if is_connected else "failed",
"message": f"Connection test {'successful' if is_connected else 'failed'} for {config.pos_system}",
"tested_at": datetime.utcnow().isoformat(),
"config_id": str(config_id),
"pos_system": config.pos_system,
"health_status": config.health_status
}
except HTTPException:
raise
except Exception as e:
logger.error("Failed to test POS connection", error=str(e),
tenant_id=tenant_id, config_id=config_id)
raise HTTPException(status_code=500, detail=f"Failed to test connection: {str(e)}")
# ============================================================================
# Webhook Operations
# ============================================================================
@router.post(
route_builder.build_webhook_route("{pos_system}"),
response_model=dict
)
async def receive_webhook(
request: Request,
pos_system: str = Path(..., description="POS system name"),
content_type: Optional[str] = Header(None),
x_signature: Optional[str] = Header(None),
x_webhook_signature: Optional[str] = Header(None),
authorization: Optional[str] = Header(None),
db=Depends(get_db)
):
"""
Receive webhooks from POS systems
Supports Square, Toast, and Lightspeed webhook formats
Includes signature verification, database logging, and duplicate detection
"""
webhook_service = POSWebhookService(db)
start_time = datetime.utcnow()
try:
# Validate POS system
supported_systems = ["square", "toast", "lightspeed"]
if pos_system.lower() not in supported_systems:
raise HTTPException(status_code=400, detail=f"Unsupported POS system: {pos_system}")
# Get request details
method = request.method
url_path = str(request.url.path)
query_params = dict(request.query_params)
headers = dict(request.headers)
# Get client IP
client_ip = None
if hasattr(request, 'client') and request.client:
client_ip = request.client.host
# Read payload
try:
body = await request.body()
raw_payload = body.decode('utf-8') if body else ""
payload_size = len(body) if body else 0
# Parse JSON if possible
parsed_payload = None
if raw_payload:
try:
parsed_payload = json.loads(raw_payload)
except json.JSONDecodeError:
logger.warning("Failed to parse webhook payload as JSON",
pos_system=pos_system, payload_size=payload_size)
except Exception as e:
logger.error("Failed to read webhook payload", error=str(e))
raise HTTPException(status_code=400, detail="Failed to read request payload")
# Determine signature from various header formats
signature = x_signature or x_webhook_signature or authorization
# Parse webhook event details
event_details = webhook_service.parse_webhook_event_details(pos_system, parsed_payload or {})
webhook_type = event_details.get("webhook_type") or "unknown"
event_id = event_details.get("event_id")
transaction_id = event_details.get("transaction_id")
order_id = event_details.get("order_id")
# Extract tenant_id from payload
tenant_id = None
if parsed_payload:
tenant_id = await webhook_service.extract_tenant_id_from_payload(pos_system, parsed_payload)
# Check for duplicate webhook
is_duplicate = False
if event_id:
is_duplicate, _ = await webhook_service.check_duplicate_webhook(
pos_system, event_id, tenant_id
)
# Verify webhook signature if tenant is identified
is_signature_valid = None
if signature and tenant_id:
webhook_secret = await webhook_service.get_webhook_secret(pos_system, tenant_id)
if webhook_secret:
is_signature_valid = await webhook_service.verify_webhook_signature(
pos_system, raw_payload, signature, webhook_secret
)
if not is_signature_valid:
logger.warning("Webhook signature verification failed",
pos_system=pos_system,
tenant_id=str(tenant_id))
# Log webhook receipt to database
webhook_log = await webhook_service.log_webhook(
pos_system=pos_system,
webhook_type=webhook_type,
method=method,
url_path=url_path,
query_params=query_params,
headers=headers,
raw_payload=raw_payload,
payload_size=payload_size,
content_type=content_type,
signature=signature,
is_signature_valid=is_signature_valid,
source_ip=client_ip,
event_id=event_id,
tenant_id=tenant_id,
transaction_id=transaction_id,
order_id=order_id
)
# Mark as duplicate if detected
if is_duplicate:
await webhook_service.update_webhook_status(
webhook_log.id,
status="duplicate",
error_message="Duplicate event already processed"
)
logger.info("Duplicate webhook ignored", event_id=event_id)
return _get_webhook_response(pos_system, success=True)
# Queue for async processing via RabbitMQ
try:
from shared.messaging import get_rabbitmq_client
import uuid as uuid_module
rabbitmq_client = get_rabbitmq_client()
if rabbitmq_client:
# Publish POS transaction event for async processing
event_payload = {
"event_id": str(uuid_module.uuid4()),
"event_type": f"pos.{webhook_type}",
"timestamp": datetime.utcnow().isoformat(),
"tenant_id": str(tenant_id) if tenant_id else None,
"data": {
"webhook_log_id": str(webhook_log.id),
"pos_system": pos_system,
"webhook_type": webhook_type,
"payload": webhook_data,
"event_id": event_id
}
}
await rabbitmq_client.publish_event(
exchange_name="pos.events",
routing_key=f"pos.{webhook_type}",
event_data=event_payload
)
logger.info("POS transaction queued for async processing",
event_id=event_payload["event_id"],
webhook_log_id=str(webhook_log.id))
# Update status to queued
processing_duration_ms = int((datetime.utcnow() - start_time).total_seconds() * 1000)
await webhook_service.update_webhook_status(
webhook_log.id,
status="queued",
processing_duration_ms=processing_duration_ms
)
else:
logger.warning("RabbitMQ client not available, marking as received only")
processing_duration_ms = int((datetime.utcnow() - start_time).total_seconds() * 1000)
await webhook_service.update_webhook_status(
webhook_log.id,
status="received",
processing_duration_ms=processing_duration_ms
)
except Exception as queue_error:
logger.error("Failed to queue POS transaction for async processing",
error=str(queue_error),
webhook_log_id=str(webhook_log.id))
# Mark as received even if queuing fails
processing_duration_ms = int((datetime.utcnow() - start_time).total_seconds() * 1000)
await webhook_service.update_webhook_status(
webhook_log.id,
status="received",
processing_duration_ms=processing_duration_ms
)
logger.info("Webhook processed and queued successfully",
pos_system=pos_system,
webhook_type=webhook_type,
event_id=event_id,
tenant_id=str(tenant_id) if tenant_id else None,
webhook_log_id=str(webhook_log.id))
# Return appropriate response based on POS system requirements
return _get_webhook_response(pos_system, success=True)
except HTTPException:
raise
except Exception as e:
logger.error("Webhook processing failed",
error=str(e),
pos_system=pos_system,
exc_info=True)
# Return 500 to trigger POS system retry
raise HTTPException(status_code=500, detail="Webhook processing failed")
def _get_webhook_response(pos_system: str, success: bool = True) -> Dict[str, Any]:
"""Get POS-specific webhook response format"""
if pos_system.lower() == "square":
return {"status": "success" if success else "error"}
elif pos_system.lower() == "toast":
return {"success": success}
elif pos_system.lower() == "lightspeed":
return {"received": success}
else:
return {"status": "received" if success else "error"}
@router.get(
route_builder.build_webhook_route("{pos_system}/status"),
response_model=dict
)
async def get_webhook_status(pos_system: str = Path(..., description="POS system name")):
"""Get webhook endpoint status for a POS system"""
try:
supported_systems = ["square", "toast", "lightspeed"]
if pos_system.lower() not in supported_systems:
raise HTTPException(status_code=400, detail=f"Unsupported POS system: {pos_system}")
return {
"pos_system": pos_system,
"status": "active",
"endpoint": f"/api/v1/webhooks/{pos_system}",
"supported_events": _get_supported_events(pos_system),
"last_received": None,
"total_received": 0
}
except Exception as e:
logger.error("Failed to get webhook status", error=str(e), pos_system=pos_system)
raise HTTPException(status_code=500, detail=f"Failed to get webhook status: {str(e)}")
def _get_supported_events(pos_system: str) -> Dict[str, Any]:
"""Get supported webhook events for each POS system"""
events = {
"square": [
"payment.created",
"payment.updated",
"order.created",
"order.updated",
"order.fulfilled",
"inventory.count.updated"
],
"toast": [
"OrderCreated",
"OrderUpdated",
"OrderPaid",
"OrderCanceled",
"OrderVoided"
],
"lightspeed": [
"order.created",
"order.updated",
"order.paid",
"sale.created",
"sale.updated"
]
}
return {
"events": events.get(pos_system.lower(), []),
"format": "JSON",
"authentication": "signature_verification"
}
# ============================================================================
# Tenant Data Deletion Operations (Internal Service Only)
# ============================================================================
@router.delete(
route_builder.build_base_route("tenant/{tenant_id}", include_tenant_prefix=False),
response_model=dict
)
@service_only_access
async def delete_tenant_data(
tenant_id: str = Path(..., description="Tenant ID to delete data for"),
current_user: dict = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""
Delete all POS data for a tenant (Internal service only)
This endpoint is called by the orchestrator during tenant deletion.
It permanently deletes all POS-related data including:
- POS configurations
- POS transactions and items
- Webhook logs
- Sync logs
- Audit logs
**WARNING**: This operation is irreversible!
Returns:
Deletion summary with counts of deleted records
"""
try:
logger.info("pos.tenant_deletion.api_called", tenant_id=tenant_id)
deletion_service = POSTenantDeletionService(db)
result = await deletion_service.safe_delete_tenant_data(tenant_id)
if not result.success:
raise HTTPException(
status_code=500,
detail=f"Tenant data deletion failed: {', '.join(result.errors)}"
)
return {
"message": "Tenant data deletion completed successfully",
"summary": result.to_dict()
}
except HTTPException:
raise
except Exception as e:
logger.error("pos.tenant_deletion.api_error",
tenant_id=tenant_id,
error=str(e),
exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to delete tenant data: {str(e)}"
)
@router.get(
route_builder.build_base_route("tenant/{tenant_id}/deletion-preview", include_tenant_prefix=False),
response_model=dict
)
@service_only_access
async def preview_tenant_data_deletion(
tenant_id: str = Path(..., description="Tenant ID to preview deletion for"),
current_user: dict = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""
Preview what data would be deleted for a tenant (dry-run)
This endpoint shows counts of all data that would be deleted
without actually deleting anything. Useful for:
- Confirming deletion scope before execution
- Auditing and compliance
- Troubleshooting
Returns:
Dictionary with entity names and their counts
"""
try:
logger.info("pos.tenant_deletion.preview_called", tenant_id=tenant_id)
deletion_service = POSTenantDeletionService(db)
preview = await deletion_service.get_tenant_data_preview(tenant_id)
total_records = sum(preview.values())
return {
"tenant_id": tenant_id,
"service": "pos",
"preview": preview,
"total_records": total_records,
"warning": "These records will be permanently deleted and cannot be recovered"
}
except Exception as e:
logger.error("pos.tenant_deletion.preview_error",
tenant_id=tenant_id,
error=str(e),
exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to preview tenant data deletion: {str(e)}"
)
# ================================================================
# POS TO SALES SYNC ENDPOINTS
# ================================================================
@router.post(
"/tenants/{tenant_id}/pos/transactions/{transaction_id}/sync-to-sales",
summary="Sync single transaction to sales",
description="Manually sync a specific POS transaction to the sales service"
)
async def sync_transaction_to_sales(
tenant_id: UUID = Path(..., description="Tenant ID"),
transaction_id: UUID = Path(..., description="Transaction ID to sync"),
current_user: Dict[str, Any] = Depends(get_current_user_dep)
):
"""
Sync a single POS transaction to the sales service
This endpoint:
- Creates sales records for each item in the transaction
- Automatically decreases inventory stock
- Updates sync status flags
- Returns detailed sync results
"""
try:
from app.services.pos_transaction_service import POSTransactionService
transaction_service = POSTransactionService()
result = await transaction_service.sync_transaction_to_sales(
transaction_id=transaction_id,
tenant_id=tenant_id
)
if result.get("success"):
logger.info("Transaction synced to sales via API",
transaction_id=transaction_id,
tenant_id=tenant_id,
user_id=current_user.get("user_id"))
return {
"success": True,
"message": "Transaction synced successfully",
**result
}
else:
logger.warning("Transaction sync failed via API",
transaction_id=transaction_id,
tenant_id=tenant_id,
error=result.get("error"))
raise HTTPException(
status_code=400,
detail=result.get("error", "Failed to sync transaction")
)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to sync transaction to sales",
error=str(e),
transaction_id=transaction_id,
tenant_id=tenant_id,
exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to sync transaction: {str(e)}"
)
@router.post(
"/tenants/{tenant_id}/pos/transactions/sync-all-to-sales",
summary="Batch sync unsynced transactions",
description="Sync all unsynced POS transactions to the sales service"
)
async def sync_all_transactions_to_sales(
tenant_id: UUID = Path(..., description="Tenant ID"),
limit: int = Query(50, ge=1, le=200, description="Max transactions to sync in one batch"),
current_user: Dict[str, Any] = Depends(get_current_user_dep)
):
"""
Batch sync all unsynced POS transactions to the sales service
This endpoint:
- Finds all unsynced completed transactions
- Syncs each one to the sales service
- Creates sales records and decreases inventory
- Returns summary with success/failure counts
Use this to:
- Manually trigger sync after POS webhooks are received
- Recover from sync failures
- Initial migration of historical POS data
"""
try:
from app.services.pos_transaction_service import POSTransactionService
transaction_service = POSTransactionService()
result = await transaction_service.sync_unsynced_transactions(
tenant_id=tenant_id,
limit=limit
)
logger.info("Batch sync completed via API",
tenant_id=tenant_id,
total=result.get("total_transactions"),
synced=result.get("synced"),
failed=result.get("failed"),
user_id=current_user.get("user_id"))
return {
"success": True,
"message": f"Synced {result.get('synced')} of {result.get('total_transactions')} transactions",
**result
}
except Exception as e:
logger.error("Failed to batch sync transactions to sales",
error=str(e),
tenant_id=tenant_id,
exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to batch sync transactions: {str(e)}"
)
@router.get(
"/tenants/{tenant_id}/pos/transactions/sync-status",
summary="Get sync status summary",
description="Get summary of synced vs unsynced transactions"
)
async def get_sync_status(
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep)
):
"""
Get sync status summary for POS transactions
Returns counts of:
- Total completed transactions
- Synced transactions
- Unsynced transactions
- Failed sync attempts
"""
try:
from app.services.pos_transaction_service import POSTransactionService
transaction_service = POSTransactionService()
# Get counts for different sync states
total_completed = await transaction_service.count_transactions_by_tenant(
tenant_id=tenant_id,
status="completed"
)
synced = await transaction_service.count_transactions_by_tenant(
tenant_id=tenant_id,
status="completed",
is_synced=True
)
unsynced = await transaction_service.count_transactions_by_tenant(
tenant_id=tenant_id,
status="completed",
is_synced=False
)
return {
"total_completed_transactions": total_completed,
"synced_to_sales": synced,
"pending_sync": unsynced,
"sync_rate": round((synced / total_completed * 100) if total_completed > 0 else 0, 2)
}
except Exception as e:
logger.error("Failed to get sync status",
error=str(e),
tenant_id=tenant_id,
exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to get sync status: {str(e)}"
)

View File

@@ -0,0 +1,148 @@
"""
POS Transactions API Endpoints
ATOMIC layer - Basic CRUD operations for POS transactions
"""
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from typing import Optional
from uuid import UUID
from datetime import datetime
from decimal import Decimal
import structlog
from app.core.database import get_db
from shared.auth.decorators import get_current_user_dep
from shared.auth.access_control import require_user_role
from shared.routing import RouteBuilder
from app.services.pos_transaction_service import POSTransactionService
from app.schemas.pos_transaction import (
POSTransactionResponse,
POSTransactionListResponse,
POSTransactionDashboardSummary
)
router = APIRouter()
logger = structlog.get_logger()
route_builder = RouteBuilder('pos')
@router.get(
route_builder.build_base_route("transactions"),
response_model=POSTransactionListResponse
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
async def list_pos_transactions(
tenant_id: UUID = Path(...),
pos_system: Optional[str] = Query(None),
start_date: Optional[datetime] = Query(None),
end_date: Optional[datetime] = Query(None),
status: Optional[str] = Query(None),
is_synced: Optional[bool] = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
current_user: dict = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""List POS transactions for a tenant"""
try:
service = POSTransactionService()
transactions = await service.get_transactions_by_tenant(
tenant_id=tenant_id,
pos_system=pos_system,
start_date=start_date,
end_date=end_date,
status=status,
is_synced=is_synced,
skip=offset,
limit=limit
)
total = await service.count_transactions_by_tenant(
tenant_id=tenant_id,
pos_system=pos_system,
start_date=start_date,
end_date=end_date,
status=status,
is_synced=is_synced
)
# Get sync metrics for summary
sync_metrics = await service.get_sync_metrics(tenant_id)
# Calculate summary
total_amount = sum(float(t.total_amount) for t in transactions if t.status == "completed")
has_more = (offset + limit) < total
return POSTransactionListResponse(
transactions=transactions,
total=total,
has_more=has_more,
summary={
"total_amount": total_amount,
"transaction_count": len(transactions),
"sync_status": sync_metrics["sync_status"]
}
)
except Exception as e:
logger.error("Failed to list POS transactions", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to list transactions: {str(e)}")
@router.get(
route_builder.build_resource_detail_route("transactions", "transaction_id"),
response_model=POSTransactionResponse
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
async def get_pos_transaction(
tenant_id: UUID = Path(...),
transaction_id: UUID = Path(...),
current_user: dict = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""Get a specific POS transaction"""
try:
service = POSTransactionService()
transaction = await service.get_transaction_with_items(
transaction_id=transaction_id,
tenant_id=tenant_id
)
if not transaction:
raise HTTPException(status_code=404, detail="Transaction not found")
return transaction
except HTTPException:
raise
except Exception as e:
logger.error("Failed to get POS transaction", error=str(e),
tenant_id=tenant_id, transaction_id=transaction_id)
raise HTTPException(status_code=500, detail=f"Failed to get transaction: {str(e)}")
@router.get(
route_builder.build_operations_route("transactions-dashboard"),
response_model=POSTransactionDashboardSummary
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
async def get_transactions_dashboard(
tenant_id: UUID = Path(...),
current_user: dict = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""Get dashboard summary for POS transactions"""
try:
service = POSTransactionService()
summary = await service.get_dashboard_summary(tenant_id)
logger.info("Transactions dashboard retrieved",
tenant_id=str(tenant_id),
total_today=summary.total_transactions_today)
return summary
except Exception as e:
logger.error("Failed to get transactions dashboard", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to get dashboard: {str(e)}")