Add POS service

This commit is contained in:
Urtzi Alfaro
2025-08-16 15:00:36 +02:00
parent 995a51e285
commit 23c5f50111
34 changed files with 6086 additions and 0 deletions

View File

@@ -25,6 +25,7 @@ volumes:
inventory_db_data: inventory_db_data:
recipes_db_data: recipes_db_data:
suppliers_db_data: suppliers_db_data:
pos_db_data:
redis_data: redis_data:
rabbitmq_data: rabbitmq_data:
prometheus_data: prometheus_data:
@@ -305,6 +306,27 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
pos-db:
image: postgres:15-alpine
container_name: bakery-pos-db
restart: unless-stopped
environment:
- POSTGRES_DB=${POS_DB_NAME}
- POSTGRES_USER=${POS_DB_USER}
- POSTGRES_PASSWORD=${POS_DB_PASSWORD}
- POSTGRES_INITDB_ARGS=${POSTGRES_INITDB_ARGS}
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- pos_db_data:/var/lib/postgresql/data
networks:
bakery-network:
ipv4_address: 172.20.0.31
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POS_DB_USER} -d ${POS_DB_NAME}"]
interval: 10s
timeout: 5s
retries: 5
# ================================================================ # ================================================================
# LOCATION SERVICES (NEW SECTION) # LOCATION SERVICES (NEW SECTION)
@@ -774,6 +796,43 @@ services:
timeout: 10s timeout: 10s
retries: 3 retries: 3
pos-service:
build:
context: .
dockerfile: ./services/pos/Dockerfile
args:
- ENVIRONMENT=${ENVIRONMENT}
- BUILD_DATE=${BUILD_DATE}
image: bakery/pos-service:${IMAGE_TAG}
container_name: bakery-pos-service
restart: unless-stopped
env_file: .env
ports:
- "${POS_SERVICE_PORT}:8000"
depends_on:
pos-db:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
auth-service:
condition: service_healthy
sales-service:
condition: service_healthy
networks:
bakery-network:
ipv4_address: 172.20.0.112
volumes:
- log_storage:/app/logs
- ./services/pos:/app
- ./shared:/app/shared
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
# ================================================================ # ================================================================
# MONITORING - SIMPLE APPROACH # MONITORING - SIMPLE APPROACH
# ================================================================ # ================================================================

View File

