Migrate KYC backend from Django to Express + Apollo Server + Prisma
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:
Ruslan Bakiev
2026-03-09 09:16:44 +07:00
parent 59dcff3d64
commit bce6b47896
45 changed files with 5079 additions and 2936 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
dist

View File

@@ -1,24 +1,28 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
NIXPACKS_POETRY_VERSION=2.2.1
FROM node:22-alpine AS builder
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential curl \
&& rm -rf /var/lib/apt/lists/*
COPY package.json ./
RUN npm install
RUN python -m venv --copies /opt/venv
ENV VIRTUAL_ENV=/opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY prisma ./prisma
RUN npx prisma generate
COPY . .
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
RUN pip install --no-cache-dir poetry==$NIXPACKS_POETRY_VERSION \
&& poetry install --no-interaction --no-ansi
FROM node:22-alpine
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"]

View File

@@ -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

View File

@@ -1 +0,0 @@
# Django orders service

View File

@@ -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')

View File

@@ -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')

View File

@@ -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),
]

View File

@@ -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()

View File

@@ -1 +0,0 @@
# Orders Django app

View File

@@ -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')
}),
)

View File

@@ -1,5 +0,0 @@
from django.apps import AppConfig
class KycAppConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'kyc_app'

View File

@@ -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"),
)

View File

@@ -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)

View File

@@ -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',),
),
]

View File

@@ -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',
},
),
]

View File

@@ -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),
),
]

View File

@@ -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',
},
),
]

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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', ''),
)

View File

@@ -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

View File

@@ -1,3 +0,0 @@
from .kyc_workflow_client import KycWorkflowClient
__all__ = ["KycWorkflowClient"]

View File

@@ -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

View File

@@ -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")

View File

@@ -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)

View File

@@ -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

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

67
prisma/schema.prisma Normal file
View 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")
}

View File

@@ -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
View 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
View File

@@ -0,0 +1,3 @@
import { PrismaClient } from '@prisma/client'
export const prisma = new PrismaClient()

80
src/index.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]
}