Initial commit - production deployment
This commit is contained in:
1
services/pos/app/api/__init__.py
Normal file
1
services/pos/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# API endpoints package
|
||||
93
services/pos/app/api/analytics.py
Normal file
93
services/pos/app/api/analytics.py
Normal 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)}")
|
||||
237
services/pos/app/api/audit.py
Normal file
237
services/pos/app/api/audit.py
Normal 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)}"
|
||||
)
|
||||
241
services/pos/app/api/configurations.py
Normal file
241
services/pos/app/api/configurations.py
Normal 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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
857
services/pos/app/api/pos_operations.py
Normal file
857
services/pos/app/api/pos_operations.py
Normal 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)}"
|
||||
)
|
||||
148
services/pos/app/api/transactions.py
Normal file
148
services/pos/app/api/transactions.py
Normal 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)}")
|
||||
Reference in New Issue
Block a user