@@ -0,0 +1,337 @@
// frontend/src/api/hooks/usePOS.ts
/**
* React hooks for POS Integration functionality
*/
import { useState, useEffect } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
posService,
POSConfiguration,
CreatePOSConfigurationRequest,
UpdatePOSConfigurationRequest,
POSTransaction,
POSSyncLog,
POSAnalytics,
SyncRequest
} from '../services/pos.service';
import { useTenantId } from './useTenant';
// ============================================================================
// CONFIGURATION HOOKS
// ============================================================================
export const usePOSConfigurations = (params?: {
pos_system?: string;
is_active?: boolean;
limit?: number;
offset?: number;
}) => {
const tenantId = useTenantId();
return useQuery({
queryKey: ['pos-configurations', tenantId, params],
queryFn: () => posService.getConfigurations(tenantId, params),
enabled: !!tenantId,
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
export const usePOSConfiguration = (configId?: string) => {
const tenantId = useTenantId();
return useQuery({
queryKey: ['pos-configuration', tenantId, configId],
queryFn: () => posService.getConfiguration(tenantId, configId!),
enabled: !!tenantId && !!configId,
staleTime: 5 * 60 * 1000,
});
};
export const useCreatePOSConfiguration = () => {
const queryClient = useQueryClient();
const tenantId = useTenantId();
return useMutation({
mutationFn: (data: CreatePOSConfigurationRequest) =>
posService.createConfiguration(tenantId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pos-configurations', tenantId] });
},
});
};
export const useUpdatePOSConfiguration = () => {
const queryClient = useQueryClient();
const tenantId = useTenantId();
return useMutation({
mutationFn: ({ configId, data }: { configId: string; data: UpdatePOSConfigurationRequest }) =>
posService.updateConfiguration(tenantId, configId, data),
onSuccess: (_, { configId }) => {
queryClient.invalidateQueries({ queryKey: ['pos-configurations', tenantId] });
queryClient.invalidateQueries({ queryKey: ['pos-configuration', tenantId, configId] });
},
});
};
export const useDeletePOSConfiguration = () => {
const queryClient = useQueryClient();
const tenantId = useTenantId();
return useMutation({
mutationFn: (configId: string) =>
posService.deleteConfiguration(tenantId, configId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pos-configurations', tenantId] });
},
});
};
export const useTestPOSConnection = () => {
const tenantId = useTenantId();
return useMutation({
mutationFn: (configId: string) =>
posService.testConnection(tenantId, configId),
});
};
// ============================================================================
// SYNCHRONIZATION HOOKS
// ============================================================================
export const useTriggerPOSSync = () => {
const queryClient = useQueryClient();
const tenantId = useTenantId();
return useMutation({
mutationFn: ({ configId, syncRequest }: { configId: string; syncRequest: SyncRequest }) =>
posService.triggerSync(tenantId, configId, syncRequest),
onSuccess: (_, { configId }) => {
queryClient.invalidateQueries({ queryKey: ['pos-sync-status', tenantId, configId] });
queryClient.invalidateQueries({ queryKey: ['pos-sync-logs', tenantId, configId] });
},
});
};
export const usePOSSyncStatus = (configId?: string, pollingInterval?: number) => {
const tenantId = useTenantId();
return useQuery({
queryKey: ['pos-sync-status', tenantId, configId],
queryFn: () => posService.getSyncStatus(tenantId, configId!),
enabled: !!tenantId && !!configId,
refetchInterval: pollingInterval || 30000, // Poll every 30 seconds by default
staleTime: 10 * 1000, // 10 seconds
});
};
export const usePOSSyncLogs = (configId?: string, params?: {
limit?: number;
offset?: number;
status?: string;
sync_type?: string;
data_type?: string;
}) => {
const tenantId = useTenantId();
return useQuery({
queryKey: ['pos-sync-logs', tenantId, configId, params],
queryFn: () => posService.getSyncLogs(tenantId, configId!, params),
enabled: !!tenantId && !!configId,
staleTime: 2 * 60 * 1000, // 2 minutes
});
};
// ============================================================================
// TRANSACTION HOOKS
// ============================================================================
export const usePOSTransactions = (params?: {
pos_system?: string;
start_date?: string;
end_date?: string;
status?: string;
is_synced?: boolean;
limit?: number;
offset?: number;
}) => {
const tenantId = useTenantId();
return useQuery({
queryKey: ['pos-transactions', tenantId, params],
queryFn: () => posService.getTransactions(tenantId, params),
enabled: !!tenantId,
staleTime: 2 * 60 * 1000, // 2 minutes
});
};
export const useSyncSingleTransaction = () => {
const queryClient = useQueryClient();
const tenantId = useTenantId();
return useMutation({
mutationFn: ({ transactionId, force }: { transactionId: string; force?: boolean }) =>
posService.syncSingleTransaction(tenantId, transactionId, force),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pos-transactions', tenantId] });
},
});
};
export const useResyncFailedTransactions = () => {
const queryClient = useQueryClient();
const tenantId = useTenantId();
return useMutation({
mutationFn: (daysBack: number) =>
posService.resyncFailedTransactions(tenantId, daysBack),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pos-transactions', tenantId] });
},
});
};
// ============================================================================
// ANALYTICS HOOKS
// ============================================================================
export const usePOSAnalytics = (days: number = 30) => {
const tenantId = useTenantId();
return useQuery({
queryKey: ['pos-analytics', tenantId, days],
queryFn: () => posService.getSyncAnalytics(tenantId, days),
enabled: !!tenantId,
staleTime: 10 * 60 * 1000, // 10 minutes
});
};
// ============================================================================
// SYSTEM INFO HOOKS
// ============================================================================
export const useSupportedPOSSystems = () => {
return useQuery({
queryKey: ['supported-pos-systems'],
queryFn: () => posService.getSupportedSystems(),
staleTime: 60 * 60 * 1000, // 1 hour
});
};
export const useWebhookStatus = (posSystem?: string) => {
return useQuery({
queryKey: ['webhook-status', posSystem],
queryFn: () => posService.getWebhookStatus(posSystem!),
enabled: !!posSystem,
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
// ============================================================================
// COMPOSITE HOOKS
// ============================================================================
export const usePOSDashboard = () => {
const tenantId = useTenantId();
// Get configurations
const { data: configurationsData, isLoading: configurationsLoading } = usePOSConfigurations();
// Get recent transactions
const { data: transactionsData, isLoading: transactionsLoading } = usePOSTransactions({
limit: 10
});
// Get analytics for last 7 days
const { data: analyticsData, isLoading: analyticsLoading } = usePOSAnalytics(7);
const isLoading = configurationsLoading || transactionsLoading || analyticsLoading;
return {
configurations: configurationsData?.configurations || [],
transactions: transactionsData?.transactions || [],
analytics: analyticsData,
isLoading,
summary: {
total_configurations: configurationsData?.total || 0,
active_configurations: configurationsData?.configurations?.filter(c => c.is_active).length || 0,
connected_configurations: configurationsData?.configurations?.filter(c => c.is_connected).length || 0,
total_transactions: transactionsData?.total || 0,
total_revenue: transactionsData?.summary?.total_amount || 0,
sync_health: analyticsData?.success_rate || 0,
}
};
};
export const usePOSConfigurationManagement = () => {
const createMutation = useCreatePOSConfiguration();
const updateMutation = useUpdatePOSConfiguration();
const deleteMutation = useDeletePOSConfiguration();
const testConnectionMutation = useTestPOSConnection();
const [selectedConfiguration, setSelectedConfiguration] = useState<POSConfiguration | null>(null);
const [isFormOpen, setIsFormOpen] = useState(false);
const handleCreate = async (data: CreatePOSConfigurationRequest) => {
await createMutation.mutateAsync(data);
setIsFormOpen(false);
};
const handleUpdate = async (configId: string, data: UpdatePOSConfigurationRequest) => {
await updateMutation.mutateAsync({ configId, data });
setIsFormOpen(false);
setSelectedConfiguration(null);
};
const handleDelete = async (configId: string) => {
await deleteMutation.mutateAsync(configId);
};
const handleTestConnection = async (configId: string) => {
return await testConnectionMutation.mutateAsync(configId);
};
const openCreateForm = () => {
setSelectedConfiguration(null);
setIsFormOpen(true);
};
const openEditForm = (configuration: POSConfiguration) => {
setSelectedConfiguration(configuration);
setIsFormOpen(true);
};
const closeForm = () => {
setIsFormOpen(false);
setSelectedConfiguration(null);
};
return {
// State
selectedConfiguration,
isFormOpen,
// Actions
handleCreate,
handleUpdate,
handleDelete,
handleTestConnection,
openCreateForm,
openEditForm,
closeForm,
// Loading states
isCreating: createMutation.isPending,
isUpdating: updateMutation.isPending,
isDeleting: deleteMutation.isPending,
isTesting: testConnectionMutation.isPending,
// Errors
createError: createMutation.error,
updateError: updateMutation.error,
deleteError: deleteMutation.error,
testError: testConnectionMutation.error,
};
};

View File

@@ -0,0 +1,392 @@
// frontend/src/api/services/pos.service.ts
/**
* POS Integration API Service
* Handles all communication with the POS service backend
*/
import { apiClient } from '../client';
// ============================================================================
// TYPES & INTERFACES
// ============================================================================
export interface POSConfiguration {
id: string;
tenant_id: string;
pos_system: 'square' | 'toast' | 'lightspeed';
provider_name: string;
is_active: boolean;
is_connected: boolean;
environment: 'sandbox' | 'production';
location_id?: string;
merchant_id?: string;
sync_enabled: boolean;
sync_interval_minutes: string;
auto_sync_products: boolean;
auto_sync_transactions: boolean;
webhook_url?: string;
last_sync_at?: string;
last_successful_sync_at?: string;
last_sync_status?: 'success' | 'failed' | 'partial';
last_sync_message?: string;
provider_settings?: Record<string, any>;
last_health_check_at?: string;
health_status: 'healthy' | 'unhealthy' | 'warning' | 'unknown';
health_message?: string;
created_at: string;
updated_at: string;
created_by?: string;
notes?: string;
}
export interface CreatePOSConfigurationRequest {
pos_system: 'square' | 'toast' | 'lightspeed';
provider_name: string;
environment: 'sandbox' | 'production';
location_id?: string;
merchant_id?: string;
sync_enabled?: boolean;
sync_interval_minutes?: string;
auto_sync_products?: boolean;
auto_sync_transactions?: boolean;
notes?: string;
// Credentials
api_key?: string;
api_secret?: string;
access_token?: string;
application_id?: string;
webhook_secret?: string;
}
export interface UpdatePOSConfigurationRequest {
provider_name?: string;
is_active?: boolean;
environment?: 'sandbox' | 'production';
location_id?: string;
merchant_id?: string;
sync_enabled?: boolean;
sync_interval_minutes?: string;
auto_sync_products?: boolean;
auto_sync_transactions?: boolean;
notes?: string;
// Credentials (only if updating)
api_key?: string;
api_secret?: string;
access_token?: string;
application_id?: string;
webhook_secret?: string;
}
export interface POSTransaction {
id: string;
tenant_id: string;
pos_config_id: string;
pos_system: string;
external_transaction_id: string;
external_order_id?: string;
transaction_type: 'sale' | 'refund' | 'void' | 'exchange';
status: 'completed' | 'pending' | 'failed' | 'refunded' | 'voided';
subtotal: number;
tax_amount: number;
tip_amount: number;
discount_amount: number;
total_amount: number;
currency: string;
payment_method?: string;
payment_status?: string;
transaction_date: string;
pos_created_at: string;
pos_updated_at?: string;
location_id?: string;
location_name?: string;
staff_id?: string;
staff_name?: string;
customer_id?: string;
customer_email?: string;
customer_phone?: string;
order_type?: string;
table_number?: string;
receipt_number?: string;
is_synced_to_sales: boolean;
sales_record_id?: string;
sync_attempted_at?: string;
sync_completed_at?: string;
sync_error?: string;
sync_retry_count: number;
is_processed: boolean;
processing_error?: string;
is_duplicate: boolean;
duplicate_of?: string;
created_at: string;
updated_at: string;
items: POSTransactionItem[];
}
export interface POSTransactionItem {
id: string;
transaction_id: string;
external_item_id?: string;
sku?: string;
product_name: string;
product_category?: string;
product_subcategory?: string;
quantity: number;
unit_price: number;
total_price: number;
discount_amount: number;
tax_amount: number;
modifiers?: Record<string, any>;
inventory_product_id?: string;
is_mapped_to_inventory: boolean;
is_synced_to_sales: boolean;
sync_error?: string;
}
export interface POSSyncLog {
id: string;
tenant_id: string;
pos_config_id: string;
sync_type: 'full' | 'incremental' | 'manual' | 'webhook_triggered';
sync_direction: 'inbound' | 'outbound' | 'bidirectional';
data_type: 'transactions' | 'products' | 'customers' | 'orders';
pos_system: string;
status: 'started' | 'in_progress' | 'completed' | 'failed' | 'cancelled';
started_at: string;
completed_at?: string;
duration_seconds?: number;
sync_from_date?: string;
sync_to_date?: string;
records_requested: number;
records_processed: number;
records_created: number;
records_updated: number;
records_skipped: number;
records_failed: number;
api_calls_made: number;
error_message?: string;
error_code?: string;
retry_attempt: number;
max_retries: number;
progress_percentage?: number;
revenue_synced?: number;
transactions_synced: number;
triggered_by?: string;
triggered_by_user_id?: string;
created_at: string;
updated_at: string;
}
export interface SyncRequest {
sync_type?: 'full' | 'incremental';
data_types?: ('transactions' | 'products' | 'customers')[];
from_date?: string;
to_date?: string;
}
export interface SyncStatus {
current_sync?: POSSyncLog;
last_successful_sync?: POSSyncLog;
recent_syncs: POSSyncLog[];
sync_health: {
status: 'healthy' | 'unhealthy' | 'warning';
success_rate: number;
average_duration_minutes: number;
last_error?: string;
};
}
export interface SupportedPOSSystem {
id: string;
name: string;
description: string;
features: string[];
supported_regions: string[];
}
export interface POSAnalytics {
period_days: number;
total_syncs: number;
successful_syncs: number;
failed_syncs: number;
success_rate: number;
average_duration_minutes: number;
total_transactions_synced: number;
total_revenue_synced: number;
sync_frequency: {
daily_average: number;
peak_day?: string;
peak_count: number;
};
error_analysis: {
common_errors: Array<{ error: string; count: number }>;
error_trends: Array<{ date: string; count: number }>;
};
}
export interface ConnectionTestResult {
status: 'success' | 'error';
message: string;
tested_at: string;
}
// ============================================================================
// API FUNCTIONS
// ============================================================================
export const posService = {
// Configuration Management
async getConfigurations(tenantId: string, params?: {
pos_system?: string;
is_active?: boolean;
limit?: number;
offset?: number;
}): Promise<{ configurations: POSConfiguration[]; total: number }> {
const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations`, {
params
});
return response.data;
},
async createConfiguration(tenantId: string, data: CreatePOSConfigurationRequest): Promise<POSConfiguration> {
const response = await apiClient.post(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations`, data);
return response.data;
},
async getConfiguration(tenantId: string, configId: string): Promise<POSConfiguration> {
const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}`);
return response.data;
},
async updateConfiguration(tenantId: string, configId: string, data: UpdatePOSConfigurationRequest): Promise<POSConfiguration> {
const response = await apiClient.put(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}`, data);
return response.data;
},
async deleteConfiguration(tenantId: string, configId: string): Promise<void> {
await apiClient.delete(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}`);
},
async testConnection(tenantId: string, configId: string): Promise<ConnectionTestResult> {
const response = await apiClient.post(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}/test-connection`);
return response.data;
},
// Synchronization
async triggerSync(tenantId: string, configId: string, syncRequest: SyncRequest): Promise<{
message: string;
sync_id: string;
status: string;
sync_type: string;
data_types: string[];
estimated_duration: string;
}> {
const response = await apiClient.post(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}/sync`, syncRequest);
return response.data;
},
async getSyncStatus(tenantId: string, configId: string, limit?: number): Promise<SyncStatus> {
const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}/sync/status`, {
params: { limit }
});
return response.data;
},
async getSyncLogs(tenantId: string, configId: string, params?: {
limit?: number;
offset?: number;
status?: string;
sync_type?: string;
data_type?: string;
}): Promise<{ logs: POSSyncLog[]; total: number; has_more: boolean }> {
const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}/sync/logs`, {
params
});
return response.data;
},
// Transaction Management
async getTransactions(tenantId: string, params?: {
pos_system?: string;
start_date?: string;
end_date?: string;
status?: string;
is_synced?: boolean;
limit?: number;
offset?: number;
}): Promise<{
transactions: POSTransaction[];
total: number;
has_more: boolean;
summary: {
total_amount: number;
transaction_count: number;
sync_status: {
synced: number;
pending: number;
failed: number;
};
};
}> {
const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/transactions`, {
params
});
return response.data;
},
async syncSingleTransaction(tenantId: string, transactionId: string, force?: boolean): Promise<{
message: string;
transaction_id: string;
sync_status: string;
sales_record_id?: string;
}> {
const response = await apiClient.post(`/pos-service/api/v1/tenants/${tenantId}/pos/transactions/${transactionId}/sync`,
{}, { params: { force } }
);
return response.data;
},
async resyncFailedTransactions(tenantId: string, daysBack: number): Promise<{
message: string;
job_id: string;
scope: string;
estimated_transactions: number;
}> {
const response = await apiClient.post(`/pos-service/api/v1/tenants/${tenantId}/pos/data/resync`,
{}, { params: { days_back: daysBack } }
);
return response.data;
},
// Analytics
async getSyncAnalytics(tenantId: string, days: number = 30): Promise<POSAnalytics> {
const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/analytics/sync-performance`, {
params: { days }
});
return response.data;
},
// System Information
async getSupportedSystems(): Promise<{ systems: SupportedPOSSystem[] }> {
const response = await apiClient.get('/pos-service/api/v1/pos/supported-systems');
return response.data;
},
// Webhook Status
async getWebhookStatus(posSystem: string): Promise<{
pos_system: string;
status: string;
endpoint: string;
supported_events: {
events: string[];
format: string;
authentication: string;
};
last_received?: string;
total_received: number;
}> {
const response = await apiClient.get(`/pos-service/api/v1/webhooks/${posSystem}/status`);
return response.data;
}
};
export default posService;

View File

@@ -0,0 +1,356 @@
import React, { useState } from 'react';
import {
Edit,
RefreshCw,
Globe,
Activity,
CheckCircle,
AlertTriangle,
Clock,
Settings,
MoreVertical,
Trash2,
Power,
Zap
} from 'lucide-react';
import Button from '../ui/Button';
import Card from '../ui/Card';
import LoadingSpinner from '../ui/LoadingSpinner';
interface POSConfiguration {
id: string;
pos_system: string;
provider_name: string;
is_active: boolean;
is_connected: boolean;
environment: string;
location_id?: string;
merchant_id?: string;
sync_enabled: boolean;
sync_interval_minutes: string;
auto_sync_products: boolean;
auto_sync_transactions: boolean;
last_sync_at?: string;
last_successful_sync_at?: string;
last_sync_status?: string;
health_status: string;
created_at: string;
updated_at: string;
}
interface POSConfigurationCardProps {
configuration: POSConfiguration;
onEdit: (config: POSConfiguration) => void;
onTriggerSync: (configId: string) => Promise<void>;
onTestConnection: (configId: string) => Promise<void>;
onToggleActive?: (configId: string, active: boolean) => Promise<void>;
onDelete?: (configId: string) => Promise<void>;
}
const POSConfigurationCard: React.FC<POSConfigurationCardProps> = ({
configuration,
onEdit,
onTriggerSync,
onTestConnection,
onToggleActive,
onDelete
}) => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [isToggling, setIsToggling] = useState(false);
const getPOSSystemIcon = (system: string) => {
switch (system) {
case 'square': return '⬜';
case 'toast': return '🍞';
case 'lightspeed': return '⚡';
default: return '💳';
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'healthy': return 'text-green-600';
case 'unhealthy': return 'text-red-600';
case 'warning': return 'text-yellow-600';
default: return 'text-gray-600';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'healthy': return CheckCircle;
case 'unhealthy': return AlertTriangle;
case 'warning': return Clock;
default: return Activity;
}
};
const getConnectionStatusColor = (isConnected: boolean) => {
return isConnected ? 'text-green-600' : 'text-red-600';
};
const getSyncStatusColor = (status?: string) => {
switch (status) {
case 'success': return 'text-green-600';
case 'failed': return 'text-red-600';
case 'partial': return 'text-yellow-600';
default: return 'text-gray-600';
}
};
const formatLastSync = (timestamp?: string) => {
if (!timestamp) return 'Never';
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`;
return `${Math.floor(diffMins / 1440)}d ago`;
};
const handleSync = async () => {
setIsSyncing(true);
try {
await onTriggerSync(configuration.id);
} finally {
setIsSyncing(false);
}
};
const handleTestConnection = async () => {
setIsTesting(true);
try {
await onTestConnection(configuration.id);
} finally {
setIsTesting(false);
}
};
const handleToggleActive = async () => {
if (!onToggleActive) return;
setIsToggling(true);
try {
await onToggleActive(configuration.id, !configuration.is_active);
} finally {
setIsToggling(false);
}
};
const StatusIcon = getStatusIcon(configuration.health_status);
return (
<Card className="p-6 hover:shadow-lg transition-shadow">
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center space-x-3">
<div className="text-2xl">
{getPOSSystemIcon(configuration.pos_system)}
</div>
<div>
<h3 className="font-semibold text-gray-900">
{configuration.provider_name}
</h3>
<p className="text-sm text-gray-600 capitalize">
{configuration.pos_system} {configuration.environment}
</p>
</div>
</div>
<div className="relative">
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
>
<MoreVertical className="h-5 w-5" />
</button>
{isMenuOpen && (
<div className="absolute right-0 top-8 w-48 bg-white rounded-md shadow-lg border z-10">
<div className="py-1">
<button
onClick={() => {
onEdit(configuration);
setIsMenuOpen(false);
}}
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center space-x-2"
>
<Edit className="h-4 w-4" />
<span>Edit Configuration</span>
</button>
{onToggleActive && (
<button
onClick={() => {
handleToggleActive();
setIsMenuOpen(false);
}}
disabled={isToggling}
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center space-x-2"
>
<Power className="h-4 w-4" />
<span>{configuration.is_active ? 'Deactivate' : 'Activate'}</span>
</button>
)}
{onDelete && (
<button
onClick={() => {
onDelete(configuration.id);
setIsMenuOpen(false);
}}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center space-x-2"
>
<Trash2 className="h-4 w-4" />
<span>Delete</span>
</button>
)}
</div>
</div>
)}
</div>
</div>
{/* Status Indicators */}
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="text-center">
<div className={`flex items-center justify-center space-x-1 ${getStatusColor(configuration.health_status)}`}>
<StatusIcon className="h-4 w-4" />
<span className="text-sm font-medium capitalize">{configuration.health_status}</span>
</div>
<p className="text-xs text-gray-500">Health</p>
</div>
<div className="text-center">
<div className={`flex items-center justify-center space-x-1 ${getConnectionStatusColor(configuration.is_connected)}`}>
<Globe className="h-4 w-4" />
<span className="text-sm font-medium">
{configuration.is_connected ? 'Connected' : 'Disconnected'}
</span>
</div>
<p className="text-xs text-gray-500">Connection</p>
</div>
<div className="text-center">
<div className={`flex items-center justify-center space-x-1 ${getSyncStatusColor(configuration.last_sync_status)}`}>
<RefreshCw className="h-4 w-4" />
<span className="text-sm font-medium capitalize">
{configuration.last_sync_status || 'Unknown'}
</span>
</div>
<p className="text-xs text-gray-500">Last Sync</p>
</div>
</div>
{/* Configuration Details */}
<div className="space-y-2 mb-4">
{configuration.location_id && (
<div className="flex justify-between text-sm">
<span className="text-gray-600">Location ID:</span>
<span className="font-medium">{configuration.location_id}</span>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-gray-600">Sync Interval:</span>
<span className="font-medium">{configuration.sync_interval_minutes}m</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Last Sync:</span>
<span className="font-medium">{formatLastSync(configuration.last_sync_at)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Auto Sync:</span>
<div className="flex space-x-1">
{configuration.auto_sync_transactions && (
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded">Transactions</span>
)}
{configuration.auto_sync_products && (
<span className="px-2 py-1 bg-green-100 text-green-800 text-xs rounded">Products</span>
)}
</div>
</div>
</div>
{/* Status Badge */}
<div className="flex items-center justify-between mb-4">
<div className="flex space-x-2">
<span className={`px-2 py-1 text-xs rounded-full ${
configuration.is_active
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{configuration.is_active ? 'Active' : 'Inactive'}
</span>
{configuration.sync_enabled && (
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
Sync Enabled
</span>
)}
</div>
</div>
{/* Action Buttons */}
<div className="flex space-x-2">
<Button
variant="outline"
size="sm"
onClick={handleTestConnection}
disabled={isTesting}
className="flex-1 flex items-center justify-center space-x-1"
>
{isTesting ? (
<LoadingSpinner size="sm" />
) : (
<Globe className="h-4 w-4" />
)}
<span>Test</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleSync}
disabled={isSyncing || !configuration.is_connected}
className="flex-1 flex items-center justify-center space-x-1"
>
{isSyncing ? (
<LoadingSpinner size="sm" />
) : (
<RefreshCw className="h-4 w-4" />
)}
<span>Sync</span>
</Button>
<Button
size="sm"
onClick={() => onEdit(configuration)}
className="flex-1 flex items-center justify-center space-x-1"
>
<Settings className="h-4 w-4" />
<span>Configure</span>
</Button>
</div>
{/* Click overlay to close menu */}
{isMenuOpen && (
<div
className="fixed inset-0 z-0"
onClick={() => setIsMenuOpen(false)}
/>
)}
</Card>
);
};
export default POSConfigurationCard;

View File

@@ -0,0 +1,595 @@
import React, { useState, useEffect } from 'react';
import {
X,
Zap,
Settings,
Globe,
Key,
Webhook,
Sync,
AlertTriangle,
CheckCircle,
Clock,
Database
} from 'lucide-react';
import Button from '../ui/Button';
import LoadingSpinner from '../ui/LoadingSpinner';
interface POSConfiguration {
id?: string;
pos_system: string;
provider_name: string;
is_active: boolean;
is_connected: boolean;
environment: string;
location_id?: string;
merchant_id?: string;
sync_enabled: boolean;
sync_interval_minutes: string;
auto_sync_products: boolean;
auto_sync_transactions: boolean;
webhook_url?: string;
notes?: string;
}
interface POSConfigurationFormProps {
configuration?: POSConfiguration | null;
isOpen: boolean;
isCreating?: boolean;
onSubmit: (data: POSConfiguration) => Promise<void>;
onClose: () => void;
}
interface FormData extends POSConfiguration {
// Credentials (these won't be in the existing config for security)
api_key?: string;
api_secret?: string;
access_token?: string;
application_id?: string;
webhook_secret?: string;
}
const SUPPORTED_POS_SYSTEMS = [
{
id: 'square',
name: 'Square POS',
description: 'Square Point of Sale system',
logo: '⬜',
fields: ['application_id', 'access_token', 'webhook_secret']
},
{
id: 'toast',
name: 'Toast POS',
description: 'Toast restaurant POS system',
logo: '🍞',
fields: ['api_key', 'api_secret', 'webhook_secret']
},
{
id: 'lightspeed',
name: 'Lightspeed Restaurant',
description: 'Lightspeed restaurant management system',
logo: '⚡',
fields: ['api_key', 'api_secret', 'cluster_id']
}
];
const POSConfigurationForm: React.FC<POSConfigurationFormProps> = ({
configuration,
isOpen,
isCreating = false,
onSubmit,
onClose
}) => {
const [formData, setFormData] = useState<FormData>({
pos_system: '',
provider_name: '',
is_active: true,
is_connected: false,
environment: 'sandbox',
sync_enabled: true,
sync_interval_minutes: '5',
auto_sync_products: true,
auto_sync_transactions: true,
...configuration
});
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [testingConnection, setTestingConnection] = useState(false);
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'success' | 'error'>('idle');
useEffect(() => {
if (configuration) {
setFormData({
...formData,
...configuration
});
}
}, [configuration]);
const selectedPOSSystem = SUPPORTED_POS_SYSTEMS.find(sys => sys.id === formData.pos_system);
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.pos_system) {
newErrors.pos_system = 'POS system is required';
}
if (!formData.provider_name.trim()) {
newErrors.provider_name = 'Provider name is required';
}
if (selectedPOSSystem?.fields.includes('api_key') && !formData.api_key?.trim()) {
newErrors.api_key = 'API Key is required';
}
if (selectedPOSSystem?.fields.includes('access_token') && !formData.access_token?.trim()) {
newErrors.access_token = 'Access Token is required';
}
if (selectedPOSSystem?.fields.includes('application_id') && !formData.application_id?.trim()) {
newErrors.application_id = 'Application ID is required';
}
const syncInterval = parseInt(formData.sync_interval_minutes);
if (isNaN(syncInterval) || syncInterval < 1 || syncInterval > 1440) {
newErrors.sync_interval_minutes = 'Sync interval must be between 1 and 1440 minutes';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setIsLoading(true);
try {
await onSubmit(formData);
onClose();
} catch (error) {
console.error('Error submitting form:', error);
} finally {
setIsLoading(false);
}
};
const handleTestConnection = async () => {
if (!validateForm()) {
return;
}
setTestingConnection(true);
setConnectionStatus('idle');
try {
// TODO: Implement connection test API call
await new Promise(resolve => setTimeout(resolve, 2000)); // Simulate API call
setConnectionStatus('success');
} catch (error) {
setConnectionStatus('error');
} finally {
setTestingConnection(false);
}
};
const handleInputChange = (field: keyof FormData, value: any) => {
setFormData(prev => ({
...prev,
[field]: value
}));
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => ({
...prev,
[field]: ''
}));
}
};
const handlePOSSystemChange = (posSystem: string) => {
const system = SUPPORTED_POS_SYSTEMS.find(sys => sys.id === posSystem);
setFormData(prev => ({
...prev,
pos_system: posSystem,
provider_name: system?.name || '',
// Clear credentials when changing systems
api_key: '',
api_secret: '',
access_token: '',
application_id: '',
webhook_secret: ''
}));
setConnectionStatus('idle');
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b">
<div className="flex items-center space-x-3">
<Zap className="h-6 w-6 text-blue-600" />
<h2 className="text-xl font-semibold text-gray-900">
{isCreating ? 'Add POS Integration' : 'Edit POS Configuration'}
</h2>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="h-6 w-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-8">
{/* POS System Selection */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900 flex items-center">
<Settings className="h-5 w-5 mr-2" />
POS System Configuration
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{SUPPORTED_POS_SYSTEMS.map((system) => (
<label
key={system.id}
className={`relative cursor-pointer rounded-lg border p-4 hover:bg-gray-50 transition-colors ${
formData.pos_system === system.id
? 'border-blue-500 bg-blue-50'
: 'border-gray-300'
}`}
>
<input
type="radio"
value={system.id}
checked={formData.pos_system === system.id}
onChange={(e) => handlePOSSystemChange(e.target.value)}
className="sr-only"
/>
<div className="flex items-center space-x-3">
<span className="text-2xl">{system.logo}</span>
<div>
<p className="font-medium text-gray-900">{system.name}</p>
<p className="text-sm text-gray-500">{system.description}</p>
</div>
</div>
{formData.pos_system === system.id && (
<CheckCircle className="absolute top-2 right-2 h-5 w-5 text-blue-600" />
)}
</label>
))}
</div>
{errors.pos_system && (
<p className="text-red-600 text-sm">{errors.pos_system}</p>
)}
</div>
{/* Basic Configuration */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Provider Name *
</label>
<input
type="text"
value={formData.provider_name}
onChange={(e) => handleInputChange('provider_name', e.target.value)}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.provider_name ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="e.g., Main Store Square POS"
/>
{errors.provider_name && (
<p className="text-red-600 text-sm mt-1">{errors.provider_name}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Environment
</label>
<select
value={formData.environment}
onChange={(e) => handleInputChange('environment', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="sandbox">Sandbox (Testing)</option>
<option value="production">Production (Live)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Location ID
</label>
<input
type="text"
value={formData.location_id || ''}
onChange={(e) => handleInputChange('location_id', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Optional location identifier"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Merchant ID
</label>
<input
type="text"
value={formData.merchant_id || ''}
onChange={(e) => handleInputChange('merchant_id', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Optional merchant identifier"
/>
</div>
</div>
{/* API Credentials */}
{selectedPOSSystem && (
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900 flex items-center">
<Key className="h-5 w-5 mr-2" />
API Credentials
</h3>
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-yellow-400 mr-2" />
<p className="text-sm text-yellow-800">
Credentials are encrypted and stored securely. Never share your API keys.
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{selectedPOSSystem.fields.includes('application_id') && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Application ID *
</label>
<input
type="text"
value={formData.application_id || ''}
onChange={(e) => handleInputChange('application_id', e.target.value)}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.application_id ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="Square Application ID"
/>
{errors.application_id && (
<p className="text-red-600 text-sm mt-1">{errors.application_id}</p>
)}
</div>
)}
{selectedPOSSystem.fields.includes('access_token') && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Access Token *
</label>
<input
type="password"
value={formData.access_token || ''}
onChange={(e) => handleInputChange('access_token', e.target.value)}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.access_token ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="Square Access Token"
/>
{errors.access_token && (
<p className="text-red-600 text-sm mt-1">{errors.access_token}</p>
)}
</div>
)}
{selectedPOSSystem.fields.includes('api_key') && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
API Key *
</label>
<input
type="password"
value={formData.api_key || ''}
onChange={(e) => handleInputChange('api_key', e.target.value)}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.api_key ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="API Key"
/>
{errors.api_key && (
<p className="text-red-600 text-sm mt-1">{errors.api_key}</p>
)}
</div>
)}
{selectedPOSSystem.fields.includes('api_secret') && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
API Secret
</label>
<input
type="password"
value={formData.api_secret || ''}
onChange={(e) => handleInputChange('api_secret', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="API Secret"
/>
</div>
)}
{selectedPOSSystem.fields.includes('webhook_secret') && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Webhook Secret
</label>
<input
type="password"
value={formData.webhook_secret || ''}
onChange={(e) => handleInputChange('webhook_secret', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Webhook verification secret"
/>
</div>
)}
</div>
{/* Test Connection */}
<div className="flex items-center space-x-4">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={testingConnection}
className="flex items-center space-x-2"
>
{testingConnection ? (
<LoadingSpinner size="sm" />
) : (
<Globe className="h-4 w-4" />
)}
<span>Test Connection</span>
</Button>
{connectionStatus === 'success' && (
<div className="flex items-center text-green-600">
<CheckCircle className="h-5 w-5 mr-2" />
<span className="text-sm">Connection successful</span>
</div>
)}
{connectionStatus === 'error' && (
<div className="flex items-center text-red-600">
<X className="h-5 w-5 mr-2" />
<span className="text-sm">Connection failed</span>
</div>
)}
</div>
</div>
)}
{/* Sync Configuration */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900 flex items-center">
<Sync className="h-5 w-5 mr-2" />
Synchronization Settings
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Sync Interval (minutes)
</label>
<input
type="number"
min="1"
max="1440"
value={formData.sync_interval_minutes}
onChange={(e) => handleInputChange('sync_interval_minutes', e.target.value)}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.sync_interval_minutes ? 'border-red-500' : 'border-gray-300'
}`}
/>
{errors.sync_interval_minutes && (
<p className="text-red-600 text-sm mt-1">{errors.sync_interval_minutes}</p>
)}
</div>
<div className="space-y-4">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={formData.sync_enabled}
onChange={(e) => handleInputChange('sync_enabled', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm font-medium text-gray-700">Enable automatic sync</span>
</label>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={formData.auto_sync_transactions}
onChange={(e) => handleInputChange('auto_sync_transactions', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm font-medium text-gray-700">Auto-sync transactions</span>
</label>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={formData.auto_sync_products}
onChange={(e) => handleInputChange('auto_sync_products', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm font-medium text-gray-700">Auto-sync products</span>
</label>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => handleInputChange('is_active', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm font-medium text-gray-700">Configuration active</span>
</label>
</div>
</div>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Notes
</label>
<textarea
value={formData.notes || ''}
onChange={(e) => handleInputChange('notes', e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Optional notes about this POS configuration..."
/>
</div>
{/* Form Actions */}
<div className="flex items-center justify-end space-x-4 pt-6 border-t">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isLoading}
>
Cancel
</Button>
<Button
type="submit"
disabled={isLoading}
className="flex items-center space-x-2"
>
{isLoading ? (
<LoadingSpinner size="sm" />
) : (
<Database className="h-4 w-4" />
)}
<span>{isCreating ? 'Create Configuration' : 'Update Configuration'}</span>
</Button>
</div>
</form>
</div>
</div>
);
};
export default POSConfigurationForm;

View File

@@ -0,0 +1,394 @@
import React, { useState, useEffect } from 'react';
import {
Plus,
Settings,
Activity,
Zap,
RefreshCw,
AlertTriangle,
CheckCircle,
Clock,
Globe,
Database,
BarChart3,
Filter,
Search
} from 'lucide-react';
import Button from '../ui/Button';
import Card from '../ui/Card';
import LoadingSpinner from '../ui/LoadingSpinner';
import POSConfigurationForm from './POSConfigurationForm';
import POSConfigurationCard from './POSConfigurationCard';
import POSSyncStatus from './POSSyncStatus';
interface POSConfiguration {
id: string;
pos_system: string;
provider_name: string;
is_active: boolean;
is_connected: boolean;
environment: string;
location_id?: string;
merchant_id?: string;
sync_enabled: boolean;
sync_interval_minutes: string;
auto_sync_products: boolean;
auto_sync_transactions: boolean;
last_sync_at?: string;
last_successful_sync_at?: string;
last_sync_status?: string;
health_status: string;
created_at: string;
updated_at: string;
}
interface SyncSummary {
total_configurations: number;
active_configurations: number;
connected_configurations: number;
sync_enabled_configurations: number;
last_24h_syncs: number;
failed_syncs_24h: number;
total_transactions_today: number;
total_revenue_today: number;
}
const POSManagementPage: React.FC = () => {
const [configurations, setConfigurations] = useState<POSConfiguration[]>([]);
const [syncSummary, setSyncSummary] = useState<SyncSummary | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isFormOpen, setIsFormOpen] = useState(false);
const [selectedConfig, setSelectedConfig] = useState<POSConfiguration | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'inactive' | 'connected' | 'disconnected'>('all');
const [filterPOSSystem, setFilterPOSSystem] = useState<'all' | 'square' | 'toast' | 'lightspeed'>('all');
useEffect(() => {
loadConfigurations();
loadSyncSummary();
}, []);
const loadConfigurations = async () => {
try {
setIsLoading(true);
// TODO: Replace with actual API call
await new Promise(resolve => setTimeout(resolve, 1000));
// Mock data
const mockConfigurations: POSConfiguration[] = [
{
id: '1',
pos_system: 'square',
provider_name: 'Main Store Square POS',
is_active: true,
is_connected: true,
environment: 'production',
location_id: 'L123456789',
sync_enabled: true,
sync_interval_minutes: '5',
auto_sync_products: true,
auto_sync_transactions: true,
last_sync_at: '2024-01-15T10:30:00Z',
last_successful_sync_at: '2024-01-15T10:30:00Z',
last_sync_status: 'success',
health_status: 'healthy',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-15T10:30:00Z'
},
{
id: '2',
pos_system: 'toast',
provider_name: 'Bakery Toast System',
is_active: true,
is_connected: false,
environment: 'sandbox',
sync_enabled: false,
sync_interval_minutes: '10',
auto_sync_products: true,
auto_sync_transactions: true,
last_sync_status: 'failed',
health_status: 'unhealthy',
created_at: '2024-01-10T00:00:00Z',
updated_at: '2024-01-15T09:00:00Z'
}
];
setConfigurations(mockConfigurations);
} catch (error) {
console.error('Error loading configurations:', error);
} finally {
setIsLoading(false);
}
};
const loadSyncSummary = async () => {
try {
// TODO: Replace with actual API call
const mockSummary: SyncSummary = {
total_configurations: 2,
active_configurations: 2,
connected_configurations: 1,
sync_enabled_configurations: 1,
last_24h_syncs: 45,
failed_syncs_24h: 2,
total_transactions_today: 156,
total_revenue_today: 2847.50
};
setSyncSummary(mockSummary);
} catch (error) {
console.error('Error loading sync summary:', error);
}
};
const handleCreateConfiguration = () => {
setSelectedConfig(null);
setIsFormOpen(true);
};
const handleEditConfiguration = (config: POSConfiguration) => {
setSelectedConfig(config);
setIsFormOpen(true);
};
const handleFormSubmit = async (data: POSConfiguration) => {
try {
// TODO: Replace with actual API call
console.log('Submitting configuration:', data);
await new Promise(resolve => setTimeout(resolve, 1000));
setIsFormOpen(false);
loadConfigurations();
loadSyncSummary();
} catch (error) {
console.error('Error submitting configuration:', error);
throw error;
}
};
const handleTriggerSync = async (configId: string) => {
try {
// TODO: Replace with actual API call
console.log('Triggering sync for configuration:', configId);
await new Promise(resolve => setTimeout(resolve, 1000));
loadConfigurations();
} catch (error) {
console.error('Error triggering sync:', error);
}
};
const handleTestConnection = async (configId: string) => {
try {
// TODO: Replace with actual API call
console.log('Testing connection for configuration:', configId);
await new Promise(resolve => setTimeout(resolve, 2000));
loadConfigurations();
} catch (error) {
console.error('Error testing connection:', error);
}
};
const filteredConfigurations = configurations.filter(config => {
const matchesSearch = config.provider_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
config.pos_system.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = filterStatus === 'all' ||
(filterStatus === 'active' && config.is_active) ||
(filterStatus === 'inactive' && !config.is_active) ||
(filterStatus === 'connected' && config.is_connected) ||
(filterStatus === 'disconnected' && !config.is_connected);
const matchesPOSSystem = filterPOSSystem === 'all' || config.pos_system === filterPOSSystem;
return matchesSearch && matchesStatus && matchesPOSSystem;
});
const getStatusColor = (status: string) => {
switch (status) {
case 'healthy': return 'text-green-600';
case 'unhealthy': return 'text-red-600';
case 'warning': return 'text-yellow-600';
default: return 'text-gray-600';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'healthy': return CheckCircle;
case 'unhealthy': return AlertTriangle;
case 'warning': return Clock;
default: return Activity;
}
};
if (isLoading && configurations.length === 0) {
return (
<div className="flex items-center justify-center h-64">
<LoadingSpinner size="lg" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">POS Integrations</h1>
<p className="text-gray-600">Manage your Point of Sale system integrations</p>
</div>
<Button
onClick={handleCreateConfiguration}
className="flex items-center space-x-2"
>
<Plus className="h-4 w-4" />
<span>Add POS Integration</span>
</Button>
</div>
{/* Summary Cards */}
{syncSummary && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Total Integrations</p>
<p className="text-2xl font-bold text-gray-900">{syncSummary.total_configurations}</p>
</div>
<Settings className="h-8 w-8 text-blue-600" />
</div>
<div className="mt-2 text-sm text-gray-600">
{syncSummary.active_configurations} active, {syncSummary.connected_configurations} connected
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Today's Transactions</p>
<p className="text-2xl font-bold text-gray-900">{syncSummary.total_transactions_today}</p>
</div>
<BarChart3 className="h-8 w-8 text-green-600" />
</div>
<div className="mt-2 text-sm text-gray-600">
{syncSummary.total_revenue_today.toLocaleString()} revenue
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">24h Syncs</p>
<p className="text-2xl font-bold text-gray-900">{syncSummary.last_24h_syncs}</p>
</div>
<RefreshCw className="h-8 w-8 text-purple-600" />
</div>
<div className="mt-2 text-sm text-gray-600">
{syncSummary.failed_syncs_24h} failed
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">System Health</p>
<p className="text-2xl font-bold text-gray-900">
{Math.round((syncSummary.connected_configurations / syncSummary.total_configurations) * 100)}%
</p>
</div>
<Activity className="h-8 w-8 text-orange-600" />
</div>
<div className="mt-2 text-sm text-gray-600">
Overall connection rate
</div>
</Card>
</div>
)}
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="Search configurations..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex gap-2">
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value as any)}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="connected">Connected</option>
<option value="disconnected">Disconnected</option>
</select>
<select
value={filterPOSSystem}
onChange={(e) => setFilterPOSSystem(e.target.value as any)}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">All Systems</option>
<option value="square">Square</option>
<option value="toast">Toast</option>
<option value="lightspeed">Lightspeed</option>
</select>
</div>
</div>
{/* Configurations Grid */}
{filteredConfigurations.length === 0 ? (
<Card className="p-12 text-center">
<Zap className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No POS integrations found</h3>
<p className="text-gray-600 mb-4">
{configurations.length === 0
? "Get started by adding your first POS integration."
: "Try adjusting your search or filter criteria."
}
</p>
{configurations.length === 0 && (
<Button onClick={handleCreateConfiguration}>
Add Your First Integration
</Button>
)}
</Card>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{filteredConfigurations.map((config) => (
<POSConfigurationCard
key={config.id}
configuration={config}
onEdit={handleEditConfiguration}
onTriggerSync={handleTriggerSync}
onTestConnection={handleTestConnection}
/>
))}
</div>
)}
{/* Sync Status Panel */}
<POSSyncStatus configurations={configurations} />
{/* Configuration Form Modal */}
<POSConfigurationForm
configuration={selectedConfig}
isOpen={isFormOpen}
isCreating={!selectedConfig}
onSubmit={handleFormSubmit}
onClose={() => setIsFormOpen(false)}
/>
</div>
);
};
export default POSManagementPage;

View File

@@ -0,0 +1,341 @@
import React, { useState, useEffect } from 'react';
import {
RefreshCw,
Clock,
CheckCircle,
AlertTriangle,
BarChart3,
TrendingUp,
Activity,
Database
} from 'lucide-react';
import Card from '../ui/Card';
import LoadingSpinner from '../ui/LoadingSpinner';
interface POSConfiguration {
id: string;
pos_system: string;
provider_name: string;
last_sync_at?: string;
last_sync_status?: string;
sync_enabled: boolean;
is_connected: boolean;
}
interface SyncLogEntry {
id: string;
config_id: string;
sync_type: string;
status: string;
started_at: string;
completed_at?: string;
records_processed: number;
records_created: number;
records_updated: number;
records_failed: number;
error_message?: string;
}
interface POSSyncStatusProps {
configurations: POSConfiguration[];
}
const POSSyncStatus: React.FC<POSSyncStatusProps> = ({ configurations }) => {
const [syncLogs, setSyncLogs] = useState<SyncLogEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedTimeframe, setSelectedTimeframe] = useState<'24h' | '7d' | '30d'>('24h');
useEffect(() => {
loadSyncLogs();
}, [selectedTimeframe]);
const loadSyncLogs = async () => {
try {
setIsLoading(true);
// TODO: Replace with actual API call
await new Promise(resolve => setTimeout(resolve, 1000));
// Mock data
const mockLogs: SyncLogEntry[] = [
{
id: '1',
config_id: '1',
sync_type: 'incremental',
status: 'completed',
started_at: '2024-01-15T10:30:00Z',
completed_at: '2024-01-15T10:32:15Z',
records_processed: 45,
records_created: 38,
records_updated: 7,
records_failed: 0
},
{
id: '2',
config_id: '1',
sync_type: 'incremental',
status: 'completed',
started_at: '2024-01-15T10:25:00Z',
completed_at: '2024-01-15T10:26:30Z',
records_processed: 23,
records_created: 20,
records_updated: 3,
records_failed: 0
},
{
id: '3',
config_id: '2',
sync_type: 'manual',
status: 'failed',
started_at: '2024-01-15T09:15:00Z',
records_processed: 0,
records_created: 0,
records_updated: 0,
records_failed: 0,
error_message: 'Authentication failed - invalid API key'
}
];
setSyncLogs(mockLogs);
} catch (error) {
console.error('Error loading sync logs:', error);
} finally {
setIsLoading(false);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed': return 'text-green-600';
case 'failed': return 'text-red-600';
case 'in_progress': return 'text-blue-600';
case 'cancelled': return 'text-gray-600';
default: return 'text-gray-600';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed': return CheckCircle;
case 'failed': return AlertTriangle;
case 'in_progress': return RefreshCw;
default: return Clock;
}
};
const formatDuration = (startTime: string, endTime?: string) => {
if (!endTime) return 'In progress...';
const start = new Date(startTime);
const end = new Date(endTime);
const diffMs = end.getTime() - start.getTime();
const diffSecs = Math.floor(diffMs / 1000);
if (diffSecs < 60) return `${diffSecs}s`;
const diffMins = Math.floor(diffSecs / 60);
const remainingSecs = diffSecs % 60;
return `${diffMins}m ${remainingSecs}s`;
};
const formatTime = (timestamp: string) => {
const date = new Date(timestamp);
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false
});
};
const getConfigurationName = (configId: string) => {
const config = configurations.find(c => c.id === configId);
return config?.provider_name || 'Unknown Configuration';
};
const calculateSyncStats = () => {
const stats = {
total: syncLogs.length,
completed: syncLogs.filter(log => log.status === 'completed').length,
failed: syncLogs.filter(log => log.status === 'failed').length,
totalRecords: syncLogs.reduce((sum, log) => sum + log.records_processed, 0),
totalCreated: syncLogs.reduce((sum, log) => sum + log.records_created, 0),
totalUpdated: syncLogs.reduce((sum, log) => sum + log.records_updated, 0),
avgDuration: 0
};
const completedLogs = syncLogs.filter(log => log.status === 'completed' && log.completed_at);
if (completedLogs.length > 0) {
const totalDuration = completedLogs.reduce((sum, log) => {
const start = new Date(log.started_at);
const end = new Date(log.completed_at!);
return sum + (end.getTime() - start.getTime());
}, 0);
stats.avgDuration = Math.floor(totalDuration / completedLogs.length / 1000);
}
return stats;
};
const stats = calculateSyncStats();
const successRate = stats.total > 0 ? Math.round((stats.completed / stats.total) * 100) : 0;
if (isLoading) {
return (
<Card className="p-6">
<div className="flex items-center justify-center h-32">
<LoadingSpinner size="lg" />
</div>
</Card>
);
}
return (
<div className="space-y-6">
{/* Sync Statistics */}
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<BarChart3 className="h-5 w-5 mr-2" />
Sync Performance
</h3>
<div className="flex space-x-2">
{(['24h', '7d', '30d'] as const).map((timeframe) => (
<button
key={timeframe}
onClick={() => setSelectedTimeframe(timeframe)}
className={`px-3 py-1 text-sm rounded-md transition-colors ${
selectedTimeframe === timeframe
? 'bg-blue-100 text-blue-700'
: 'text-gray-600 hover:text-gray-900'
}`}
>
{timeframe}
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div className="text-center">
<div className="text-2xl font-bold text-gray-900">{stats.total}</div>
<div className="text-sm text-gray-600">Total Syncs</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">{successRate}%</div>
<div className="text-sm text-gray-600">Success Rate</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">{stats.totalRecords}</div>
<div className="text-sm text-gray-600">Records Synced</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-600">{stats.avgDuration}s</div>
<div className="text-sm text-gray-600">Avg Duration</div>
</div>
</div>
</Card>
{/* Recent Sync Logs */}
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<Activity className="h-5 w-5 mr-2" />
Recent Sync Activity
</h3>
<button
onClick={loadSyncLogs}
className="flex items-center space-x-1 text-blue-600 hover:text-blue-700 transition-colors"
>
<RefreshCw className="h-4 w-4" />
<span className="text-sm">Refresh</span>
</button>
</div>
{syncLogs.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Database className="h-12 w-12 mx-auto mb-4 text-gray-400" />
<p>No sync activity found for the selected timeframe.</p>
</div>
) : (
<div className="space-y-4">
{syncLogs.slice(0, 10).map((log) => {
const StatusIcon = getStatusIcon(log.status);
return (
<div
key={log.id}
className="flex items-center justify-between p-4 bg-gray-50 rounded-lg"
>
<div className="flex items-center space-x-4">
<div className={`${getStatusColor(log.status)}`}>
<StatusIcon className="h-5 w-5" />
</div>
<div>
<div className="font-medium text-gray-900">
{getConfigurationName(log.config_id)}
</div>
<div className="text-sm text-gray-600">
{log.sync_type} sync Started at {formatTime(log.started_at)}
</div>
</div>
</div>
<div className="flex items-center space-x-6 text-sm">
{log.status === 'completed' && (
<>
<div className="text-center">
<div className="font-medium text-gray-900">{log.records_processed}</div>
<div className="text-gray-600">Processed</div>
</div>
<div className="text-center">
<div className="font-medium text-green-600">{log.records_created}</div>
<div className="text-gray-600">Created</div>
</div>
<div className="text-center">
<div className="font-medium text-blue-600">{log.records_updated}</div>
<div className="text-gray-600">Updated</div>
</div>
<div className="text-center">
<div className="font-medium text-gray-900">
{formatDuration(log.started_at, log.completed_at)}
</div>
<div className="text-gray-600">Duration</div>
</div>
</>
)}
{log.status === 'failed' && log.error_message && (
<div className="max-w-xs">
<div className="font-medium text-red-600">Failed</div>
<div className="text-gray-600 text-xs truncate">
{log.error_message}
</div>
</div>
)}
{log.status === 'in_progress' && (
<div className="text-center">
<LoadingSpinner size="sm" />
<div className="text-gray-600">Running</div>
</div>
)}
</div>
</div>
);
})}
</div>
)}
</Card>
</div>
);
};
export default POSSyncStatus;

View File

@@ -0,0 +1,4 @@
export { default as POSConfigurationForm } from './POSConfigurationForm';
export { default as POSConfigurationCard } from './POSConfigurationCard';
export { default as POSManagementPage } from './POSManagementPage';
export { default as POSSyncStatus } from './POSSyncStatus';

32
services/pos/Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies
COPY services/pos/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY services/pos/app ./app
COPY shared ./shared
# Create necessary directories
RUN mkdir -p logs
# Set Python path
ENV PYTHONPATH=/app
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# Run the application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

138
services/pos/README.md Normal file
View File

@@ -0,0 +1,138 @@
# POS Integration Service
This service handles integration with external Point of Sale (POS) systems for the Bakery IA platform.
## Supported POS Systems
- **Square POS** - Popular payment and POS solution with strong API support
- **Toast POS** - Restaurant-focused POS system with comprehensive features
- **Lightspeed Restaurant** - Full-featured restaurant management system
## Features
- **Real-time webhook handling** from POS systems
- **Bidirectional data synchronization** with sales service
- **Secure credential management** with encryption
- **Multi-tenant support** with tenant-specific configurations
- **Comprehensive transaction logging** and audit trails
- **Automatic duplicate detection** and handling
- **Rate limiting and retry mechanisms** for reliability
## Architecture
The POS service follows the established microservices architecture:
```
POS Service
├── API Layer (FastAPI)
├── Business Logic (Services)
├── Data Access (Repositories)
├── External Integrations (POS Providers)
├── Webhook Handlers
└── Background Sync Jobs
```
## API Endpoints
### Configuration Management
- `GET /api/v1/tenants/{tenant_id}/pos/configurations` - List POS configurations
- `POST /api/v1/tenants/{tenant_id}/pos/configurations` - Create new configuration
- `PUT /api/v1/tenants/{tenant_id}/pos/configurations/{config_id}` - Update configuration
- `DELETE /api/v1/tenants/{tenant_id}/pos/configurations/{config_id}` - Delete configuration
### Webhook Handling
- `POST /api/v1/webhooks/{pos_system}` - Receive webhooks from POS systems
- `GET /api/v1/webhooks/{pos_system}/status` - Get webhook status
### Data Synchronization
- `POST /api/v1/tenants/{tenant_id}/pos/configurations/{config_id}/sync` - Trigger sync
- `GET /api/v1/tenants/{tenant_id}/pos/configurations/{config_id}/sync/status` - Get sync status
- `GET /api/v1/tenants/{tenant_id}/pos/transactions` - Get POS transactions
## Database Schema
### Core Tables
- `pos_configurations` - POS system configurations per tenant
- `pos_transactions` - Transaction data from POS systems
- `pos_transaction_items` - Individual items within transactions
- `pos_webhook_logs` - Webhook event logs
- `pos_sync_logs` - Synchronization operation logs
## Environment Variables
See `app/core/config.py` for all configuration options. Key variables include:
```bash
# Database
POS_DATABASE_URL=postgresql+asyncpg://pos_user:pos_pass123@pos-db:5432/pos_db
# POS Provider Credentials
SQUARE_APPLICATION_ID=your_square_app_id
SQUARE_ACCESS_TOKEN=your_square_token
TOAST_CLIENT_ID=your_toast_client_id
LIGHTSPEED_CLIENT_ID=your_lightspeed_client_id
# Webhook Configuration
WEBHOOK_BASE_URL=https://your-domain.com
WEBHOOK_SECRET=your_webhook_secret
```
## Development
### Running the Service
```bash
# Using Docker Compose (recommended)
docker-compose up pos-service
# Local development
cd services/pos
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000
```
### Database Migrations
```bash
# Create migration
alembic revision --autogenerate -m "Description"
# Apply migrations
alembic upgrade head
```
### Testing
```bash
# Run tests
pytest tests/
# Run with coverage
pytest --cov=app tests/
```
## Security Considerations
- POS credentials are encrypted before storage
- Webhook signatures are verified for authenticity
- All API endpoints require tenant-based authentication
- Rate limiting prevents abuse
- Sensitive data is logged with appropriate redaction
## Monitoring
The service includes comprehensive monitoring:
- Health check endpoints
- Prometheus metrics
- Structured logging
- Performance tracking
- Error rate monitoring
## Integration Flow
1. **Configuration**: Set up POS system credentials via API
2. **Webhook Registration**: Register webhook URLs with POS providers
3. **Real-time Events**: Receive and process webhook events
4. **Data Sync**: Periodic synchronization of transaction data
5. **Sales Integration**: Forward processed data to sales service

View File

@@ -0,0 +1 @@
# POS Integration Service

View File

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

View File

@@ -0,0 +1,192 @@
# services/pos/app/api/pos_config.py
"""
POS Configuration API Endpoints
"""
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, get_current_tenant_id_dep
router = APIRouter(tags=["pos-config"])
logger = structlog.get_logger()
@router.get("/tenants/{tenant_id}/pos/configurations")
async def get_pos_configurations(
tenant_id: UUID = Path(..., description="Tenant ID"),
pos_system: Optional[str] = Query(None, description="Filter by POS system"),
is_active: Optional[bool] = Query(None, description="Filter by active status"),
current_tenant: str = Depends(get_current_tenant_id_dep),
db=Depends(get_db)
):
"""Get POS configurations for a tenant"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
# TODO: Implement configuration retrieval
# This is a placeholder for the basic structure
return {
"configurations": [],
"total": 0,
"supported_systems": ["square", "toast", "lightspeed"]
}
except Exception as e:
logger.error("Failed to get POS configurations", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to get POS configurations: {str(e)}")
@router.post("/tenants/{tenant_id}/pos/configurations")
async def create_pos_configuration(
configuration_data: Dict[str, Any],
tenant_id: UUID = Path(..., description="Tenant ID"),
current_tenant: str = Depends(get_current_tenant_id_dep),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""Create a new POS configuration"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
# TODO: Implement configuration creation
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"}
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 POS configuration: {str(e)}")
@router.get("/tenants/{tenant_id}/pos/configurations/{config_id}")
async def get_pos_configuration(
tenant_id: UUID = Path(..., description="Tenant ID"),
config_id: UUID = Path(..., description="Configuration ID"),
current_tenant: str = Depends(get_current_tenant_id_dep),
db=Depends(get_db)
):
"""Get a specific POS configuration"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
# TODO: Implement configuration retrieval
return {"message": "Configuration details", "id": str(config_id)}
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 POS configuration: {str(e)}")
@router.put("/tenants/{tenant_id}/pos/configurations/{config_id}")
async def update_pos_configuration(
configuration_data: Dict[str, Any],
tenant_id: UUID = Path(..., description="Tenant ID"),
config_id: UUID = Path(..., description="Configuration ID"),
current_tenant: str = Depends(get_current_tenant_id_dep),
db=Depends(get_db)
):
"""Update a POS configuration"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
# TODO: Implement configuration update
return {"message": "Configuration updated successfully"}
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 POS configuration: {str(e)}")
@router.delete("/tenants/{tenant_id}/pos/configurations/{config_id}")
async def delete_pos_configuration(
tenant_id: UUID = Path(..., description="Tenant ID"),
config_id: UUID = Path(..., description="Configuration ID"),
current_tenant: str = Depends(get_current_tenant_id_dep),
db=Depends(get_db)
):
"""Delete a POS configuration"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
# TODO: Implement configuration deletion
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 POS configuration: {str(e)}")
@router.post("/tenants/{tenant_id}/pos/configurations/{config_id}/test-connection")
async def test_pos_connection(
tenant_id: UUID = Path(..., description="Tenant ID"),
config_id: UUID = Path(..., description="Configuration ID"),
current_tenant: str = Depends(get_current_tenant_id_dep),
db=Depends(get_db)
):
"""Test connection to POS system"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
# TODO: Implement connection testing
return {
"status": "success",
"message": "Connection test successful",
"tested_at": "2024-01-01T00:00:00Z"
}
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 POS connection: {str(e)}")
@router.get("/pos/supported-systems")
async def get_supported_pos_systems():
"""Get list of supported POS systems"""
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,273 @@
# services/pos/app/api/sync.py
"""
POS Sync API Endpoints
Handles data synchronization with POS systems
"""
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Body
from typing import List, 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, get_current_tenant_id_dep
router = APIRouter(tags=["sync"])
logger = structlog.get_logger()
@router.post("/tenants/{tenant_id}/pos/configurations/{config_id}/sync")
async def trigger_sync(
sync_request: Dict[str, Any] = Body(...),
tenant_id: UUID = Path(..., description="Tenant ID"),
config_id: UUID = Path(..., description="Configuration ID"),
current_tenant: str = Depends(get_current_tenant_id_dep),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""Trigger manual synchronization with POS system"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
sync_type = sync_request.get("sync_type", "incremental") # full, incremental
data_types = sync_request.get("data_types", ["transactions"]) # transactions, products, customers
from_date = sync_request.get("from_date")
to_date = sync_request.get("to_date")
logger.info("Manual sync triggered",
tenant_id=tenant_id,
config_id=config_id,
sync_type=sync_type,
data_types=data_types,
user_id=current_user.get("user_id"))
# TODO: Implement sync logic
# TODO: Queue sync job for background processing
# TODO: Return sync job ID for tracking
return {
"message": "Sync triggered successfully",
"sync_id": "placeholder-sync-id",
"status": "queued",
"sync_type": sync_type,
"data_types": data_types,
"estimated_duration": "5-10 minutes"
}
except Exception as e:
logger.error("Failed to trigger sync", error=str(e),
tenant_id=tenant_id, config_id=config_id)
raise HTTPException(status_code=500, detail=f"Failed to trigger sync: {str(e)}")
@router.get("/tenants/{tenant_id}/pos/configurations/{config_id}/sync/status")
async def get_sync_status(
tenant_id: UUID = Path(..., description="Tenant ID"),
config_id: UUID = Path(..., description="Configuration ID"),
limit: int = Query(10, ge=1, le=100, description="Number of sync logs to return"),
current_tenant: str = Depends(get_current_tenant_id_dep),
db=Depends(get_db)
):
"""Get synchronization status and recent sync history"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
# TODO: Get sync status from database
# TODO: Get recent sync logs
return {
"current_sync": None,
"last_successful_sync": None,
"recent_syncs": [],
"sync_health": {
"status": "healthy",
"success_rate": 95.5,
"average_duration_minutes": 3.2,
"last_error": None
}
}
except Exception as e:
logger.error("Failed to get sync status", error=str(e),
tenant_id=tenant_id, config_id=config_id)
raise HTTPException(status_code=500, detail=f"Failed to get sync status: {str(e)}")
@router.get("/tenants/{tenant_id}/pos/configurations/{config_id}/sync/logs")
async def get_sync_logs(
tenant_id: UUID = Path(..., description="Tenant ID"),
config_id: UUID = Path(..., description="Configuration ID"),
limit: int = Query(50, ge=1, le=200, description="Number of logs to return"),
offset: int = Query(0, ge=0, description="Number of logs to skip"),
status: Optional[str] = Query(None, description="Filter by sync status"),
sync_type: Optional[str] = Query(None, description="Filter by sync type"),
data_type: Optional[str] = Query(None, description="Filter by data type"),
current_tenant: str = Depends(get_current_tenant_id_dep),
db=Depends(get_db)
):
"""Get detailed sync logs"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
# TODO: Implement log retrieval with filters
return {
"logs": [],
"total": 0,
"has_more": False
}
except Exception as e:
logger.error("Failed to get sync logs", error=str(e),
tenant_id=tenant_id, config_id=config_id)
raise HTTPException(status_code=500, detail=f"Failed to get sync logs: {str(e)}")
@router.get("/tenants/{tenant_id}/pos/transactions")
async def get_pos_transactions(
tenant_id: UUID = Path(..., description="Tenant ID"),
pos_system: Optional[str] = Query(None, description="Filter by POS system"),
start_date: Optional[datetime] = Query(None, description="Start date filter"),
end_date: Optional[datetime] = Query(None, description="End date filter"),
status: Optional[str] = Query(None, description="Filter by transaction status"),
is_synced: Optional[bool] = Query(None, description="Filter by sync status"),
limit: int = Query(50, ge=1, le=200, description="Number of transactions to return"),
offset: int = Query(0, ge=0, description="Number of transactions to skip"),
current_tenant: str = Depends(get_current_tenant_id_dep),
db=Depends(get_db)
):
"""Get POS transactions for a tenant"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
# TODO: Implement transaction retrieval with filters
return {
"transactions": [],
"total": 0,
"has_more": False,
"summary": {
"total_amount": 0,
"transaction_count": 0,
"sync_status": {
"synced": 0,
"pending": 0,
"failed": 0
}
}
}
except Exception as e:
logger.error("Failed to get POS transactions", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to get POS transactions: {str(e)}")
@router.post("/tenants/{tenant_id}/pos/transactions/{transaction_id}/sync")
async def sync_single_transaction(
tenant_id: UUID = Path(..., description="Tenant ID"),
transaction_id: UUID = Path(..., description="Transaction ID"),
force: bool = Query(False, description="Force sync even if already synced"),
current_tenant: str = Depends(get_current_tenant_id_dep),
db=Depends(get_db)
):
"""Manually sync a single transaction to sales service"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
# TODO: Implement single transaction sync
return {
"message": "Transaction sync completed",
"transaction_id": str(transaction_id),
"sync_status": "success",
"sales_record_id": "placeholder"
}
except Exception as e:
logger.error("Failed to sync transaction", error=str(e),
tenant_id=tenant_id, transaction_id=transaction_id)
raise HTTPException(status_code=500, detail=f"Failed to sync transaction: {str(e)}")
@router.get("/tenants/{tenant_id}/pos/analytics/sync-performance")
async def get_sync_analytics(
tenant_id: UUID = Path(..., description="Tenant ID"),
days: int = Query(30, ge=1, le=365, description="Number of days to analyze"),
current_tenant: str = Depends(get_current_tenant_id_dep),
db=Depends(get_db)
):
"""Get sync performance analytics"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
# TODO: Implement analytics calculation
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 sync analytics: {str(e)}")
@router.post("/tenants/{tenant_id}/pos/data/resync")
async def resync_failed_transactions(
tenant_id: UUID = Path(..., description="Tenant ID"),
days_back: int = Query(7, ge=1, le=90, description="How many days back to resync"),
current_tenant: str = Depends(get_current_tenant_id_dep),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""Resync failed transactions from the specified time period"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
logger.info("Resync failed transactions requested",
tenant_id=tenant_id,
days_back=days_back,
user_id=current_user.get("user_id"))
# TODO: Implement failed transaction resync
return {
"message": "Resync job queued successfully",
"job_id": "placeholder-resync-job-id",
"scope": f"Failed transactions from last {days_back} days",
"estimated_transactions": 0
}
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)}")

View File

@@ -0,0 +1,179 @@
# services/pos/app/api/webhooks.py
"""
POS Webhook API Endpoints
Handles incoming webhooks from POS systems
"""
from fastapi import APIRouter, Request, HTTPException, Header, Path
from typing import Optional, Dict, Any
import structlog
import json
from datetime import datetime
from app.core.database import get_db
router = APIRouter(tags=["webhooks"])
logger = structlog.get_logger()
@router.post("/webhooks/{pos_system}")
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)
):
"""
Receive webhooks from POS systems
Supports Square, Toast, and Lightspeed webhook formats
"""
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
# Log webhook receipt
logger.info("Webhook received",
pos_system=pos_system,
method=method,
url_path=url_path,
payload_size=payload_size,
client_ip=client_ip,
has_signature=bool(signature),
content_type=content_type)
# TODO: Store webhook log in database
# TODO: Verify webhook signature
# TODO: Extract tenant_id from payload
# TODO: Process webhook based on POS system type
# TODO: Queue for async processing if needed
# Parse webhook type based on POS system
webhook_type = None
event_id = None
if parsed_payload:
if pos_system.lower() == "square":
webhook_type = parsed_payload.get("type")
event_id = parsed_payload.get("event_id")
elif pos_system.lower() == "toast":
webhook_type = parsed_payload.get("eventType")
event_id = parsed_payload.get("guid")
elif pos_system.lower() == "lightspeed":
webhook_type = parsed_payload.get("action")
event_id = parsed_payload.get("id")
logger.info("Webhook processed successfully",
pos_system=pos_system,
webhook_type=webhook_type,
event_id=event_id)
# Return appropriate response based on POS system requirements
if pos_system.lower() == "square":
return {"status": "success"}
elif pos_system.lower() == "toast":
return {"success": True}
elif pos_system.lower() == "lightspeed":
return {"received": True}
else:
return {"status": "received"}
except HTTPException:
raise
except Exception as e:
logger.error("Webhook processing failed",
error=str(e),
pos_system=pos_system)
# Return 500 to trigger POS system retry
raise HTTPException(status_code=500, detail="Webhook processing failed")
@router.get("/webhooks/{pos_system}/status")
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, # TODO: Get from database
"total_received": 0 # TODO: Get from database
}
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"
}

View File

@@ -0,0 +1 @@
# Core configuration and utilities

View File

@@ -0,0 +1,179 @@
# services/pos/app/core/config.py
"""
POS Integration Service Configuration
"""
from typing import List, Optional
from pydantic import Field
from shared.config.base import BaseServiceSettings
class Settings(BaseServiceSettings):
"""POS Integration service settings extending base configuration"""
# Override service-specific settings
SERVICE_NAME: str = "pos-service"
VERSION: str = "1.0.0"
APP_NAME: str = "Bakery POS Integration Service"
DESCRIPTION: str = "Integration service for external POS systems (Square, Toast, Lightspeed)"
# API Configuration
API_V1_STR: str = "/api/v1"
# Override database URL to use POS_DATABASE_URL
DATABASE_URL: str = Field(
default="postgresql+asyncpg://pos_user:pos_pass123@pos-db:5432/pos_db",
env="POS_DATABASE_URL"
)
# POS-specific Redis database
REDIS_DB: int = Field(default=5, env="POS_REDIS_DB")
# ================================================================
# POS PROVIDER CONFIGURATIONS
# ================================================================
# Square POS Configuration
SQUARE_APPLICATION_ID: Optional[str] = Field(default=None, env="SQUARE_APPLICATION_ID")
SQUARE_ACCESS_TOKEN: Optional[str] = Field(default=None, env="SQUARE_ACCESS_TOKEN")
SQUARE_WEBHOOK_SIGNATURE_KEY: Optional[str] = Field(default=None, env="SQUARE_WEBHOOK_SIGNATURE_KEY")
SQUARE_ENVIRONMENT: str = Field(default="sandbox", env="SQUARE_ENVIRONMENT") # sandbox or production
SQUARE_BASE_URL: str = "https://connect.squareup.com"
SQUARE_SANDBOX_URL: str = "https://connect.squareupsandbox.com"
@property
def SQUARE_API_URL(self) -> str:
return self.SQUARE_SANDBOX_URL if self.SQUARE_ENVIRONMENT == "sandbox" else self.SQUARE_BASE_URL
# Toast POS Configuration
TOAST_CLIENT_ID: Optional[str] = Field(default=None, env="TOAST_CLIENT_ID")
TOAST_CLIENT_SECRET: Optional[str] = Field(default=None, env="TOAST_CLIENT_SECRET")
TOAST_WEBHOOK_SECRET: Optional[str] = Field(default=None, env="TOAST_WEBHOOK_SECRET")
TOAST_ENVIRONMENT: str = Field(default="sandbox", env="TOAST_ENVIRONMENT") # sandbox or production
TOAST_BASE_URL: str = "https://ws-api.toasttab.com"
TOAST_SANDBOX_URL: str = "https://ws-sandbox-api.toasttab.com"
@property
def TOAST_API_URL(self) -> str:
return self.TOAST_SANDBOX_URL if self.TOAST_ENVIRONMENT == "sandbox" else self.TOAST_BASE_URL
# Lightspeed POS Configuration
LIGHTSPEED_CLIENT_ID: Optional[str] = Field(default=None, env="LIGHTSPEED_CLIENT_ID")
LIGHTSPEED_CLIENT_SECRET: Optional[str] = Field(default=None, env="LIGHTSPEED_CLIENT_SECRET")
LIGHTSPEED_WEBHOOK_SECRET: Optional[str] = Field(default=None, env="LIGHTSPEED_WEBHOOK_SECRET")
LIGHTSPEED_CLUSTER_ID: Optional[str] = Field(default=None, env="LIGHTSPEED_CLUSTER_ID")
LIGHTSPEED_BASE_URL: str = "https://api-{cluster}.lightspeedhq.com"
def get_lightspeed_api_url(self, cluster_id: Optional[str] = None) -> str:
cluster = cluster_id or self.LIGHTSPEED_CLUSTER_ID or "us1"
return self.LIGHTSPEED_BASE_URL.format(cluster=cluster)
# ================================================================
# WEBHOOK CONFIGURATION
# ================================================================
# Webhook Base Configuration
WEBHOOK_BASE_URL: str = Field(default="https://your-domain.com", env="WEBHOOK_BASE_URL")
WEBHOOK_SECRET: str = Field(default="your-webhook-secret", env="WEBHOOK_SECRET")
WEBHOOK_TIMEOUT_SECONDS: int = Field(default=30, env="WEBHOOK_TIMEOUT_SECONDS")
# Webhook Rate Limiting
WEBHOOK_RATE_LIMIT_PER_MINUTE: int = Field(default=1000, env="WEBHOOK_RATE_LIMIT_PER_MINUTE")
WEBHOOK_BURST_LIMIT: int = Field(default=100, env="WEBHOOK_BURST_LIMIT")
# Webhook Retry Configuration
WEBHOOK_MAX_RETRIES: int = Field(default=3, env="WEBHOOK_MAX_RETRIES")
WEBHOOK_RETRY_DELAY_SECONDS: int = Field(default=5, env="WEBHOOK_RETRY_DELAY_SECONDS")
# ================================================================
# SYNC CONFIGURATION
# ================================================================
# Data Synchronization Settings
SYNC_ENABLED: bool = Field(default=True, env="POS_SYNC_ENABLED")
SYNC_INTERVAL_SECONDS: int = Field(default=300, env="POS_SYNC_INTERVAL_SECONDS") # 5 minutes
SYNC_BATCH_SIZE: int = Field(default=100, env="POS_SYNC_BATCH_SIZE")
SYNC_MAX_RETRY_ATTEMPTS: int = Field(default=3, env="POS_SYNC_MAX_RETRY_ATTEMPTS")
SYNC_RETRY_DELAY_SECONDS: int = Field(default=60, env="POS_SYNC_RETRY_DELAY_SECONDS")
# Historical Data Sync
HISTORICAL_SYNC_DAYS: int = Field(default=30, env="POS_HISTORICAL_SYNC_DAYS")
INITIAL_SYNC_BATCH_SIZE: int = Field(default=50, env="POS_INITIAL_SYNC_BATCH_SIZE")
# ================================================================
# SECURITY & ENCRYPTION
# ================================================================
# API Credential Encryption
ENCRYPTION_KEY: Optional[str] = Field(default=None, env="POS_ENCRYPTION_KEY")
CREDENTIALS_ENCRYPTION_ENABLED: bool = Field(default=True, env="POS_CREDENTIALS_ENCRYPTION_ENABLED")
# API Rate Limiting
API_RATE_LIMIT_PER_MINUTE: int = Field(default=60, env="POS_API_RATE_LIMIT_PER_MINUTE")
API_BURST_LIMIT: int = Field(default=10, env="POS_API_BURST_LIMIT")
# ================================================================
# CACHING CONFIGURATION
# ================================================================
# POS Data Cache TTL
POS_CONFIG_CACHE_TTL: int = Field(default=3600, env="POS_CONFIG_CACHE_TTL") # 1 hour
POS_TRANSACTION_CACHE_TTL: int = Field(default=300, env="POS_TRANSACTION_CACHE_TTL") # 5 minutes
POS_PRODUCT_CACHE_TTL: int = Field(default=1800, env="POS_PRODUCT_CACHE_TTL") # 30 minutes
# ================================================================
# SUPPORTED POS SYSTEMS
# ================================================================
SUPPORTED_POS_SYSTEMS: List[str] = ["square", "toast", "lightspeed"]
# Default POS system for new tenants
DEFAULT_POS_SYSTEM: str = Field(default="square", env="DEFAULT_POS_SYSTEM")
# ================================================================
# INTER-SERVICE COMMUNICATION
# ================================================================
# Override service URLs
SALES_SERVICE_URL: str = Field(
default="http://sales-service:8000",
env="SALES_SERVICE_URL"
)
INVENTORY_SERVICE_URL: str = Field(
default="http://inventory-service:8000",
env="INVENTORY_SERVICE_URL"
)
# ================================================================
# BUSINESS RULES
# ================================================================
# Transaction Processing
MIN_TRANSACTION_AMOUNT: float = Field(default=0.01, env="POS_MIN_TRANSACTION_AMOUNT")
MAX_TRANSACTION_AMOUNT: float = Field(default=10000.0, env="POS_MAX_TRANSACTION_AMOUNT")
# Duplicate Detection Window (in minutes)
DUPLICATE_DETECTION_WINDOW: int = Field(default=5, env="POS_DUPLICATE_DETECTION_WINDOW")
# Data Retention
TRANSACTION_RETENTION_DAYS: int = Field(default=1095, env="POS_TRANSACTION_RETENTION_DAYS") # 3 years
WEBHOOK_LOG_RETENTION_DAYS: int = Field(default=30, env="POS_WEBHOOK_LOG_RETENTION_DAYS")
SYNC_LOG_RETENTION_DAYS: int = Field(default=90, env="POS_SYNC_LOG_RETENTION_DAYS")
# ================================================================
# MONITORING & ALERTING
# ================================================================
# Health Check Configuration
POS_HEALTH_CHECK_ENABLED: bool = Field(default=True, env="POS_HEALTH_CHECK_ENABLED")
POS_HEALTH_CHECK_INTERVAL: int = Field(default=60, env="POS_HEALTH_CHECK_INTERVAL") # seconds
# Alert Thresholds
WEBHOOK_FAILURE_THRESHOLD: int = Field(default=5, env="POS_WEBHOOK_FAILURE_THRESHOLD")
SYNC_FAILURE_THRESHOLD: int = Field(default=3, env="POS_SYNC_FAILURE_THRESHOLD")
API_ERROR_THRESHOLD: int = Field(default=10, env="POS_API_ERROR_THRESHOLD")
# Global settings instance
settings = Settings()

View File

@@ -0,0 +1,85 @@
# services/pos/app/core/database.py
"""
POS Integration Service Database Configuration using shared database manager
"""
import structlog
from contextlib import asynccontextmanager
from app.core.config import settings
from shared.database.base import DatabaseManager, Base
logger = structlog.get_logger()
# Create database manager instance
database_manager = DatabaseManager(
database_url=settings.DATABASE_URL,
service_name="pos-service",
pool_size=settings.DB_POOL_SIZE,
max_overflow=settings.DB_MAX_OVERFLOW,
pool_recycle=settings.DB_POOL_RECYCLE,
echo=settings.DB_ECHO
)
async def get_db():
"""
Database dependency for FastAPI - using shared database manager
"""
async for session in database_manager.get_db():
yield session
async def init_db():
"""Initialize database tables using shared database manager"""
try:
logger.info("Initializing POS Integration Service database...")
# Import all models to ensure they're registered
from app.models import pos_config, pos_transaction, pos_webhook, pos_sync # noqa: F401
# Create all tables using database manager
await database_manager.create_tables(Base.metadata)
logger.info("POS Integration Service database initialized successfully")
except Exception as e:
logger.error("Failed to initialize database", error=str(e))
raise
async def close_db():
"""Close database connections using shared database manager"""
try:
await database_manager.close_connections()
logger.info("Database connections closed")
except Exception as e:
logger.error("Error closing database connections", error=str(e))
@asynccontextmanager
async def get_db_transaction():
"""
Context manager for database transactions using shared database manager
"""
async with database_manager.get_session() as session:
try:
async with session.begin():
yield session
except Exception as e:
logger.error("Transaction error", error=str(e))
raise
@asynccontextmanager
async def get_background_session():
"""
Context manager for background tasks using shared database manager
"""
async with database_manager.get_background_session() as session:
yield session
async def health_check():
"""Database health check using shared database manager"""
return await database_manager.health_check()

View File

@@ -0,0 +1 @@
# POS Integration providers

View File

@@ -0,0 +1,365 @@
# services/pos/app/integrations/base_pos_client.py
"""
Base POS Client
Abstract base class for all POS system integrations
"""
from abc import ABC, abstractmethod
from typing import Dict, List, Optional, Any, Tuple
from datetime import datetime
from dataclasses import dataclass
import structlog
logger = structlog.get_logger()
@dataclass
class POSCredentials:
"""POS system credentials"""
pos_system: str
environment: str
api_key: Optional[str] = None
api_secret: Optional[str] = None
access_token: Optional[str] = None
application_id: Optional[str] = None
merchant_id: Optional[str] = None
location_id: Optional[str] = None
webhook_secret: Optional[str] = None
additional_params: Optional[Dict[str, Any]] = None
@dataclass
class POSTransaction:
"""Standardized POS transaction"""
external_id: str
transaction_type: str
status: str
total_amount: float
subtotal: float
tax_amount: float
tip_amount: float
discount_amount: float
currency: str
transaction_date: datetime
payment_method: Optional[str] = None
payment_status: Optional[str] = None
location_id: Optional[str] = None
location_name: Optional[str] = None
staff_id: Optional[str] = None
staff_name: Optional[str] = None
customer_id: Optional[str] = None
customer_email: Optional[str] = None
order_type: Optional[str] = None
table_number: Optional[str] = None
receipt_number: Optional[str] = None
external_order_id: Optional[str] = None
items: List['POSTransactionItem']
raw_data: Dict[str, Any]
@dataclass
class POSTransactionItem:
"""Standardized POS transaction item"""
external_id: Optional[str]
sku: Optional[str]
name: str
category: Optional[str]
quantity: float
unit_price: float
total_price: float
discount_amount: float
tax_amount: float
modifiers: Optional[Dict[str, Any]] = None
raw_data: Optional[Dict[str, Any]] = None
@dataclass
class POSProduct:
"""Standardized POS product"""
external_id: str
name: str
sku: Optional[str]
category: Optional[str]
subcategory: Optional[str]
price: float
description: Optional[str]
is_active: bool
raw_data: Dict[str, Any]
@dataclass
class SyncResult:
"""Result of a sync operation"""
success: bool
records_processed: int
records_created: int
records_updated: int
records_skipped: int
records_failed: int
errors: List[str]
warnings: List[str]
duration_seconds: float
api_calls_made: int
class POSClientError(Exception):
"""Base exception for POS client errors"""
pass
class POSAuthenticationError(POSClientError):
"""Authentication failed"""
pass
class POSRateLimitError(POSClientError):
"""Rate limit exceeded"""
pass
class POSConnectionError(POSClientError):
"""Connection to POS system failed"""
pass
class BasePOSClient(ABC):
"""
Abstract base class for POS system integrations
Provides common interface for all POS providers:
- Square, Toast, Lightspeed, etc.
"""
def __init__(self, credentials: POSCredentials):
self.credentials = credentials
self.pos_system = credentials.pos_system
self.logger = logger.bind(pos_system=self.pos_system)
@abstractmethod
async def test_connection(self) -> Tuple[bool, str]:
"""
Test connection to POS system
Returns:
Tuple of (success: bool, message: str)
"""
pass
@abstractmethod
async def get_transactions(
self,
start_date: datetime,
end_date: datetime,
location_id: Optional[str] = None,
limit: int = 100,
cursor: Optional[str] = None
) -> Tuple[List[POSTransaction], Optional[str]]:
"""
Get transactions from POS system
Args:
start_date: Start date for transaction query
end_date: End date for transaction query
location_id: Optional location filter
limit: Maximum number of records to return
cursor: Pagination cursor for next page
Returns:
Tuple of (transactions: List[POSTransaction], next_cursor: Optional[str])
"""
pass
@abstractmethod
async def get_transaction(self, transaction_id: str) -> Optional[POSTransaction]:
"""
Get a specific transaction by ID
Args:
transaction_id: External transaction ID
Returns:
POSTransaction if found, None otherwise
"""
pass
@abstractmethod
async def get_products(
self,
location_id: Optional[str] = None,
limit: int = 100,
cursor: Optional[str] = None
) -> Tuple[List[POSProduct], Optional[str]]:
"""
Get products/menu items from POS system
Args:
location_id: Optional location filter
limit: Maximum number of records to return
cursor: Pagination cursor for next page
Returns:
Tuple of (products: List[POSProduct], next_cursor: Optional[str])
"""
pass
@abstractmethod
def verify_webhook_signature(self, payload: bytes, signature: str) -> bool:
"""
Verify webhook signature
Args:
payload: Raw webhook payload
signature: Signature from webhook headers
Returns:
True if signature is valid
"""
pass
@abstractmethod
def parse_webhook_payload(self, payload: Dict[str, Any]) -> Optional[POSTransaction]:
"""
Parse webhook payload into standardized transaction
Args:
payload: Webhook payload
Returns:
POSTransaction if parseable, None otherwise
"""
pass
@abstractmethod
def get_webhook_events(self) -> List[str]:
"""
Get list of supported webhook events
Returns:
List of supported event types
"""
pass
@abstractmethod
def get_rate_limits(self) -> Dict[str, Any]:
"""
Get rate limit information
Returns:
Dictionary with rate limit details
"""
pass
# Common utility methods
def get_pos_system(self) -> str:
"""Get POS system identifier"""
return self.pos_system
def get_environment(self) -> str:
"""Get environment (sandbox/production)"""
return self.credentials.environment
def is_production(self) -> bool:
"""Check if running in production environment"""
return self.credentials.environment.lower() == "production"
def log_api_call(self, method: str, endpoint: str, status_code: int, duration_ms: int):
"""Log API call for monitoring"""
self.logger.info(
"POS API call",
method=method,
endpoint=endpoint,
status_code=status_code,
duration_ms=duration_ms,
environment=self.get_environment()
)
def log_error(self, error: Exception, context: str):
"""Log error with context"""
self.logger.error(
f"POS client error: {context}",
error=str(error),
error_type=type(error).__name__,
pos_system=self.pos_system
)
async def sync_transactions(
self,
start_date: datetime,
end_date: datetime,
location_id: Optional[str] = None,
batch_size: int = 100
) -> SyncResult:
"""
Sync transactions from POS system with error handling and batching
Args:
start_date: Start date for sync
end_date: End date for sync
location_id: Optional location filter
batch_size: Number of records per batch
Returns:
SyncResult with operation details
"""
start_time = datetime.utcnow()
result = SyncResult(
success=False,
records_processed=0,
records_created=0,
records_updated=0,
records_skipped=0,
records_failed=0,
errors=[],
warnings=[],
duration_seconds=0,
api_calls_made=0
)
try:
cursor = None
while True:
try:
transactions, next_cursor = await self.get_transactions(
start_date=start_date,
end_date=end_date,
location_id=location_id,
limit=batch_size,
cursor=cursor
)
result.api_calls_made += 1
result.records_processed += len(transactions)
if not transactions:
break
# Process transactions would be implemented by the service layer
self.logger.info(
"Synced transaction batch",
batch_size=len(transactions),
total_processed=result.records_processed
)
cursor = next_cursor
if not cursor:
break
except Exception as e:
result.errors.append(f"Batch sync error: {str(e)}")
result.records_failed += batch_size
self.log_error(e, "Transaction sync batch")
break
result.success = len(result.errors) == 0
except Exception as e:
result.errors.append(f"Sync operation failed: {str(e)}")
self.log_error(e, "Transaction sync operation")
finally:
end_time = datetime.utcnow()
result.duration_seconds = (end_time - start_time).total_seconds()
return result

View File

@@ -0,0 +1,463 @@
# services/pos/app/integrations/square_client.py
"""
Square POS Client
Integration with Square Point of Sale API
"""
import hashlib
import hmac
import json
from typing import Dict, List, Optional, Any, Tuple
from datetime import datetime
import asyncio
import httpx
import structlog
from .base_pos_client import (
BasePOSClient,
POSCredentials,
POSTransaction,
POSTransactionItem,
POSProduct,
POSClientError,
POSAuthenticationError,
POSRateLimitError,
POSConnectionError
)
logger = structlog.get_logger()
class SquarePOSClient(BasePOSClient):
"""Square POS API client implementation"""
def __init__(self, credentials: POSCredentials):
super().__init__(credentials)
self.base_url = self._get_base_url()
self.application_id = credentials.application_id
self.access_token = credentials.access_token
self.webhook_secret = credentials.webhook_secret
self.location_id = credentials.location_id
if not self.access_token:
raise POSAuthenticationError("Square access token is required")
def _get_base_url(self) -> str:
"""Get Square API base URL based on environment"""
if self.credentials.environment.lower() == "production":
return "https://connect.squareup.com"
else:
return "https://connect.squareupsandbox.com"
def _get_headers(self) -> Dict[str, str]:
"""Get headers for Square API requests"""
headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
if self.application_id:
headers["Square-Version"] = "2024-01-18" # Use latest API version
return headers
async def _make_request(
self,
method: str,
endpoint: str,
data: Optional[Dict] = None,
params: Optional[Dict] = None
) -> Dict[str, Any]:
"""Make HTTP request to Square API with error handling"""
url = f"{self.base_url}{endpoint}"
headers = self._get_headers()
start_time = datetime.utcnow()
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.request(
method=method,
url=url,
headers=headers,
json=data,
params=params
)
duration_ms = int((datetime.utcnow() - start_time).total_seconds() * 1000)
self.log_api_call(method, endpoint, response.status_code, duration_ms)
if response.status_code == 401:
raise POSAuthenticationError("Invalid Square access token")
elif response.status_code == 429:
raise POSRateLimitError("Square API rate limit exceeded")
elif response.status_code >= 400:
error_text = response.text
raise POSClientError(f"Square API error {response.status_code}: {error_text}")
return response.json()
except httpx.TimeoutException:
raise POSConnectionError("Timeout connecting to Square API")
except httpx.ConnectError:
raise POSConnectionError("Failed to connect to Square API")
async def test_connection(self) -> Tuple[bool, str]:
"""Test connection to Square API"""
try:
# Try to get location info
response = await self._make_request("GET", "/v2/locations")
locations = response.get("locations", [])
if locations:
return True, f"Connected successfully. Found {len(locations)} location(s)."
else:
return False, "Connected but no locations found"
except POSAuthenticationError:
return False, "Authentication failed - invalid access token"
except POSRateLimitError:
return False, "Rate limit exceeded"
except POSConnectionError as e:
return False, f"Connection failed: {str(e)}"
except Exception as e:
return False, f"Test failed: {str(e)}"
async def get_transactions(
self,
start_date: datetime,
end_date: datetime,
location_id: Optional[str] = None,
limit: int = 100,
cursor: Optional[str] = None
) -> Tuple[List[POSTransaction], Optional[str]]:
"""Get transactions from Square API"""
# Use provided location_id or fall back to configured one
target_location = location_id or self.location_id
if not target_location:
# Get first available location
locations_response = await self._make_request("GET", "/v2/locations")
locations = locations_response.get("locations", [])
if not locations:
return [], None
target_location = locations[0]["id"]
# Build query parameters
query = {
"location_ids": [target_location],
"begin_time": start_date.isoformat() + "Z",
"end_time": end_date.isoformat() + "Z",
"limit": min(limit, 200), # Square max is 200
}
if cursor:
query["cursor"] = cursor
try:
response = await self._make_request("POST", "/v2/orders/search", data={"query": query})
orders = response.get("orders", [])
transactions = []
for order in orders:
transaction = self._parse_square_order(order)
if transaction:
transactions.append(transaction)
next_cursor = response.get("cursor")
return transactions, next_cursor
except Exception as e:
self.log_error(e, "Getting transactions")
raise
async def get_transaction(self, transaction_id: str) -> Optional[POSTransaction]:
"""Get specific transaction by ID"""
try:
response = await self._make_request("GET", f"/v2/orders/{transaction_id}")
order = response.get("order")
if order:
return self._parse_square_order(order)
return None
except Exception as e:
self.log_error(e, f"Getting transaction {transaction_id}")
return None
def _parse_square_order(self, order: Dict[str, Any]) -> Optional[POSTransaction]:
"""Parse Square order into standardized transaction"""
try:
# Extract basic transaction info
external_id = order.get("id", "")
state = order.get("state", "")
# Map Square states to our standard states
status_map = {
"COMPLETED": "completed",
"CANCELED": "voided",
"DRAFT": "pending",
"OPEN": "pending"
}
status = status_map.get(state, "pending")
# Parse amounts (Square uses smallest currency unit, e.g., cents)
total_money = order.get("total_money", {})
total_amount = float(total_money.get("amount", 0)) / 100.0
base_price_money = order.get("base_price_money", {})
subtotal = float(base_price_money.get("amount", 0)) / 100.0
total_tax_money = order.get("total_tax_money", {})
tax_amount = float(total_tax_money.get("amount", 0)) / 100.0
total_tip_money = order.get("total_tip_money", {})
tip_amount = float(total_tip_money.get("amount", 0)) / 100.0
total_discount_money = order.get("total_discount_money", {})
discount_amount = float(total_discount_money.get("amount", 0)) / 100.0
currency = total_money.get("currency", "USD")
# Parse timestamps
created_at = order.get("created_at")
transaction_date = datetime.fromisoformat(created_at.replace("Z", "+00:00")) if created_at else datetime.utcnow()
# Parse location info
location_id = order.get("location_id")
# Parse line items
items = []
line_items = order.get("line_items", [])
for line_item in line_items:
item = self._parse_square_line_item(line_item)
if item:
items.append(item)
# Parse payments for payment method
payment_method = None
tenders = order.get("tenders", [])
if tenders:
payment_method = tenders[0].get("type", "").lower()
# Create transaction
transaction = POSTransaction(
external_id=external_id,
transaction_type="sale", # Square orders are typically sales
status=status,
total_amount=total_amount,
subtotal=subtotal,
tax_amount=tax_amount,
tip_amount=tip_amount,
discount_amount=discount_amount,
currency=currency,
transaction_date=transaction_date,
payment_method=payment_method,
payment_status="paid" if status == "completed" else "pending",
location_id=location_id,
items=items,
raw_data=order
)
return transaction
except Exception as e:
self.log_error(e, f"Parsing Square order {order.get('id', 'unknown')}")
return None
def _parse_square_line_item(self, line_item: Dict[str, Any]) -> Optional[POSTransactionItem]:
"""Parse Square line item into standardized transaction item"""
try:
name = line_item.get("name", "Unknown Item")
quantity = float(line_item.get("quantity", "1"))
# Parse pricing
item_total_money = line_item.get("item_total_money", {})
total_price = float(item_total_money.get("amount", 0)) / 100.0
unit_price = total_price / quantity if quantity > 0 else 0
# Parse variations for SKU
variation = line_item.get("catalog_object_id")
sku = variation if variation else None
# Parse category from item data
item_data = line_item.get("item_data", {})
category = item_data.get("category_name")
# Parse modifiers
modifiers_data = line_item.get("modifiers", [])
modifiers = {}
for modifier in modifiers_data:
mod_name = modifier.get("name", "")
mod_price = float(modifier.get("total_price_money", {}).get("amount", 0)) / 100.0
modifiers[mod_name] = mod_price
item = POSTransactionItem(
external_id=line_item.get("uid"),
sku=sku,
name=name,
category=category,
quantity=quantity,
unit_price=unit_price,
total_price=total_price,
discount_amount=0, # Square handles discounts at order level
tax_amount=0, # Square handles taxes at order level
modifiers=modifiers if modifiers else None,
raw_data=line_item
)
return item
except Exception as e:
self.log_error(e, f"Parsing Square line item {line_item.get('uid', 'unknown')}")
return None
async def get_products(
self,
location_id: Optional[str] = None,
limit: int = 100,
cursor: Optional[str] = None
) -> Tuple[List[POSProduct], Optional[str]]:
"""Get products from Square Catalog API"""
query_params = {
"types": "ITEM",
"limit": min(limit, 1000) # Square catalog max
}
if cursor:
query_params["cursor"] = cursor
try:
response = await self._make_request("GET", "/v2/catalog/list", params=query_params)
objects = response.get("objects", [])
products = []
for obj in objects:
product = self._parse_square_catalog_item(obj)
if product:
products.append(product)
next_cursor = response.get("cursor")
return products, next_cursor
except Exception as e:
self.log_error(e, "Getting products")
raise
def _parse_square_catalog_item(self, catalog_object: Dict[str, Any]) -> Optional[POSProduct]:
"""Parse Square catalog item into standardized product"""
try:
item_data = catalog_object.get("item_data", {})
external_id = catalog_object.get("id", "")
name = item_data.get("name", "Unknown Product")
description = item_data.get("description")
category = item_data.get("category_name")
is_active = not catalog_object.get("is_deleted", False)
# Get price from first variation
variations = item_data.get("variations", [])
price = 0.0
sku = None
if variations:
first_variation = variations[0]
variation_data = first_variation.get("item_variation_data", {})
price_money = variation_data.get("price_money", {})
price = float(price_money.get("amount", 0)) / 100.0
sku = variation_data.get("sku")
product = POSProduct(
external_id=external_id,
name=name,
sku=sku,
category=category,
subcategory=None,
price=price,
description=description,
is_active=is_active,
raw_data=catalog_object
)
return product
except Exception as e:
self.log_error(e, f"Parsing Square catalog item {catalog_object.get('id', 'unknown')}")
return None
def verify_webhook_signature(self, payload: bytes, signature: str) -> bool:
"""Verify Square webhook signature"""
if not self.webhook_secret:
self.logger.warning("No webhook secret configured for signature verification")
return True # Allow webhooks without verification if no secret
try:
# Square uses HMAC-SHA256
expected_signature = hmac.new(
self.webhook_secret.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
# Remove any prefix from signature
clean_signature = signature.replace("sha256=", "")
return hmac.compare_digest(expected_signature, clean_signature)
except Exception as e:
self.log_error(e, "Webhook signature verification")
return False
def parse_webhook_payload(self, payload: Dict[str, Any]) -> Optional[POSTransaction]:
"""Parse Square webhook payload"""
try:
event_type = payload.get("type")
# Handle different Square webhook events
if event_type in ["order.created", "order.updated", "order.fulfilled"]:
order_data = payload.get("data", {}).get("object", {}).get("order")
if order_data:
return self._parse_square_order(order_data)
elif event_type in ["payment.created", "payment.updated"]:
# For payment events, we might need to fetch the full order
payment_data = payload.get("data", {}).get("object", {}).get("payment", {})
order_id = payment_data.get("order_id")
if order_id:
# Note: This would require an async call, so this is a simplified version
self.logger.info("Payment webhook received", order_id=order_id, event_type=event_type)
return None
except Exception as e:
self.log_error(e, "Parsing webhook payload")
return None
def get_webhook_events(self) -> List[str]:
"""Get list of supported Square webhook events"""
return [
"order.created",
"order.updated",
"order.fulfilled",
"payment.created",
"payment.updated",
"inventory.count.updated"
]
def get_rate_limits(self) -> Dict[str, Any]:
"""Get Square API rate limit information"""
return {
"requests_per_second": 100,
"daily_limit": 50000,
"burst_limit": 200,
"webhook_limit": 1000
}

140
services/pos/app/main.py Normal file
View File

@@ -0,0 +1,140 @@
"""
POS Integration Service
Handles integration with external POS systems (Square, Toast, Lightspeed)
"""
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import structlog
import time
from contextlib import asynccontextmanager
from app.core.config import settings
from app.api import pos_config, webhooks, sync
from app.core.database import init_db, close_db
from shared.monitoring.health import router as health_router
from shared.monitoring.logging import setup_logging
# Setup logging
setup_logging(service_name="pos-service")
logger = structlog.get_logger()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifecycle management for FastAPI app"""
logger.info("Starting POS Integration Service")
# Startup
try:
# Initialize database connection
logger.info("Initializing database connection")
await init_db()
# Add any startup logic here
logger.info("POS Integration Service started successfully")
yield
except Exception as e:
logger.error("Failed to start POS Integration Service", error=str(e))
raise
finally:
# Shutdown
logger.info("Shutting down POS Integration Service")
await close_db()
# Create FastAPI app
app = FastAPI(
title="POS Integration Service",
description="Handles integration with external POS systems",
version="1.0.0",
docs_url="/docs" if settings.ENVIRONMENT != "production" else None,
redoc_url="/redoc" if settings.ENVIRONMENT != "production" else None,
lifespan=lifespan
)
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Middleware for request logging and timing
@app.middleware("http")
async def log_requests(request: Request, call_next):
start_time = time.time()
# Log request
logger.info(
"Incoming request",
method=request.method,
url=str(request.url),
client_ip=request.client.host if request.client else None
)
response = await call_next(request)
# Log response
process_time = time.time() - start_time
logger.info(
"Request completed",
method=request.method,
url=str(request.url),
status_code=response.status_code,
process_time=f"{process_time:.4f}s"
)
response.headers["X-Process-Time"] = str(process_time)
return response
# Global exception handler
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
logger.error(
"Unhandled exception",
error=str(exc),
method=request.method,
url=str(request.url)
)
return JSONResponse(
status_code=500,
content={"detail": "Internal server error"}
)
# Include routers
app.include_router(health_router, prefix="/health", tags=["health"])
app.include_router(pos_config.router, prefix="/api/v1", tags=["pos-config"])
app.include_router(webhooks.router, prefix="/api/v1", tags=["webhooks"])
app.include_router(sync.router, prefix="/api/v1", tags=["sync"])
@app.get("/")
async def root():
"""Root endpoint"""
return {
"service": "POS Integration Service",
"version": "1.0.0",
"status": "running",
"supported_pos_systems": ["square", "toast", "lightspeed"]
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=True
)

View File

@@ -0,0 +1,16 @@
"""
Database models for POS Integration Service
"""
from .pos_config import POSConfiguration
from .pos_transaction import POSTransaction, POSTransactionItem
from .pos_webhook import POSWebhookLog
from .pos_sync import POSSyncLog
__all__ = [
"POSConfiguration",
"POSTransaction",
"POSTransactionItem",
"POSWebhookLog",
"POSSyncLog"
]

View File

@@ -0,0 +1,83 @@
# services/pos/app/models/pos_config.py
"""
POS Configuration Model
Stores POS system configurations for each tenant
"""
from sqlalchemy import Column, String, DateTime, Boolean, Text, JSON, Index
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func
import uuid
from shared.database.base import Base
class POSConfiguration(Base):
"""
POS system configuration for tenants
Stores encrypted credentials and settings for each POS provider
"""
__tablename__ = "pos_configurations"
# Primary identifiers
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# POS Provider Information
pos_system = Column(String(50), nullable=False) # square, toast, lightspeed
provider_name = Column(String(100), nullable=False) # Display name for the provider
# Configuration Status
is_active = Column(Boolean, default=True, nullable=False)
is_connected = Column(Boolean, default=False, nullable=False)
# Authentication & Credentials (encrypted)
encrypted_credentials = Column(Text, nullable=True) # JSON with encrypted API keys/tokens
webhook_url = Column(String(500), nullable=True)
webhook_secret = Column(String(255), nullable=True)
# Provider-specific Settings
environment = Column(String(20), default="sandbox", nullable=False) # sandbox, production
location_id = Column(String(100), nullable=True) # For multi-location setups
merchant_id = Column(String(100), nullable=True) # Provider merchant ID
# Sync Configuration
sync_enabled = Column(Boolean, default=True, nullable=False)
sync_interval_minutes = Column(String(10), default="5", nullable=False)
auto_sync_products = Column(Boolean, default=True, nullable=False)
auto_sync_transactions = Column(Boolean, default=True, nullable=False)
# Last Sync Information
last_sync_at = Column(DateTime(timezone=True), nullable=True)
last_successful_sync_at = Column(DateTime(timezone=True), nullable=True)
last_sync_status = Column(String(50), nullable=True) # success, failed, partial
last_sync_message = Column(Text, nullable=True)
# Provider-specific Configuration (JSON)
provider_settings = Column(JSON, nullable=True)
# Connection Health
last_health_check_at = Column(DateTime(timezone=True), nullable=True)
health_status = Column(String(50), default="unknown", nullable=False) # healthy, unhealthy, unknown
health_message = Column(Text, nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# Metadata
created_by = Column(UUID(as_uuid=True), nullable=True)
notes = Column(Text, nullable=True)
# Indexes for performance
__table_args__ = (
Index('idx_pos_config_tenant_pos_system', 'tenant_id', 'pos_system'),
Index('idx_pos_config_active', 'is_active'),
Index('idx_pos_config_connected', 'is_connected'),
Index('idx_pos_config_sync_enabled', 'sync_enabled'),
Index('idx_pos_config_health_status', 'health_status'),
Index('idx_pos_config_created_at', 'created_at'),
)
def __repr__(self):
return f"<POSConfiguration(id={self.id}, tenant_id={self.tenant_id}, pos_system='{self.pos_system}', is_active={self.is_active})>"

View File

@@ -0,0 +1,126 @@
# services/pos/app/models/pos_sync.py
"""
POS Sync Log Model
Tracks synchronization operations with POS systems
"""
from sqlalchemy import Column, String, DateTime, Boolean, Integer, Text, JSON, Index, Numeric
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func
import uuid
from shared.database.base import Base
class POSSyncLog(Base):
"""
Log of synchronization operations with POS systems
"""
__tablename__ = "pos_sync_logs"
# Primary identifiers
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
pos_config_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Sync Operation Details
sync_type = Column(String(50), nullable=False, index=True) # full, incremental, manual, webhook_triggered
sync_direction = Column(String(20), nullable=False) # inbound, outbound, bidirectional
data_type = Column(String(50), nullable=False, index=True) # transactions, products, customers, orders
# POS Provider Information
pos_system = Column(String(50), nullable=False, index=True) # square, toast, lightspeed
# Sync Status
status = Column(String(50), nullable=False, default="started", index=True) # started, in_progress, completed, failed, cancelled
# Timing Information
started_at = Column(DateTime(timezone=True), nullable=False, index=True)
completed_at = Column(DateTime(timezone=True), nullable=True)
duration_seconds = Column(Numeric(10, 3), nullable=True)
# Date Range for Sync
sync_from_date = Column(DateTime(timezone=True), nullable=True)
sync_to_date = Column(DateTime(timezone=True), nullable=True)
# Statistics
records_requested = Column(Integer, default=0, nullable=False)
records_processed = Column(Integer, default=0, nullable=False)
records_created = Column(Integer, default=0, nullable=False)
records_updated = Column(Integer, default=0, nullable=False)
records_skipped = Column(Integer, default=0, nullable=False)
records_failed = Column(Integer, default=0, nullable=False)
# API Usage Statistics
api_calls_made = Column(Integer, default=0, nullable=False)
api_rate_limit_hits = Column(Integer, default=0, nullable=False)
total_api_time_ms = Column(Integer, default=0, nullable=False)
# Error Information
error_message = Column(Text, nullable=True)
error_code = Column(String(100), nullable=True)
error_details = Column(JSON, nullable=True)
# Retry Information
retry_attempt = Column(Integer, default=0, nullable=False)
max_retries = Column(Integer, default=3, nullable=False)
parent_sync_id = Column(UUID(as_uuid=True), nullable=True) # Reference to original sync for retries
# Configuration Snapshot
sync_configuration = Column(JSON, nullable=True) # Settings used for this sync
# Progress Tracking
current_page = Column(Integer, nullable=True)
total_pages = Column(Integer, nullable=True)
current_batch = Column(Integer, nullable=True)
total_batches = Column(Integer, nullable=True)
progress_percentage = Column(Numeric(5, 2), nullable=True)
# Data Quality
validation_errors = Column(JSON, nullable=True) # Array of validation issues
data_quality_score = Column(Numeric(5, 2), nullable=True) # 0-100 score
# Performance Metrics
memory_usage_mb = Column(Numeric(10, 2), nullable=True)
cpu_usage_percentage = Column(Numeric(5, 2), nullable=True)
network_bytes_received = Column(Integer, nullable=True)
network_bytes_sent = Column(Integer, nullable=True)
# Business Impact
revenue_synced = Column(Numeric(12, 2), nullable=True) # Total monetary value synced
transactions_synced = Column(Integer, default=0, nullable=False)
# Trigger Information
triggered_by = Column(String(50), nullable=True) # system, user, webhook, schedule
triggered_by_user_id = Column(UUID(as_uuid=True), nullable=True)
trigger_details = Column(JSON, nullable=True)
# External References
external_batch_id = Column(String(255), nullable=True) # POS system's batch/job ID
webhook_log_id = Column(UUID(as_uuid=True), nullable=True) # If triggered by webhook
# Timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# Metadata
notes = Column(Text, nullable=True)
tags = Column(JSON, nullable=True) # Array of tags for categorization
# Indexes for performance
__table_args__ = (
Index('idx_sync_log_tenant_started', 'tenant_id', 'started_at'),
Index('idx_sync_log_pos_system_type', 'pos_system', 'sync_type'),
Index('idx_sync_log_status', 'status'),
Index('idx_sync_log_data_type', 'data_type'),
Index('idx_sync_log_trigger', 'triggered_by'),
Index('idx_sync_log_completed', 'completed_at'),
Index('idx_sync_log_duration', 'duration_seconds'),
Index('idx_sync_log_retry', 'retry_attempt'),
Index('idx_sync_log_parent', 'parent_sync_id'),
Index('idx_sync_log_webhook', 'webhook_log_id'),
Index('idx_sync_log_external_batch', 'external_batch_id'),
)
def __repr__(self):
return f"<POSSyncLog(id={self.id}, pos_system='{self.pos_system}', type='{self.sync_type}', status='{self.status}')>"

View File

@@ -0,0 +1,174 @@
# services/pos/app/models/pos_transaction.py
"""
POS Transaction Models
Stores transaction data from POS systems
"""
from sqlalchemy import Column, String, DateTime, Boolean, Numeric, Integer, Text, JSON, Index, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
import uuid
from shared.database.base import Base
class POSTransaction(Base):
"""
Main transaction record from POS systems
"""
__tablename__ = "pos_transactions"
# Primary identifiers
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
pos_config_id = Column(UUID(as_uuid=True), ForeignKey("pos_configurations.id"), nullable=False, index=True)
# POS Provider Information
pos_system = Column(String(50), nullable=False, index=True) # square, toast, lightspeed
external_transaction_id = Column(String(255), nullable=False, index=True) # POS system's transaction ID
external_order_id = Column(String(255), nullable=True, index=True) # POS system's order ID
# Transaction Details
transaction_type = Column(String(50), nullable=False) # sale, refund, void, exchange
status = Column(String(50), nullable=False) # completed, pending, failed, refunded, voided
# Financial Information
subtotal = Column(Numeric(10, 2), nullable=False)
tax_amount = Column(Numeric(10, 2), default=0, nullable=False)
tip_amount = Column(Numeric(10, 2), default=0, nullable=False)
discount_amount = Column(Numeric(10, 2), default=0, nullable=False)
total_amount = Column(Numeric(10, 2), nullable=False)
currency = Column(String(3), default="EUR", nullable=False)
# Payment Information
payment_method = Column(String(50), nullable=True) # card, cash, digital_wallet, etc.
payment_status = Column(String(50), nullable=True) # paid, pending, failed
# Transaction Timing
transaction_date = Column(DateTime(timezone=True), nullable=False, index=True)
pos_created_at = Column(DateTime(timezone=True), nullable=False) # Original POS timestamp
pos_updated_at = Column(DateTime(timezone=True), nullable=True) # Last update in POS
# Location & Staff
location_id = Column(String(100), nullable=True)
location_name = Column(String(255), nullable=True)
staff_id = Column(String(100), nullable=True)
staff_name = Column(String(255), nullable=True)
# Customer Information
customer_id = Column(String(100), nullable=True)
customer_email = Column(String(255), nullable=True)
customer_phone = Column(String(50), nullable=True)
# Order Context
order_type = Column(String(50), nullable=True) # dine_in, takeout, delivery, pickup
table_number = Column(String(20), nullable=True)
receipt_number = Column(String(100), nullable=True)
# Sync Status
is_synced_to_sales = Column(Boolean, default=False, nullable=False, index=True)
sales_record_id = Column(UUID(as_uuid=True), nullable=True, index=True) # Reference to sales service
sync_attempted_at = Column(DateTime(timezone=True), nullable=True)
sync_completed_at = Column(DateTime(timezone=True), nullable=True)
sync_error = Column(Text, nullable=True)
sync_retry_count = Column(Integer, default=0, nullable=False)
# Raw Data
raw_data = Column(JSON, nullable=True) # Complete raw response from POS
# Processing Status
is_processed = Column(Boolean, default=False, nullable=False)
processing_error = Column(Text, nullable=True)
# Duplicate Detection
is_duplicate = Column(Boolean, default=False, nullable=False)
duplicate_of = Column(UUID(as_uuid=True), nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# Relationships
items = relationship("POSTransactionItem", back_populates="transaction", cascade="all, delete-orphan")
# Indexes for performance
__table_args__ = (
Index('idx_pos_transaction_tenant_date', 'tenant_id', 'transaction_date'),
Index('idx_pos_transaction_external_id', 'pos_system', 'external_transaction_id'),
Index('idx_pos_transaction_sync_status', 'is_synced_to_sales'),
Index('idx_pos_transaction_status', 'status'),
Index('idx_pos_transaction_type', 'transaction_type'),
Index('idx_pos_transaction_processed', 'is_processed'),
Index('idx_pos_transaction_duplicate', 'is_duplicate'),
Index('idx_pos_transaction_location', 'location_id'),
Index('idx_pos_transaction_customer', 'customer_id'),
)
def __repr__(self):
return f"<POSTransaction(id={self.id}, external_id='{self.external_transaction_id}', pos_system='{self.pos_system}', total={self.total_amount})>"
class POSTransactionItem(Base):
"""
Individual items within a POS transaction
"""
__tablename__ = "pos_transaction_items"
# Primary identifiers
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
transaction_id = Column(UUID(as_uuid=True), ForeignKey("pos_transactions.id"), nullable=False, index=True)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# POS Item Information
external_item_id = Column(String(255), nullable=True) # POS system's item ID
sku = Column(String(100), nullable=True, index=True)
# Product Details
product_name = Column(String(255), nullable=False)
product_category = Column(String(100), nullable=True, index=True)
product_subcategory = Column(String(100), nullable=True)
# Quantity & Pricing
quantity = Column(Numeric(10, 3), nullable=False)
unit_price = Column(Numeric(10, 2), nullable=False)
total_price = Column(Numeric(10, 2), nullable=False)
# Discounts & Modifiers
discount_amount = Column(Numeric(10, 2), default=0, nullable=False)
tax_amount = Column(Numeric(10, 2), default=0, nullable=False)
# Modifiers (e.g., extra shot, no foam for coffee)
modifiers = Column(JSON, nullable=True)
# Inventory Mapping
inventory_product_id = Column(UUID(as_uuid=True), nullable=True, index=True) # Mapped to inventory service
is_mapped_to_inventory = Column(Boolean, default=False, nullable=False)
# Sync Status
is_synced_to_sales = Column(Boolean, default=False, nullable=False)
sync_error = Column(Text, nullable=True)
# Raw Data
raw_data = Column(JSON, nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# Relationships
transaction = relationship("POSTransaction", back_populates="items")
# Indexes for performance
__table_args__ = (
Index('idx_pos_item_transaction', 'transaction_id'),
Index('idx_pos_item_product', 'product_name'),
Index('idx_pos_item_category', 'product_category'),
Index('idx_pos_item_sku', 'sku'),
Index('idx_pos_item_inventory', 'inventory_product_id'),
Index('idx_pos_item_sync', 'is_synced_to_sales'),
Index('idx_pos_item_mapped', 'is_mapped_to_inventory'),
)
def __repr__(self):
return f"<POSTransactionItem(id={self.id}, product='{self.product_name}', quantity={self.quantity}, price={self.total_price})>"

View File

@@ -0,0 +1,109 @@
# services/pos/app/models/pos_webhook.py
"""
POS Webhook Log Model
Tracks webhook events from POS systems
"""
from sqlalchemy import Column, String, DateTime, Boolean, Integer, Text, JSON, Index
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func
import uuid
from shared.database.base import Base
class POSWebhookLog(Base):
"""
Log of webhook events received from POS systems
"""
__tablename__ = "pos_webhook_logs"
# Primary identifiers
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
tenant_id = Column(UUID(as_uuid=True), nullable=True, index=True) # May be null until parsed
# POS Provider Information
pos_system = Column(String(50), nullable=False, index=True) # square, toast, lightspeed
webhook_type = Column(String(100), nullable=False, index=True) # payment.created, order.updated, etc.
# Request Information
method = Column(String(10), nullable=False) # POST, PUT, etc.
url_path = Column(String(500), nullable=False)
query_params = Column(JSON, nullable=True)
headers = Column(JSON, nullable=True)
# Payload
raw_payload = Column(Text, nullable=False) # Raw webhook payload
payload_size = Column(Integer, nullable=False, default=0)
content_type = Column(String(100), nullable=True)
# Security
signature = Column(String(500), nullable=True) # Webhook signature for verification
is_signature_valid = Column(Boolean, nullable=True) # null = not checked, true/false = verified
source_ip = Column(String(45), nullable=True) # IPv4 or IPv6
# Processing Status
status = Column(String(50), nullable=False, default="received", index=True) # received, processing, processed, failed
processing_started_at = Column(DateTime(timezone=True), nullable=True)
processing_completed_at = Column(DateTime(timezone=True), nullable=True)
processing_duration_ms = Column(Integer, nullable=True)
# Error Handling
error_message = Column(Text, nullable=True)
error_code = Column(String(50), nullable=True)
retry_count = Column(Integer, default=0, nullable=False)
max_retries = Column(Integer, default=3, nullable=False)
# Response Information
response_status_code = Column(Integer, nullable=True)
response_body = Column(Text, nullable=True)
response_sent_at = Column(DateTime(timezone=True), nullable=True)
# Event Metadata
event_id = Column(String(255), nullable=True, index=True) # POS system's event ID
event_timestamp = Column(DateTime(timezone=True), nullable=True) # When event occurred in POS
sequence_number = Column(Integer, nullable=True) # For ordered events
# Business Data References
transaction_id = Column(String(255), nullable=True, index=True) # Referenced transaction
order_id = Column(String(255), nullable=True, index=True) # Referenced order
customer_id = Column(String(255), nullable=True) # Referenced customer
# Internal References
created_transaction_id = Column(UUID(as_uuid=True), nullable=True) # Created POSTransaction record
updated_transaction_id = Column(UUID(as_uuid=True), nullable=True) # Updated POSTransaction record
# Duplicate Detection
is_duplicate = Column(Boolean, default=False, nullable=False, index=True)
duplicate_of = Column(UUID(as_uuid=True), nullable=True)
# Processing Priority
priority = Column(String(20), default="normal", nullable=False) # low, normal, high, urgent
# Debugging Information
user_agent = Column(String(500), nullable=True)
forwarded_for = Column(String(200), nullable=True) # X-Forwarded-For header
request_id = Column(String(100), nullable=True) # For request tracing
# Timestamps
received_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# Indexes for performance
__table_args__ = (
Index('idx_webhook_pos_system_type', 'pos_system', 'webhook_type'),
Index('idx_webhook_status', 'status'),
Index('idx_webhook_event_id', 'event_id'),
Index('idx_webhook_received_at', 'received_at'),
Index('idx_webhook_tenant_received', 'tenant_id', 'received_at'),
Index('idx_webhook_transaction_id', 'transaction_id'),
Index('idx_webhook_order_id', 'order_id'),
Index('idx_webhook_duplicate', 'is_duplicate'),
Index('idx_webhook_priority', 'priority'),
Index('idx_webhook_retry', 'retry_count'),
Index('idx_webhook_signature_valid', 'is_signature_valid'),
)
def __repr__(self):
return f"<POSWebhookLog(id={self.id}, pos_system='{self.pos_system}', type='{self.webhook_type}', status='{self.status}')>"

View File

@@ -0,0 +1 @@
# POS Services

View File

@@ -0,0 +1,473 @@
# services/pos/app/services/pos_integration_service.py
"""
POS Integration Service
Handles real-time sync and webhook processing for POS systems
"""
import asyncio
import json
from typing import Dict, List, Optional, Any
from datetime import datetime, timedelta
from uuid import UUID
import structlog
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db_transaction
from app.models.pos_config import POSConfiguration
from app.models.pos_transaction import POSTransaction, POSTransactionItem
from app.models.pos_webhook import POSWebhookLog
from app.models.pos_sync import POSSyncLog
from app.integrations.base_pos_client import (
POSCredentials,
BasePOSClient,
POSTransaction as ClientPOSTransaction,
SyncResult
)
from app.integrations.square_client import SquarePOSClient
logger = structlog.get_logger()
class POSIntegrationService:
"""
Main service for POS integrations
Handles webhook processing, real-time sync, and data transformation
"""
def __init__(self):
self.supported_clients = {
"square": SquarePOSClient,
# "toast": ToastPOSClient, # To be implemented
# "lightspeed": LightspeedPOSClient, # To be implemented
}
def _create_pos_client(self, config: POSConfiguration) -> BasePOSClient:
"""Create POS client from configuration"""
if config.pos_system not in self.supported_clients:
raise ValueError(f"Unsupported POS system: {config.pos_system}")
# Decrypt credentials (simplified - in production use proper encryption)
credentials_data = json.loads(config.encrypted_credentials or "{}")
credentials = POSCredentials(
pos_system=config.pos_system,
environment=config.environment,
api_key=credentials_data.get("api_key"),
api_secret=credentials_data.get("api_secret"),
access_token=credentials_data.get("access_token"),
application_id=credentials_data.get("application_id"),
merchant_id=config.merchant_id,
location_id=config.location_id,
webhook_secret=config.webhook_secret
)
client_class = self.supported_clients[config.pos_system]
return client_class(credentials)
async def test_connection(self, config: POSConfiguration) -> Dict[str, Any]:
"""Test connection to POS system"""
try:
client = self._create_pos_client(config)
success, message = await client.test_connection()
# Update health status in database
async with get_db_transaction() as session:
config.health_status = "healthy" if success else "unhealthy"
config.health_message = message
config.last_health_check_at = datetime.utcnow()
config.is_connected = success
session.add(config)
await session.commit()
return {
"success": success,
"message": message,
"tested_at": datetime.utcnow().isoformat()
}
except Exception as e:
logger.error("Connection test failed", error=str(e), config_id=config.id)
# Update health status
async with get_db_transaction() as session:
config.health_status = "unhealthy"
config.health_message = f"Test failed: {str(e)}"
config.last_health_check_at = datetime.utcnow()
config.is_connected = False
session.add(config)
await session.commit()
return {
"success": False,
"message": f"Connection test failed: {str(e)}",
"tested_at": datetime.utcnow().isoformat()
}
async def process_webhook(
self,
pos_system: str,
payload: bytes,
headers: Dict[str, str],
query_params: Dict[str, str],
method: str,
url_path: str,
source_ip: str
) -> Dict[str, Any]:
"""Process incoming webhook from POS system"""
webhook_log = None
try:
# Parse payload
raw_payload = payload.decode('utf-8')
payload_data = json.loads(raw_payload) if raw_payload else {}
# Extract webhook type and event info
webhook_type = self._extract_webhook_type(pos_system, payload_data)
event_id = self._extract_event_id(pos_system, payload_data)
# Create webhook log
async with get_db_transaction() as session:
webhook_log = POSWebhookLog(
pos_system=pos_system,
webhook_type=webhook_type or "unknown",
method=method,
url_path=url_path,
query_params=query_params,
headers=headers,
raw_payload=raw_payload,
payload_size=len(payload),
content_type=headers.get("content-type"),
signature=headers.get("x-square-signature") or headers.get("x-toast-signature"),
source_ip=source_ip,
status="received",
event_id=event_id,
priority="normal"
)
session.add(webhook_log)
await session.commit()
await session.refresh(webhook_log)
# Find relevant POS configuration
config = await self._find_pos_config_for_webhook(pos_system, payload_data)
if not config:
logger.warning("No POS configuration found for webhook", pos_system=pos_system)
await self._update_webhook_status(webhook_log.id, "failed", "No configuration found")
return {"status": "error", "message": "No configuration found"}
# Update webhook log with tenant info
async with get_db_transaction() as session:
webhook_log.tenant_id = config.tenant_id
session.add(webhook_log)
await session.commit()
# Verify webhook signature
if config.webhook_secret:
client = self._create_pos_client(config)
signature = webhook_log.signature or ""
is_valid = client.verify_webhook_signature(payload, signature)
async with get_db_transaction() as session:
webhook_log.is_signature_valid = is_valid
session.add(webhook_log)
await session.commit()
if not is_valid:
logger.warning("Invalid webhook signature", config_id=config.id)
await self._update_webhook_status(webhook_log.id, "failed", "Invalid signature")
return {"status": "error", "message": "Invalid signature"}
# Process webhook payload
await self._update_webhook_status(webhook_log.id, "processing")
result = await self._process_webhook_payload(config, payload_data, webhook_log)
if result["success"]:
await self._update_webhook_status(webhook_log.id, "processed", result.get("message"))
return {"status": "success", "message": result.get("message", "Processed successfully")}
else:
await self._update_webhook_status(webhook_log.id, "failed", result.get("error"))
return {"status": "error", "message": result.get("error", "Processing failed")}
except Exception as e:
logger.error("Webhook processing failed", error=str(e), pos_system=pos_system)
if webhook_log:
await self._update_webhook_status(webhook_log.id, "failed", f"Processing error: {str(e)}")
return {"status": "error", "message": "Processing failed"}
async def _process_webhook_payload(
self,
config: POSConfiguration,
payload_data: Dict[str, Any],
webhook_log: POSWebhookLog
) -> Dict[str, Any]:
"""Process webhook payload and extract transaction data"""
try:
client = self._create_pos_client(config)
# Parse webhook into transaction
client_transaction = client.parse_webhook_payload(payload_data)
if not client_transaction:
return {"success": False, "error": "No transaction data in webhook"}
# Convert to database model and save
transaction = await self._save_pos_transaction(
config,
client_transaction,
webhook_log.id
)
if transaction:
# Queue for sync to sales service
await self._queue_sales_sync(transaction)
return {
"success": True,
"message": f"Transaction {transaction.external_transaction_id} processed",
"transaction_id": str(transaction.id)
}
else:
return {"success": False, "error": "Failed to save transaction"}
except Exception as e:
logger.error("Webhook payload processing failed", error=str(e), config_id=config.id)
return {"success": False, "error": str(e)}
async def _save_pos_transaction(
self,
config: POSConfiguration,
client_transaction: ClientPOSTransaction,
webhook_log_id: Optional[UUID] = None
) -> Optional[POSTransaction]:
"""Save POS transaction to database"""
try:
async with get_db_transaction() as session:
# Check for duplicate
existing = await session.execute(
"SELECT id FROM pos_transactions WHERE external_transaction_id = :ext_id AND pos_config_id = :config_id",
{
"ext_id": client_transaction.external_id,
"config_id": config.id
}
)
if existing.first():
logger.info("Duplicate transaction detected",
external_id=client_transaction.external_id)
return None
# Create transaction record
transaction = POSTransaction(
tenant_id=config.tenant_id,
pos_config_id=config.id,
pos_system=config.pos_system,
external_transaction_id=client_transaction.external_id,
external_order_id=client_transaction.external_order_id,
transaction_type=client_transaction.transaction_type,
status=client_transaction.status,
subtotal=client_transaction.subtotal,
tax_amount=client_transaction.tax_amount,
tip_amount=client_transaction.tip_amount,
discount_amount=client_transaction.discount_amount,
total_amount=client_transaction.total_amount,
currency=client_transaction.currency,
payment_method=client_transaction.payment_method,
payment_status=client_transaction.payment_status,
transaction_date=client_transaction.transaction_date,
pos_created_at=client_transaction.transaction_date,
location_id=client_transaction.location_id,
location_name=client_transaction.location_name,
staff_id=client_transaction.staff_id,
staff_name=client_transaction.staff_name,
customer_id=client_transaction.customer_id,
customer_email=client_transaction.customer_email,
order_type=client_transaction.order_type,
table_number=client_transaction.table_number,
receipt_number=client_transaction.receipt_number,
raw_data=client_transaction.raw_data,
is_processed=True
)
session.add(transaction)
await session.flush() # Get the ID
# Create transaction items
for client_item in client_transaction.items:
item = POSTransactionItem(
transaction_id=transaction.id,
tenant_id=config.tenant_id,
external_item_id=client_item.external_id,
sku=client_item.sku,
product_name=client_item.name,
product_category=client_item.category,
quantity=client_item.quantity,
unit_price=client_item.unit_price,
total_price=client_item.total_price,
discount_amount=client_item.discount_amount,
tax_amount=client_item.tax_amount,
modifiers=client_item.modifiers,
raw_data=client_item.raw_data
)
session.add(item)
await session.commit()
await session.refresh(transaction)
logger.info("Transaction saved",
transaction_id=transaction.id,
external_id=client_transaction.external_id)
return transaction
except Exception as e:
logger.error("Failed to save transaction", error=str(e))
return None
async def _queue_sales_sync(self, transaction: POSTransaction):
"""Queue transaction for sync to sales service"""
try:
# Send transaction data to sales service
sales_data = {
"product_name": f"POS Transaction {transaction.external_transaction_id}",
"quantity_sold": 1,
"unit_price": float(transaction.total_amount),
"total_revenue": float(transaction.total_amount),
"sale_date": transaction.transaction_date.isoformat(),
"sales_channel": f"{transaction.pos_system}_pos",
"location_id": transaction.location_id,
"source": "pos_integration",
"external_transaction_id": transaction.external_transaction_id,
"payment_method": transaction.payment_method,
"raw_pos_data": transaction.raw_data
}
async with httpx.AsyncClient() as client:
response = await client.post(
f"{settings.SALES_SERVICE_URL}/api/v1/tenants/{transaction.tenant_id}/sales",
json=sales_data,
timeout=30.0
)
if response.status_code == 200:
# Update transaction as synced
async with get_db_transaction() as session:
transaction.is_synced_to_sales = True
transaction.sync_completed_at = datetime.utcnow()
session.add(transaction)
await session.commit()
logger.info("Transaction synced to sales service",
transaction_id=transaction.id)
else:
logger.error("Failed to sync to sales service",
status_code=response.status_code,
transaction_id=transaction.id)
except Exception as e:
logger.error("Sales sync failed", error=str(e), transaction_id=transaction.id)
def _extract_webhook_type(self, pos_system: str, payload: Dict[str, Any]) -> Optional[str]:
"""Extract webhook type from payload"""
if pos_system == "square":
return payload.get("type")
elif pos_system == "toast":
return payload.get("eventType")
elif pos_system == "lightspeed":
return payload.get("action")
return None
def _extract_event_id(self, pos_system: str, payload: Dict[str, Any]) -> Optional[str]:
"""Extract event ID from payload"""
if pos_system == "square":
return payload.get("event_id")
elif pos_system == "toast":
return payload.get("guid")
elif pos_system == "lightspeed":
return payload.get("id")
return None
async def _find_pos_config_for_webhook(
self,
pos_system: str,
payload: Dict[str, Any]
) -> Optional[POSConfiguration]:
"""Find POS configuration that matches the webhook"""
# Extract location ID or merchant ID from payload
location_id = self._extract_location_id(pos_system, payload)
merchant_id = self._extract_merchant_id(pos_system, payload)
async with get_db_transaction() as session:
query = """
SELECT * FROM pos_configurations
WHERE pos_system = :pos_system
AND is_active = true
"""
params = {"pos_system": pos_system}
if location_id:
query += " AND location_id = :location_id"
params["location_id"] = location_id
elif merchant_id:
query += " AND merchant_id = :merchant_id"
params["merchant_id"] = merchant_id
query += " LIMIT 1"
result = await session.execute(query, params)
row = result.first()
if row:
return POSConfiguration(**row._asdict())
return None
def _extract_location_id(self, pos_system: str, payload: Dict[str, Any]) -> Optional[str]:
"""Extract location ID from webhook payload"""
if pos_system == "square":
# Square includes location_id in various places
return (payload.get("data", {})
.get("object", {})
.get("order", {})
.get("location_id"))
return None
def _extract_merchant_id(self, pos_system: str, payload: Dict[str, Any]) -> Optional[str]:
"""Extract merchant ID from webhook payload"""
if pos_system == "toast":
return payload.get("restaurantGuid")
return None
async def _update_webhook_status(
self,
webhook_id: UUID,
status: str,
message: Optional[str] = None
):
"""Update webhook log status"""
try:
async with get_db_transaction() as session:
webhook_log = await session.get(POSWebhookLog, webhook_id)
if webhook_log:
webhook_log.status = status
webhook_log.processing_completed_at = datetime.utcnow()
if message:
webhook_log.error_message = message
session.add(webhook_log)
await session.commit()
except Exception as e:
logger.error("Failed to update webhook status", error=str(e), webhook_id=webhook_id)

View File

@@ -0,0 +1,45 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -0,0 +1,97 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
import os
import sys
# Add the app directory to the path
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
from app.core.config import settings
from shared.database.base import Base
# Import all models to ensure they're registered
from app.models import pos_config, pos_transaction, pos_webhook, pos_sync
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_database_url():
"""Get database URL from settings"""
return settings.DATABASE_URL
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = get_database_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# Override the ini file database URL with our settings
configuration = config.get_section(config.config_ini_section)
configuration["sqlalchemy.url"] = get_database_url()
connectable = engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,394 @@
"""Initial POS Integration tables
Revision ID: 001
Revises:
Create Date: 2024-01-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create pos_configurations table
op.create_table('pos_configurations',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('pos_system', sa.String(length=50), nullable=False),
sa.Column('provider_name', sa.String(length=100), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('is_connected', sa.Boolean(), nullable=False),
sa.Column('encrypted_credentials', sa.Text(), nullable=True),
sa.Column('webhook_url', sa.String(length=500), nullable=True),
sa.Column('webhook_secret', sa.String(length=255), nullable=True),
sa.Column('environment', sa.String(length=20), nullable=False),
sa.Column('location_id', sa.String(length=100), nullable=True),
sa.Column('merchant_id', sa.String(length=100), nullable=True),
sa.Column('sync_enabled', sa.Boolean(), nullable=False),
sa.Column('sync_interval_minutes', sa.String(length=10), nullable=False),
sa.Column('auto_sync_products', sa.Boolean(), nullable=False),
sa.Column('auto_sync_transactions', sa.Boolean(), nullable=False),
sa.Column('last_sync_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_successful_sync_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_sync_status', sa.String(length=50), nullable=True),
sa.Column('last_sync_message', sa.Text(), nullable=True),
sa.Column('provider_settings', sa.JSON(), nullable=True),
sa.Column('last_health_check_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('health_status', sa.String(length=50), nullable=False),
sa.Column('health_message', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('created_by', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_pos_config_active', 'pos_configurations', ['is_active'], unique=False)
op.create_index('idx_pos_config_connected', 'pos_configurations', ['is_connected'], unique=False)
op.create_index('idx_pos_config_created_at', 'pos_configurations', ['created_at'], unique=False)
op.create_index('idx_pos_config_health_status', 'pos_configurations', ['health_status'], unique=False)
op.create_index('idx_pos_config_sync_enabled', 'pos_configurations', ['sync_enabled'], unique=False)
op.create_index('idx_pos_config_tenant_pos_system', 'pos_configurations', ['tenant_id', 'pos_system'], unique=False)
op.create_index(op.f('ix_pos_configurations_id'), 'pos_configurations', ['id'], unique=False)
op.create_index(op.f('ix_pos_configurations_tenant_id'), 'pos_configurations', ['tenant_id'], unique=False)
# Create pos_transactions table
op.create_table('pos_transactions',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('pos_config_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('pos_system', sa.String(length=50), nullable=False),
sa.Column('external_transaction_id', sa.String(length=255), nullable=False),
sa.Column('external_order_id', sa.String(length=255), nullable=True),
sa.Column('transaction_type', sa.String(length=50), nullable=False),
sa.Column('status', sa.String(length=50), nullable=False),
sa.Column('subtotal', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('tax_amount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('tip_amount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('discount_amount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('total_amount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('currency', sa.String(length=3), nullable=False),
sa.Column('payment_method', sa.String(length=50), nullable=True),
sa.Column('payment_status', sa.String(length=50), nullable=True),
sa.Column('transaction_date', sa.DateTime(timezone=True), nullable=False),
sa.Column('pos_created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('pos_updated_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('location_id', sa.String(length=100), nullable=True),
sa.Column('location_name', sa.String(length=255), nullable=True),
sa.Column('staff_id', sa.String(length=100), nullable=True),
sa.Column('staff_name', sa.String(length=255), nullable=True),
sa.Column('customer_id', sa.String(length=100), nullable=True),
sa.Column('customer_email', sa.String(length=255), nullable=True),
sa.Column('customer_phone', sa.String(length=50), nullable=True),
sa.Column('order_type', sa.String(length=50), nullable=True),
sa.Column('table_number', sa.String(length=20), nullable=True),
sa.Column('receipt_number', sa.String(length=100), nullable=True),
sa.Column('is_synced_to_sales', sa.Boolean(), nullable=False),
sa.Column('sales_record_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('sync_attempted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('sync_completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('sync_error', sa.Text(), nullable=True),
sa.Column('sync_retry_count', sa.Integer(), nullable=False),
sa.Column('raw_data', sa.JSON(), nullable=True),
sa.Column('is_processed', sa.Boolean(), nullable=False),
sa.Column('processing_error', sa.Text(), nullable=True),
sa.Column('is_duplicate', sa.Boolean(), nullable=False),
sa.Column('duplicate_of', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['pos_config_id'], ['pos_configurations.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_pos_transaction_customer', 'pos_transactions', ['customer_id'], unique=False)
op.create_index('idx_pos_transaction_duplicate', 'pos_transactions', ['is_duplicate'], unique=False)
op.create_index('idx_pos_transaction_external_id', 'pos_transactions', ['pos_system', 'external_transaction_id'], unique=False)
op.create_index('idx_pos_transaction_location', 'pos_transactions', ['location_id'], unique=False)
op.create_index('idx_pos_transaction_processed', 'pos_transactions', ['is_processed'], unique=False)
op.create_index('idx_pos_transaction_status', 'pos_transactions', ['status'], unique=False)
op.create_index('idx_pos_transaction_sync_status', 'pos_transactions', ['is_synced_to_sales'], unique=False)
op.create_index('idx_pos_transaction_tenant_date', 'pos_transactions', ['tenant_id', 'transaction_date'], unique=False)
op.create_index('idx_pos_transaction_type', 'pos_transactions', ['transaction_type'], unique=False)
op.create_index(op.f('ix_pos_transactions_external_order_id'), 'pos_transactions', ['external_order_id'], unique=False)
op.create_index(op.f('ix_pos_transactions_external_transaction_id'), 'pos_transactions', ['external_transaction_id'], unique=False)
op.create_index(op.f('ix_pos_transactions_id'), 'pos_transactions', ['id'], unique=False)
op.create_index(op.f('ix_pos_transactions_pos_config_id'), 'pos_transactions', ['pos_config_id'], unique=False)
op.create_index(op.f('ix_pos_transactions_pos_system'), 'pos_transactions', ['pos_system'], unique=False)
op.create_index(op.f('ix_pos_transactions_sales_record_id'), 'pos_transactions', ['sales_record_id'], unique=False)
op.create_index(op.f('ix_pos_transactions_tenant_id'), 'pos_transactions', ['tenant_id'], unique=False)
op.create_index(op.f('ix_pos_transactions_transaction_date'), 'pos_transactions', ['transaction_date'], unique=False)
# Create pos_transaction_items table
op.create_table('pos_transaction_items',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('transaction_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('external_item_id', sa.String(length=255), nullable=True),
sa.Column('sku', sa.String(length=100), nullable=True),
sa.Column('product_name', sa.String(length=255), nullable=False),
sa.Column('product_category', sa.String(length=100), nullable=True),
sa.Column('product_subcategory', sa.String(length=100), nullable=True),
sa.Column('quantity', sa.Numeric(precision=10, scale=3), nullable=False),
sa.Column('unit_price', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('total_price', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('discount_amount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('tax_amount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('modifiers', sa.JSON(), nullable=True),
sa.Column('inventory_product_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('is_mapped_to_inventory', sa.Boolean(), nullable=False),
sa.Column('is_synced_to_sales', sa.Boolean(), nullable=False),
sa.Column('sync_error', sa.Text(), nullable=True),
sa.Column('raw_data', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['transaction_id'], ['pos_transactions.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_pos_item_category', 'pos_transaction_items', ['product_category'], unique=False)
op.create_index('idx_pos_item_inventory', 'pos_transaction_items', ['inventory_product_id'], unique=False)
op.create_index('idx_pos_item_mapped', 'pos_transaction_items', ['is_mapped_to_inventory'], unique=False)
op.create_index('idx_pos_item_product', 'pos_transaction_items', ['product_name'], unique=False)
op.create_index('idx_pos_item_sku', 'pos_transaction_items', ['sku'], unique=False)
op.create_index('idx_pos_item_sync', 'pos_transaction_items', ['is_synced_to_sales'], unique=False)
op.create_index('idx_pos_item_transaction', 'pos_transaction_items', ['transaction_id'], unique=False)
op.create_index(op.f('ix_pos_transaction_items_id'), 'pos_transaction_items', ['id'], unique=False)
op.create_index(op.f('ix_pos_transaction_items_inventory_product_id'), 'pos_transaction_items', ['inventory_product_id'], unique=False)
op.create_index(op.f('ix_pos_transaction_items_product_category'), 'pos_transaction_items', ['product_category'], unique=False)
op.create_index(op.f('ix_pos_transaction_items_sku'), 'pos_transaction_items', ['sku'], unique=False)
op.create_index(op.f('ix_pos_transaction_items_tenant_id'), 'pos_transaction_items', ['tenant_id'], unique=False)
op.create_index(op.f('ix_pos_transaction_items_transaction_id'), 'pos_transaction_items', ['transaction_id'], unique=False)
# Create pos_webhook_logs table
op.create_table('pos_webhook_logs',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('pos_system', sa.String(length=50), nullable=False),
sa.Column('webhook_type', sa.String(length=100), nullable=False),
sa.Column('method', sa.String(length=10), nullable=False),
sa.Column('url_path', sa.String(length=500), nullable=False),
sa.Column('query_params', sa.JSON(), nullable=True),
sa.Column('headers', sa.JSON(), nullable=True),
sa.Column('raw_payload', sa.Text(), nullable=False),
sa.Column('payload_size', sa.Integer(), nullable=False),
sa.Column('content_type', sa.String(length=100), nullable=True),
sa.Column('signature', sa.String(length=500), nullable=True),
sa.Column('is_signature_valid', sa.Boolean(), nullable=True),
sa.Column('source_ip', sa.String(length=45), nullable=True),
sa.Column('status', sa.String(length=50), nullable=False),
sa.Column('processing_started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('processing_completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('processing_duration_ms', sa.Integer(), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('error_code', sa.String(length=50), nullable=True),
sa.Column('retry_count', sa.Integer(), nullable=False),
sa.Column('max_retries', sa.Integer(), nullable=False),
sa.Column('response_status_code', sa.Integer(), nullable=True),
sa.Column('response_body', sa.Text(), nullable=True),
sa.Column('response_sent_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('event_id', sa.String(length=255), nullable=True),
sa.Column('event_timestamp', sa.DateTime(timezone=True), nullable=True),
sa.Column('sequence_number', sa.Integer(), nullable=True),
sa.Column('transaction_id', sa.String(length=255), nullable=True),
sa.Column('order_id', sa.String(length=255), nullable=True),
sa.Column('customer_id', sa.String(length=255), nullable=True),
sa.Column('created_transaction_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('updated_transaction_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('is_duplicate', sa.Boolean(), nullable=False),
sa.Column('duplicate_of', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('priority', sa.String(length=20), nullable=False),
sa.Column('user_agent', sa.String(length=500), nullable=True),
sa.Column('forwarded_for', sa.String(length=200), nullable=True),
sa.Column('request_id', sa.String(length=100), nullable=True),
sa.Column('received_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_webhook_duplicate', 'pos_webhook_logs', ['is_duplicate'], unique=False)
op.create_index('idx_webhook_event_id', 'pos_webhook_logs', ['event_id'], unique=False)
op.create_index('idx_webhook_order_id', 'pos_webhook_logs', ['order_id'], unique=False)
op.create_index('idx_webhook_pos_system_type', 'pos_webhook_logs', ['pos_system', 'webhook_type'], unique=False)
op.create_index('idx_webhook_priority', 'pos_webhook_logs', ['priority'], unique=False)
op.create_index('idx_webhook_received_at', 'pos_webhook_logs', ['received_at'], unique=False)
op.create_index('idx_webhook_retry', 'pos_webhook_logs', ['retry_count'], unique=False)
op.create_index('idx_webhook_signature_valid', 'pos_webhook_logs', ['is_signature_valid'], unique=False)
op.create_index('idx_webhook_status', 'pos_webhook_logs', ['status'], unique=False)
op.create_index('idx_webhook_tenant_received', 'pos_webhook_logs', ['tenant_id', 'received_at'], unique=False)
op.create_index('idx_webhook_transaction_id', 'pos_webhook_logs', ['transaction_id'], unique=False)
op.create_index(op.f('ix_pos_webhook_logs_event_id'), 'pos_webhook_logs', ['event_id'], unique=False)
op.create_index(op.f('ix_pos_webhook_logs_id'), 'pos_webhook_logs', ['id'], unique=False)
op.create_index(op.f('ix_pos_webhook_logs_pos_system'), 'pos_webhook_logs', ['pos_system'], unique=False)
op.create_index(op.f('ix_pos_webhook_logs_received_at'), 'pos_webhook_logs', ['received_at'], unique=False)
op.create_index(op.f('ix_pos_webhook_logs_tenant_id'), 'pos_webhook_logs', ['tenant_id'], unique=False)
op.create_index(op.f('ix_pos_webhook_logs_transaction_id'), 'pos_webhook_logs', ['transaction_id'], unique=False)
op.create_index(op.f('ix_pos_webhook_logs_webhook_type'), 'pos_webhook_logs', ['webhook_type'], unique=False)
# Create pos_sync_logs table
op.create_table('pos_sync_logs',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('pos_config_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('sync_type', sa.String(length=50), nullable=False),
sa.Column('sync_direction', sa.String(length=20), nullable=False),
sa.Column('data_type', sa.String(length=50), nullable=False),
sa.Column('pos_system', sa.String(length=50), nullable=False),
sa.Column('status', sa.String(length=50), nullable=False),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('duration_seconds', sa.Numeric(precision=10, scale=3), nullable=True),
sa.Column('sync_from_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('sync_to_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('records_requested', sa.Integer(), nullable=False),
sa.Column('records_processed', sa.Integer(), nullable=False),
sa.Column('records_created', sa.Integer(), nullable=False),
sa.Column('records_updated', sa.Integer(), nullable=False),
sa.Column('records_skipped', sa.Integer(), nullable=False),
sa.Column('records_failed', sa.Integer(), nullable=False),
sa.Column('api_calls_made', sa.Integer(), nullable=False),
sa.Column('api_rate_limit_hits', sa.Integer(), nullable=False),
sa.Column('total_api_time_ms', sa.Integer(), nullable=False),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('error_code', sa.String(length=100), nullable=True),
sa.Column('error_details', sa.JSON(), nullable=True),
sa.Column('retry_attempt', sa.Integer(), nullable=False),
sa.Column('max_retries', sa.Integer(), nullable=False),
sa.Column('parent_sync_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('sync_configuration', sa.JSON(), nullable=True),
sa.Column('current_page', sa.Integer(), nullable=True),
sa.Column('total_pages', sa.Integer(), nullable=True),
sa.Column('current_batch', sa.Integer(), nullable=True),
sa.Column('total_batches', sa.Integer(), nullable=True),
sa.Column('progress_percentage', sa.Numeric(precision=5, scale=2), nullable=True),
sa.Column('validation_errors', sa.JSON(), nullable=True),
sa.Column('data_quality_score', sa.Numeric(precision=5, scale=2), nullable=True),
sa.Column('memory_usage_mb', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('cpu_usage_percentage', sa.Numeric(precision=5, scale=2), nullable=True),
sa.Column('network_bytes_received', sa.Integer(), nullable=True),
sa.Column('network_bytes_sent', sa.Integer(), nullable=True),
sa.Column('revenue_synced', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('transactions_synced', sa.Integer(), nullable=False),
sa.Column('triggered_by', sa.String(length=50), nullable=True),
sa.Column('triggered_by_user_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('trigger_details', sa.JSON(), nullable=True),
sa.Column('external_batch_id', sa.String(length=255), nullable=True),
sa.Column('webhook_log_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('tags', sa.JSON(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_sync_log_completed', 'pos_sync_logs', ['completed_at'], unique=False)
op.create_index('idx_sync_log_data_type', 'pos_sync_logs', ['data_type'], unique=False)
op.create_index('idx_sync_log_duration', 'pos_sync_logs', ['duration_seconds'], unique=False)
op.create_index('idx_sync_log_external_batch', 'pos_sync_logs', ['external_batch_id'], unique=False)
op.create_index('idx_sync_log_parent', 'pos_sync_logs', ['parent_sync_id'], unique=False)
op.create_index('idx_sync_log_pos_system_type', 'pos_sync_logs', ['pos_system', 'sync_type'], unique=False)
op.create_index('idx_sync_log_retry', 'pos_sync_logs', ['retry_attempt'], unique=False)
op.create_index('idx_sync_log_status', 'pos_sync_logs', ['status'], unique=False)
op.create_index('idx_sync_log_tenant_started', 'pos_sync_logs', ['tenant_id', 'started_at'], unique=False)
op.create_index('idx_sync_log_trigger', 'pos_sync_logs', ['triggered_by'], unique=False)
op.create_index('idx_sync_log_webhook', 'pos_sync_logs', ['webhook_log_id'], unique=False)
op.create_index(op.f('ix_pos_sync_logs_data_type'), 'pos_sync_logs', ['data_type'], unique=False)
op.create_index(op.f('ix_pos_sync_logs_id'), 'pos_sync_logs', ['id'], unique=False)
op.create_index(op.f('ix_pos_sync_logs_pos_config_id'), 'pos_sync_logs', ['pos_config_id'], unique=False)
op.create_index(op.f('ix_pos_sync_logs_pos_system'), 'pos_sync_logs', ['pos_system'], unique=False)
op.create_index(op.f('ix_pos_sync_logs_started_at'), 'pos_sync_logs', ['started_at'], unique=False)
op.create_index(op.f('ix_pos_sync_logs_sync_type'), 'pos_sync_logs', ['sync_type'], unique=False)
op.create_index(op.f('ix_pos_sync_logs_tenant_id'), 'pos_sync_logs', ['tenant_id'], unique=False)
def downgrade() -> None:
# Drop pos_sync_logs table
op.drop_index(op.f('ix_pos_sync_logs_tenant_id'), table_name='pos_sync_logs')
op.drop_index(op.f('ix_pos_sync_logs_sync_type'), table_name='pos_sync_logs')
op.drop_index(op.f('ix_pos_sync_logs_started_at'), table_name='pos_sync_logs')
op.drop_index(op.f('ix_pos_sync_logs_pos_system'), table_name='pos_sync_logs')
op.drop_index(op.f('ix_pos_sync_logs_pos_config_id'), table_name='pos_sync_logs')
op.drop_index(op.f('ix_pos_sync_logs_id'), table_name='pos_sync_logs')
op.drop_index(op.f('ix_pos_sync_logs_data_type'), table_name='pos_sync_logs')
op.drop_index('idx_sync_log_webhook', table_name='pos_sync_logs')
op.drop_index('idx_sync_log_trigger', table_name='pos_sync_logs')
op.drop_index('idx_sync_log_tenant_started', table_name='pos_sync_logs')
op.drop_index('idx_sync_log_status', table_name='pos_sync_logs')
op.drop_index('idx_sync_log_retry', table_name='pos_sync_logs')
op.drop_index('idx_sync_log_pos_system_type', table_name='pos_sync_logs')
op.drop_index('idx_sync_log_parent', table_name='pos_sync_logs')
op.drop_index('idx_sync_log_external_batch', table_name='pos_sync_logs')
op.drop_index('idx_sync_log_duration', table_name='pos_sync_logs')
op.drop_index('idx_sync_log_data_type', table_name='pos_sync_logs')
op.drop_index('idx_sync_log_completed', table_name='pos_sync_logs')
op.drop_table('pos_sync_logs')
# Drop pos_webhook_logs table
op.drop_index(op.f('ix_pos_webhook_logs_webhook_type'), table_name='pos_webhook_logs')
op.drop_index(op.f('ix_pos_webhook_logs_transaction_id'), table_name='pos_webhook_logs')
op.drop_index(op.f('ix_pos_webhook_logs_tenant_id'), table_name='pos_webhook_logs')
op.drop_index(op.f('ix_pos_webhook_logs_received_at'), table_name='pos_webhook_logs')
op.drop_index(op.f('ix_pos_webhook_logs_pos_system'), table_name='pos_webhook_logs')
op.drop_index(op.f('ix_pos_webhook_logs_id'), table_name='pos_webhook_logs')
op.drop_index(op.f('ix_pos_webhook_logs_event_id'), table_name='pos_webhook_logs')
op.drop_index('idx_webhook_transaction_id', table_name='pos_webhook_logs')
op.drop_index('idx_webhook_tenant_received', table_name='pos_webhook_logs')
op.drop_index('idx_webhook_status', table_name='pos_webhook_logs')
op.drop_index('idx_webhook_signature_valid', table_name='pos_webhook_logs')
op.drop_index('idx_webhook_retry', table_name='pos_webhook_logs')
op.drop_index('idx_webhook_received_at', table_name='pos_webhook_logs')
op.drop_index('idx_webhook_priority', table_name='pos_webhook_logs')
op.drop_index('idx_webhook_pos_system_type', table_name='pos_webhook_logs')
op.drop_index('idx_webhook_order_id', table_name='pos_webhook_logs')
op.drop_index('idx_webhook_event_id', table_name='pos_webhook_logs')
op.drop_index('idx_webhook_duplicate', table_name='pos_webhook_logs')
op.drop_table('pos_webhook_logs')
# Drop pos_transaction_items table
op.drop_index(op.f('ix_pos_transaction_items_transaction_id'), table_name='pos_transaction_items')
op.drop_index(op.f('ix_pos_transaction_items_tenant_id'), table_name='pos_transaction_items')
op.drop_index(op.f('ix_pos_transaction_items_sku'), table_name='pos_transaction_items')
op.drop_index(op.f('ix_pos_transaction_items_product_category'), table_name='pos_transaction_items')
op.drop_index(op.f('ix_pos_transaction_items_inventory_product_id'), table_name='pos_transaction_items')
op.drop_index(op.f('ix_pos_transaction_items_id'), table_name='pos_transaction_items')
op.drop_index('idx_pos_item_transaction', table_name='pos_transaction_items')
op.drop_index('idx_pos_item_sync', table_name='pos_transaction_items')
op.drop_index('idx_pos_item_sku', table_name='pos_transaction_items')
op.drop_index('idx_pos_item_product', table_name='pos_transaction_items')
op.drop_index('idx_pos_item_mapped', table_name='pos_transaction_items')
op.drop_index('idx_pos_item_inventory', table_name='pos_transaction_items')
op.drop_index('idx_pos_item_category', table_name='pos_transaction_items')
op.drop_table('pos_transaction_items')
# Drop pos_transactions table
op.drop_index(op.f('ix_pos_transactions_transaction_date'), table_name='pos_transactions')
op.drop_index(op.f('ix_pos_transactions_tenant_id'), table_name='pos_transactions')
op.drop_index(op.f('ix_pos_transactions_sales_record_id'), table_name='pos_transactions')
op.drop_index(op.f('ix_pos_transactions_pos_system'), table_name='pos_transactions')
op.drop_index(op.f('ix_pos_transactions_pos_config_id'), table_name='pos_transactions')
op.drop_index(op.f('ix_pos_transactions_id'), table_name='pos_transactions')
op.drop_index(op.f('ix_pos_transactions_external_transaction_id'), table_name='pos_transactions')
op.drop_index(op.f('ix_pos_transactions_external_order_id'), table_name='pos_transactions')
op.drop_index('idx_pos_transaction_type', table_name='pos_transactions')
op.drop_index('idx_pos_transaction_tenant_date', table_name='pos_transactions')
op.drop_index('idx_pos_transaction_sync_status', table_name='pos_transactions')
op.drop_index('idx_pos_transaction_status', table_name='pos_transactions')
op.drop_index('idx_pos_transaction_processed', table_name='pos_transactions')
op.drop_index('idx_pos_transaction_location', table_name='pos_transactions')
op.drop_index('idx_pos_transaction_external_id', table_name='pos_transactions')
op.drop_index('idx_pos_transaction_duplicate', table_name='pos_transactions')
op.drop_index('idx_pos_transaction_customer', table_name='pos_transactions')
op.drop_table('pos_transactions')
# Drop pos_configurations table
op.drop_index(op.f('ix_pos_configurations_tenant_id'), table_name='pos_configurations')
op.drop_index(op.f('ix_pos_configurations_id'), table_name='pos_configurations')
op.drop_index('idx_pos_config_tenant_pos_system', table_name='pos_configurations')
op.drop_index('idx_pos_config_sync_enabled', table_name='pos_configurations')
op.drop_index('idx_pos_config_health_status', table_name='pos_configurations')
op.drop_index('idx_pos_config_created_at', table_name='pos_configurations')
op.drop_index('idx_pos_config_connected', table_name='pos_configurations')
op.drop_index('idx_pos_config_active', table_name='pos_configurations')
op.drop_table('pos_configurations')

View File

@@ -0,0 +1,16 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
pydantic==2.5.0
pydantic-settings==2.1.0
sqlalchemy==2.0.23
asyncpg==0.29.0
alembic==1.13.1
structlog==23.2.0
aiohttp==3.9.1
redis==5.0.1
celery==5.3.4
cryptography>=41.0.0
python-jose[cryptography]==3.3.0
httpx==0.25.2
websockets==12.0
prometheus-client==0.19.0