Migrate KYC backend from Django to Express + Apollo Server + Prisma
All checks were successful
Build Docker Image / build (push) Successful in 2m12s
All checks were successful
Build Docker Image / build (push) Successful in 2m12s
Replace Python/Django/Graphene with TypeScript/Express/Apollo Server. Same 3 endpoints (public/user/m2m), same JWT auth via Logto. Prisma replaces Django ORM. MongoDB, Temporal and SurrealDB integrations preserved.
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
36
Dockerfile
36
Dockerfile
@@ -1,24 +1,28 @@
|
|||||||
FROM python:3.12-slim
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PYTHONUNBUFFERED=1 \
|
|
||||||
NIXPACKS_POETRY_VERSION=2.2.1
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update \
|
COPY package.json ./
|
||||||
&& apt-get install -y --no-install-recommends build-essential curl \
|
RUN npm install
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
RUN python -m venv --copies /opt/venv
|
COPY prisma ./prisma
|
||||||
ENV VIRTUAL_ENV=/opt/venv
|
RUN npx prisma generate
|
||||||
ENV PATH="/opt/venv/bin:$PATH"
|
|
||||||
|
|
||||||
COPY . .
|
COPY tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
RUN pip install --no-cache-dir poetry==$NIXPACKS_POETRY_VERSION \
|
FROM node:22-alpine
|
||||||
&& poetry install --no-interaction --no-ansi
|
|
||||||
|
|
||||||
ENV PORT=8000
|
WORKDIR /app
|
||||||
|
|
||||||
CMD ["sh", "-c", "poetry run python manage.py migrate && poetry run python manage.py collectstatic --noinput && poetry run python -m gunicorn kyc.wsgi:application --bind 0.0.0.0:${PORT:-8000}"]
|
COPY package.json ./
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
|
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY prisma ./prisma
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"]
|
||||||
|
|||||||
36
README.md
36
README.md
@@ -1,36 +0,0 @@
|
|||||||
# KYC Service
|
|
||||||
|
|
||||||
Backend сервис для процедур Know Your Customer (KYC) в системе Optovia.
|
|
||||||
|
|
||||||
## Описание
|
|
||||||
|
|
||||||
Сервис для проверки и верификации компаний с интеграцией DaData и поддержкой различных стран. Включает проверку российских компаний с использованием ИНН, КПП, ОГРН.
|
|
||||||
|
|
||||||
## Основные функции
|
|
||||||
|
|
||||||
- Проверка и верификация компаний
|
|
||||||
- Интеграция с DaData для российских компаний
|
|
||||||
- Обработка банковских реквизитов
|
|
||||||
- Управление статусами KYC (pending, in_review, approved, rejected, expired)
|
|
||||||
- Поддержка различных стран
|
|
||||||
|
|
||||||
## Модели данных
|
|
||||||
|
|
||||||
- **KYCRequest** - базовая модель для KYC запросов
|
|
||||||
- **KYCRequestRussia** - специализированная модель для российских компаний
|
|
||||||
|
|
||||||
## Технологии
|
|
||||||
|
|
||||||
- Django 5.2.8
|
|
||||||
- GraphQL (Graphene-Django)
|
|
||||||
- PostgreSQL
|
|
||||||
- DaData API Integration
|
|
||||||
- Gunicorn
|
|
||||||
|
|
||||||
## Развертывание
|
|
||||||
|
|
||||||
Проект развертывается через Nixpacks на Dokploy с автоматическими миграциями.
|
|
||||||
|
|
||||||
## Автор
|
|
||||||
|
|
||||||
Ruslan Bakiev
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# Django orders service
|
|
||||||
151
kyc/settings.py
151
kyc/settings.py
@@ -1,151 +0,0 @@
|
|||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
from infisical_sdk import InfisicalSDKClient
|
|
||||||
import sentry_sdk
|
|
||||||
from sentry_sdk.integrations.django import DjangoIntegration
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
INFISICAL_API_URL = os.environ["INFISICAL_API_URL"]
|
|
||||||
INFISICAL_CLIENT_ID = os.environ["INFISICAL_CLIENT_ID"]
|
|
||||||
INFISICAL_CLIENT_SECRET = os.environ["INFISICAL_CLIENT_SECRET"]
|
|
||||||
INFISICAL_PROJECT_ID = os.environ["INFISICAL_PROJECT_ID"]
|
|
||||||
INFISICAL_ENV = os.environ.get("INFISICAL_ENV", "prod")
|
|
||||||
|
|
||||||
client = InfisicalSDKClient(host=INFISICAL_API_URL)
|
|
||||||
client.auth.universal_auth.login(
|
|
||||||
client_id=INFISICAL_CLIENT_ID,
|
|
||||||
client_secret=INFISICAL_CLIENT_SECRET,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fetch secrets from /kyc and /shared
|
|
||||||
for secret_path in ["/kyc", "/shared"]:
|
|
||||||
secrets_response = client.secrets.list_secrets(
|
|
||||||
environment_slug=INFISICAL_ENV,
|
|
||||||
secret_path=secret_path,
|
|
||||||
project_id=INFISICAL_PROJECT_ID,
|
|
||||||
expand_secret_references=True,
|
|
||||||
view_secret_value=True,
|
|
||||||
)
|
|
||||||
for secret in secrets_response.secrets:
|
|
||||||
os.environ[secret.secretKey] = secret.secretValue
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
||||||
|
|
||||||
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'dev-secret-key-change-in-production')
|
|
||||||
|
|
||||||
DEBUG = os.getenv('DEBUG', 'False') == 'True'
|
|
||||||
|
|
||||||
# Sentry/GlitchTip configuration
|
|
||||||
SENTRY_DSN = os.getenv('SENTRY_DSN', '')
|
|
||||||
if SENTRY_DSN:
|
|
||||||
sentry_sdk.init(
|
|
||||||
dsn=SENTRY_DSN,
|
|
||||||
integrations=[DjangoIntegration()],
|
|
||||||
auto_session_tracking=False,
|
|
||||||
traces_sample_rate=0.01,
|
|
||||||
release=os.getenv('RELEASE_VERSION', '1.0.0'),
|
|
||||||
environment=os.getenv('ENVIRONMENT', 'production'),
|
|
||||||
send_default_pii=False,
|
|
||||||
debug=DEBUG,
|
|
||||||
)
|
|
||||||
|
|
||||||
ALLOWED_HOSTS = ['*']
|
|
||||||
|
|
||||||
CSRF_TRUSTED_ORIGINS = ['https://kyc.optovia.ru']
|
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
|
||||||
'whitenoise.runserver_nostatic',
|
|
||||||
'django.contrib.admin',
|
|
||||||
'django.contrib.auth',
|
|
||||||
'django.contrib.contenttypes',
|
|
||||||
'django.contrib.sessions',
|
|
||||||
'django.contrib.messages',
|
|
||||||
'django.contrib.staticfiles',
|
|
||||||
'corsheaders',
|
|
||||||
'graphene_django',
|
|
||||||
'kyc_app',
|
|
||||||
]
|
|
||||||
|
|
||||||
MIDDLEWARE = [
|
|
||||||
'corsheaders.middleware.CorsMiddleware',
|
|
||||||
'django.middleware.security.SecurityMiddleware',
|
|
||||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
|
||||||
'django.middleware.common.CommonMiddleware',
|
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
|
||||||
]
|
|
||||||
|
|
||||||
ROOT_URLCONF = 'kyc.urls'
|
|
||||||
|
|
||||||
TEMPLATES = [
|
|
||||||
{
|
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
|
||||||
'DIRS': [],
|
|
||||||
'APP_DIRS': True,
|
|
||||||
'OPTIONS': {
|
|
||||||
'context_processors': [
|
|
||||||
'django.template.context_processors.debug',
|
|
||||||
'django.template.context_processors.request',
|
|
||||||
'django.contrib.auth.context_processors.auth',
|
|
||||||
'django.contrib.messages.context_processors.messages',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
WSGI_APPLICATION = 'kyc.wsgi.application'
|
|
||||||
|
|
||||||
db_url = os.environ["KYC_DATABASE_URL"]
|
|
||||||
parsed = urlparse(db_url)
|
|
||||||
DATABASES = {
|
|
||||||
'default': {
|
|
||||||
'ENGINE': 'django.db.backends.postgresql',
|
|
||||||
'NAME': parsed.path.lstrip('/'),
|
|
||||||
'USER': parsed.username,
|
|
||||||
'PASSWORD': parsed.password,
|
|
||||||
'HOST': parsed.hostname,
|
|
||||||
'PORT': str(parsed.port) if parsed.port else '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Internationalization
|
|
||||||
LANGUAGE_CODE = 'ru-ru'
|
|
||||||
TIME_ZONE = 'UTC'
|
|
||||||
USE_I18N = True
|
|
||||||
USE_TZ = True
|
|
||||||
|
|
||||||
# Static files
|
|
||||||
STATIC_URL = '/static/'
|
|
||||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
|
||||||
|
|
||||||
# Default primary key field type
|
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
|
||||||
|
|
||||||
# CORS
|
|
||||||
CORS_ALLOW_ALL_ORIGINS = False
|
|
||||||
CORS_ALLOWED_ORIGINS = ['https://optovia.ru']
|
|
||||||
CORS_ALLOW_CREDENTIALS = True
|
|
||||||
|
|
||||||
# Logto JWT settings
|
|
||||||
LOGTO_JWKS_URL = os.getenv('LOGTO_JWKS_URL', 'https://auth.optovia.ru/oidc/jwks')
|
|
||||||
LOGTO_ISSUER = os.getenv('LOGTO_ISSUER', 'https://auth.optovia.ru/oidc')
|
|
||||||
LOGTO_KYC_AUDIENCE = os.getenv('LOGTO_KYC_AUDIENCE', 'https://kyc.optovia.ru')
|
|
||||||
LOGTO_ID_TOKEN_AUDIENCE = os.getenv('LOGTO_ID_TOKEN_AUDIENCE')
|
|
||||||
|
|
||||||
# Odoo connection (internal M2M)
|
|
||||||
ODOO_INTERNAL_URL = os.getenv('ODOO_INTERNAL_URL', 'odoo:8069')
|
|
||||||
|
|
||||||
# Temporal connection
|
|
||||||
TEMPORAL_HOST = os.getenv('TEMPORAL_HOST', 'temporal:7233')
|
|
||||||
TEMPORAL_NAMESPACE = os.getenv('TEMPORAL_NAMESPACE', 'default')
|
|
||||||
TEMPORAL_TASK_QUEUE = os.getenv('TEMPORAL_TASK_QUEUE', 'platform-worker')
|
|
||||||
|
|
||||||
# MongoDB connection (for company monitoring data)
|
|
||||||
MONGODB_URI = os.getenv('MONGODB_URI', '')
|
|
||||||
MONGODB_DB = os.getenv('MONGODB_DB', 'optovia')
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
import sentry_sdk
|
|
||||||
from sentry_sdk.integrations.django import DjangoIntegration
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
||||||
|
|
||||||
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'dev-secret-key-change-in-production')
|
|
||||||
|
|
||||||
DEBUG = True
|
|
||||||
|
|
||||||
# Sentry/GlitchTip configuration
|
|
||||||
SENTRY_DSN = os.getenv('SENTRY_DSN', '')
|
|
||||||
if SENTRY_DSN:
|
|
||||||
sentry_sdk.init(
|
|
||||||
dsn=SENTRY_DSN,
|
|
||||||
integrations=[DjangoIntegration()],
|
|
||||||
auto_session_tracking=False,
|
|
||||||
traces_sample_rate=0.01,
|
|
||||||
release=os.getenv('RELEASE_VERSION', '1.0.0'),
|
|
||||||
environment=os.getenv('ENVIRONMENT', 'production'),
|
|
||||||
send_default_pii=False,
|
|
||||||
debug=DEBUG,
|
|
||||||
)
|
|
||||||
|
|
||||||
ALLOWED_HOSTS = ['*']
|
|
||||||
|
|
||||||
CSRF_TRUSTED_ORIGINS = ['https://kyc.optovia.ru']
|
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
|
||||||
'whitenoise.runserver_nostatic',
|
|
||||||
'django.contrib.admin',
|
|
||||||
'django.contrib.auth',
|
|
||||||
'django.contrib.contenttypes',
|
|
||||||
'django.contrib.sessions',
|
|
||||||
'django.contrib.messages',
|
|
||||||
'django.contrib.staticfiles',
|
|
||||||
'corsheaders',
|
|
||||||
'graphene_django',
|
|
||||||
'kyc_app',
|
|
||||||
]
|
|
||||||
|
|
||||||
MIDDLEWARE = [
|
|
||||||
'corsheaders.middleware.CorsMiddleware',
|
|
||||||
'django.middleware.security.SecurityMiddleware',
|
|
||||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
|
||||||
'django.middleware.common.CommonMiddleware',
|
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
|
||||||
]
|
|
||||||
|
|
||||||
ROOT_URLCONF = 'kyc.urls'
|
|
||||||
|
|
||||||
TEMPLATES = [
|
|
||||||
{
|
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
|
||||||
'DIRS': [],
|
|
||||||
'APP_DIRS': True,
|
|
||||||
'OPTIONS': {
|
|
||||||
'context_processors': [
|
|
||||||
'django.template.context_processors.debug',
|
|
||||||
'django.template.context_processors.request',
|
|
||||||
'django.contrib.auth.context_processors.auth',
|
|
||||||
'django.contrib.messages.context_processors.messages',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
WSGI_APPLICATION = 'kyc.wsgi.application'
|
|
||||||
|
|
||||||
DATABASES = {
|
|
||||||
"default": {
|
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
|
||||||
"NAME": BASE_DIR / "db.sqlite3",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
# Internationalization
|
|
||||||
LANGUAGE_CODE = 'ru-ru'
|
|
||||||
TIME_ZONE = 'UTC'
|
|
||||||
USE_I18N = True
|
|
||||||
USE_TZ = True
|
|
||||||
|
|
||||||
# Static files
|
|
||||||
STATIC_URL = '/static/'
|
|
||||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
|
||||||
|
|
||||||
# Default primary key field type
|
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
|
||||||
|
|
||||||
# CORS
|
|
||||||
CORS_ALLOW_ALL_ORIGINS = False
|
|
||||||
CORS_ALLOWED_ORIGINS = ['http://localhost:3000', 'https://optovia.ru']
|
|
||||||
CORS_ALLOW_CREDENTIALS = True
|
|
||||||
|
|
||||||
# Logto JWT settings
|
|
||||||
LOGTO_JWKS_URL = os.getenv('LOGTO_JWKS_URL', 'https://auth.optovia.ru/oidc/jwks')
|
|
||||||
LOGTO_ISSUER = os.getenv('LOGTO_ISSUER', 'https://auth.optovia.ru/oidc')
|
|
||||||
LOGTO_KYC_AUDIENCE = os.getenv('LOGTO_KYC_AUDIENCE', 'https://kyc.optovia.ru')
|
|
||||||
LOGTO_ID_TOKEN_AUDIENCE = os.getenv('LOGTO_ID_TOKEN_AUDIENCE')
|
|
||||||
|
|
||||||
# Odoo connection (internal M2M)
|
|
||||||
ODOO_INTERNAL_URL = os.getenv('ODOO_INTERNAL_URL', 'odoo:8069')
|
|
||||||
|
|
||||||
# Temporal connection
|
|
||||||
TEMPORAL_HOST = os.getenv('TEMPORAL_HOST', 'temporal:7233')
|
|
||||||
TEMPORAL_NAMESPACE = os.getenv('TEMPORAL_NAMESPACE', 'default')
|
|
||||||
TEMPORAL_TASK_QUEUE = os.getenv('TEMPORAL_TASK_QUEUE', 'platform-worker')
|
|
||||||
15
kyc/urls.py
15
kyc/urls.py
@@ -1,15 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
from django.urls import path
|
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
|
||||||
from kyc_app.views import UserGraphQLView, M2MGraphQLView, PublicGraphQLView, metrics_view
|
|
||||||
from kyc_app.schemas.user_schema import user_schema
|
|
||||||
from kyc_app.schemas.m2m_schema import m2m_schema
|
|
||||||
from kyc_app.schemas.public_schema import public_schema
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path('admin/', admin.site.urls),
|
|
||||||
path('graphql/user/', csrf_exempt(UserGraphQLView.as_view(graphiql=True, schema=user_schema))),
|
|
||||||
path('graphql/m2m/', csrf_exempt(M2MGraphQLView.as_view(graphiql=True, schema=m2m_schema))),
|
|
||||||
path('graphql/public/', csrf_exempt(PublicGraphQLView.as_view(graphiql=True, schema=public_schema))),
|
|
||||||
path('metrics', metrics_view),
|
|
||||||
]
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import os
|
|
||||||
from django.core.wsgi import get_wsgi_application
|
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'kyc.settings')
|
|
||||||
|
|
||||||
application = get_wsgi_application()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# Orders Django app
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
from django.contrib import admin, messages
|
|
||||||
from .models import KYCApplication, KYCProfile, KYCDetailsRussia
|
|
||||||
from .temporal import KycWorkflowClient
|
|
||||||
|
|
||||||
|
|
||||||
@admin.action(description="Start KYC workflow")
|
|
||||||
def start_kyc_workflow(modeladmin, request, queryset):
|
|
||||||
"""Start KYC workflow for selected applications."""
|
|
||||||
for kyc_app in queryset:
|
|
||||||
workflow_id = KycWorkflowClient.start(kyc_app)
|
|
||||||
if workflow_id:
|
|
||||||
messages.success(request, f"Workflow started for {kyc_app.uuid}")
|
|
||||||
else:
|
|
||||||
messages.error(request, f"Failed to start workflow for {kyc_app.uuid}")
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(KYCApplication)
|
|
||||||
class KYCApplicationAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ('uuid', 'user_id', 'team_name', 'country_code', 'workflow_status', 'contact_person', 'created_at')
|
|
||||||
list_filter = ('workflow_status', 'country_code', 'created_at')
|
|
||||||
search_fields = ('uuid', 'user_id', 'team_name', 'contact_email', 'contact_person')
|
|
||||||
readonly_fields = ('uuid', 'created_at', 'updated_at', 'content_type', 'object_id')
|
|
||||||
ordering = ('-created_at',)
|
|
||||||
actions = [start_kyc_workflow]
|
|
||||||
|
|
||||||
fieldsets = (
|
|
||||||
('Основная информация', {
|
|
||||||
'fields': ('uuid', 'user_id', 'team_name', 'country_code', 'workflow_status')
|
|
||||||
}),
|
|
||||||
('Контактная информация', {
|
|
||||||
'fields': ('contact_person', 'contact_email', 'contact_phone')
|
|
||||||
}),
|
|
||||||
('Детали страны (GenericFK)', {
|
|
||||||
'fields': ('content_type', 'object_id'),
|
|
||||||
'classes': ('collapse',)
|
|
||||||
}),
|
|
||||||
('Статус', {
|
|
||||||
'fields': ('score', 'approved_by', 'approved_at', 'created_at', 'updated_at')
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(KYCProfile)
|
|
||||||
class KYCProfileAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ('uuid', 'user_id', 'team_name', 'country_code', 'workflow_status', 'contact_person', 'created_at')
|
|
||||||
list_filter = ('workflow_status', 'country_code', 'created_at')
|
|
||||||
search_fields = ('uuid', 'user_id', 'team_name', 'contact_email', 'contact_person')
|
|
||||||
readonly_fields = ('uuid', 'created_at', 'updated_at', 'content_type', 'object_id')
|
|
||||||
ordering = ('-created_at',)
|
|
||||||
|
|
||||||
fieldsets = (
|
|
||||||
('Основная информация', {
|
|
||||||
'fields': ('uuid', 'user_id', 'team_name', 'country_code', 'workflow_status')
|
|
||||||
}),
|
|
||||||
('Контактная информация', {
|
|
||||||
'fields': ('contact_person', 'contact_email', 'contact_phone')
|
|
||||||
}),
|
|
||||||
('Детали страны (GenericFK)', {
|
|
||||||
'fields': ('content_type', 'object_id'),
|
|
||||||
'classes': ('collapse',)
|
|
||||||
}),
|
|
||||||
('Статус', {
|
|
||||||
'fields': ('score', 'approved_by', 'approved_at', 'created_at', 'updated_at')
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(KYCDetailsRussia)
|
|
||||||
class KYCDetailsRussiaAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ('id', 'company_name', 'inn', 'ogrn')
|
|
||||||
search_fields = ('company_name', 'inn', 'ogrn')
|
|
||||||
ordering = ('-id',)
|
|
||||||
|
|
||||||
fieldsets = (
|
|
||||||
('Компания', {
|
|
||||||
'fields': ('company_name', 'company_full_name', 'inn', 'kpp', 'ogrn', 'address')
|
|
||||||
}),
|
|
||||||
('Банковские реквизиты', {
|
|
||||||
'fields': ('bank_name', 'bik', 'correspondent_account')
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
class KycAppConfig(AppConfig):
|
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
|
||||||
name = 'kyc_app'
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
"""
|
|
||||||
JWT authentication utilities for KYC API.
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
from typing import Iterable, Optional
|
|
||||||
|
|
||||||
import jwt
|
|
||||||
from django.conf import settings
|
|
||||||
from jwt import InvalidTokenError, PyJWKClient
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class LogtoTokenValidator:
|
|
||||||
"""Validate JWTs issued by Logto using the published JWKS."""
|
|
||||||
|
|
||||||
def __init__(self, jwks_url: str, issuer: str):
|
|
||||||
self._issuer = issuer
|
|
||||||
self._jwks_client = PyJWKClient(jwks_url)
|
|
||||||
|
|
||||||
def decode(
|
|
||||||
self,
|
|
||||||
token: str,
|
|
||||||
audience: Optional[str] = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Decode and verify a JWT, enforcing issuer and optional audience."""
|
|
||||||
try:
|
|
||||||
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
|
|
||||||
header_alg = jwt.get_unverified_header(token).get("alg")
|
|
||||||
|
|
||||||
return jwt.decode(
|
|
||||||
token,
|
|
||||||
signing_key.key,
|
|
||||||
algorithms=[header_alg] if header_alg else None,
|
|
||||||
issuer=self._issuer,
|
|
||||||
audience=audience,
|
|
||||||
options={"verify_aud": audience is not None},
|
|
||||||
)
|
|
||||||
except InvalidTokenError as exc:
|
|
||||||
logger.warning("Failed to validate Logto token: %s", exc)
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def get_bearer_token(request) -> str:
|
|
||||||
"""Extract Bearer token from Authorization header."""
|
|
||||||
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
|
|
||||||
if not auth_header.startswith("Bearer "):
|
|
||||||
raise InvalidTokenError("Missing Bearer token")
|
|
||||||
|
|
||||||
token = auth_header.split(" ", 1)[1]
|
|
||||||
if not token or token == "undefined":
|
|
||||||
raise InvalidTokenError("Empty Bearer token")
|
|
||||||
|
|
||||||
return token
|
|
||||||
|
|
||||||
|
|
||||||
def scopes_from_payload(payload: dict) -> list[str]:
|
|
||||||
"""Split scope string (if present) into a list."""
|
|
||||||
scope_value = payload.get("scope")
|
|
||||||
if not scope_value:
|
|
||||||
return []
|
|
||||||
if isinstance(scope_value, str):
|
|
||||||
return scope_value.split()
|
|
||||||
if isinstance(scope_value, Iterable):
|
|
||||||
return list(scope_value)
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
validator = LogtoTokenValidator(
|
|
||||||
getattr(settings, "LOGTO_JWKS_URL", "https://auth.optovia.ru/oidc/jwks"),
|
|
||||||
getattr(settings, "LOGTO_ISSUER", "https://auth.optovia.ru/oidc"),
|
|
||||||
)
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"""
|
|
||||||
GraphQL middleware for JWT authentication.
|
|
||||||
"""
|
|
||||||
from graphql import GraphQLError
|
|
||||||
from jwt import InvalidTokenError
|
|
||||||
|
|
||||||
from .auth import get_bearer_token, validator
|
|
||||||
|
|
||||||
|
|
||||||
def _is_introspection(info) -> bool:
|
|
||||||
"""Возвращает True для любых introspection резолвов."""
|
|
||||||
field = getattr(info, "field_name", "")
|
|
||||||
parent = getattr(getattr(info, "parent_type", None), "name", "")
|
|
||||||
return field.startswith("__") or parent.startswith("__")
|
|
||||||
|
|
||||||
|
|
||||||
class UserJWTMiddleware:
|
|
||||||
"""User endpoint - requires ID token."""
|
|
||||||
|
|
||||||
def resolve(self, next, root, info, **kwargs):
|
|
||||||
request = info.context
|
|
||||||
if _is_introspection(info):
|
|
||||||
return next(root, info, **kwargs)
|
|
||||||
|
|
||||||
try:
|
|
||||||
token = get_bearer_token(request)
|
|
||||||
payload = validator.decode(token)
|
|
||||||
request.user_id = payload.get('sub')
|
|
||||||
except InvalidTokenError as exc:
|
|
||||||
raise GraphQLError("Unauthorized") from exc
|
|
||||||
|
|
||||||
return next(root, info, **kwargs)
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# Generated manually
|
|
||||||
import uuid
|
|
||||||
from django.db import migrations, models
|
|
||||||
import django.db.models.deletion
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='KYCRequest',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('uuid', models.CharField(default=uuid.uuid4, max_length=100, unique=True)),
|
|
||||||
('user_id', models.CharField(max_length=255)),
|
|
||||||
('team_uuid', models.CharField(max_length=100)),
|
|
||||||
('team_name', models.CharField(blank=True, max_length=200)),
|
|
||||||
('country_code', models.CharField(blank=True, max_length=2)),
|
|
||||||
('status', models.CharField(choices=[('pending', 'Ожидает проверки'), ('in_review', 'На рассмотрении'), ('approved', 'Одобрено'), ('rejected', 'Отклонено'), ('expired', 'Истекло')], default='pending', max_length=50)),
|
|
||||||
('score', models.IntegerField(default=0)),
|
|
||||||
('contact_person', models.CharField(blank=True, default='', max_length=255)),
|
|
||||||
('contact_email', models.EmailField(blank=True, default='', max_length=254)),
|
|
||||||
('contact_phone', models.CharField(blank=True, default='', max_length=50)),
|
|
||||||
('registration_number', models.CharField(blank=True, max_length=50)),
|
|
||||||
('approved_by', models.CharField(blank=True, max_length=255, null=True)),
|
|
||||||
('approved_at', models.DateTimeField(blank=True, null=True)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'db_table': 'kyc_requests',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='KYCRequestRussia',
|
|
||||||
fields=[
|
|
||||||
('kycrequest_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='kyc_app.kycrequest')),
|
|
||||||
('company_name', models.CharField(max_length=255)),
|
|
||||||
('company_full_name', models.TextField()),
|
|
||||||
('inn', models.CharField(max_length=12)),
|
|
||||||
('kpp', models.CharField(blank=True, max_length=9)),
|
|
||||||
('ogrn', models.CharField(blank=True, max_length=15)),
|
|
||||||
('address', models.TextField()),
|
|
||||||
('bank_name', models.CharField(max_length=255)),
|
|
||||||
('bik', models.CharField(max_length=9)),
|
|
||||||
('correspondent_account', models.CharField(blank=True, max_length=20)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'db_table': 'kyc_requests_russia',
|
|
||||||
},
|
|
||||||
bases=('kyc_app.kycrequest',),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
# Migration: Remove inheritance, add ContentType GenericFK
|
|
||||||
import uuid
|
|
||||||
from django.db import migrations, models
|
|
||||||
import django.db.models.deletion
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('contenttypes', '0002_remove_content_type_name'),
|
|
||||||
('kyc_app', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
# 1. Drop the old inherited table
|
|
||||||
migrations.DeleteModel(
|
|
||||||
name='KYCRequestRussia',
|
|
||||||
),
|
|
||||||
|
|
||||||
# 2. Remove registration_number from KYCRequest (was for inherited ogrn)
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='kycrequest',
|
|
||||||
name='registration_number',
|
|
||||||
),
|
|
||||||
|
|
||||||
# 3. Add ContentType FK to KYCRequest
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='kycrequest',
|
|
||||||
name='content_type',
|
|
||||||
field=models.ForeignKey(
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.SET_NULL,
|
|
||||||
related_name='kyc_requests',
|
|
||||||
to='contenttypes.contenttype',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
# 4. Add object_id to KYCRequest
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='kycrequest',
|
|
||||||
name='object_id',
|
|
||||||
field=models.PositiveIntegerField(blank=True, null=True),
|
|
||||||
),
|
|
||||||
|
|
||||||
# 5. Create new KYCRequestRussia (without inheritance)
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='KYCRequestRussia',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('company_name', models.CharField(max_length=255)),
|
|
||||||
('company_full_name', models.TextField()),
|
|
||||||
('inn', models.CharField(max_length=12)),
|
|
||||||
('kpp', models.CharField(blank=True, max_length=9)),
|
|
||||||
('ogrn', models.CharField(blank=True, max_length=15)),
|
|
||||||
('address', models.TextField()),
|
|
||||||
('bank_name', models.CharField(max_length=255)),
|
|
||||||
('bik', models.CharField(max_length=9)),
|
|
||||||
('correspondent_account', models.CharField(blank=True, max_length=20)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'db_table': 'kyc_details_russia',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# Generated by Django 5.2.8 on 2025-12-30 02:48
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('kyc_app', '0002_remove_inheritance'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='kycrequest',
|
|
||||||
name='status',
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='kycrequest',
|
|
||||||
name='team_uuid',
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='kycrequest',
|
|
||||||
name='workflow_status',
|
|
||||||
field=models.CharField(choices=[('pending', 'Ожидает обработки'), ('active', 'Активен'), ('error', 'Ошибка')], default='pending', max_length=20),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='kycrequest',
|
|
||||||
name='user_id',
|
|
||||||
field=models.CharField(db_index=True, max_length=255),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# Generated by Django 5.2.8 on 2026-02-03 05:06
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
import uuid
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('contenttypes', '0002_remove_content_type_name'),
|
|
||||||
('kyc_app', '0003_remove_kycrequest_status_remove_kycrequest_team_uuid_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RenameModel(
|
|
||||||
old_name='KYCRequest',
|
|
||||||
new_name='KYCApplication',
|
|
||||||
),
|
|
||||||
migrations.RenameModel(
|
|
||||||
old_name='KYCRequestRussia',
|
|
||||||
new_name='KYCDetailsRussia',
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='kycapplication',
|
|
||||||
name='content_type',
|
|
||||||
field=models.ForeignKey(
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.SET_NULL,
|
|
||||||
related_name='%(class)s_set',
|
|
||||||
to='contenttypes.contenttype',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='KYCProfile',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('uuid', models.CharField(default=uuid.uuid4, max_length=100, unique=True)),
|
|
||||||
('user_id', models.CharField(db_index=True, max_length=255)),
|
|
||||||
('team_name', models.CharField(blank=True, max_length=200)),
|
|
||||||
('country_code', models.CharField(blank=True, max_length=2)),
|
|
||||||
('workflow_status', models.CharField(choices=[('pending', 'Ожидает обработки'), ('active', 'Активен'), ('error', 'Ошибка')], default='pending', max_length=20)),
|
|
||||||
('score', models.IntegerField(default=0)),
|
|
||||||
('contact_person', models.CharField(blank=True, default='', max_length=255)),
|
|
||||||
('contact_email', models.EmailField(blank=True, default='', max_length=254)),
|
|
||||||
('contact_phone', models.CharField(blank=True, default='', max_length=50)),
|
|
||||||
('object_id', models.PositiveIntegerField(blank=True, null=True)),
|
|
||||||
('approved_by', models.CharField(blank=True, max_length=255, null=True)),
|
|
||||||
('approved_at', models.DateTimeField(blank=True, null=True)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_set', to='contenttypes.contenttype')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'KYC Profile',
|
|
||||||
'verbose_name_plural': 'KYC Profiles',
|
|
||||||
'db_table': 'kyc_monitoring',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
from django.db import models
|
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
|
|
||||||
class AbstractKycBase(models.Model):
|
|
||||||
"""Общая база для KYC сущностей с идентичными полями."""
|
|
||||||
|
|
||||||
WORKFLOW_STATUS_CHOICES = [
|
|
||||||
('pending', 'Ожидает обработки'),
|
|
||||||
('active', 'Активен'),
|
|
||||||
('error', 'Ошибка'),
|
|
||||||
]
|
|
||||||
|
|
||||||
uuid = models.CharField(max_length=100, unique=True, default=uuid.uuid4)
|
|
||||||
user_id = models.CharField(max_length=255, db_index=True)
|
|
||||||
team_name = models.CharField(max_length=200, blank=True)
|
|
||||||
country_code = models.CharField(max_length=2, blank=True)
|
|
||||||
workflow_status = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
choices=WORKFLOW_STATUS_CHOICES,
|
|
||||||
default='pending',
|
|
||||||
)
|
|
||||||
score = models.IntegerField(default=0)
|
|
||||||
|
|
||||||
# Общие контактные данные
|
|
||||||
contact_person = models.CharField(max_length=255, blank=True, default='')
|
|
||||||
contact_email = models.EmailField(blank=True, default='')
|
|
||||||
contact_phone = models.CharField(max_length=50, blank=True, default='')
|
|
||||||
|
|
||||||
# Ссылка на детали страны через ContentType
|
|
||||||
content_type = models.ForeignKey(
|
|
||||||
ContentType,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name='%(class)s_set'
|
|
||||||
)
|
|
||||||
object_id = models.PositiveIntegerField(null=True, blank=True)
|
|
||||||
country_details = GenericForeignKey('content_type', 'object_id')
|
|
||||||
|
|
||||||
approved_by = models.CharField(max_length=255, null=True, blank=True)
|
|
||||||
approved_at = models.DateTimeField(null=True, blank=True)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
abstract = True
|
|
||||||
|
|
||||||
|
|
||||||
class KYCApplication(AbstractKycBase):
|
|
||||||
"""KYC заявка на верификацию. Одноразовый процесс."""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = 'kyc_requests' # Сохраняем для совместимости
|
|
||||||
verbose_name = 'KYC Application'
|
|
||||||
verbose_name_plural = 'KYC Applications'
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"KYC Application {self.user_id} - {self.workflow_status}"
|
|
||||||
|
|
||||||
def get_country_data(self) -> dict:
|
|
||||||
"""Получить данные страны как словарь для Temporal workflow."""
|
|
||||||
if self.country_details and hasattr(self.country_details, 'to_dict'):
|
|
||||||
return self.country_details.to_dict()
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
class KYCProfile(AbstractKycBase):
|
|
||||||
"""KYC профиль компании для долгосрочного мониторинга.
|
|
||||||
|
|
||||||
Создается после успешной верификации (approval) заявки.
|
|
||||||
Используется Dagster для сбора данных о компании.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = 'kyc_monitoring' # Сохраняем для совместимости
|
|
||||||
verbose_name = 'KYC Profile'
|
|
||||||
verbose_name_plural = 'KYC Profiles'
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"KYC Profile {self.user_id} - {self.workflow_status}"
|
|
||||||
|
|
||||||
|
|
||||||
# Aliases for backwards compatibility
|
|
||||||
KYCRequest = KYCApplication
|
|
||||||
KYCMonitoring = KYCProfile
|
|
||||||
|
|
||||||
|
|
||||||
class KYCDetailsRussia(models.Model):
|
|
||||||
"""Детали KYC для России. Отдельная модель без наследования."""
|
|
||||||
|
|
||||||
# Данные компании от DaData
|
|
||||||
company_name = models.CharField(max_length=255)
|
|
||||||
company_full_name = models.TextField()
|
|
||||||
inn = models.CharField(max_length=12)
|
|
||||||
kpp = models.CharField(max_length=9, blank=True)
|
|
||||||
ogrn = models.CharField(max_length=15, blank=True)
|
|
||||||
address = models.TextField()
|
|
||||||
|
|
||||||
# Банковские реквизиты
|
|
||||||
bank_name = models.CharField(max_length=255)
|
|
||||||
bik = models.CharField(max_length=9)
|
|
||||||
correspondent_account = models.CharField(max_length=20, blank=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = 'kyc_details_russia'
|
|
||||||
verbose_name = 'KYC Details Russia'
|
|
||||||
verbose_name_plural = 'KYC Details Russia'
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"KYC Russia: {self.company_name} (ИНН: {self.inn})"
|
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
|
||||||
"""Конвертировать в словарь для передачи в Temporal workflow."""
|
|
||||||
return {
|
|
||||||
"company_name": self.company_name,
|
|
||||||
"company_full_name": self.company_full_name,
|
|
||||||
"inn": self.inn,
|
|
||||||
"kpp": self.kpp,
|
|
||||||
"ogrn": self.ogrn,
|
|
||||||
"address": self.address,
|
|
||||||
"bank_name": self.bank_name,
|
|
||||||
"bik": self.bik,
|
|
||||||
"correspondent_account": self.correspondent_account,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Alias for backwards compatibility
|
|
||||||
KYCRequestRussia = KYCDetailsRussia
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
"""
|
|
||||||
M2M GraphQL Schema for internal service-to-service calls.
|
|
||||||
|
|
||||||
This endpoint is called by Temporal worker to create KYCProfile records.
|
|
||||||
No authentication required (internal network only).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import graphene
|
|
||||||
|
|
||||||
from ..models import KYCApplication, KYCProfile
|
|
||||||
|
|
||||||
|
|
||||||
class CreateKycProfileMutation(graphene.Mutation):
|
|
||||||
"""Create KYCProfile record from existing KYCApplication.
|
|
||||||
|
|
||||||
Called after KYC approval to create long-term monitoring profile.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Arguments:
|
|
||||||
kyc_application_id = graphene.String(required=True)
|
|
||||||
|
|
||||||
success = graphene.Boolean()
|
|
||||||
profile_uuid = graphene.String()
|
|
||||||
message = graphene.String()
|
|
||||||
|
|
||||||
def mutate(self, info, kyc_application_id: str):
|
|
||||||
try:
|
|
||||||
# Find the KYCApplication
|
|
||||||
kyc_application = KYCApplication.objects.get(uuid=kyc_application_id)
|
|
||||||
|
|
||||||
# Check if profile already exists for this user/team
|
|
||||||
existing = KYCProfile.objects.filter(
|
|
||||||
user_id=kyc_application.user_id,
|
|
||||||
team_name=kyc_application.team_name,
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
return CreateKycProfileMutation(
|
|
||||||
success=True,
|
|
||||||
profile_uuid=str(existing.uuid),
|
|
||||||
message="Profile already exists",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create KYCProfile by copying fields from KYCApplication
|
|
||||||
profile = KYCProfile.objects.create(
|
|
||||||
user_id=kyc_application.user_id,
|
|
||||||
team_name=kyc_application.team_name,
|
|
||||||
country_code=kyc_application.country_code,
|
|
||||||
workflow_status='active', # Approved = active for profile
|
|
||||||
score=kyc_application.score,
|
|
||||||
contact_person=kyc_application.contact_person,
|
|
||||||
contact_email=kyc_application.contact_email,
|
|
||||||
contact_phone=kyc_application.contact_phone,
|
|
||||||
content_type=kyc_application.content_type,
|
|
||||||
object_id=kyc_application.object_id,
|
|
||||||
approved_by=kyc_application.approved_by,
|
|
||||||
approved_at=kyc_application.approved_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
return CreateKycProfileMutation(
|
|
||||||
success=True,
|
|
||||||
profile_uuid=str(profile.uuid),
|
|
||||||
message="Profile created",
|
|
||||||
)
|
|
||||||
|
|
||||||
except KYCApplication.DoesNotExist:
|
|
||||||
return CreateKycProfileMutation(
|
|
||||||
success=False,
|
|
||||||
profile_uuid="",
|
|
||||||
message=f"KYCApplication not found: {kyc_application_id}",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return CreateKycProfileMutation(
|
|
||||||
success=False,
|
|
||||||
profile_uuid="",
|
|
||||||
message=str(e),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class M2MQuery(graphene.ObjectType):
|
|
||||||
"""M2M Query - health check only."""
|
|
||||||
|
|
||||||
health = graphene.String()
|
|
||||||
|
|
||||||
def resolve_health(self, info):
|
|
||||||
return "ok"
|
|
||||||
|
|
||||||
|
|
||||||
class M2MMutation(graphene.ObjectType):
|
|
||||||
"""M2M Mutations for internal service calls."""
|
|
||||||
|
|
||||||
# New name
|
|
||||||
create_kyc_profile = CreateKycProfileMutation.Field()
|
|
||||||
# Old name for backwards compatibility
|
|
||||||
create_kyc_monitoring = CreateKycProfileMutation.Field()
|
|
||||||
|
|
||||||
|
|
||||||
m2m_schema = graphene.Schema(query=M2MQuery, mutation=M2MMutation)
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
"""
|
|
||||||
Public GraphQL Schema for KYC profile data.
|
|
||||||
|
|
||||||
This endpoint provides:
|
|
||||||
- kycProfileTeaser: public data (no auth required)
|
|
||||||
- kycProfileFull: full data (requires authentication)
|
|
||||||
|
|
||||||
Data is read from MongoDB company_documents collection.
|
|
||||||
Queries are by KYC Profile UUID.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import graphene
|
|
||||||
from django.conf import settings
|
|
||||||
from pymongo import MongoClient
|
|
||||||
|
|
||||||
from ..models import KYCProfile, KYCDetailsRussia
|
|
||||||
|
|
||||||
|
|
||||||
def get_mongo_client():
|
|
||||||
"""Get MongoDB client."""
|
|
||||||
if not settings.MONGODB_URI:
|
|
||||||
return None
|
|
||||||
return MongoClient(settings.MONGODB_URI)
|
|
||||||
|
|
||||||
|
|
||||||
def get_company_documents(inn: str) -> list:
|
|
||||||
"""Get all documents for a company by INN."""
|
|
||||||
client = get_mongo_client()
|
|
||||||
if not client:
|
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
|
||||||
db = client[settings.MONGODB_DB]
|
|
||||||
collection = db["company_documents"]
|
|
||||||
return list(collection.find({"inn": inn}))
|
|
||||||
finally:
|
|
||||||
client.close()
|
|
||||||
|
|
||||||
|
|
||||||
def get_inn_by_profile_uuid(profile_uuid: str) -> str | None:
|
|
||||||
"""Get INN from KYCProfile by its UUID."""
|
|
||||||
try:
|
|
||||||
profile = KYCProfile.objects.get(uuid=profile_uuid)
|
|
||||||
# Get country details (KYCDetailsRussia) via GenericForeignKey
|
|
||||||
if profile.country_details and isinstance(profile.country_details, KYCDetailsRussia):
|
|
||||||
return profile.country_details.inn
|
|
||||||
return None
|
|
||||||
except KYCProfile.DoesNotExist:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def aggregate_company_data(documents: list) -> dict:
|
|
||||||
"""Aggregate data from multiple source documents into summary."""
|
|
||||||
if not documents:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
summary = {
|
|
||||||
"inn": None,
|
|
||||||
"ogrn": None,
|
|
||||||
"name": None,
|
|
||||||
"company_type": None,
|
|
||||||
"registration_year": None,
|
|
||||||
"is_active": True,
|
|
||||||
"address": None,
|
|
||||||
"director": None,
|
|
||||||
"capital": None,
|
|
||||||
"activities": [],
|
|
||||||
"sources": [],
|
|
||||||
"last_updated": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
for doc in documents:
|
|
||||||
source = doc.get("source", "unknown")
|
|
||||||
summary["sources"].append(source)
|
|
||||||
|
|
||||||
data = doc.get("data", {})
|
|
||||||
|
|
||||||
# Extract common fields
|
|
||||||
if not summary["inn"]:
|
|
||||||
summary["inn"] = doc.get("inn")
|
|
||||||
|
|
||||||
if not summary["ogrn"] and data.get("ogrn"):
|
|
||||||
summary["ogrn"] = data["ogrn"]
|
|
||||||
|
|
||||||
if not summary["name"] and data.get("name"):
|
|
||||||
summary["name"] = data["name"]
|
|
||||||
|
|
||||||
# Parse company type from name
|
|
||||||
if not summary["company_type"] and summary["name"]:
|
|
||||||
name = summary["name"].upper()
|
|
||||||
if "ООО" in name or "ОБЩЕСТВО С ОГРАНИЧЕННОЙ" in name:
|
|
||||||
summary["company_type"] = "ООО"
|
|
||||||
elif "АО" in name or "АКЦИОНЕРНОЕ ОБЩЕСТВО" in name:
|
|
||||||
summary["company_type"] = "АО"
|
|
||||||
elif "ИП" in name or "ИНДИВИДУАЛЬНЫЙ ПРЕДПРИНИМАТЕЛЬ" in name:
|
|
||||||
summary["company_type"] = "ИП"
|
|
||||||
elif "ПАО" in name:
|
|
||||||
summary["company_type"] = "ПАО"
|
|
||||||
|
|
||||||
# Track last update
|
|
||||||
collected_at = doc.get("collected_at")
|
|
||||||
if collected_at:
|
|
||||||
if not summary["last_updated"] or collected_at > summary["last_updated"]:
|
|
||||||
summary["last_updated"] = collected_at
|
|
||||||
|
|
||||||
return summary
|
|
||||||
|
|
||||||
|
|
||||||
class CompanyTeaserType(graphene.ObjectType):
|
|
||||||
"""Public company data (teaser)."""
|
|
||||||
|
|
||||||
company_type = graphene.String(description="Company type: ООО, АО, ИП, etc.")
|
|
||||||
registration_year = graphene.Int(description="Year of registration")
|
|
||||||
is_active = graphene.Boolean(description="Is company active")
|
|
||||||
sources_count = graphene.Int(description="Number of data sources")
|
|
||||||
|
|
||||||
|
|
||||||
class CompanyFullType(graphene.ObjectType):
|
|
||||||
"""Full company data (requires auth)."""
|
|
||||||
|
|
||||||
inn = graphene.String()
|
|
||||||
ogrn = graphene.String()
|
|
||||||
name = graphene.String()
|
|
||||||
company_type = graphene.String()
|
|
||||||
registration_year = graphene.Int()
|
|
||||||
is_active = graphene.Boolean()
|
|
||||||
address = graphene.String()
|
|
||||||
director = graphene.String()
|
|
||||||
capital = graphene.String()
|
|
||||||
activities = graphene.List(graphene.String)
|
|
||||||
sources = graphene.List(graphene.String)
|
|
||||||
last_updated = graphene.DateTime()
|
|
||||||
|
|
||||||
|
|
||||||
class PublicQuery(graphene.ObjectType):
|
|
||||||
"""Public queries - no authentication required."""
|
|
||||||
|
|
||||||
# Query by KYC Profile UUID (preferred - used by frontend)
|
|
||||||
kyc_profile_teaser = graphene.Field(
|
|
||||||
CompanyTeaserType,
|
|
||||||
profile_uuid=graphene.String(required=True),
|
|
||||||
description="Get public KYC profile teaser data by UUID",
|
|
||||||
)
|
|
||||||
|
|
||||||
kyc_profile_full = graphene.Field(
|
|
||||||
CompanyFullType,
|
|
||||||
profile_uuid=graphene.String(required=True),
|
|
||||||
description="Get full KYC profile data by UUID (requires auth)",
|
|
||||||
)
|
|
||||||
|
|
||||||
health = graphene.String()
|
|
||||||
|
|
||||||
def resolve_health(self, info):
|
|
||||||
return "ok"
|
|
||||||
|
|
||||||
def resolve_kyc_profile_teaser(self, info, profile_uuid: str):
|
|
||||||
"""Return public teaser data by KYC Profile UUID."""
|
|
||||||
inn = get_inn_by_profile_uuid(profile_uuid)
|
|
||||||
if not inn:
|
|
||||||
return None
|
|
||||||
|
|
||||||
documents = get_company_documents(inn)
|
|
||||||
if not documents:
|
|
||||||
return None
|
|
||||||
|
|
||||||
summary = aggregate_company_data(documents)
|
|
||||||
|
|
||||||
return CompanyTeaserType(
|
|
||||||
company_type=summary.get("company_type"),
|
|
||||||
registration_year=summary.get("registration_year"),
|
|
||||||
is_active=summary.get("is_active", True),
|
|
||||||
sources_count=len(summary.get("sources", [])),
|
|
||||||
)
|
|
||||||
|
|
||||||
def resolve_kyc_profile_full(self, info, profile_uuid: str):
|
|
||||||
"""Return full KYC profile data by UUID (requires auth)."""
|
|
||||||
# Check authentication
|
|
||||||
user_id = getattr(info.context, 'user_id', None)
|
|
||||||
if not user_id:
|
|
||||||
return None # Not authenticated
|
|
||||||
|
|
||||||
inn = get_inn_by_profile_uuid(profile_uuid)
|
|
||||||
if not inn:
|
|
||||||
return None
|
|
||||||
|
|
||||||
documents = get_company_documents(inn)
|
|
||||||
if not documents:
|
|
||||||
return None
|
|
||||||
|
|
||||||
summary = aggregate_company_data(documents)
|
|
||||||
|
|
||||||
return CompanyFullType(
|
|
||||||
inn=summary.get("inn"),
|
|
||||||
ogrn=summary.get("ogrn"),
|
|
||||||
name=summary.get("name"),
|
|
||||||
company_type=summary.get("company_type"),
|
|
||||||
registration_year=summary.get("registration_year"),
|
|
||||||
is_active=summary.get("is_active", True),
|
|
||||||
address=summary.get("address"),
|
|
||||||
director=summary.get("director"),
|
|
||||||
capital=summary.get("capital"),
|
|
||||||
activities=summary.get("activities", []),
|
|
||||||
sources=summary.get("sources", []),
|
|
||||||
last_updated=summary.get("last_updated"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
public_schema = graphene.Schema(query=PublicQuery)
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import graphene
|
|
||||||
from graphene_django import DjangoObjectType
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from ..models import KYCApplication, KYCDetailsRussia
|
|
||||||
from ..temporal import KycWorkflowClient
|
|
||||||
|
|
||||||
|
|
||||||
class KYCApplicationType(DjangoObjectType):
|
|
||||||
"""GraphQL type for KYC Application (заявка)."""
|
|
||||||
class Meta:
|
|
||||||
model = KYCApplication
|
|
||||||
fields = '__all__'
|
|
||||||
|
|
||||||
country_data = graphene.JSONString()
|
|
||||||
workflow_status = graphene.String()
|
|
||||||
|
|
||||||
def resolve_country_data(self, info):
|
|
||||||
return self.get_country_data()
|
|
||||||
|
|
||||||
|
|
||||||
class KYCDetailsRussiaType(DjangoObjectType):
|
|
||||||
"""GraphQL type for Russia-specific KYC details."""
|
|
||||||
class Meta:
|
|
||||||
model = KYCDetailsRussia
|
|
||||||
fields = '__all__'
|
|
||||||
|
|
||||||
|
|
||||||
class KYCApplicationRussiaInput(graphene.InputObjectType):
|
|
||||||
"""Input for creating KYC Application for Russia."""
|
|
||||||
companyName = graphene.String(required=True)
|
|
||||||
companyFullName = graphene.String(required=True)
|
|
||||||
inn = graphene.String(required=True)
|
|
||||||
kpp = graphene.String()
|
|
||||||
ogrn = graphene.String()
|
|
||||||
address = graphene.String(required=True)
|
|
||||||
bankName = graphene.String(required=True)
|
|
||||||
bik = graphene.String(required=True)
|
|
||||||
correspondentAccount = graphene.String()
|
|
||||||
contactPerson = graphene.String(required=True)
|
|
||||||
contactEmail = graphene.String(required=True)
|
|
||||||
contactPhone = graphene.String(required=True)
|
|
||||||
|
|
||||||
|
|
||||||
class CreateKYCApplicationRussia(graphene.Mutation):
|
|
||||||
"""Create KYC Application for Russian company."""
|
|
||||||
class Arguments:
|
|
||||||
input = KYCApplicationRussiaInput(required=True)
|
|
||||||
|
|
||||||
kyc_application = graphene.Field(KYCApplicationType)
|
|
||||||
success = graphene.Boolean()
|
|
||||||
|
|
||||||
def mutate(self, info, input):
|
|
||||||
# Get user_id from JWT token
|
|
||||||
user_id = getattr(info.context, 'user_id', None)
|
|
||||||
if not user_id:
|
|
||||||
raise Exception("Not authenticated")
|
|
||||||
|
|
||||||
# 1. Create Russia details
|
|
||||||
russia_details = KYCDetailsRussia.objects.create(
|
|
||||||
company_name=input.companyName,
|
|
||||||
company_full_name=input.companyFullName,
|
|
||||||
inn=input.inn,
|
|
||||||
kpp=input.kpp or '',
|
|
||||||
ogrn=input.ogrn or '',
|
|
||||||
address=input.address,
|
|
||||||
bank_name=input.bankName,
|
|
||||||
bik=input.bik,
|
|
||||||
correspondent_account=input.correspondentAccount or '',
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. Create main KYC Application with reference to details
|
|
||||||
kyc_application = KYCApplication.objects.create(
|
|
||||||
user_id=user_id,
|
|
||||||
team_name=input.companyName,
|
|
||||||
country_code='RU',
|
|
||||||
contact_person=input.contactPerson,
|
|
||||||
contact_email=input.contactEmail,
|
|
||||||
contact_phone=input.contactPhone,
|
|
||||||
content_type=ContentType.objects.get_for_model(KYCDetailsRussia),
|
|
||||||
object_id=russia_details.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3. Start Temporal workflow
|
|
||||||
KycWorkflowClient.start(kyc_application)
|
|
||||||
|
|
||||||
return CreateKYCApplicationRussia(kyc_application=kyc_application, success=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_applications(info):
|
|
||||||
user_id = getattr(info.context, 'user_id', None)
|
|
||||||
if not user_id:
|
|
||||||
return []
|
|
||||||
return KYCApplication.objects.filter(user_id=user_id)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_application(info, uuid):
|
|
||||||
user_id = getattr(info.context, 'user_id', None)
|
|
||||||
if not user_id:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return KYCApplication.objects.get(uuid=uuid, user_id=user_id)
|
|
||||||
except KYCApplication.DoesNotExist:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class UserQuery(graphene.ObjectType):
|
|
||||||
"""User schema - ID token authentication"""
|
|
||||||
# Keep old names for backwards compatibility
|
|
||||||
kyc_requests = graphene.List(KYCApplicationType, description="Get user's KYC applications")
|
|
||||||
kyc_request = graphene.Field(KYCApplicationType, uuid=graphene.String(required=True))
|
|
||||||
# New names
|
|
||||||
kyc_applications = graphene.List(KYCApplicationType, description="Get user's KYC applications")
|
|
||||||
kyc_application = graphene.Field(KYCApplicationType, uuid=graphene.String(required=True))
|
|
||||||
|
|
||||||
def resolve_kyc_requests(root, info):
|
|
||||||
return _get_applications(info)
|
|
||||||
|
|
||||||
def resolve_kyc_applications(root, info):
|
|
||||||
return _get_applications(info)
|
|
||||||
|
|
||||||
def resolve_kyc_request(root, info, uuid):
|
|
||||||
return _get_application(info, uuid)
|
|
||||||
|
|
||||||
def resolve_kyc_application(root, info, uuid):
|
|
||||||
return _get_application(info, uuid)
|
|
||||||
|
|
||||||
|
|
||||||
class UserMutation(graphene.ObjectType):
|
|
||||||
"""User mutations - ID token authentication"""
|
|
||||||
# Keep old name for backwards compatibility
|
|
||||||
create_kyc_request_russia = CreateKYCApplicationRussia.Field()
|
|
||||||
# New name
|
|
||||||
create_kyc_application_russia = CreateKYCApplicationRussia.Field()
|
|
||||||
|
|
||||||
|
|
||||||
user_schema = graphene.Schema(query=UserQuery, mutation=UserMutation)
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
import requests
|
|
||||||
from django.conf import settings
|
|
||||||
from .models import Order, OrderLine, Stage, Trip
|
|
||||||
|
|
||||||
class OdooService:
|
|
||||||
def __init__(self):
|
|
||||||
self.base_url = f"http://{settings.ODOO_INTERNAL_URL}"
|
|
||||||
|
|
||||||
def get_odoo_orders(self, team_uuid):
|
|
||||||
"""Получить заказы из Odoo API"""
|
|
||||||
try:
|
|
||||||
url = f"{self.base_url}/fastapi/orders/api/v1/orders/team/{team_uuid}"
|
|
||||||
response = requests.get(url, timeout=10)
|
|
||||||
if response.status_code == 200:
|
|
||||||
return response.json()
|
|
||||||
return []
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error fetching from Odoo: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def get_odoo_order(self, order_uuid):
|
|
||||||
"""Получить заказ из Odoo API"""
|
|
||||||
try:
|
|
||||||
url = f"{self.base_url}/fastapi/orders/api/v1/orders/{order_uuid}"
|
|
||||||
response = requests.get(url, timeout=10)
|
|
||||||
if response.status_code == 200:
|
|
||||||
return response.json()
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error fetching order from Odoo: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def sync_team_orders(self, team_uuid):
|
|
||||||
"""Синхронизировать заказы команды с Odoo"""
|
|
||||||
odoo_orders = self.get_odoo_orders(team_uuid)
|
|
||||||
django_orders = []
|
|
||||||
|
|
||||||
for odoo_order in odoo_orders:
|
|
||||||
# Создаем или обновляем заказ в Django
|
|
||||||
order, created = Order.objects.get_or_create(
|
|
||||||
uuid=odoo_order['uuid'],
|
|
||||||
defaults={
|
|
||||||
'name': odoo_order['name'],
|
|
||||||
'team_uuid': odoo_order['teamUuid'],
|
|
||||||
'user_id': odoo_order['userId'],
|
|
||||||
'source_location_uuid': odoo_order['sourceLocationUuid'],
|
|
||||||
'source_location_name': odoo_order['sourceLocationName'],
|
|
||||||
'destination_location_uuid': odoo_order['destinationLocationUuid'],
|
|
||||||
'destination_location_name': odoo_order['destinationLocationName'],
|
|
||||||
'status': odoo_order['status'],
|
|
||||||
'total_amount': odoo_order['totalAmount'],
|
|
||||||
'currency': odoo_order['currency'],
|
|
||||||
'notes': odoo_order.get('notes', ''),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Синхронизируем order lines
|
|
||||||
self.sync_order_lines(order, odoo_order.get('orderLines', []))
|
|
||||||
|
|
||||||
# Синхронизируем stages
|
|
||||||
self.sync_stages(order, odoo_order.get('stages', []))
|
|
||||||
|
|
||||||
django_orders.append(order)
|
|
||||||
|
|
||||||
return django_orders
|
|
||||||
|
|
||||||
def sync_order(self, order_uuid):
|
|
||||||
"""Синхронизировать один заказ с Odoo"""
|
|
||||||
odoo_order = self.get_odoo_order(order_uuid)
|
|
||||||
if not odoo_order:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Создаем или обновляем заказ
|
|
||||||
order, created = Order.objects.get_or_create(
|
|
||||||
uuid=odoo_order['uuid'],
|
|
||||||
defaults={
|
|
||||||
'name': odoo_order['name'],
|
|
||||||
'team_uuid': odoo_order['teamUuid'],
|
|
||||||
'user_id': odoo_order['userId'],
|
|
||||||
'source_location_uuid': odoo_order['sourceLocationUuid'],
|
|
||||||
'source_location_name': odoo_order['sourceLocationName'],
|
|
||||||
'destination_location_uuid': odoo_order['destinationLocationUuid'],
|
|
||||||
'destination_location_name': odoo_order['destinationLocationName'],
|
|
||||||
'status': odoo_order['status'],
|
|
||||||
'total_amount': odoo_order['totalAmount'],
|
|
||||||
'currency': odoo_order['currency'],
|
|
||||||
'notes': odoo_order.get('notes', ''),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Синхронизируем связанные данные
|
|
||||||
self.sync_order_lines(order, odoo_order.get('orderLines', []))
|
|
||||||
self.sync_stages(order, odoo_order.get('stages', []))
|
|
||||||
|
|
||||||
return order
|
|
||||||
|
|
||||||
def sync_order_lines(self, order, odoo_lines):
|
|
||||||
"""Синхронизировать строки заказа"""
|
|
||||||
# Удаляем старые
|
|
||||||
order.order_lines.all().delete()
|
|
||||||
|
|
||||||
# Создаем новые
|
|
||||||
for line_data in odoo_lines:
|
|
||||||
OrderLine.objects.create(
|
|
||||||
uuid=line_data['uuid'],
|
|
||||||
order=order,
|
|
||||||
product_uuid=line_data['productUuid'],
|
|
||||||
product_name=line_data['productName'],
|
|
||||||
quantity=line_data['quantity'],
|
|
||||||
unit=line_data['unit'],
|
|
||||||
price_unit=line_data['priceUnit'],
|
|
||||||
subtotal=line_data['subtotal'],
|
|
||||||
currency=line_data.get('currency', 'RUB'),
|
|
||||||
notes=line_data.get('notes', ''),
|
|
||||||
)
|
|
||||||
|
|
||||||
def sync_stages(self, order, odoo_stages):
|
|
||||||
"""Синхронизировать этапы заказа"""
|
|
||||||
# Удаляем старые
|
|
||||||
order.stages.all().delete()
|
|
||||||
|
|
||||||
# Создаем новые
|
|
||||||
for stage_data in odoo_stages:
|
|
||||||
stage = Stage.objects.create(
|
|
||||||
uuid=stage_data['uuid'],
|
|
||||||
order=order,
|
|
||||||
name=stage_data['name'],
|
|
||||||
sequence=stage_data['sequence'],
|
|
||||||
stage_type=stage_data['stageType'],
|
|
||||||
transport_type=stage_data.get('transportType', ''),
|
|
||||||
source_location_name=stage_data.get('sourceLocationName', ''),
|
|
||||||
destination_location_name=stage_data.get('destinationLocationName', ''),
|
|
||||||
location_name=stage_data.get('locationName', ''),
|
|
||||||
selected_company_uuid=stage_data.get('selectedCompany', {}).get('uuid', '') if stage_data.get('selectedCompany') else '',
|
|
||||||
selected_company_name=stage_data.get('selectedCompany', {}).get('name', '') if stage_data.get('selectedCompany') else '',
|
|
||||||
)
|
|
||||||
|
|
||||||
# Синхронизируем trips
|
|
||||||
self.sync_trips(stage, stage_data.get('trips', []))
|
|
||||||
|
|
||||||
def sync_trips(self, stage, odoo_trips):
|
|
||||||
"""Синхронизировать рейсы этапа"""
|
|
||||||
for trip_data in odoo_trips:
|
|
||||||
Trip.objects.create(
|
|
||||||
uuid=trip_data['uuid'],
|
|
||||||
stage=stage,
|
|
||||||
name=trip_data['name'],
|
|
||||||
sequence=trip_data['sequence'],
|
|
||||||
company_uuid=trip_data.get('company', {}).get('uuid', '') if trip_data.get('company') else '',
|
|
||||||
company_name=trip_data.get('company', {}).get('name', '') if trip_data.get('company') else '',
|
|
||||||
planned_weight=trip_data.get('plannedWeight'),
|
|
||||||
weight_at_loading=trip_data.get('weightAtLoading'),
|
|
||||||
weight_at_unloading=trip_data.get('weightAtUnloading'),
|
|
||||||
planned_loading_date=trip_data.get('plannedLoadingDate'),
|
|
||||||
actual_loading_date=trip_data.get('actualLoadingDate'),
|
|
||||||
real_loading_date=trip_data.get('realLoadingDate'),
|
|
||||||
planned_unloading_date=trip_data.get('plannedUnloadingDate'),
|
|
||||||
actual_unloading_date=trip_data.get('actualUnloadingDate'),
|
|
||||||
notes=trip_data.get('notes', ''),
|
|
||||||
)
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import base64
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import urllib.request
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _surreal_headers() -> dict[str, str]:
|
|
||||||
headers = {
|
|
||||||
"Content-Type": "text/plain",
|
|
||||||
"Accept": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
ns = os.getenv("SURREALDB_NS", "optovia")
|
|
||||||
db = os.getenv("SURREALDB_DB", "events")
|
|
||||||
user = os.getenv("SURREALDB_USER")
|
|
||||||
password = os.getenv("SURREALDB_PASS")
|
|
||||||
|
|
||||||
headers["NS"] = ns
|
|
||||||
headers["DB"] = db
|
|
||||||
|
|
||||||
if user and password:
|
|
||||||
token = base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("utf-8")
|
|
||||||
headers["Authorization"] = f"Basic {token}"
|
|
||||||
|
|
||||||
return headers
|
|
||||||
|
|
||||||
|
|
||||||
def log_kyc_event(kyc_id: str, user_id: str, event: str, description: str) -> bool:
|
|
||||||
url = os.getenv("SURREALDB_URL")
|
|
||||||
if not url:
|
|
||||||
logger.warning("SURREALDB_URL is not set; skipping KYC event log")
|
|
||||||
return False
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"kyc_id": kyc_id,
|
|
||||||
"user_id": user_id,
|
|
||||||
"event": event,
|
|
||||||
"description": description,
|
|
||||||
"created_at": datetime.utcnow().isoformat() + "Z",
|
|
||||||
}
|
|
||||||
|
|
||||||
query = f"CREATE kyc_event CONTENT {json.dumps(payload)};"
|
|
||||||
|
|
||||||
req = urllib.request.Request(
|
|
||||||
f"{url.rstrip('/')}/sql",
|
|
||||||
data=query.encode("utf-8"),
|
|
||||||
method="POST",
|
|
||||||
headers=_surreal_headers(),
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=10) as response:
|
|
||||||
response.read()
|
|
||||||
return True
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Failed to log KYC event: %s", exc)
|
|
||||||
return False
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from .kyc_workflow_client import KycWorkflowClient
|
|
||||||
|
|
||||||
__all__ = ["KycWorkflowClient"]
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
"""
|
|
||||||
KYC Workflow Client - контракт взаимодействия с Temporal KYC workflow.
|
|
||||||
|
|
||||||
Этот файл содержит методы для запуска KYC workflow из Django.
|
|
||||||
Approve/reject сигналы отправляются из Odoo.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from temporalio.client import Client
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class KycWorkflowData:
|
|
||||||
"""Данные для запуска KYC workflow."""
|
|
||||||
|
|
||||||
kyc_request_id: str
|
|
||||||
team_name: str
|
|
||||||
owner_id: str
|
|
||||||
owner_email: str
|
|
||||||
country_code: str
|
|
||||||
country_data: dict = field(default_factory=dict)
|
|
||||||
# team_id создаётся в workflow после approve, не передаётся сюда
|
|
||||||
|
|
||||||
|
|
||||||
class KycWorkflowClient:
|
|
||||||
"""
|
|
||||||
Клиент для запуска KYC Application workflow.
|
|
||||||
|
|
||||||
Использование:
|
|
||||||
KycWorkflowClient.start(kyc_request)
|
|
||||||
|
|
||||||
Flow:
|
|
||||||
1. Django вызывает start() → запускает workflow
|
|
||||||
2. Workflow добавляет KYC в Odoo, ставит статус KYC_IN_REVIEW
|
|
||||||
3. Workflow ждёт сигнала approve/reject (из Odoo)
|
|
||||||
4. После approve → создаёт Logto org, ставит ACTIVE
|
|
||||||
"""
|
|
||||||
|
|
||||||
WORKFLOW_NAME = "kyc_application"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def _get_client(cls) -> Client:
|
|
||||||
"""Получить подключение к Temporal."""
|
|
||||||
return await Client.connect(
|
|
||||||
settings.TEMPORAL_HOST,
|
|
||||||
namespace=settings.TEMPORAL_NAMESPACE,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def start_async(cls, data: KycWorkflowData) -> str:
|
|
||||||
"""
|
|
||||||
Запустить KYC Application workflow (async).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
workflow_id: ID запущенного workflow
|
|
||||||
"""
|
|
||||||
client = await cls._get_client()
|
|
||||||
|
|
||||||
workflow_id = data.kyc_request_id
|
|
||||||
|
|
||||||
await client.start_workflow(
|
|
||||||
cls.WORKFLOW_NAME,
|
|
||||||
{
|
|
||||||
"kyc_request_id": data.kyc_request_id,
|
|
||||||
"team_name": data.team_name,
|
|
||||||
"owner_id": data.owner_id,
|
|
||||||
"owner_email": data.owner_email,
|
|
||||||
"country_code": data.country_code,
|
|
||||||
"country_data": data.country_data,
|
|
||||||
},
|
|
||||||
id=workflow_id,
|
|
||||||
task_queue=settings.TEMPORAL_TASK_QUEUE,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"KYC workflow started: {workflow_id}")
|
|
||||||
return workflow_id
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def start(cls, kyc_request) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Запустить KYC Application workflow (sync wrapper).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
kyc_request: KYCRequest model instance (главная модель)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
workflow_id или None при ошибке
|
|
||||||
"""
|
|
||||||
# Собираем данные страны через GenericForeignKey
|
|
||||||
country_data = kyc_request.get_country_data()
|
|
||||||
|
|
||||||
data = KycWorkflowData(
|
|
||||||
kyc_request_id=str(kyc_request.uuid),
|
|
||||||
team_name=kyc_request.team_name or country_data.get('company_name', ''),
|
|
||||||
owner_id=kyc_request.user_id,
|
|
||||||
owner_email=kyc_request.contact_email,
|
|
||||||
country_code=kyc_request.country_code,
|
|
||||||
country_data=country_data,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
workflow_id = asyncio.run(cls.start_async(data))
|
|
||||||
kyc_request.workflow_status = "active"
|
|
||||||
kyc_request.save(update_fields=["workflow_status", "updated_at"])
|
|
||||||
return workflow_id
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to start KYC workflow: {e}")
|
|
||||||
kyc_request.workflow_status = "error"
|
|
||||||
kyc_request.save(update_fields=["workflow_status", "updated_at"])
|
|
||||||
return None
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
"""
|
|
||||||
Views for KYC API.
|
|
||||||
|
|
||||||
Authentication is handled by GRAPHENE MIDDLEWARE in settings.py
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from django.db.models import Count, Q
|
|
||||||
from django.http import HttpResponse
|
|
||||||
from django.utils import timezone
|
|
||||||
from graphene_django.views import GraphQLView
|
|
||||||
|
|
||||||
from .graphql_middleware import UserJWTMiddleware
|
|
||||||
from .models import KYCProfile
|
|
||||||
|
|
||||||
|
|
||||||
class UserGraphQLView(GraphQLView):
|
|
||||||
"""User endpoint - requires ID Token."""
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
kwargs['middleware'] = [UserJWTMiddleware()]
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class M2MGraphQLView(GraphQLView):
|
|
||||||
"""M2M endpoint - no authentication (internal network only)."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class OptionalUserJWTMiddleware:
|
|
||||||
"""Middleware that optionally extracts user_id but doesn't fail if missing."""
|
|
||||||
|
|
||||||
def resolve(self, next, root, info, **args):
|
|
||||||
request = info.context
|
|
||||||
|
|
||||||
# Try to extract user_id from Authorization header if present
|
|
||||||
auth_header = request.META.get('HTTP_AUTHORIZATION', '')
|
|
||||||
if auth_header.startswith('Bearer '):
|
|
||||||
try:
|
|
||||||
from .auth import validate_jwt_token
|
|
||||||
token = auth_header.split(' ', 1)[1]
|
|
||||||
payload = validate_jwt_token(token)
|
|
||||||
if payload:
|
|
||||||
request.user_id = payload.get('sub')
|
|
||||||
except Exception:
|
|
||||||
pass # Ignore auth errors - user just won't get full data
|
|
||||||
|
|
||||||
return next(root, info, **args)
|
|
||||||
|
|
||||||
|
|
||||||
class PublicGraphQLView(GraphQLView):
|
|
||||||
"""Public endpoint - optional auth for full data, no auth for teaser."""
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
# Use optional auth middleware that doesn't fail on missing token
|
|
||||||
kwargs['middleware'] = [OptionalUserJWTMiddleware()]
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def metrics_view(request):
|
|
||||||
"""Prometheus metrics endpoint for KYC monitoring status."""
|
|
||||||
stale_days = int(os.getenv("KYC_STALE_DAYS", "90"))
|
|
||||||
cutoff = timezone.now() - timedelta(days=stale_days)
|
|
||||||
|
|
||||||
stats = KYCProfile.objects.filter(workflow_status="active").aggregate(
|
|
||||||
total=Count("id"),
|
|
||||||
stale=Count("id", filter=Q(updated_at__lt=cutoff) | Q(updated_at__isnull=True)),
|
|
||||||
fresh=Count("id", filter=Q(updated_at__gte=cutoff)),
|
|
||||||
)
|
|
||||||
|
|
||||||
total = int(stats.get("total") or 0)
|
|
||||||
stale = int(stats.get("stale") or 0)
|
|
||||||
fresh = int(stats.get("fresh") or 0)
|
|
||||||
ratio = 0.0 if total == 0 else stale / total
|
|
||||||
|
|
||||||
payload = (
|
|
||||||
"# HELP kyc_monitoring_total Total active profiles in monitoring\n"
|
|
||||||
"# TYPE kyc_monitoring_total gauge\n"
|
|
||||||
f"kyc_monitoring_total {total}\n"
|
|
||||||
"# HELP kyc_monitoring_stale Stale profiles (no refresh within window)\n"
|
|
||||||
"# TYPE kyc_monitoring_stale gauge\n"
|
|
||||||
f"kyc_monitoring_stale {stale}\n"
|
|
||||||
"# HELP kyc_monitoring_fresh Fresh profiles (refreshed within window)\n"
|
|
||||||
"# TYPE kyc_monitoring_fresh gauge\n"
|
|
||||||
f"kyc_monitoring_fresh {fresh}\n"
|
|
||||||
"# HELP kyc_monitoring_stale_ratio Stale / total ratio\n"
|
|
||||||
"# TYPE kyc_monitoring_stale_ratio gauge\n"
|
|
||||||
f"kyc_monitoring_stale_ratio {ratio}\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
return HttpResponse(payload, content_type="text/plain; version=0.0.4; charset=utf-8")
|
|
||||||
17
manage.py
17
manage.py
@@ -1,17 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
"""Django's command-line utility for administrative tasks."""
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
"""Run administrative tasks."""
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'kyc.settings')
|
|
||||||
try:
|
|
||||||
from django.core.management import execute_from_command_line
|
|
||||||
except ImportError as exc:
|
|
||||||
raise ImportError(
|
|
||||||
"Couldn't import Django. Are you sure it's installed and "
|
|
||||||
"available on your PYTHONPATH environment variable? Did you "
|
|
||||||
"forget to activate a virtual environment?"
|
|
||||||
) from exc
|
|
||||||
execute_from_command_line(sys.argv)
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
providers = ["python"]
|
|
||||||
|
|
||||||
[build]
|
|
||||||
|
|
||||||
[phases.install]
|
|
||||||
cmds = [
|
|
||||||
"python -m venv --copies /opt/venv",
|
|
||||||
". /opt/venv/bin/activate",
|
|
||||||
"pip install poetry==$NIXPACKS_POETRY_VERSION",
|
|
||||||
"poetry install --no-interaction --no-ansi"
|
|
||||||
]
|
|
||||||
|
|
||||||
[start]
|
|
||||||
cmd = "poetry run python manage.py migrate && poetry run python manage.py collectstatic --noinput && poetry run python -m gunicorn kyc.wsgi:application --bind 0.0.0.0:${PORT:-8000}"
|
|
||||||
|
|
||||||
[variables]
|
|
||||||
# Set Poetry version to match local environment
|
|
||||||
NIXPACKS_POETRY_VERSION = "2.2.1"
|
|
||||||
4331
package-lock.json
generated
Normal file
4331
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "kyc",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "prisma generate && tsc",
|
||||||
|
"start": "prisma migrate deploy && node dist/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@apollo/server": "^4.11.3",
|
||||||
|
"@prisma/client": "^6.5.0",
|
||||||
|
"@temporalio/client": "^1.11.7",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"graphql": "^16.10.0",
|
||||||
|
"graphql-tag": "^2.12.6",
|
||||||
|
"jose": "^6.0.11",
|
||||||
|
"mongodb": "^6.13.0",
|
||||||
|
"@sentry/node": "^9.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/node": "^22.13.0",
|
||||||
|
"prisma": "^6.5.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1120
poetry.lock
generated
1120
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
67
prisma/schema.prisma
Normal file
67
prisma/schema.prisma
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("KYC_DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model KYCApplication {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
uuid String @unique @default(uuid())
|
||||||
|
userId String @map("user_id") @db.VarChar(255)
|
||||||
|
teamName String @default("") @map("team_name") @db.VarChar(200)
|
||||||
|
countryCode String @default("") @map("country_code") @db.VarChar(2)
|
||||||
|
workflowStatus String @default("pending") @map("workflow_status") @db.VarChar(20)
|
||||||
|
score Int @default(0)
|
||||||
|
contactPerson String @default("") @map("contact_person") @db.VarChar(255)
|
||||||
|
contactEmail String @default("") @map("contact_email") @db.VarChar(254)
|
||||||
|
contactPhone String @default("") @map("contact_phone") @db.VarChar(50)
|
||||||
|
contentTypeId Int? @map("content_type_id")
|
||||||
|
objectId Int? @map("object_id")
|
||||||
|
approvedBy String? @map("approved_by") @db.VarChar(255)
|
||||||
|
approvedAt DateTime? @map("approved_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@map("kyc_requests")
|
||||||
|
}
|
||||||
|
|
||||||
|
model KYCProfile {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
uuid String @unique @default(uuid())
|
||||||
|
userId String @map("user_id") @db.VarChar(255)
|
||||||
|
teamName String @default("") @map("team_name") @db.VarChar(200)
|
||||||
|
countryCode String @default("") @map("country_code") @db.VarChar(2)
|
||||||
|
workflowStatus String @default("pending") @map("workflow_status") @db.VarChar(20)
|
||||||
|
score Int @default(0)
|
||||||
|
contactPerson String @default("") @map("contact_person") @db.VarChar(255)
|
||||||
|
contactEmail String @default("") @map("contact_email") @db.VarChar(254)
|
||||||
|
contactPhone String @default("") @map("contact_phone") @db.VarChar(50)
|
||||||
|
contentTypeId Int? @map("content_type_id")
|
||||||
|
objectId Int? @map("object_id")
|
||||||
|
approvedBy String? @map("approved_by") @db.VarChar(255)
|
||||||
|
approvedAt DateTime? @map("approved_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@map("kyc_monitoring")
|
||||||
|
}
|
||||||
|
|
||||||
|
model KYCDetailsRussia {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
companyName String @map("company_name") @db.VarChar(255)
|
||||||
|
companyFullName String @map("company_full_name")
|
||||||
|
inn String @db.VarChar(12)
|
||||||
|
kpp String @default("") @db.VarChar(9)
|
||||||
|
ogrn String @default("") @db.VarChar(15)
|
||||||
|
address String
|
||||||
|
bankName String @map("bank_name") @db.VarChar(255)
|
||||||
|
bik String @db.VarChar(9)
|
||||||
|
correspondentAccount String @default("") @map("correspondent_account") @db.VarChar(20)
|
||||||
|
|
||||||
|
@@map("kyc_details_russia")
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
[project]
|
|
||||||
name = "kyc"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "KYC backend service"
|
|
||||||
authors = [
|
|
||||||
{name = "Ruslan Bakiev",email = "572431+veikab@users.noreply.github.com"}
|
|
||||||
]
|
|
||||||
readme = "README.md"
|
|
||||||
requires-python = "^3.11"
|
|
||||||
dependencies = [
|
|
||||||
"django (>=5.2.8,<6.0)",
|
|
||||||
"graphene-django (>=3.2.3,<4.0.0)",
|
|
||||||
"django-cors-headers (>=4.9.0,<5.0.0)",
|
|
||||||
"psycopg2-binary (>=2.9.11,<3.0.0)",
|
|
||||||
"python-dotenv (>=1.2.1,<2.0.0)",
|
|
||||||
"infisicalsdk (>=1.0.12,<2.0.0)",
|
|
||||||
"gunicorn (>=23.0.0,<24.0.0)",
|
|
||||||
"whitenoise (>=6.7.0,<7.0.0)",
|
|
||||||
"sentry-sdk (>=2.47.0,<3.0.0)",
|
|
||||||
"pyjwt (>=2.10.1,<3.0.0)",
|
|
||||||
"cryptography (>=41.0.0)",
|
|
||||||
"temporalio (>=1.20.0,<2.0.0)",
|
|
||||||
"pymongo (>=4.16.0,<5.0.0)"
|
|
||||||
]
|
|
||||||
|
|
||||||
[build-system]
|
|
||||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
||||||
build-backend = "poetry.core.masonry.api"
|
|
||||||
47
src/auth.ts
Normal file
47
src/auth.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'
|
||||||
|
import { GraphQLError } from 'graphql'
|
||||||
|
import type { Request } from 'express'
|
||||||
|
|
||||||
|
const LOGTO_JWKS_URL = process.env.LOGTO_JWKS_URL || 'https://auth.optovia.ru/oidc/jwks'
|
||||||
|
const LOGTO_ISSUER = process.env.LOGTO_ISSUER || 'https://auth.optovia.ru/oidc'
|
||||||
|
|
||||||
|
const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL))
|
||||||
|
|
||||||
|
export interface AuthContext {
|
||||||
|
userId?: string
|
||||||
|
scopes: string[]
|
||||||
|
isM2M?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBearerToken(req: Request): string | null {
|
||||||
|
const auth = req.headers.authorization || ''
|
||||||
|
if (!auth.startsWith('Bearer ')) return null
|
||||||
|
const token = auth.slice(7)
|
||||||
|
if (!token || token === 'undefined') return null
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publicContext(req: Request): Promise<AuthContext> {
|
||||||
|
// Optional auth - try to extract userId if token present
|
||||||
|
const token = getBearerToken(req)
|
||||||
|
if (!token) return { scopes: [] }
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER })
|
||||||
|
return { userId: payload.sub, scopes: [] }
|
||||||
|
} catch {
|
||||||
|
return { scopes: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function userContext(req: Request): Promise<AuthContext> {
|
||||||
|
const token = getBearerToken(req)
|
||||||
|
if (!token) {
|
||||||
|
throw new GraphQLError('Unauthorized', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||||
|
}
|
||||||
|
const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER })
|
||||||
|
return { userId: payload.sub, scopes: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function m2mContext(): Promise<AuthContext> {
|
||||||
|
return { scopes: [], isM2M: true }
|
||||||
|
}
|
||||||
3
src/db.ts
Normal file
3
src/db.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
export const prisma = new PrismaClient()
|
||||||
80
src/index.ts
Normal file
80
src/index.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import cors from 'cors'
|
||||||
|
import { ApolloServer } from '@apollo/server'
|
||||||
|
import { expressMiddleware } from '@apollo/server/express4'
|
||||||
|
import * as Sentry from '@sentry/node'
|
||||||
|
import { publicTypeDefs, publicResolvers } from './schemas/public.js'
|
||||||
|
import { userTypeDefs, userResolvers } from './schemas/user.js'
|
||||||
|
import { m2mTypeDefs, m2mResolvers } from './schemas/m2m.js'
|
||||||
|
import { publicContext, userContext, m2mContext, type AuthContext } from './auth.js'
|
||||||
|
|
||||||
|
const PORT = parseInt(process.env.PORT || '8000', 10)
|
||||||
|
const SENTRY_DSN = process.env.SENTRY_DSN || ''
|
||||||
|
|
||||||
|
if (SENTRY_DSN) {
|
||||||
|
Sentry.init({
|
||||||
|
dsn: SENTRY_DSN,
|
||||||
|
tracesSampleRate: 0.01,
|
||||||
|
release: process.env.RELEASE_VERSION || '1.0.0',
|
||||||
|
environment: process.env.ENVIRONMENT || 'production',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
|
||||||
|
app.use(cors({ origin: ['https://optovia.ru'], credentials: true }))
|
||||||
|
|
||||||
|
const publicServer = new ApolloServer<AuthContext>({
|
||||||
|
typeDefs: publicTypeDefs,
|
||||||
|
resolvers: publicResolvers,
|
||||||
|
introspection: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const userServer = new ApolloServer<AuthContext>({
|
||||||
|
typeDefs: userTypeDefs,
|
||||||
|
resolvers: userResolvers,
|
||||||
|
introspection: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const m2mServer = new ApolloServer<AuthContext>({
|
||||||
|
typeDefs: m2mTypeDefs,
|
||||||
|
resolvers: m2mResolvers,
|
||||||
|
introspection: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all([publicServer.start(), userServer.start(), m2mServer.start()])
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
'/graphql/public',
|
||||||
|
express.json(),
|
||||||
|
expressMiddleware(publicServer, {
|
||||||
|
context: async ({ req }) => publicContext(req as unknown as import('express').Request),
|
||||||
|
}) as unknown as express.RequestHandler,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
'/graphql/user',
|
||||||
|
express.json(),
|
||||||
|
expressMiddleware(userServer, {
|
||||||
|
context: async ({ req }) => userContext(req as unknown as import('express').Request),
|
||||||
|
}) as unknown as express.RequestHandler,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
'/graphql/m2m',
|
||||||
|
express.json(),
|
||||||
|
expressMiddleware(m2mServer, {
|
||||||
|
context: async () => m2mContext(),
|
||||||
|
}) as unknown as express.RequestHandler,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.get('/health', (_, res) => {
|
||||||
|
res.json({ status: 'ok' })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`KYC server ready on port ${PORT}`)
|
||||||
|
console.log(` /graphql/public - public (optional auth)`)
|
||||||
|
console.log(` /graphql/user - id token auth`)
|
||||||
|
console.log(` /graphql/m2m - internal services (no auth)`)
|
||||||
|
})
|
||||||
70
src/schemas/m2m.ts
Normal file
70
src/schemas/m2m.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { prisma } from '../db.js'
|
||||||
|
|
||||||
|
export const m2mTypeDefs = `#graphql
|
||||||
|
type CreateKycProfileResult {
|
||||||
|
success: Boolean!
|
||||||
|
profileUuid: String
|
||||||
|
message: String
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query {
|
||||||
|
health: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mutation {
|
||||||
|
createKycProfile(kycApplicationId: String!): CreateKycProfileResult
|
||||||
|
createKycMonitoring(kycApplicationId: String!): CreateKycProfileResult
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
async function createProfile(_: unknown, args: { kycApplicationId: string }) {
|
||||||
|
try {
|
||||||
|
const app = await prisma.kYCApplication.findUnique({
|
||||||
|
where: { uuid: args.kycApplicationId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!app) {
|
||||||
|
return { success: false, profileUuid: '', message: `KYCApplication not found: ${args.kycApplicationId}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if profile already exists
|
||||||
|
const existing = await prisma.kYCProfile.findFirst({
|
||||||
|
where: { userId: app.userId, teamName: app.teamName },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return { success: true, profileUuid: existing.uuid, message: 'Profile already exists' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await prisma.kYCProfile.create({
|
||||||
|
data: {
|
||||||
|
userId: app.userId,
|
||||||
|
teamName: app.teamName,
|
||||||
|
countryCode: app.countryCode,
|
||||||
|
workflowStatus: 'active',
|
||||||
|
score: app.score,
|
||||||
|
contactPerson: app.contactPerson,
|
||||||
|
contactEmail: app.contactEmail,
|
||||||
|
contactPhone: app.contactPhone,
|
||||||
|
contentTypeId: app.contentTypeId,
|
||||||
|
objectId: app.objectId,
|
||||||
|
approvedBy: app.approvedBy,
|
||||||
|
approvedAt: app.approvedAt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true, profileUuid: profile.uuid, message: 'Profile created' }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, profileUuid: '', message: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const m2mResolvers = {
|
||||||
|
Query: {
|
||||||
|
health: () => 'ok',
|
||||||
|
},
|
||||||
|
Mutation: {
|
||||||
|
createKycProfile: createProfile,
|
||||||
|
createKycMonitoring: createProfile,
|
||||||
|
},
|
||||||
|
}
|
||||||
89
src/schemas/public.ts
Normal file
89
src/schemas/public.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { prisma } from '../db.js'
|
||||||
|
import { getCompanyDocuments, aggregateCompanyData } from '../services/mongodb.js'
|
||||||
|
import type { AuthContext } from '../auth.js'
|
||||||
|
|
||||||
|
export const publicTypeDefs = `#graphql
|
||||||
|
type CompanyTeaser {
|
||||||
|
companyType: String
|
||||||
|
registrationYear: Int
|
||||||
|
isActive: Boolean
|
||||||
|
sourcesCount: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
type CompanyFull {
|
||||||
|
inn: String
|
||||||
|
ogrn: String
|
||||||
|
name: String
|
||||||
|
companyType: String
|
||||||
|
registrationYear: Int
|
||||||
|
isActive: Boolean
|
||||||
|
address: String
|
||||||
|
director: String
|
||||||
|
capital: String
|
||||||
|
activities: [String]
|
||||||
|
sources: [String]
|
||||||
|
lastUpdated: String
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query {
|
||||||
|
kycProfileTeaser(profileUuid: String!): CompanyTeaser
|
||||||
|
kycProfileFull(profileUuid: String!): CompanyFull
|
||||||
|
health: String!
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
async function getInnByProfileUuid(profileUuid: string): Promise<string | null> {
|
||||||
|
const profile = await prisma.kYCProfile.findUnique({ where: { uuid: profileUuid } })
|
||||||
|
if (!profile || !profile.objectId) return null
|
||||||
|
|
||||||
|
const details = await prisma.kYCDetailsRussia.findUnique({ where: { id: profile.objectId } })
|
||||||
|
return details?.inn ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const publicResolvers = {
|
||||||
|
Query: {
|
||||||
|
health: () => 'ok',
|
||||||
|
|
||||||
|
kycProfileTeaser: async (_: unknown, args: { profileUuid: string }) => {
|
||||||
|
const inn = await getInnByProfileUuid(args.profileUuid)
|
||||||
|
if (!inn) return null
|
||||||
|
|
||||||
|
const docs = await getCompanyDocuments(inn)
|
||||||
|
if (docs.length === 0) return null
|
||||||
|
|
||||||
|
const summary = aggregateCompanyData(docs as Record<string, unknown>[])
|
||||||
|
return {
|
||||||
|
companyType: summary.companyType,
|
||||||
|
registrationYear: summary.registrationYear,
|
||||||
|
isActive: summary.isActive,
|
||||||
|
sourcesCount: summary.sources.length,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
kycProfileFull: async (_: unknown, args: { profileUuid: string }, ctx: AuthContext) => {
|
||||||
|
if (!ctx.userId) return null
|
||||||
|
|
||||||
|
const inn = await getInnByProfileUuid(args.profileUuid)
|
||||||
|
if (!inn) return null
|
||||||
|
|
||||||
|
const docs = await getCompanyDocuments(inn)
|
||||||
|
if (docs.length === 0) return null
|
||||||
|
|
||||||
|
const summary = aggregateCompanyData(docs as Record<string, unknown>[])
|
||||||
|
return {
|
||||||
|
inn: summary.inn,
|
||||||
|
ogrn: summary.ogrn,
|
||||||
|
name: summary.name,
|
||||||
|
companyType: summary.companyType,
|
||||||
|
registrationYear: summary.registrationYear,
|
||||||
|
isActive: summary.isActive,
|
||||||
|
address: summary.address,
|
||||||
|
director: summary.director,
|
||||||
|
capital: summary.capital,
|
||||||
|
activities: summary.activities,
|
||||||
|
sources: summary.sources,
|
||||||
|
lastUpdated: summary.lastUpdated,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
181
src/schemas/user.ts
Normal file
181
src/schemas/user.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { GraphQLError } from 'graphql'
|
||||||
|
import { prisma } from '../db.js'
|
||||||
|
import { startKycWorkflow } from '../services/temporal.js'
|
||||||
|
import type { AuthContext } from '../auth.js'
|
||||||
|
|
||||||
|
export const userTypeDefs = `#graphql
|
||||||
|
type KYCApplication {
|
||||||
|
id: Int!
|
||||||
|
uuid: String!
|
||||||
|
userId: String!
|
||||||
|
teamName: String
|
||||||
|
countryCode: String
|
||||||
|
workflowStatus: String!
|
||||||
|
score: Int!
|
||||||
|
contactPerson: String
|
||||||
|
contactEmail: String
|
||||||
|
contactPhone: String
|
||||||
|
countryData: String
|
||||||
|
createdAt: String!
|
||||||
|
updatedAt: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input KYCApplicationRussiaInput {
|
||||||
|
companyName: String!
|
||||||
|
companyFullName: String!
|
||||||
|
inn: String!
|
||||||
|
kpp: String
|
||||||
|
ogrn: String
|
||||||
|
address: String!
|
||||||
|
bankName: String!
|
||||||
|
bik: String!
|
||||||
|
correspondentAccount: String
|
||||||
|
contactPerson: String!
|
||||||
|
contactEmail: String!
|
||||||
|
contactPhone: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateKYCApplicationResult {
|
||||||
|
kycApplication: KYCApplication
|
||||||
|
success: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query {
|
||||||
|
kycApplications: [KYCApplication]
|
||||||
|
kycApplication(uuid: String!): KYCApplication
|
||||||
|
kycRequests: [KYCApplication]
|
||||||
|
kycRequest(uuid: String!): KYCApplication
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mutation {
|
||||||
|
createKycApplicationRussia(input: KYCApplicationRussiaInput!): CreateKYCApplicationResult
|
||||||
|
createKycRequestRussia(input: KYCApplicationRussiaInput!): CreateKYCApplicationResult
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
async function getApplications(ctx: AuthContext) {
|
||||||
|
if (!ctx.userId) return []
|
||||||
|
return prisma.kYCApplication.findMany({ where: { userId: ctx.userId } })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getApplication(ctx: AuthContext, uuid: string) {
|
||||||
|
if (!ctx.userId) return null
|
||||||
|
return prisma.kYCApplication.findFirst({ where: { uuid, userId: ctx.userId } })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCountryData(app: { objectId: number | null }) {
|
||||||
|
if (!app.objectId) return null
|
||||||
|
const details = await prisma.kYCDetailsRussia.findUnique({ where: { id: app.objectId } })
|
||||||
|
if (!details) return null
|
||||||
|
return JSON.stringify({
|
||||||
|
company_name: details.companyName,
|
||||||
|
company_full_name: details.companyFullName,
|
||||||
|
inn: details.inn,
|
||||||
|
kpp: details.kpp,
|
||||||
|
ogrn: details.ogrn,
|
||||||
|
address: details.address,
|
||||||
|
bank_name: details.bankName,
|
||||||
|
bik: details.bik,
|
||||||
|
correspondent_account: details.correspondentAccount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RussiaInput {
|
||||||
|
companyName: string
|
||||||
|
companyFullName: string
|
||||||
|
inn: string
|
||||||
|
kpp?: string
|
||||||
|
ogrn?: string
|
||||||
|
address: string
|
||||||
|
bankName: string
|
||||||
|
bik: string
|
||||||
|
correspondentAccount?: string
|
||||||
|
contactPerson: string
|
||||||
|
contactEmail: string
|
||||||
|
contactPhone: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createApplicationRussia(_: unknown, args: { input: RussiaInput }, ctx: AuthContext) {
|
||||||
|
if (!ctx.userId) throw new GraphQLError('Not authenticated')
|
||||||
|
|
||||||
|
const details = await prisma.kYCDetailsRussia.create({
|
||||||
|
data: {
|
||||||
|
companyName: args.input.companyName,
|
||||||
|
companyFullName: args.input.companyFullName,
|
||||||
|
inn: args.input.inn,
|
||||||
|
kpp: args.input.kpp || '',
|
||||||
|
ogrn: args.input.ogrn || '',
|
||||||
|
address: args.input.address,
|
||||||
|
bankName: args.input.bankName,
|
||||||
|
bik: args.input.bik,
|
||||||
|
correspondentAccount: args.input.correspondentAccount || '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Django ContentType ID for kyc_details_russia - need to look this up
|
||||||
|
// For compatibility, we store the objectId but skip content_type_id
|
||||||
|
const app = await prisma.kYCApplication.create({
|
||||||
|
data: {
|
||||||
|
userId: ctx.userId,
|
||||||
|
teamName: args.input.companyName,
|
||||||
|
countryCode: 'RU',
|
||||||
|
contactPerson: args.input.contactPerson,
|
||||||
|
contactEmail: args.input.contactEmail,
|
||||||
|
contactPhone: args.input.contactPhone,
|
||||||
|
objectId: details.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start Temporal workflow
|
||||||
|
try {
|
||||||
|
await startKycWorkflow({
|
||||||
|
kyc_request_id: app.uuid,
|
||||||
|
team_name: app.teamName || args.input.companyName,
|
||||||
|
owner_id: ctx.userId,
|
||||||
|
owner_email: args.input.contactEmail,
|
||||||
|
country_code: 'RU',
|
||||||
|
country_data: {
|
||||||
|
company_name: details.companyName,
|
||||||
|
company_full_name: details.companyFullName,
|
||||||
|
inn: details.inn,
|
||||||
|
kpp: details.kpp,
|
||||||
|
ogrn: details.ogrn,
|
||||||
|
address: details.address,
|
||||||
|
bank_name: details.bankName,
|
||||||
|
bik: details.bik,
|
||||||
|
correspondent_account: details.correspondentAccount,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await prisma.kYCApplication.update({
|
||||||
|
where: { id: app.id },
|
||||||
|
data: { workflowStatus: 'active' },
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to start KYC workflow:', e)
|
||||||
|
await prisma.kYCApplication.update({
|
||||||
|
where: { id: app.id },
|
||||||
|
data: { workflowStatus: 'error' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.kYCApplication.findUnique({ where: { id: app.id } })
|
||||||
|
return { kycApplication: updated, success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const userResolvers = {
|
||||||
|
Query: {
|
||||||
|
kycApplications: (_: unknown, __: unknown, ctx: AuthContext) => getApplications(ctx),
|
||||||
|
kycApplication: (_: unknown, args: { uuid: string }, ctx: AuthContext) => getApplication(ctx, args.uuid),
|
||||||
|
kycRequests: (_: unknown, __: unknown, ctx: AuthContext) => getApplications(ctx),
|
||||||
|
kycRequest: (_: unknown, args: { uuid: string }, ctx: AuthContext) => getApplication(ctx, args.uuid),
|
||||||
|
},
|
||||||
|
Mutation: {
|
||||||
|
createKycApplicationRussia: createApplicationRussia,
|
||||||
|
createKycRequestRussia: createApplicationRussia,
|
||||||
|
},
|
||||||
|
KYCApplication: {
|
||||||
|
countryData: async (parent: { objectId: number | null }) => getCountryData(parent),
|
||||||
|
createdAt: (parent: { createdAt: Date }) => parent.createdAt.toISOString(),
|
||||||
|
updatedAt: (parent: { updatedAt: Date }) => parent.updatedAt.toISOString(),
|
||||||
|
},
|
||||||
|
}
|
||||||
65
src/services/mongodb.ts
Normal file
65
src/services/mongodb.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { MongoClient } from 'mongodb'
|
||||||
|
|
||||||
|
const MONGODB_URI = process.env.MONGODB_URI || ''
|
||||||
|
const MONGODB_DB = process.env.MONGODB_DB || 'kyc'
|
||||||
|
|
||||||
|
export async function getCompanyDocuments(inn: string) {
|
||||||
|
if (!MONGODB_URI) return []
|
||||||
|
const client = new MongoClient(MONGODB_URI)
|
||||||
|
try {
|
||||||
|
await client.connect()
|
||||||
|
const db = client.db(MONGODB_DB)
|
||||||
|
return await db.collection('company_documents').find({ inn }).toArray()
|
||||||
|
} finally {
|
||||||
|
await client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompanySummary {
|
||||||
|
inn?: string
|
||||||
|
ogrn?: string
|
||||||
|
name?: string
|
||||||
|
companyType?: string
|
||||||
|
registrationYear?: number
|
||||||
|
isActive: boolean
|
||||||
|
address?: string
|
||||||
|
director?: string
|
||||||
|
capital?: string
|
||||||
|
activities: string[]
|
||||||
|
sources: string[]
|
||||||
|
lastUpdated?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateCompanyData(documents: Record<string, unknown>[]): CompanySummary {
|
||||||
|
const summary: CompanySummary = {
|
||||||
|
isActive: true,
|
||||||
|
activities: [],
|
||||||
|
sources: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const doc of documents) {
|
||||||
|
const source = (doc.source as string) || 'unknown'
|
||||||
|
summary.sources.push(source)
|
||||||
|
|
||||||
|
const data = (doc.data as Record<string, unknown>) || {}
|
||||||
|
|
||||||
|
if (!summary.inn) summary.inn = doc.inn as string
|
||||||
|
if (!summary.ogrn && data.ogrn) summary.ogrn = data.ogrn as string
|
||||||
|
if (!summary.name && data.name) summary.name = data.name as string
|
||||||
|
|
||||||
|
if (!summary.companyType && summary.name) {
|
||||||
|
const name = summary.name.toUpperCase()
|
||||||
|
if (name.includes('ООО') || name.includes('ОБЩЕСТВО С ОГРАНИЧЕННОЙ')) summary.companyType = 'ООО'
|
||||||
|
else if (name.includes('ПАО')) summary.companyType = 'ПАО'
|
||||||
|
else if (name.includes('АО') || name.includes('АКЦИОНЕРНОЕ ОБЩЕСТВО')) summary.companyType = 'АО'
|
||||||
|
else if (name.includes('ИП') || name.includes('ИНДИВИДУАЛЬНЫЙ ПРЕДПРИНИМАТЕЛЬ')) summary.companyType = 'ИП'
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectedAt = doc.collected_at as string | undefined
|
||||||
|
if (collectedAt && (!summary.lastUpdated || collectedAt > summary.lastUpdated)) {
|
||||||
|
summary.lastUpdated = collectedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary
|
||||||
|
}
|
||||||
47
src/services/surrealdb.ts
Normal file
47
src/services/surrealdb.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
const SURREALDB_URL = process.env.SURREALDB_URL || ''
|
||||||
|
const SURREALDB_NS = process.env.SURREALDB_NS || 'optovia'
|
||||||
|
const SURREALDB_DB = process.env.SURREALDB_DB || 'events'
|
||||||
|
const SURREALDB_USER = process.env.SURREALDB_USER || ''
|
||||||
|
const SURREALDB_PASS = process.env.SURREALDB_PASS || ''
|
||||||
|
|
||||||
|
export async function logKycEvent(
|
||||||
|
kycId: string,
|
||||||
|
userId: string,
|
||||||
|
event: string,
|
||||||
|
description: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!SURREALDB_URL) return false
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
kyc_id: kycId,
|
||||||
|
user_id: userId,
|
||||||
|
event,
|
||||||
|
description,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `CREATE kyc_event CONTENT ${JSON.stringify(payload)};`
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
Accept: 'application/json',
|
||||||
|
NS: SURREALDB_NS,
|
||||||
|
DB: SURREALDB_DB,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SURREALDB_USER && SURREALDB_PASS) {
|
||||||
|
headers.Authorization = `Basic ${Buffer.from(`${SURREALDB_USER}:${SURREALDB_PASS}`).toString('base64')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${SURREALDB_URL.replace(/\/$/, '')}/sql`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: query,
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
})
|
||||||
|
return res.ok
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to log KYC event:', e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/services/temporal.ts
Normal file
28
src/services/temporal.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Client, Connection } from '@temporalio/client'
|
||||||
|
|
||||||
|
const TEMPORAL_HOST = process.env.TEMPORAL_HOST || 'temporal:7233'
|
||||||
|
const TEMPORAL_NAMESPACE = process.env.TEMPORAL_NAMESPACE || 'default'
|
||||||
|
const TEMPORAL_TASK_QUEUE = process.env.TEMPORAL_TASK_QUEUE || 'kyc-task-queue'
|
||||||
|
|
||||||
|
interface KycWorkflowData {
|
||||||
|
kyc_request_id: string
|
||||||
|
team_name: string
|
||||||
|
owner_id: string
|
||||||
|
owner_email: string
|
||||||
|
country_code: string
|
||||||
|
country_data: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startKycWorkflow(data: KycWorkflowData): Promise<string> {
|
||||||
|
const connection = await Connection.connect({ address: TEMPORAL_HOST })
|
||||||
|
const client = new Client({ connection, namespace: TEMPORAL_NAMESPACE })
|
||||||
|
|
||||||
|
const handle = await client.workflow.start('kyc_application', {
|
||||||
|
args: [data],
|
||||||
|
taskQueue: TEMPORAL_TASK_QUEUE,
|
||||||
|
workflowId: data.kyc_request_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`KYC workflow started: ${handle.workflowId}`)
|
||||||
|
return handle.workflowId
|
||||||
|
}
|
||||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "Node16",
|
||||||
|
"moduleResolution": "Node16",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user