Add POS service
This commit is contained in:
@@ -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
|
||||||
# ================================================================
|
# ================================================================
|
||||||
|
|||||||
337
frontend/src/api/hooks/usePOS.ts
Normal file
337
frontend/src/api/hooks/usePOS.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
392
frontend/src/api/services/pos.service.ts
Normal file
392
frontend/src/api/services/pos.service.ts
Normal 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;
|
||||||
356
frontend/src/components/pos/POSConfigurationCard.tsx
Normal file
356
frontend/src/components/pos/POSConfigurationCard.tsx
Normal 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;
|
||||||
595
frontend/src/components/pos/POSConfigurationForm.tsx
Normal file
595
frontend/src/components/pos/POSConfigurationForm.tsx
Normal 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;
|
||||||
394
frontend/src/components/pos/POSManagementPage.tsx
Normal file
394
frontend/src/components/pos/POSManagementPage.tsx
Normal 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;
|
||||||
341
frontend/src/components/pos/POSSyncStatus.tsx
Normal file
341
frontend/src/components/pos/POSSyncStatus.tsx
Normal 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;
|
||||||
4
frontend/src/components/pos/index.ts
Normal file
4
frontend/src/components/pos/index.ts
Normal 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
32
services/pos/Dockerfile
Normal 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
138
services/pos/README.md
Normal 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
|
||||||
1
services/pos/app/__init__.py
Normal file
1
services/pos/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# POS Integration Service
|
||||||
1
services/pos/app/api/__init__.py
Normal file
1
services/pos/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# API endpoints package
|
||||||
192
services/pos/app/api/pos_config.py
Normal file
192
services/pos/app/api/pos_config.py
Normal 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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
273
services/pos/app/api/sync.py
Normal file
273
services/pos/app/api/sync.py
Normal 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)}")
|
||||||
179
services/pos/app/api/webhooks.py
Normal file
179
services/pos/app/api/webhooks.py
Normal 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"
|
||||||
|
}
|
||||||
1
services/pos/app/core/__init__.py
Normal file
1
services/pos/app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Core configuration and utilities
|
||||||
179
services/pos/app/core/config.py
Normal file
179
services/pos/app/core/config.py
Normal 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()
|
||||||
85
services/pos/app/core/database.py
Normal file
85
services/pos/app/core/database.py
Normal 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()
|
||||||
1
services/pos/app/integrations/__init__.py
Normal file
1
services/pos/app/integrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# POS Integration providers
|
||||||
365
services/pos/app/integrations/base_pos_client.py
Normal file
365
services/pos/app/integrations/base_pos_client.py
Normal 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
|
||||||
463
services/pos/app/integrations/square_client.py
Normal file
463
services/pos/app/integrations/square_client.py
Normal 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
140
services/pos/app/main.py
Normal 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
|
||||||
|
)
|
||||||
16
services/pos/app/models/__init__.py
Normal file
16
services/pos/app/models/__init__.py
Normal 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"
|
||||||
|
]
|
||||||
83
services/pos/app/models/pos_config.py
Normal file
83
services/pos/app/models/pos_config.py
Normal 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})>"
|
||||||
126
services/pos/app/models/pos_sync.py
Normal file
126
services/pos/app/models/pos_sync.py
Normal 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}')>"
|
||||||
174
services/pos/app/models/pos_transaction.py
Normal file
174
services/pos/app/models/pos_transaction.py
Normal 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})>"
|
||||||
109
services/pos/app/models/pos_webhook.py
Normal file
109
services/pos/app/models/pos_webhook.py
Normal 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}')>"
|
||||||
1
services/pos/app/services/__init__.py
Normal file
1
services/pos/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# POS Services
|
||||||
473
services/pos/app/services/pos_integration_service.py
Normal file
473
services/pos/app/services/pos_integration_service.py
Normal 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)
|
||||||
45
services/pos/migrations/alembic.ini
Normal file
45
services/pos/migrations/alembic.ini
Normal 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
|
||||||
97
services/pos/migrations/env.py
Normal file
97
services/pos/migrations/env.py
Normal 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()
|
||||||
24
services/pos/migrations/script.py.mako
Normal file
24
services/pos/migrations/script.py.mako
Normal 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"}
|
||||||
394
services/pos/migrations/versions/001_initial_pos_tables.py
Normal file
394
services/pos/migrations/versions/001_initial_pos_tables.py
Normal 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')
|
||||||
16
services/pos/requirements.txt
Normal file
16
services/pos/requirements.txt
Normal 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
|
||||||
Reference in New Issue
Block a user