Migrate billing backend from Django to Express + Apollo Server + Prisma
All checks were successful
Build Docker Image / build (push) Successful in 1m44s
All checks were successful
Build Docker Image / build (push) Successful in 1m44s
Replace Python/Django/Graphene with TypeScript/Express/Apollo Server. Same 2 endpoints (team/m2m), same JWT auth, same TigerBeetle integration. Prisma ORM replaces Django ORM for Account/OperationCode/ServiceAccount.
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 billing.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"]
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
# Billing Service
|
|
||||||
|
|
||||||
This service is responsible for handling all billing-related logic, including integration with TigerBeetle for double-entry accounting.
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
"""
|
|
||||||
ASGI config for billing project.
|
|
||||||
|
|
||||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
|
||||||
|
|
||||||
For more information on this file, see
|
|
||||||
https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
from django.core.asgi import get_asgi_application
|
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'billing.settings')
|
|
||||||
|
|
||||||
application = get_asgi_application()
|
|
||||||
@@ -1,164 +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 /billing and /shared
|
|
||||||
for secret_path in ["/billing", "/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://billing.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',
|
|
||||||
'billing_app', # Added billing_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 = 'billing.urls' # Changed to billing.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 = 'billing.wsgi.application' # Changed to billing.wsgi.application
|
|
||||||
|
|
||||||
db_url = os.environ["BILLING_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
|
|
||||||
|
|
||||||
# GraphQL
|
|
||||||
GRAPHENE = {
|
|
||||||
'SCHEMA': 'billing_app.schema.schema',
|
|
||||||
}
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
LOGGING = {
|
|
||||||
'version': 1,
|
|
||||||
'disable_existing_loggers': False,
|
|
||||||
'handlers': {
|
|
||||||
'console': {
|
|
||||||
'class': 'logging.StreamHandler',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'loggers': {
|
|
||||||
'django.request': {
|
|
||||||
'handlers': ['console'],
|
|
||||||
'level': 'DEBUG',
|
|
||||||
'propagate': False,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# 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_BILLING_AUDIENCE = os.getenv('LOGTO_BILLING_AUDIENCE', 'https://billing.optovia.ru') # Changed
|
|
||||||
# ID Token audience can be omitted when we only need signature + issuer validation.
|
|
||||||
LOGTO_ID_TOKEN_AUDIENCE = os.getenv('LOGTO_ID_TOKEN_AUDIENCE')
|
|
||||||
@@ -1,134 +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://billing.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',
|
|
||||||
'billing_app', # Added billing_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 = 'billing.urls' # Changed to billing.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 = 'billing.wsgi.application' # Changed to billing.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
|
|
||||||
|
|
||||||
# GraphQL
|
|
||||||
GRAPHENE = {
|
|
||||||
'SCHEMA': 'billing_app.schema.schema',
|
|
||||||
}
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
LOGGING = {
|
|
||||||
'version': 1,
|
|
||||||
'disable_existing_loggers': False,
|
|
||||||
'handlers': {
|
|
||||||
'console': {
|
|
||||||
'class': 'logging.StreamHandler',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'loggers': {
|
|
||||||
'django.request': {
|
|
||||||
'handlers': ['console'],
|
|
||||||
'level': 'DEBUG',
|
|
||||||
'propagate': False,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# 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_BILLING_AUDIENCE = os.getenv('LOGTO_BILLING_AUDIENCE', 'https://billing.optovia.ru') # Changed
|
|
||||||
# ID Token audience can be omitted when we only need signature + issuer validation.
|
|
||||||
LOGTO_ID_TOKEN_AUDIENCE = os.getenv('LOGTO_ID_TOKEN_AUDIENCE')
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
from django.urls import path
|
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
|
||||||
from billing_app.views import PublicGraphQLView, M2MGraphQLView, TeamGraphQLView
|
|
||||||
from billing_app.schemas.m2m_schema import m2m_schema
|
|
||||||
from billing_app.schemas.team_schema import team_schema
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path('admin/', admin.site.urls),
|
|
||||||
path('graphql/m2m/', csrf_exempt(M2MGraphQLView.as_view(graphiql=False, schema=m2m_schema))),
|
|
||||||
path('graphql/team/', csrf_exempt(TeamGraphQLView.as_view(graphiql=True, schema=team_schema))),
|
|
||||||
]
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
"""
|
|
||||||
WSGI config for billing project.
|
|
||||||
|
|
||||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
|
||||||
|
|
||||||
For more information on this file, see
|
|
||||||
https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
from django.core.wsgi import get_wsgi_application
|
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'billing.settings')
|
|
||||||
|
|
||||||
application = get_wsgi_application()
|
|
||||||
Binary file not shown.
@@ -1,23 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
from .models import Account, OperationCode, ServiceAccount
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Account)
|
|
||||||
class AccountAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ('uuid', 'name', 'account_type', 'created_at')
|
|
||||||
list_filter = ('account_type', 'created_at')
|
|
||||||
search_fields = ('uuid', 'name')
|
|
||||||
readonly_fields = ('uuid', 'created_at')
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(OperationCode)
|
|
||||||
class OperationCodeAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ('code', 'name', 'created_at')
|
|
||||||
search_fields = ('code', 'name')
|
|
||||||
ordering = ('code',)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ServiceAccount)
|
|
||||||
class ServiceAccountAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ('slug', 'account')
|
|
||||||
search_fields = ('slug', 'account__name')
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class BillingAppConfig(AppConfig):
|
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
|
||||||
name = 'billing_app'
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
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,74 +0,0 @@
|
|||||||
"""
|
|
||||||
GraphQL middleware for JWT authentication and scope checking in the Billing service.
|
|
||||||
|
|
||||||
This middleware runs for every GraphQL resolver and sets user_id and scopes
|
|
||||||
on info.context based on the JWT token in Authorization header.
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
from django.conf import settings
|
|
||||||
from graphql import GraphQLError
|
|
||||||
from jwt import InvalidTokenError
|
|
||||||
|
|
||||||
from .auth import get_bearer_token, scopes_from_payload, 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 PublicNoAuthMiddleware:
|
|
||||||
"""Public endpoint - no authentication required."""
|
|
||||||
|
|
||||||
def resolve(self, next, root, info, **kwargs):
|
|
||||||
return next(root, info, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class M2MNoAuthMiddleware:
|
|
||||||
"""M2M endpoint - internal services only, no auth for now."""
|
|
||||||
|
|
||||||
def resolve(self, next, root, info, **kwargs):
|
|
||||||
return next(root, info, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class BillingJWTMiddleware:
|
|
||||||
"""
|
|
||||||
Middleware for Billing service operations.
|
|
||||||
Sets info.context.user_id and scopes from the JWT access token.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def resolve(self, next, root, info, **kwargs):
|
|
||||||
request = info.context
|
|
||||||
if _is_introspection(info):
|
|
||||||
return next(root, info, **kwargs)
|
|
||||||
|
|
||||||
# Skip if JWT processing already happened (e.g., another middleware already set it)
|
|
||||||
if hasattr(request, '_billing_jwt_processed') and request._billing_jwt_processed:
|
|
||||||
return next(root, info, **kwargs)
|
|
||||||
|
|
||||||
request._billing_jwt_processed = True
|
|
||||||
|
|
||||||
try:
|
|
||||||
token = get_bearer_token(request)
|
|
||||||
payload = validator.decode(
|
|
||||||
token,
|
|
||||||
audience=getattr(settings, 'LOGTO_BILLING_AUDIENCE', None),
|
|
||||||
)
|
|
||||||
request.user_id = payload.get('sub') # Subject (user ID)
|
|
||||||
request.team_uuid = payload.get('team_uuid')
|
|
||||||
request.scopes = scopes_from_payload(payload)
|
|
||||||
if not request.team_uuid or 'teams:member' not in request.scopes:
|
|
||||||
raise GraphQLError("Unauthorized")
|
|
||||||
|
|
||||||
logging.debug(f"JWT processed for user_id: {request.user_id}, scopes: {request.scopes}")
|
|
||||||
|
|
||||||
except InvalidTokenError as exc:
|
|
||||||
logging.info(f"Billing JWT authentication failed: {exc}")
|
|
||||||
raise GraphQLError("Unauthorized") from exc
|
|
||||||
except Exception as exc:
|
|
||||||
logging.error(f"An unexpected error occurred during JWT processing: {exc}")
|
|
||||||
raise GraphQLError("Unauthorized") from exc
|
|
||||||
|
|
||||||
return next(root, info, **kwargs)
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
# Generated by Django 5.2.9 on 2025-12-10 11:19
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
import uuid
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Account',
|
|
||||||
fields=[
|
|
||||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
|
||||||
('name', models.CharField(help_text='Name or identifier for the account', max_length=255)),
|
|
||||||
('account_type', models.CharField(choices=[('USER', 'User Account'), ('SERVICE', 'Service Account'), ('ASSET', 'Asset Account'), ('LIABILITY', 'Liability Account'), ('REVENUE', 'Revenue Account'), ('EXPENSE', 'Expense Account')], default='USER', help_text='Type of the account (e.g., User, Service, Revenue)', max_length=50)),
|
|
||||||
('description', models.TextField(blank=True, help_text='Detailed description of the account', null=True)),
|
|
||||||
('tigerbeetle_account_id', models.BigIntegerField(help_text='The unique ID of this account in TigerBeetle', unique=True)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Billing Account',
|
|
||||||
'verbose_name_plural': 'Billing Accounts',
|
|
||||||
'ordering': ['-created_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='TransactionReason',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('name', models.CharField(help_text='Unique name for the transaction reason', max_length=255, unique=True)),
|
|
||||||
('description', models.TextField(blank=True, help_text='Detailed description of this transaction reason', null=True)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Transaction Reason',
|
|
||||||
'verbose_name_plural': 'Transaction Reasons',
|
|
||||||
'ordering': ['name'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Operation',
|
|
||||||
fields=[
|
|
||||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
|
||||||
('amount', models.DecimalField(decimal_places=4, help_text='The amount transferred in this operation', max_digits=20)),
|
|
||||||
('state', models.CharField(choices=[('PENDING', 'Pending TigerBeetle transfer'), ('COMPLETED', 'TigerBeetle transfer completed'), ('FAILED', 'TigerBeetle transfer failed')], default='PENDING', help_text='Current state of the TigerBeetle transfer for this operation', max_length=50)),
|
|
||||||
('tigerbeetle_transfer_id', models.BigIntegerField(blank=True, help_text='The unique ID of the transfer in TigerBeetle, set after successful transfer', null=True, unique=True)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('destination_account', models.ForeignKey(help_text='The account to which funds are moved', on_delete=django.db.models.deletion.PROTECT, related_name='incoming_operations', to='billing_app.account')),
|
|
||||||
('source_account', models.ForeignKey(help_text='The account from which funds are moved', on_delete=django.db.models.deletion.PROTECT, related_name='outgoing_operations', to='billing_app.account')),
|
|
||||||
('reason', models.ForeignKey(help_text='The business reason for this operation', on_delete=django.db.models.deletion.PROTECT, related_name='operations', to='billing_app.transactionreason')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Business Operation',
|
|
||||||
'verbose_name_plural': 'Business Operations',
|
|
||||||
'ordering': ['-created_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# Generated manually
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('billing_app', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='account',
|
|
||||||
name='tigerbeetle_account_id',
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
# Generated manually - refactor billing models
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('billing_app', '0002_remove_account_tigerbeetle_account_id'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
# 1. Delete old models
|
|
||||||
migrations.DeleteModel(
|
|
||||||
name='Operation',
|
|
||||||
),
|
|
||||||
migrations.DeleteModel(
|
|
||||||
name='TransactionReason',
|
|
||||||
),
|
|
||||||
|
|
||||||
# 2. Alter Account model - update account_type choices and remove name help_text
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='account',
|
|
||||||
name='account_type',
|
|
||||||
field=models.CharField(
|
|
||||||
choices=[('USER', 'User Account'), ('SERVICE', 'Service Account')],
|
|
||||||
default='USER',
|
|
||||||
max_length=50,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='account',
|
|
||||||
name='name',
|
|
||||||
field=models.CharField(help_text='Название аккаунта', max_length=255),
|
|
||||||
),
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name='account',
|
|
||||||
options={'ordering': ['-created_at'], 'verbose_name': 'Account', 'verbose_name_plural': 'Accounts'},
|
|
||||||
),
|
|
||||||
|
|
||||||
# 3. Create OperationCode model
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='OperationCode',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('code', models.PositiveIntegerField(help_text='Числовой код для TigerBeetle', unique=True)),
|
|
||||||
('name', models.CharField(help_text='Название операции', max_length=255, unique=True)),
|
|
||||||
('description', models.TextField(blank=True, null=True)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Operation Code',
|
|
||||||
'verbose_name_plural': 'Operation Codes',
|
|
||||||
'ordering': ['code'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
# 4. Create ServiceAccount model
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='ServiceAccount',
|
|
||||||
fields=[
|
|
||||||
('account', models.OneToOneField(
|
|
||||||
on_delete=models.deletion.CASCADE,
|
|
||||||
primary_key=True,
|
|
||||||
related_name='service_info',
|
|
||||||
serialize=False,
|
|
||||||
to='billing_app.account'
|
|
||||||
)),
|
|
||||||
('slug', models.SlugField(help_text='Уникальный идентификатор (bank, revenue, etc)', unique=True)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Service Account',
|
|
||||||
'verbose_name_plural': 'Service Accounts',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
|
|
||||||
class AccountType(models.TextChoices):
|
|
||||||
"""Типы аккаунтов в системе биллинга."""
|
|
||||||
USER = 'USER', 'User Account'
|
|
||||||
SERVICE = 'SERVICE', 'Service Account'
|
|
||||||
|
|
||||||
|
|
||||||
class Account(models.Model):
|
|
||||||
"""
|
|
||||||
Аккаунт в системе биллинга.
|
|
||||||
Хранит метаданные об аккаунтах, созданных в TigerBeetle.
|
|
||||||
UUID используется как ID аккаунта в TigerBeetle (uuid.int).
|
|
||||||
"""
|
|
||||||
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
||||||
name = models.CharField(max_length=255, help_text="Название аккаунта")
|
|
||||||
account_type = models.CharField(
|
|
||||||
max_length=50,
|
|
||||||
choices=AccountType.choices,
|
|
||||||
default=AccountType.USER,
|
|
||||||
)
|
|
||||||
description = models.TextField(blank=True, null=True)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tigerbeetle_id(self):
|
|
||||||
"""ID аккаунта в TigerBeetle (128-bit int из UUID)."""
|
|
||||||
return self.uuid.int
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.name} ({self.account_type})"
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Account"
|
|
||||||
verbose_name_plural = "Accounts"
|
|
||||||
ordering = ['-created_at']
|
|
||||||
|
|
||||||
|
|
||||||
class OperationCode(models.Model):
|
|
||||||
"""
|
|
||||||
Код операции (справочник типов транзакций).
|
|
||||||
Используется как 'code' поле в TigerBeetle transfer.
|
|
||||||
"""
|
|
||||||
code = models.PositiveIntegerField(unique=True, help_text="Числовой код для TigerBeetle")
|
|
||||||
name = models.CharField(max_length=255, unique=True, help_text="Название операции")
|
|
||||||
description = models.TextField(blank=True, null=True)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.code}: {self.name}"
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Operation Code"
|
|
||||||
verbose_name_plural = "Operation Codes"
|
|
||||||
ordering = ['code']
|
|
||||||
|
|
||||||
|
|
||||||
class ServiceAccount(models.Model):
|
|
||||||
"""
|
|
||||||
Сервисный аккаунт (банк, revenue, etc).
|
|
||||||
Это Account с особым назначением в системе.
|
|
||||||
"""
|
|
||||||
account = models.OneToOneField(
|
|
||||||
Account,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
primary_key=True,
|
|
||||||
related_name='service_info'
|
|
||||||
)
|
|
||||||
slug = models.SlugField(unique=True, help_text="Уникальный идентификатор (bank, revenue, etc)")
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.slug}: {self.account.name}"
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Service Account"
|
|
||||||
verbose_name_plural = "Service Accounts"
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
"""
|
|
||||||
Декоратор для проверки scopes в JWT токене.
|
|
||||||
Используется для защиты GraphQL резолверов.
|
|
||||||
"""
|
|
||||||
from functools import wraps
|
|
||||||
from graphql import GraphQLError
|
|
||||||
|
|
||||||
|
|
||||||
def require_scopes(*scopes: str):
|
|
||||||
"""
|
|
||||||
Декоратор для проверки наличия scopes в JWT токене.
|
|
||||||
|
|
||||||
Использование:
|
|
||||||
@require_scopes("read:teams")
|
|
||||||
def resolve_team(self, info):
|
|
||||||
...
|
|
||||||
|
|
||||||
@require_scopes("read:teams", "write:teams")
|
|
||||||
def resolve_update_team(self, info):
|
|
||||||
...
|
|
||||||
"""
|
|
||||||
def decorator(func):
|
|
||||||
# Сохраняем scopes в метаданных для возможности сбора всех scopes
|
|
||||||
if not hasattr(func, '_required_scopes'):
|
|
||||||
func._required_scopes = []
|
|
||||||
func._required_scopes.extend(scopes)
|
|
||||||
|
|
||||||
@wraps(func)
|
|
||||||
def wrapper(self, info, *args, **kwargs):
|
|
||||||
# Получаем scopes из контекста (должны быть добавлены в middleware)
|
|
||||||
user_scopes = set(getattr(info.context, 'scopes', []) or [])
|
|
||||||
|
|
||||||
missing = set(scopes) - user_scopes
|
|
||||||
if missing:
|
|
||||||
raise GraphQLError(f"Missing required scopes: {', '.join(missing)}")
|
|
||||||
|
|
||||||
return func(self, info, *args, **kwargs)
|
|
||||||
|
|
||||||
# Переносим метаданные на wrapper
|
|
||||||
wrapper._required_scopes = func._required_scopes
|
|
||||||
return wrapper
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
|
|
||||||
def collect_scopes_from_schema(schema) -> set:
|
|
||||||
"""
|
|
||||||
Собирает все scopes из схемы для синхронизации с Logto.
|
|
||||||
|
|
||||||
Использование:
|
|
||||||
from .schema import schema
|
|
||||||
scopes = collect_scopes_from_schema(schema)
|
|
||||||
# {'read:team', 'invite:member', ...}
|
|
||||||
"""
|
|
||||||
scopes = set()
|
|
||||||
|
|
||||||
# Query resolvers
|
|
||||||
if hasattr(schema, 'query') and schema.query:
|
|
||||||
query_type = schema.query
|
|
||||||
for field_name in dir(query_type):
|
|
||||||
if field_name.startswith('resolve_'):
|
|
||||||
resolver = getattr(query_type, field_name, None)
|
|
||||||
if resolver and hasattr(resolver, '_required_scopes'):
|
|
||||||
scopes.update(resolver._required_scopes)
|
|
||||||
|
|
||||||
# Mutation resolvers
|
|
||||||
if hasattr(schema, 'mutation') and schema.mutation:
|
|
||||||
mutation_type = schema.mutation
|
|
||||||
for field_name, field in mutation_type._meta.fields.items():
|
|
||||||
if hasattr(field, 'type') and hasattr(field.type, 'mutate'):
|
|
||||||
mutate = field.type.mutate
|
|
||||||
if hasattr(mutate, '_required_scopes'):
|
|
||||||
scopes.update(mutate._required_scopes)
|
|
||||||
|
|
||||||
return scopes
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# Billing schemas
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,150 +0,0 @@
|
|||||||
"""
|
|
||||||
M2M (Machine-to-Machine) GraphQL schema for Billing service.
|
|
||||||
Used by internal services (Temporal workflows, etc.) without user authentication.
|
|
||||||
|
|
||||||
Provides:
|
|
||||||
- createTransaction mutation (auto-creates accounts in TigerBeetle if needed)
|
|
||||||
- operationCodes query (справочник кодов операций)
|
|
||||||
- serviceAccounts query (bank, revenue и т.д.)
|
|
||||||
"""
|
|
||||||
import graphene
|
|
||||||
import uuid
|
|
||||||
import logging
|
|
||||||
from graphene_django import DjangoObjectType
|
|
||||||
|
|
||||||
import tigerbeetle as tb
|
|
||||||
from ..tigerbeetle_client import tigerbeetle_client
|
|
||||||
from ..models import Account as AccountModel, OperationCode as OperationCodeModel, ServiceAccount as ServiceAccountModel, AccountType
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Hardcoded ledger for RUB
|
|
||||||
LEDGER = 1
|
|
||||||
DEFAULT_ACCOUNT_CODE = 100
|
|
||||||
|
|
||||||
|
|
||||||
class OperationCodeType(DjangoObjectType):
|
|
||||||
"""Код операции (справочник)."""
|
|
||||||
class Meta:
|
|
||||||
model = OperationCodeModel
|
|
||||||
fields = ('code', 'name', 'description')
|
|
||||||
|
|
||||||
|
|
||||||
class ServiceAccountType(DjangoObjectType):
|
|
||||||
"""Сервисный аккаунт (bank, revenue и т.д.)."""
|
|
||||||
accountUuid = graphene.UUID()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = ServiceAccountModel
|
|
||||||
fields = ('slug',)
|
|
||||||
|
|
||||||
def resolve_accountUuid(self, info):
|
|
||||||
return self.account.uuid
|
|
||||||
|
|
||||||
|
|
||||||
class CreateTransactionInput(graphene.InputObjectType):
|
|
||||||
fromUuid = graphene.String(required=True, description="Source account UUID")
|
|
||||||
toUuid = graphene.String(required=True, description="Destination account UUID")
|
|
||||||
amount = graphene.Int(required=True, description="Amount in kopecks")
|
|
||||||
code = graphene.Int(required=True, description="Operation code from OperationCode table")
|
|
||||||
|
|
||||||
|
|
||||||
class CreateTransaction(graphene.Mutation):
|
|
||||||
"""
|
|
||||||
Creates a financial transaction between two accounts.
|
|
||||||
If accounts do not exist in TigerBeetle, they are created automatically.
|
|
||||||
"""
|
|
||||||
class Arguments:
|
|
||||||
input = CreateTransactionInput(required=True)
|
|
||||||
|
|
||||||
success = graphene.Boolean()
|
|
||||||
message = graphene.String()
|
|
||||||
transferId = graphene.String()
|
|
||||||
|
|
||||||
def mutate(self, info, input):
|
|
||||||
# 1. Validate UUIDs
|
|
||||||
try:
|
|
||||||
from_uuid = uuid.UUID(input.fromUuid)
|
|
||||||
to_uuid = uuid.UUID(input.toUuid)
|
|
||||||
except ValueError:
|
|
||||||
return CreateTransaction(success=False, message="Invalid UUID format")
|
|
||||||
|
|
||||||
# 2. Ensure local Account records exist
|
|
||||||
all_uuids = [from_uuid, to_uuid]
|
|
||||||
existing_accounts = {acc.uuid for acc in AccountModel.objects.filter(uuid__in=all_uuids)}
|
|
||||||
|
|
||||||
accounts_to_create_in_tb = []
|
|
||||||
|
|
||||||
for acc_uuid in all_uuids:
|
|
||||||
if acc_uuid not in existing_accounts:
|
|
||||||
# Create local record
|
|
||||||
AccountModel.objects.create(
|
|
||||||
uuid=acc_uuid,
|
|
||||||
name=f"Team {str(acc_uuid)[:8]}",
|
|
||||||
account_type=AccountType.USER,
|
|
||||||
)
|
|
||||||
logger.info(f"Created local Account record for {acc_uuid}")
|
|
||||||
|
|
||||||
# Check if account exists in TigerBeetle
|
|
||||||
tb_accounts = tigerbeetle_client.lookup_accounts([acc_uuid.int])
|
|
||||||
if not tb_accounts:
|
|
||||||
accounts_to_create_in_tb.append(
|
|
||||||
tb.Account(
|
|
||||||
id=acc_uuid.int,
|
|
||||||
ledger=LEDGER,
|
|
||||||
code=DEFAULT_ACCOUNT_CODE,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3. Create accounts in TigerBeetle if needed
|
|
||||||
if accounts_to_create_in_tb:
|
|
||||||
errors = tigerbeetle_client.create_accounts(accounts_to_create_in_tb)
|
|
||||||
if errors:
|
|
||||||
error_details = [str(e) for e in errors]
|
|
||||||
error_msg = f"Failed to create accounts in TigerBeetle: {error_details}"
|
|
||||||
logger.error(error_msg)
|
|
||||||
return CreateTransaction(success=False, message=error_msg)
|
|
||||||
logger.info(f"Created {len(accounts_to_create_in_tb)} accounts in TigerBeetle")
|
|
||||||
|
|
||||||
# 4. Execute transfer
|
|
||||||
transfer_id = uuid.uuid4()
|
|
||||||
transfer = tb.Transfer(
|
|
||||||
id=transfer_id.int,
|
|
||||||
debit_account_id=from_uuid.int,
|
|
||||||
credit_account_id=to_uuid.int,
|
|
||||||
amount=input.amount,
|
|
||||||
ledger=LEDGER,
|
|
||||||
code=input.code,
|
|
||||||
)
|
|
||||||
|
|
||||||
errors = tigerbeetle_client.create_transfers([transfer])
|
|
||||||
if errors:
|
|
||||||
error_details = [str(e) for e in errors]
|
|
||||||
error_msg = f"Transfer failed: {error_details}"
|
|
||||||
logger.error(error_msg)
|
|
||||||
return CreateTransaction(success=False, message=error_msg)
|
|
||||||
|
|
||||||
logger.info(f"Transfer {transfer_id} completed: {from_uuid} -> {to_uuid}, amount={input.amount}")
|
|
||||||
return CreateTransaction(
|
|
||||||
success=True,
|
|
||||||
message="Transaction completed",
|
|
||||||
transferId=str(transfer_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class M2MQuery(graphene.ObjectType):
|
|
||||||
operationCodes = graphene.List(OperationCodeType, description="List of operation codes")
|
|
||||||
serviceAccounts = graphene.List(ServiceAccountType, description="List of service accounts (bank, revenue)")
|
|
||||||
|
|
||||||
def resolve_operationCodes(self, info):
|
|
||||||
return OperationCodeModel.objects.all()
|
|
||||||
|
|
||||||
def resolve_serviceAccounts(self, info):
|
|
||||||
return ServiceAccountModel.objects.select_related('account').all()
|
|
||||||
|
|
||||||
|
|
||||||
class M2MMutation(graphene.ObjectType):
|
|
||||||
createTransaction = CreateTransaction.Field()
|
|
||||||
|
|
||||||
|
|
||||||
m2m_schema = graphene.Schema(query=M2MQuery, mutation=M2MMutation)
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
"""
|
|
||||||
Team-level GraphQL schema for Billing service.
|
|
||||||
Requires teams:member scope (verified by middleware).
|
|
||||||
Provides teamBalance and teamTransactions for the authenticated team.
|
|
||||||
|
|
||||||
All data comes directly from TigerBeetle.
|
|
||||||
"""
|
|
||||||
import graphene
|
|
||||||
import uuid
|
|
||||||
import logging
|
|
||||||
from graphql import GraphQLError
|
|
||||||
|
|
||||||
from ..tigerbeetle_client import tigerbeetle_client
|
|
||||||
from ..permissions import require_scopes
|
|
||||||
from ..models import OperationCode
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class TeamBalance(graphene.ObjectType):
|
|
||||||
"""Balance information for a team's account from TigerBeetle."""
|
|
||||||
balance = graphene.Float(required=True, description="Current balance (credits - debits)")
|
|
||||||
creditsPosted = graphene.Float(required=True, description="Total credits posted")
|
|
||||||
debitsPosted = graphene.Float(required=True, description="Total debits posted")
|
|
||||||
exists = graphene.Boolean(required=True, description="Whether account exists in TigerBeetle")
|
|
||||||
|
|
||||||
|
|
||||||
class TeamTransaction(graphene.ObjectType):
|
|
||||||
"""Transaction from TigerBeetle."""
|
|
||||||
id = graphene.String(required=True)
|
|
||||||
amount = graphene.Float(required=True)
|
|
||||||
timestamp = graphene.Float(description="TigerBeetle timestamp")
|
|
||||||
code = graphene.Int(description="Operation code")
|
|
||||||
codeName = graphene.String(description="Operation code name from OperationCode table")
|
|
||||||
direction = graphene.String(required=True, description="'credit' or 'debit' relative to team account")
|
|
||||||
counterpartyUuid = graphene.String(description="UUID of the other account in transaction")
|
|
||||||
|
|
||||||
|
|
||||||
class TeamQuery(graphene.ObjectType):
|
|
||||||
teamBalance = graphene.Field(TeamBalance, description="Get balance for the authenticated team")
|
|
||||||
teamTransactions = graphene.List(
|
|
||||||
TeamTransaction,
|
|
||||||
limit=graphene.Int(default_value=50),
|
|
||||||
description="Get transactions for the authenticated team"
|
|
||||||
)
|
|
||||||
|
|
||||||
@require_scopes("teams:member")
|
|
||||||
def resolve_teamBalance(self, info):
|
|
||||||
team_uuid = getattr(info.context, 'team_uuid', None)
|
|
||||||
if not team_uuid:
|
|
||||||
raise GraphQLError("Team UUID not found in context")
|
|
||||||
|
|
||||||
try:
|
|
||||||
team_uuid_obj = uuid.UUID(team_uuid)
|
|
||||||
tb_account_id = team_uuid_obj.int
|
|
||||||
|
|
||||||
# Look up account in TigerBeetle
|
|
||||||
accounts = tigerbeetle_client.lookup_accounts([tb_account_id])
|
|
||||||
|
|
||||||
if not accounts:
|
|
||||||
# Account doesn't exist yet - return zero balance
|
|
||||||
return TeamBalance(
|
|
||||||
balance=0.0,
|
|
||||||
creditsPosted=0.0,
|
|
||||||
debitsPosted=0.0,
|
|
||||||
exists=False
|
|
||||||
)
|
|
||||||
|
|
||||||
account = accounts[0]
|
|
||||||
credits_posted = float(account.credits_posted)
|
|
||||||
debits_posted = float(account.debits_posted)
|
|
||||||
balance = credits_posted - debits_posted
|
|
||||||
|
|
||||||
return TeamBalance(
|
|
||||||
balance=balance,
|
|
||||||
creditsPosted=credits_posted,
|
|
||||||
debitsPosted=debits_posted,
|
|
||||||
exists=True
|
|
||||||
)
|
|
||||||
|
|
||||||
except ValueError as e:
|
|
||||||
logger.error(f"Invalid team UUID format: {team_uuid} - {e}")
|
|
||||||
raise GraphQLError("Invalid team UUID format")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching team balance: {e}")
|
|
||||||
raise GraphQLError("Failed to fetch team balance")
|
|
||||||
|
|
||||||
@require_scopes("teams:member")
|
|
||||||
def resolve_teamTransactions(self, info, limit=50):
|
|
||||||
team_uuid = getattr(info.context, 'team_uuid', None)
|
|
||||||
if not team_uuid:
|
|
||||||
raise GraphQLError("Team UUID not found in context")
|
|
||||||
|
|
||||||
try:
|
|
||||||
team_uuid_obj = uuid.UUID(team_uuid)
|
|
||||||
tb_account_id = team_uuid_obj.int
|
|
||||||
|
|
||||||
# Get transfers from TigerBeetle
|
|
||||||
transfers = tigerbeetle_client.get_account_transfers(tb_account_id, limit=limit)
|
|
||||||
|
|
||||||
# Load operation codes for name lookup
|
|
||||||
code_map = {oc.code: oc.name for oc in OperationCode.objects.all()}
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for t in transfers:
|
|
||||||
# Determine direction relative to team account
|
|
||||||
if t.credit_account_id == tb_account_id:
|
|
||||||
direction = "credit"
|
|
||||||
counterparty_id = t.debit_account_id
|
|
||||||
else:
|
|
||||||
direction = "debit"
|
|
||||||
counterparty_id = t.credit_account_id
|
|
||||||
|
|
||||||
result.append(TeamTransaction(
|
|
||||||
id=str(uuid.UUID(int=t.id)),
|
|
||||||
amount=float(t.amount),
|
|
||||||
timestamp=float(t.timestamp),
|
|
||||||
code=t.code,
|
|
||||||
codeName=code_map.get(t.code),
|
|
||||||
direction=direction,
|
|
||||||
counterpartyUuid=str(uuid.UUID(int=counterparty_id)),
|
|
||||||
))
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
except ValueError as e:
|
|
||||||
logger.error(f"Invalid team UUID format: {team_uuid} - {e}")
|
|
||||||
raise GraphQLError("Invalid team UUID format")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching team transactions: {e}")
|
|
||||||
raise GraphQLError("Failed to fetch team transactions")
|
|
||||||
|
|
||||||
|
|
||||||
team_schema = graphene.Schema(query=TeamQuery)
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
{% extends 'admin/base.html' %}
|
|
||||||
{% load i18n admin_urls static admin_list %}
|
|
||||||
|
|
||||||
{% block extrahead %}
|
|
||||||
{{ block.super }}
|
|
||||||
{{ media.css }}
|
|
||||||
{{ media.js }}
|
|
||||||
<style>
|
|
||||||
.raw-data-section {
|
|
||||||
margin-top: 20px;
|
|
||||||
border-top: 1px solid #ccc;
|
|
||||||
padding-top: 20px;
|
|
||||||
}
|
|
||||||
.raw-data-section h2 {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.raw-data-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.raw-data-table th, .raw-data-table td {
|
|
||||||
border: 1px solid #eee;
|
|
||||||
padding: 8px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.raw-data-table th {
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div id="content-main">
|
|
||||||
<h1>{{ title }}</h1>
|
|
||||||
|
|
||||||
{% if error_message %}
|
|
||||||
<p class="errornote">{{ error_message }}</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if accounts %}
|
|
||||||
<div class="module raw-data-section">
|
|
||||||
<h2>{% translate "Account Details" %}</h2>
|
|
||||||
<table class="raw-data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{% translate "Account UUID" %}</th>
|
|
||||||
<th>{% translate "Net Balance" %}</th>
|
|
||||||
<th>{% translate "Debits Posted" %}</th>
|
|
||||||
<th>{% translate "Credits Posted" %}</th>
|
|
||||||
<th>{% translate "Debits Pending" %}</th>
|
|
||||||
<th>{% translate "Credits Pending" %}</th>
|
|
||||||
<th>{% translate "Ledger" %}</th>
|
|
||||||
<th>{% translate "Code" %}</th>
|
|
||||||
<th>{% translate "Flags" %}</th>
|
|
||||||
<th>{% translate "Timestamp" %}</th>
|
|
||||||
<th>{% translate "User Data 128" %}</th>
|
|
||||||
<th>{% translate "User Data 64" %}</th>
|
|
||||||
<th>{% translate "User Data 32" %}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for account in accounts %}
|
|
||||||
<tr class="{% cycle 'row1' 'row2' %}">
|
|
||||||
<td>{{ account.uuid }}</td>
|
|
||||||
<td>{{ account.net_balance }}</td>
|
|
||||||
<td>{{ account.debits_posted }}</td>
|
|
||||||
<td>{{ account.credits_posted }}</td>
|
|
||||||
<td>{{ account.debits_pending }}</td>
|
|
||||||
<td>{{ account.credits_pending }}</td>
|
|
||||||
<td>{{ account.ledger }}</td>
|
|
||||||
<td>{{ account.code }}</td>
|
|
||||||
<td>{{ account.flags }}</td>
|
|
||||||
<td>{{ account.timestamp }}</td>
|
|
||||||
<td>{{ account.user_data_128 }}</td>
|
|
||||||
<td>{{ account.user_data_64 }}</td>
|
|
||||||
<td>{{ account.user_data_32 }}</td>
|
|
||||||
</tr>
|
|
||||||
{% empty %}
|
|
||||||
<tr><td colspan="13">{% translate "No account details to display." %}</td></tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p>{% translate "No account details to display." %}</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if transfers %}
|
|
||||||
<div class="module raw-data-section">
|
|
||||||
<h2>{% translate "Transfer Details" %}</h2>
|
|
||||||
<table class="raw-data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{% translate "Transfer ID" %}</th>
|
|
||||||
<th>{% translate "Debit Account" %}</th>
|
|
||||||
<th>{% translate "Credit Account" %}</th>
|
|
||||||
<th>{% translate "Amount" %}</th>
|
|
||||||
<th>{% translate "Ledger" %}</th>
|
|
||||||
<th>{% translate "Code" %}</th>
|
|
||||||
<th>{% translate "Flags" %}</th>
|
|
||||||
<th>{% translate "Timestamp" %}</th>
|
|
||||||
<th>{% translate "Pending ID" %}</th>
|
|
||||||
<th>{% translate "Timeout" %}</th>
|
|
||||||
<th>{% translate "User Data 128" %}</th>
|
|
||||||
<th>{% translate "User Data 64" %}</th>
|
|
||||||
<th>{% translate "User Data 32" %}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for transfer in transfers %}
|
|
||||||
<tr class="{% cycle 'row1' 'row2' %}">
|
|
||||||
<td>{{ transfer.id }}</td>
|
|
||||||
<td>{{ transfer.debit_account_id }}</td>
|
|
||||||
<td>{{ transfer.credit_account_id }}</td>
|
|
||||||
<td>{{ transfer.amount }}</td>
|
|
||||||
<td>{{ transfer.ledger }}</td>
|
|
||||||
<td>{{ transfer.code }}</td>
|
|
||||||
<td>{{ transfer.flags }}</td>
|
|
||||||
<td>{{ transfer.timestamp }}</td>
|
|
||||||
<td>{{ transfer.pending_id }}</td>
|
|
||||||
<td>{{ transfer.timeout }}</td>
|
|
||||||
<td>{{ transfer.user_data_128 }}</td>
|
|
||||||
<td>{{ transfer.user_data_64 }}</td>
|
|
||||||
<td>{{ transfer.user_data_32 }}</td>
|
|
||||||
</tr>
|
|
||||||
{% empty %}
|
|
||||||
<tr><td colspan="13">{% translate "No transfer details to display." %}</td></tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p>{% translate "No transfer details to display." %}</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<ul class="object-tools">
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'admin:billing_app_account_changelist' %}" class="viewsitelink">{% translate "Back to Account List" %}</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
import json
|
|
||||||
from unittest.mock import patch, MagicMock
|
|
||||||
from graphene_django.utils.testing import GraphQLTestCase
|
|
||||||
from .models import Account
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
class BillingGraphQLTests(GraphQLTestCase):
|
|
||||||
GRAPHQL_URL = '/graphql/public/'
|
|
||||||
|
|
||||||
@patch('billing_app.schemas.tigerbeetle_client')
|
|
||||||
def test_create_transfer_new_accounts(self, mock_tb_client):
|
|
||||||
"""
|
|
||||||
Tests that a transfer is created successfully and that new accounts
|
|
||||||
are created lazily in both the local DB and in TigerBeetle.
|
|
||||||
"""
|
|
||||||
# Configure the mock to return no errors
|
|
||||||
mock_tb_client.create_accounts.return_value = []
|
|
||||||
mock_tb_client.create_transfers.return_value = []
|
|
||||||
|
|
||||||
debit_account_id = str(uuid.uuid4())
|
|
||||||
credit_account_id = str(uuid.uuid4())
|
|
||||||
amount = 100
|
|
||||||
ledger = 1
|
|
||||||
code = 200
|
|
||||||
|
|
||||||
# Ensure accounts do not exist locally
|
|
||||||
self.assertEqual(Account.objects.count(), 0)
|
|
||||||
|
|
||||||
response = self.query(
|
|
||||||
'''
|
|
||||||
mutation createTransfer(
|
|
||||||
$debitAccountId: String!,
|
|
||||||
$creditAccountId: String!,
|
|
||||||
$amount: Int!,
|
|
||||||
$ledger: Int!,
|
|
||||||
$code: Int!
|
|
||||||
) {
|
|
||||||
createTransfer(
|
|
||||||
debitAccountId: $debitAccountId,
|
|
||||||
creditAccountId: $creditAccountId,
|
|
||||||
amount: $amount,
|
|
||||||
ledger: $ledger,
|
|
||||||
code: $code
|
|
||||||
) {
|
|
||||||
success
|
|
||||||
message
|
|
||||||
transferId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
''',
|
|
||||||
variables={
|
|
||||||
'debitAccountId': debit_account_id,
|
|
||||||
'creditAccountId': credit_account_id,
|
|
||||||
'amount': amount,
|
|
||||||
'ledger': ledger,
|
|
||||||
'code': code
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertResponseNoErrors(response)
|
|
||||||
content = json.loads(response.content)
|
|
||||||
result = content['data']['createTransfer']
|
|
||||||
|
|
||||||
self.assertTrue(result['success'])
|
|
||||||
self.assertIsNotNone(result['transferId'])
|
|
||||||
self.assertEqual(result['message'], 'Transfer completed successfully.')
|
|
||||||
|
|
||||||
# Verify local accounts were created
|
|
||||||
self.assertEqual(Account.objects.count(), 2)
|
|
||||||
self.assertTrue(Account.objects.filter(uuid=debit_account_id).exists())
|
|
||||||
self.assertTrue(Account.objects.filter(uuid=credit_account_id).exists())
|
|
||||||
|
|
||||||
# Verify TigerBeetle client was called correctly
|
|
||||||
mock_tb_client.create_accounts.assert_called_once()
|
|
||||||
self.assertEqual(len(mock_tb_client.create_accounts.call_args[0][0]), 2) # Called with 2 accounts
|
|
||||||
|
|
||||||
mock_tb_client.create_transfers.assert_called_once()
|
|
||||||
self.assertEqual(len(mock_tb_client.create_transfers.call_args[0][0]), 1) # Called with 1 transfer
|
|
||||||
transfer_arg = mock_tb_client.create_transfers.call_args[0][0][0]
|
|
||||||
self.assertEqual(transfer_arg.amount, amount)
|
|
||||||
|
|
||||||
@patch('billing_app.schemas.tigerbeetle_client')
|
|
||||||
def test_create_transfer_existing_accounts(self, mock_tb_client):
|
|
||||||
"""
|
|
||||||
Tests that a transfer is created successfully with existing accounts
|
|
||||||
and that `create_accounts` is not called.
|
|
||||||
"""
|
|
||||||
mock_tb_client.create_transfers.return_value = []
|
|
||||||
|
|
||||||
debit_account_id = uuid.uuid4()
|
|
||||||
credit_account_id = uuid.uuid4()
|
|
||||||
|
|
||||||
# Pre-populate the local database
|
|
||||||
Account.objects.create(uuid=debit_account_id)
|
|
||||||
Account.objects.create(uuid=credit_account_id)
|
|
||||||
|
|
||||||
self.assertEqual(Account.objects.count(), 2)
|
|
||||||
|
|
||||||
response = self.query(
|
|
||||||
'''
|
|
||||||
mutation createTransfer(
|
|
||||||
$debitAccountId: String!,
|
|
||||||
$creditAccountId: String!,
|
|
||||||
$amount: Int!,
|
|
||||||
$ledger: Int!,
|
|
||||||
$code: Int!
|
|
||||||
) {
|
|
||||||
createTransfer(
|
|
||||||
debitAccountId: $debitAccountId,
|
|
||||||
creditAccountId: $creditAccountId,
|
|
||||||
amount: $amount,
|
|
||||||
ledger: $ledger,
|
|
||||||
code: $code
|
|
||||||
) {
|
|
||||||
success
|
|
||||||
message
|
|
||||||
transferId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
''',
|
|
||||||
variables={
|
|
||||||
'debitAccountId': str(debit_account_id),
|
|
||||||
'creditAccountId': str(credit_account_id),
|
|
||||||
'amount': 50,
|
|
||||||
'ledger': 1,
|
|
||||||
'code': 201
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertResponseNoErrors(response)
|
|
||||||
|
|
||||||
# Verify that create_accounts was NOT called
|
|
||||||
mock_tb_client.create_accounts.assert_not_called()
|
|
||||||
|
|
||||||
# Verify that create_transfers WAS called
|
|
||||||
mock_tb_client.create_transfers.assert_called_once()
|
|
||||||
|
|
||||||
def test_create_transfer_invalid_uuid(self):
|
|
||||||
"""
|
|
||||||
Tests that the mutation fails gracefully with an invalid UUID.
|
|
||||||
"""
|
|
||||||
response = self.query(
|
|
||||||
'''
|
|
||||||
mutation createTransfer(
|
|
||||||
$debitAccountId: String!,
|
|
||||||
$creditAccountId: String!,
|
|
||||||
$amount: Int!,
|
|
||||||
$ledger: Int!,
|
|
||||||
$code: Int!
|
|
||||||
) {
|
|
||||||
createTransfer(
|
|
||||||
debitAccountId: $debitAccountId,
|
|
||||||
creditAccountId: $creditAccountId,
|
|
||||||
amount: $amount,
|
|
||||||
ledger: $ledger,
|
|
||||||
code: $code
|
|
||||||
) {
|
|
||||||
success
|
|
||||||
message
|
|
||||||
transferId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
''',
|
|
||||||
variables={
|
|
||||||
'debitAccountId': 'not-a-uuid',
|
|
||||||
'creditAccountId': str(uuid.uuid4()),
|
|
||||||
'amount': 50,
|
|
||||||
'ledger': 1,
|
|
||||||
'code': 202
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertResponseNoErrors(response)
|
|
||||||
content = json.loads(response.content)
|
|
||||||
result = content['data']['createTransfer']
|
|
||||||
|
|
||||||
self.assertFalse(result['success'])
|
|
||||||
self.assertEqual(result['message'], 'Invalid account ID format. Must be a valid UUID.')
|
|
||||||
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
import os
|
|
||||||
import logging
|
|
||||||
import socket
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_address(address: str) -> str:
|
|
||||||
"""Resolve hostname to IP address for TigerBeetle client.
|
|
||||||
|
|
||||||
TigerBeetle Python client doesn't handle DNS resolution properly,
|
|
||||||
so we need to resolve hostnames to IPs before connecting.
|
|
||||||
"""
|
|
||||||
if ":" in address:
|
|
||||||
host, port = address.rsplit(":", 1)
|
|
||||||
else:
|
|
||||||
host, port = address, "3000"
|
|
||||||
|
|
||||||
try:
|
|
||||||
ip = socket.gethostbyname(host)
|
|
||||||
resolved = f"{ip}:{port}"
|
|
||||||
logger.info(f"Resolved {address} to {resolved}")
|
|
||||||
return resolved
|
|
||||||
except socket.gaierror as e:
|
|
||||||
logger.warning(f"Failed to resolve {host}: {e}, using original address")
|
|
||||||
return address
|
|
||||||
|
|
||||||
|
|
||||||
class TigerBeetleClient:
|
|
||||||
"""
|
|
||||||
Lazy-initialized TigerBeetle client singleton.
|
|
||||||
Connection is only established on first actual use.
|
|
||||||
|
|
||||||
IMPORTANT: TigerBeetle Python client requires io_uring.
|
|
||||||
Docker container must run with: --security-opt seccomp=unconfined
|
|
||||||
"""
|
|
||||||
_instance = None
|
|
||||||
_initialized = False
|
|
||||||
|
|
||||||
def __new__(cls):
|
|
||||||
if cls._instance is None:
|
|
||||||
cls._instance = super(TigerBeetleClient, cls).__new__(cls)
|
|
||||||
cls._instance._client = None
|
|
||||||
cls._instance._initialized = False
|
|
||||||
return cls._instance
|
|
||||||
|
|
||||||
def _ensure_connected(self):
|
|
||||||
"""Lazy initialization - connect only when needed."""
|
|
||||||
if self._initialized:
|
|
||||||
return self._client is not None
|
|
||||||
|
|
||||||
self._initialized = True
|
|
||||||
self.cluster_id = int(os.getenv("TB_CLUSTER_ID", "0"))
|
|
||||||
raw_address = os.getenv("TB_ADDRESS", "127.0.0.1:3000")
|
|
||||||
self.replica_addresses = resolve_address(raw_address)
|
|
||||||
|
|
||||||
try:
|
|
||||||
import tigerbeetle as tb
|
|
||||||
self._client = tb.ClientSync(
|
|
||||||
cluster_id=self.cluster_id, replica_addresses=self.replica_addresses
|
|
||||||
)
|
|
||||||
logger.info(f"Connected to TigerBeetle cluster {self.cluster_id} at {self.replica_addresses}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to connect to TigerBeetle: {e}")
|
|
||||||
self._client = None
|
|
||||||
return False
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
if self._client:
|
|
||||||
self._client.close()
|
|
||||||
logger.info("TigerBeetle client closed.")
|
|
||||||
|
|
||||||
def create_accounts(self, accounts):
|
|
||||||
"""Create accounts in TigerBeetle."""
|
|
||||||
if not self._ensure_connected():
|
|
||||||
logger.error("TigerBeetle client not available.")
|
|
||||||
return [Exception("TigerBeetle not connected")]
|
|
||||||
|
|
||||||
try:
|
|
||||||
errors = self._client.create_accounts(accounts)
|
|
||||||
if errors:
|
|
||||||
for error in errors:
|
|
||||||
if hasattr(error, 'error'):
|
|
||||||
logger.error(f"Error creating account: {error.error.name}")
|
|
||||||
else:
|
|
||||||
logger.error(f"Error creating account: {error}")
|
|
||||||
return errors
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Exception during account creation: {e}")
|
|
||||||
return [e]
|
|
||||||
|
|
||||||
def create_transfers(self, transfers):
|
|
||||||
"""Create transfers in TigerBeetle."""
|
|
||||||
if not self._ensure_connected():
|
|
||||||
logger.error("TigerBeetle client not available.")
|
|
||||||
return [Exception("TigerBeetle not connected")]
|
|
||||||
|
|
||||||
try:
|
|
||||||
errors = self._client.create_transfers(transfers)
|
|
||||||
if errors:
|
|
||||||
for error in errors:
|
|
||||||
if hasattr(error, 'error'):
|
|
||||||
logger.error(f"Error creating transfer: {error.error.name}")
|
|
||||||
else:
|
|
||||||
logger.error(f"Error creating transfer: {error}")
|
|
||||||
return errors
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Exception during transfer creation: {e}")
|
|
||||||
return [e]
|
|
||||||
|
|
||||||
def lookup_accounts(self, account_ids: list[int]):
|
|
||||||
"""Look up accounts in TigerBeetle."""
|
|
||||||
if not self._ensure_connected():
|
|
||||||
logger.error("TigerBeetle client not available.")
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
accounts = self._client.lookup_accounts(account_ids)
|
|
||||||
return accounts
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Exception during account lookup: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def get_account_transfers(self, account_id: int, limit: int = 50):
|
|
||||||
"""Get transfers for an account from TigerBeetle."""
|
|
||||||
if not self._ensure_connected():
|
|
||||||
logger.error("TigerBeetle client not available.")
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
import tigerbeetle as tb
|
|
||||||
account_filter = tb.AccountFilter(
|
|
||||||
account_id=account_id,
|
|
||||||
timestamp_min=0,
|
|
||||||
timestamp_max=0,
|
|
||||||
limit=limit,
|
|
||||||
flags=tb.AccountFilterFlags.CREDITS | tb.AccountFilterFlags.DEBITS | tb.AccountFilterFlags.REVERSED,
|
|
||||||
)
|
|
||||||
transfers = self._client.get_account_transfers(account_filter)
|
|
||||||
return transfers
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Exception during get_account_transfers: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance (lazy - doesn't connect until first use)
|
|
||||||
tigerbeetle_client = TigerBeetleClient()
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
from graphene_django.views import GraphQLView
|
|
||||||
from .graphql_middleware import M2MNoAuthMiddleware, PublicNoAuthMiddleware, BillingJWTMiddleware
|
|
||||||
|
|
||||||
|
|
||||||
class PublicGraphQLView(GraphQLView):
|
|
||||||
"""GraphQL view for public operations (no authentication)."""
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
kwargs['middleware'] = [PublicNoAuthMiddleware()]
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class M2MGraphQLView(GraphQLView):
|
|
||||||
"""GraphQL view for M2M (machine-to-machine) operations.
|
|
||||||
No authentication required - used by internal services (Temporal, etc.)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
kwargs['middleware'] = [M2MNoAuthMiddleware()]
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class TeamGraphQLView(GraphQLView):
|
|
||||||
"""GraphQL view for team-level operations.
|
|
||||||
Requires Access token with teams:member scope.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
kwargs['middleware'] = [BillingJWTMiddleware()]
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
from billing_app.models import Account
|
|
||||||
from django.db import transaction
|
|
||||||
|
|
||||||
print("Creating sample accounts...")
|
|
||||||
|
|
||||||
with transaction.atomic():
|
|
||||||
for i in range(5):
|
|
||||||
account = Account.objects.create()
|
|
||||||
print(f"Created account: {account.uuid}")
|
|
||||||
|
|
||||||
print("Sample accounts created successfully.")
|
|
||||||
@@ -9,13 +9,11 @@ services:
|
|||||||
- PORT=8000
|
- PORT=8000
|
||||||
- TB_CLUSTER_ID=0
|
- TB_CLUSTER_ID=0
|
||||||
- TB_ADDRESS=tigerbeetle:3000
|
- TB_ADDRESS=tigerbeetle:3000
|
||||||
- INFISICAL_API_URL=${INFISICAL_API_URL}
|
- BILLING_DATABASE_URL=postgresql://postgres:${BILLING_DB_PASSWORD}@${BILLING_DB_HOST}:5432/postgres
|
||||||
- INFISICAL_ENV=${INFISICAL_ENV}
|
- LOGTO_JWKS_URL=${LOGTO_JWKS_URL}
|
||||||
- INFISICAL_PROJECT_ID=${INFISICAL_PROJECT_ID}
|
- LOGTO_ISSUER=${LOGTO_ISSUER}
|
||||||
- INFISICAL_CLIENT_ID=${INFISICAL_CLIENT_ID}
|
- LOGTO_BILLING_AUDIENCE=${LOGTO_BILLING_AUDIENCE}
|
||||||
- INFISICAL_CLIENT_SECRET=${INFISICAL_CLIENT_SECRET}
|
- SENTRY_DSN=${SENTRY_DSN}
|
||||||
security_opt:
|
|
||||||
- seccomp=unconfined
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- tigerbeetle
|
- tigerbeetle
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
22
manage.py
22
manage.py
@@ -1,22 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
"""Django's command-line utility for administrative tasks."""
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Run administrative tasks."""
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'billing.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)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@@ -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 billing.wsgi:application --bind 0.0.0.0:${PORT:-8000}"
|
|
||||||
|
|
||||||
[variables]
|
|
||||||
# Set Poetry version to match local environment
|
|
||||||
NIXPACKS_POETRY_VERSION = "2.2.1"
|
|
||||||
3804
package-lock.json
generated
Normal file
3804
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": "billing",
|
||||||
|
"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",
|
||||||
|
"prisma:migrate": "prisma migrate dev"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@apollo/server": "^4.11.3",
|
||||||
|
"@prisma/client": "^6.5.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"graphql": "^16.10.0",
|
||||||
|
"graphql-tag": "^2.12.6",
|
||||||
|
"jose": "^6.0.11",
|
||||||
|
"tigerbeetle-node": "^0.16.43",
|
||||||
|
"@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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1016
poetry.lock
generated
1016
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
43
prisma/schema.prisma
Normal file
43
prisma/schema.prisma
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("BILLING_DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AccountType {
|
||||||
|
USER
|
||||||
|
SERVICE
|
||||||
|
}
|
||||||
|
|
||||||
|
model Account {
|
||||||
|
uuid String @id @default(uuid()) @db.Uuid
|
||||||
|
name String @db.VarChar(255)
|
||||||
|
accountType AccountType @default(USER) @map("account_type")
|
||||||
|
description String?
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
serviceInfo ServiceAccount?
|
||||||
|
|
||||||
|
@@map("billing_app_account")
|
||||||
|
}
|
||||||
|
|
||||||
|
model OperationCode {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
code Int @unique
|
||||||
|
name String @unique @db.VarChar(255)
|
||||||
|
description String?
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@map("billing_app_operationcode")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ServiceAccount {
|
||||||
|
accountUuid String @id @map("account_id") @db.Uuid
|
||||||
|
slug String @unique @db.VarChar(50)
|
||||||
|
account Account @relation(fields: [accountUuid], references: [uuid], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("billing_app_serviceaccount")
|
||||||
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
[project]
|
|
||||||
name = "billing"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "Billing service for Optovia"
|
|
||||||
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)",
|
|
||||||
"requests (>=2.32.5,<3.0.0)",
|
|
||||||
"temporalio (>=1.4.0,<2.0.0)",
|
|
||||||
"python-dotenv (>=1.2.1,<2.0.0)",
|
|
||||||
"pyjwt (>=2.10.1,<3.0.0)",
|
|
||||||
"cryptography (>=46.0.3,<47.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)",
|
|
||||||
"tigerbeetle (>=0.1.1,<1.0.0)"
|
|
||||||
]
|
|
||||||
|
|
||||||
[build-system]
|
|
||||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
||||||
build-backend = "poetry.core.masonry.api"
|
|
||||||
66
src/auth.ts
Normal file
66
src/auth.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
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 LOGTO_BILLING_AUDIENCE = process.env.LOGTO_BILLING_AUDIENCE || 'https://billing.optovia.ru'
|
||||||
|
|
||||||
|
const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL))
|
||||||
|
|
||||||
|
export interface AuthContext {
|
||||||
|
userId?: string
|
||||||
|
teamUuid?: string
|
||||||
|
scopes: string[]
|
||||||
|
isM2M?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBearerToken(req: Request): string {
|
||||||
|
const auth = req.headers.authorization || ''
|
||||||
|
if (!auth.startsWith('Bearer ')) {
|
||||||
|
throw new GraphQLError('Missing Bearer token', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||||
|
}
|
||||||
|
const token = auth.slice(7)
|
||||||
|
if (!token || token === 'undefined') {
|
||||||
|
throw new GraphQLError('Empty Bearer token', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopesFromPayload(payload: JWTPayload): string[] {
|
||||||
|
const scope = payload.scope
|
||||||
|
if (!scope) return []
|
||||||
|
if (typeof scope === 'string') return scope.split(' ')
|
||||||
|
if (Array.isArray(scope)) return scope as string[]
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function m2mContext(): Promise<AuthContext> {
|
||||||
|
return { scopes: [], isM2M: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function teamContext(req: Request): Promise<AuthContext> {
|
||||||
|
const token = getBearerToken(req)
|
||||||
|
const { payload } = await jwtVerify(token, jwks, {
|
||||||
|
issuer: LOGTO_ISSUER,
|
||||||
|
audience: LOGTO_BILLING_AUDIENCE,
|
||||||
|
})
|
||||||
|
|
||||||
|
const teamUuid = (payload as Record<string, unknown>).team_uuid as string | undefined
|
||||||
|
const scopes = scopesFromPayload(payload)
|
||||||
|
|
||||||
|
if (!teamUuid || !scopes.includes('teams:member')) {
|
||||||
|
throw new GraphQLError('Unauthorized', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { userId: payload.sub, teamUuid, scopes }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireScopes(ctx: AuthContext, ...required: string[]): void {
|
||||||
|
const missing = required.filter(s => !ctx.scopes.includes(s))
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new GraphQLError(`Missing required scopes: ${missing.join(', ')}`, {
|
||||||
|
extensions: { code: 'FORBIDDEN' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
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()
|
||||||
64
src/index.ts
Normal file
64
src/index.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
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 { teamTypeDefs, teamResolvers } from './schemas/team.js'
|
||||||
|
import { m2mTypeDefs, m2mResolvers } from './schemas/m2m.js'
|
||||||
|
import { m2mContext, teamContext, 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 teamServer = new ApolloServer<AuthContext>({
|
||||||
|
typeDefs: teamTypeDefs,
|
||||||
|
resolvers: teamResolvers,
|
||||||
|
introspection: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const m2mServer = new ApolloServer<AuthContext>({
|
||||||
|
typeDefs: m2mTypeDefs,
|
||||||
|
resolvers: m2mResolvers,
|
||||||
|
introspection: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all([teamServer.start(), m2mServer.start()])
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
'/graphql/team',
|
||||||
|
express.json(),
|
||||||
|
expressMiddleware(teamServer, {
|
||||||
|
context: async ({ req }) => teamContext(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(`Billing server ready on port ${PORT}`)
|
||||||
|
console.log(` /graphql/team - team access token auth`)
|
||||||
|
console.log(` /graphql/m2m - internal services (no auth)`)
|
||||||
|
})
|
||||||
138
src/schemas/m2m.ts
Normal file
138
src/schemas/m2m.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { GraphQLError } from 'graphql'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import { prisma } from '../db.js'
|
||||||
|
import {
|
||||||
|
lookupAccounts,
|
||||||
|
createAccounts,
|
||||||
|
createTransfers,
|
||||||
|
Account,
|
||||||
|
Transfer,
|
||||||
|
uuidToBigInt,
|
||||||
|
} from '../tigerbeetle.js'
|
||||||
|
import type { AuthContext } from '../auth.js'
|
||||||
|
|
||||||
|
const LEDGER = 1
|
||||||
|
const DEFAULT_ACCOUNT_CODE = 100
|
||||||
|
|
||||||
|
export const m2mTypeDefs = `#graphql
|
||||||
|
type OperationCode {
|
||||||
|
code: Int!
|
||||||
|
name: String!
|
||||||
|
description: String
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServiceAccount {
|
||||||
|
slug: String!
|
||||||
|
accountUuid: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateTransactionInput {
|
||||||
|
fromUuid: String!
|
||||||
|
toUuid: String!
|
||||||
|
amount: Int!
|
||||||
|
code: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateTransactionResult {
|
||||||
|
success: Boolean!
|
||||||
|
message: String
|
||||||
|
transferId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query {
|
||||||
|
operationCodes: [OperationCode]
|
||||||
|
serviceAccounts: [ServiceAccount]
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mutation {
|
||||||
|
createTransaction(input: CreateTransactionInput!): CreateTransactionResult
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const m2mResolvers = {
|
||||||
|
Query: {
|
||||||
|
operationCodes: async () => {
|
||||||
|
return prisma.operationCode.findMany({ orderBy: { code: 'asc' } })
|
||||||
|
},
|
||||||
|
serviceAccounts: async () => {
|
||||||
|
const sas = await prisma.serviceAccount.findMany({ include: { account: true } })
|
||||||
|
return sas.map(sa => ({ slug: sa.slug, accountUuid: sa.accountUuid }))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Mutation: {
|
||||||
|
createTransaction: async (
|
||||||
|
_: unknown,
|
||||||
|
args: { input: { fromUuid: string; toUuid: string; amount: number; code: number } },
|
||||||
|
_ctx: AuthContext,
|
||||||
|
) => {
|
||||||
|
const { fromUuid, toUuid, amount, code } = args.input
|
||||||
|
|
||||||
|
// Validate UUIDs
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||||
|
if (!uuidRegex.test(fromUuid) || !uuidRegex.test(toUuid)) {
|
||||||
|
return { success: false, message: 'Invalid UUID format', transferId: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure local Account records exist
|
||||||
|
for (const uuid of [fromUuid, toUuid]) {
|
||||||
|
const exists = await prisma.account.findUnique({ where: { uuid } })
|
||||||
|
if (!exists) {
|
||||||
|
await prisma.account.create({
|
||||||
|
data: { uuid, name: `Team ${uuid.slice(0, 8)}`, accountType: 'USER' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if account exists in TigerBeetle, create if not
|
||||||
|
const tbId = uuidToBigInt(uuid)
|
||||||
|
const tbAccounts = await lookupAccounts([tbId])
|
||||||
|
if (tbAccounts.length === 0) {
|
||||||
|
const account: Account = {
|
||||||
|
id: tbId,
|
||||||
|
debits_pending: 0n,
|
||||||
|
debits_posted: 0n,
|
||||||
|
credits_pending: 0n,
|
||||||
|
credits_posted: 0n,
|
||||||
|
user_data_128: 0n,
|
||||||
|
user_data_64: 0n,
|
||||||
|
user_data_32: 0,
|
||||||
|
reserved: 0,
|
||||||
|
ledger: LEDGER,
|
||||||
|
code: DEFAULT_ACCOUNT_CODE,
|
||||||
|
flags: 0,
|
||||||
|
timestamp: 0n,
|
||||||
|
}
|
||||||
|
const errors = await createAccounts([account])
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return { success: false, message: `Failed to create account in TigerBeetle: ${errors}`, transferId: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute transfer
|
||||||
|
const transferId = randomUUID()
|
||||||
|
const transfer: Transfer = {
|
||||||
|
id: uuidToBigInt(transferId),
|
||||||
|
debit_account_id: uuidToBigInt(fromUuid),
|
||||||
|
credit_account_id: uuidToBigInt(toUuid),
|
||||||
|
amount: BigInt(amount),
|
||||||
|
pending_id: 0n,
|
||||||
|
user_data_128: 0n,
|
||||||
|
user_data_64: 0n,
|
||||||
|
user_data_32: 0,
|
||||||
|
timeout: 0,
|
||||||
|
ledger: LEDGER,
|
||||||
|
code,
|
||||||
|
flags: 0,
|
||||||
|
timestamp: 0n,
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = await createTransfers([transfer])
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return { success: false, message: `Transfer failed: ${errors}`, transferId: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Transfer ${transferId} completed: ${fromUuid} -> ${toUuid}, amount=${amount}`)
|
||||||
|
return { success: true, message: 'Transaction completed', transferId }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
90
src/schemas/team.ts
Normal file
90
src/schemas/team.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { GraphQLError } from 'graphql'
|
||||||
|
import { requireScopes, type AuthContext } from '../auth.js'
|
||||||
|
import { lookupAccounts, getAccountTransfers, uuidToBigInt, bigIntToUuid } from '../tigerbeetle.js'
|
||||||
|
import { prisma } from '../db.js'
|
||||||
|
|
||||||
|
export const teamTypeDefs = `#graphql
|
||||||
|
type TeamBalance {
|
||||||
|
balance: Float!
|
||||||
|
creditsPosted: Float!
|
||||||
|
debitsPosted: Float!
|
||||||
|
exists: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
type TeamTransaction {
|
||||||
|
id: String!
|
||||||
|
amount: Float!
|
||||||
|
timestamp: Float
|
||||||
|
code: Int
|
||||||
|
codeName: String
|
||||||
|
direction: String!
|
||||||
|
counterpartyUuid: String
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query {
|
||||||
|
teamBalance: TeamBalance
|
||||||
|
teamTransactions(limit: Int = 50): [TeamTransaction]
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const teamResolvers = {
|
||||||
|
Query: {
|
||||||
|
teamBalance: async (_: unknown, __: unknown, ctx: AuthContext) => {
|
||||||
|
requireScopes(ctx, 'teams:member')
|
||||||
|
if (!ctx.teamUuid) throw new GraphQLError('Team UUID not found in context')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tbAccountId = uuidToBigInt(ctx.teamUuid)
|
||||||
|
const accounts = await lookupAccounts([tbAccountId])
|
||||||
|
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
return { balance: 0, creditsPosted: 0, debitsPosted: 0, exists: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = accounts[0]
|
||||||
|
const creditsPosted = Number(account.credits_posted)
|
||||||
|
const debitsPosted = Number(account.debits_posted)
|
||||||
|
return {
|
||||||
|
balance: creditsPosted - debitsPosted,
|
||||||
|
creditsPosted,
|
||||||
|
debitsPosted,
|
||||||
|
exists: true,
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof GraphQLError) throw e
|
||||||
|
console.error('Error fetching team balance:', e)
|
||||||
|
throw new GraphQLError('Failed to fetch team balance')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
teamTransactions: async (_: unknown, args: { limit: number }, ctx: AuthContext) => {
|
||||||
|
requireScopes(ctx, 'teams:member')
|
||||||
|
if (!ctx.teamUuid) throw new GraphQLError('Team UUID not found in context')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tbAccountId = uuidToBigInt(ctx.teamUuid)
|
||||||
|
const transfers = await getAccountTransfers(tbAccountId, args.limit)
|
||||||
|
|
||||||
|
const codes = await prisma.operationCode.findMany()
|
||||||
|
const codeMap = new Map(codes.map(c => [c.code, c.name]))
|
||||||
|
|
||||||
|
return transfers.map(t => {
|
||||||
|
const isCredit = t.credit_account_id === tbAccountId
|
||||||
|
return {
|
||||||
|
id: bigIntToUuid(t.id),
|
||||||
|
amount: Number(t.amount),
|
||||||
|
timestamp: Number(t.timestamp),
|
||||||
|
code: t.code,
|
||||||
|
codeName: codeMap.get(t.code) ?? null,
|
||||||
|
direction: isCredit ? 'credit' : 'debit',
|
||||||
|
counterpartyUuid: bigIntToUuid(isCredit ? t.debit_account_id : t.credit_account_id),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof GraphQLError) throw e
|
||||||
|
console.error('Error fetching team transactions:', e)
|
||||||
|
throw new GraphQLError('Failed to fetch team transactions')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
72
src/tigerbeetle.ts
Normal file
72
src/tigerbeetle.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
createClient,
|
||||||
|
Account,
|
||||||
|
Transfer,
|
||||||
|
AccountFilter,
|
||||||
|
AccountFilterFlags,
|
||||||
|
type Client,
|
||||||
|
} from 'tigerbeetle-node'
|
||||||
|
|
||||||
|
const TB_CLUSTER_ID = parseInt(process.env.TB_CLUSTER_ID || '0', 10)
|
||||||
|
const TB_ADDRESS = process.env.TB_ADDRESS || '127.0.0.1:3000'
|
||||||
|
|
||||||
|
let client: Client | null = null
|
||||||
|
|
||||||
|
async function ensureConnected(): Promise<Client> {
|
||||||
|
if (client) return client
|
||||||
|
client = createClient({
|
||||||
|
cluster_id: BigInt(TB_CLUSTER_ID),
|
||||||
|
replica_addresses: [TB_ADDRESS],
|
||||||
|
})
|
||||||
|
console.log(`Connected to TigerBeetle cluster ${TB_CLUSTER_ID} at ${TB_ADDRESS}`)
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
function uuidToBigInt(uuid: string): bigint {
|
||||||
|
const hex = uuid.replace(/-/g, '')
|
||||||
|
return BigInt('0x' + hex)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bigIntToUuid(n: bigint): string {
|
||||||
|
const hex = n.toString(16).padStart(32, '0')
|
||||||
|
return [
|
||||||
|
hex.slice(0, 8),
|
||||||
|
hex.slice(8, 12),
|
||||||
|
hex.slice(12, 16),
|
||||||
|
hex.slice(16, 20),
|
||||||
|
hex.slice(20, 32),
|
||||||
|
].join('-')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lookupAccounts(accountIds: bigint[]) {
|
||||||
|
const tb = await ensureConnected()
|
||||||
|
return tb.lookupAccounts(accountIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAccounts(accounts: Account[]) {
|
||||||
|
const tb = await ensureConnected()
|
||||||
|
return tb.createAccounts(accounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTransfers(transfers: Transfer[]) {
|
||||||
|
const tb = await ensureConnected()
|
||||||
|
return tb.createTransfers(transfers)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAccountTransfers(accountId: bigint, limit = 50) {
|
||||||
|
const tb = await ensureConnected()
|
||||||
|
const filter: AccountFilter = {
|
||||||
|
account_id: accountId,
|
||||||
|
timestamp_min: 0n,
|
||||||
|
timestamp_max: 0n,
|
||||||
|
limit,
|
||||||
|
flags: AccountFilterFlags.credits | AccountFilterFlags.debits | AccountFilterFlags.reversed,
|
||||||
|
user_data_128: 0n,
|
||||||
|
user_data_64: 0n,
|
||||||
|
user_data_32: 0,
|
||||||
|
code: 0,
|
||||||
|
}
|
||||||
|
return tb.getAccountTransfers(filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Account, Transfer, uuidToBigInt, bigIntToUuid }
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import os
|
|
||||||
import uuid
|
|
||||||
from tigerbeetle import Account, ClientSync
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Ensure these are set correctly in your environment or .env file
|
|
||||||
CLUSTER_ID = int(os.getenv("TB_CLUSTER_ID", "0"))
|
|
||||||
TB_ADDRESS = os.getenv("TB_ADDRESS", "127.0.0.1:3000")
|
|
||||||
|
|
||||||
def test_tigerbeetle_connection():
|
|
||||||
client = None
|
|
||||||
try:
|
|
||||||
logger.info(f"Attempting to connect to TigerBeetle cluster {CLUSTER_ID} at {TB_ADDRESS}...")
|
|
||||||
client = ClientSync(CLUSTER_ID, TB_ADDRESS)
|
|
||||||
logger.info("Successfully connected to TigerBeetle.")
|
|
||||||
|
|
||||||
# Try to create a dummy account to verify functionality
|
|
||||||
test_account_id = uuid.uuid4().int # Use uuid.int for 128-bit ID
|
|
||||||
|
|
||||||
account_to_create = Account(
|
|
||||||
id=test_account_id,
|
|
||||||
ledger=1, # Example ledger
|
|
||||||
code=100, # Example code
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Attempting to create a test account with ID: {test_account_id}...")
|
|
||||||
errors = client.create_accounts([account_to_create])
|
|
||||||
|
|
||||||
if errors:
|
|
||||||
for error in errors:
|
|
||||||
logger.error(f"Error creating test account {error.index}: {error.code.name}")
|
|
||||||
return False, f"Failed to create test account: {', '.join([e.code.name for e in errors])}"
|
|
||||||
else:
|
|
||||||
logger.info("Test account created successfully.")
|
|
||||||
return True, "Connection and basic account creation successful."
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to connect or interact with TigerBeetle: {e}")
|
|
||||||
return False, str(e)
|
|
||||||
finally:
|
|
||||||
if client:
|
|
||||||
client.close()
|
|
||||||
logger.info("TigerBeetle client closed.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
success, message = test_tigerbeetle_connection()
|
|
||||||
if success:
|
|
||||||
logger.info(f"TigerBeetle connection check PASSED: {message}")
|
|
||||||
else:
|
|
||||||
logger.error(f"TigerBeetle connection check FAILED: {message}")
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
import os
|
|
||||||
import uuid
|
|
||||||
import tigerbeetle as tb
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.conf import settings
|
|
||||||
from billing_app.models import Account as AccountModel
|
|
||||||
from billing_app.tigerbeetle_client import tigerbeetle_client
|
|
||||||
|
|
||||||
# Ensure Django settings are configured if not already
|
|
||||||
if not settings.configured:
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'billing.settings')
|
|
||||||
import django
|
|
||||||
django.setup()
|
|
||||||
|
|
||||||
# Define Ledger and Code (these are example values, adjust as needed)
|
|
||||||
LEDGER = 700
|
|
||||||
CODE = 10
|
|
||||||
|
|
||||||
print("--- Starting Raw Data Display for Multiple Transactions ---")
|
|
||||||
|
|
||||||
# 1. Create source, destination, and bank accounts
|
|
||||||
print("Creating source, destination, and bank accounts...")
|
|
||||||
source_account_id = uuid.uuid4()
|
|
||||||
destination_account_id = uuid.uuid4()
|
|
||||||
bank_account_id = uuid.uuid4() # Bank account for initial funding
|
|
||||||
|
|
||||||
# Create in local DB
|
|
||||||
source_local_account = AccountModel.objects.create(uuid=source_account_id)
|
|
||||||
destination_local_account = AccountModel.objects.create(uuid=destination_account_id)
|
|
||||||
bank_local_account = AccountModel.objects.create(uuid=bank_account_id)
|
|
||||||
|
|
||||||
# Prepare for TigerBeetle
|
|
||||||
accounts_to_create_details = [
|
|
||||||
{
|
|
||||||
"id": source_account_id.int,
|
|
||||||
"ledger": LEDGER,
|
|
||||||
"code": CODE,
|
|
||||||
"flags": tb.AccountFlags.NONE # All flags set to NONE for simplicity
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": destination_account_id.int,
|
|
||||||
"ledger": LEDGER,
|
|
||||||
"code": CODE,
|
|
||||||
"flags": tb.AccountFlags.NONE # All flags set to NONE for simplicity
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": bank_account_id.int,
|
|
||||||
"ledger": LEDGER,
|
|
||||||
"code": CODE,
|
|
||||||
"flags": tb.AccountFlags.NONE # Changed: Removed CREDIT_RESERVED, set to NONE
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
# Create accounts in TigerBeetle using create_account (singular) for each
|
|
||||||
account_creation_results = []
|
|
||||||
for acc_details in accounts_to_create_details:
|
|
||||||
flags = acc_details.get("flags", tb.AccountFlags.NONE)
|
|
||||||
results = tigerbeetle_client.create_account(
|
|
||||||
acc_details["id"],
|
|
||||||
acc_details["ledger"],
|
|
||||||
acc_details["code"],
|
|
||||||
flags
|
|
||||||
)
|
|
||||||
if results: # If non-empty, it means there are errors
|
|
||||||
account_creation_results.extend(results)
|
|
||||||
|
|
||||||
if account_creation_results:
|
|
||||||
print(f"Failed to create accounts in TigerBeetle. Raw results: {account_creation_results}") # Print raw results
|
|
||||||
import sys
|
|
||||||
sys.exit(1)
|
|
||||||
else:
|
|
||||||
print(f"Source account created: {source_account_id}")
|
|
||||||
print(f"Destination account created: {destination_account_id}")
|
|
||||||
print(f"Bank account created: {bank_account_id}")
|
|
||||||
|
|
||||||
# Store all transfer IDs for later lookup
|
|
||||||
all_transfer_ids = []
|
|
||||||
|
|
||||||
# 2. Fund the source account (initial balance)
|
|
||||||
print("Funding source account with initial balance...")
|
|
||||||
initial_fund_id = uuid.uuid4()
|
|
||||||
initial_fund_transfer = tb.Transfer(
|
|
||||||
id=initial_fund_id.int,
|
|
||||||
debit_account_id=bank_account_id.int, # Use real bank account ID
|
|
||||||
credit_account_id=source_account_id.int,
|
|
||||||
amount=1000, # Initial amount
|
|
||||||
ledger=LEDGER,
|
|
||||||
code=CODE,
|
|
||||||
)
|
|
||||||
fund_results = tigerbeetle_client.create_transfer(
|
|
||||||
initial_fund_transfer.id,
|
|
||||||
initial_fund_transfer.debit_account_id,
|
|
||||||
initial_fund_transfer.credit_account_id,
|
|
||||||
initial_fund_transfer.amount,
|
|
||||||
initial_fund_transfer.ledger,
|
|
||||||
initial_fund_transfer.code,
|
|
||||||
initial_fund_transfer.flags
|
|
||||||
)
|
|
||||||
if fund_results: # If non-empty, it means there are errors
|
|
||||||
print(f"Failed to fund source account. Raw results: {fund_results}")
|
|
||||||
import sys
|
|
||||||
sys.exit(1)
|
|
||||||
else:
|
|
||||||
print(f"Source account funded with 1000. Transfer ID: {initial_fund_id}")
|
|
||||||
all_transfer_ids.append(initial_fund_id.int) # Store ID
|
|
||||||
|
|
||||||
# 3. Perform 5 transactions from source to destination
|
|
||||||
print("Performing 5 transactions from source to destination...")
|
|
||||||
for i in range(1, 6):
|
|
||||||
transfer_id = uuid.uuid4()
|
|
||||||
transfer_amount = 100 # Example amount for each transfer
|
|
||||||
|
|
||||||
transfer = tb.Transfer( # tb.Transfer object
|
|
||||||
id=transfer_id.int,
|
|
||||||
debit_account_id=source_account_id.int,
|
|
||||||
credit_account_id=destination_account_id.int,
|
|
||||||
amount=transfer_amount,
|
|
||||||
ledger=LEDGER,
|
|
||||||
code=CODE,
|
|
||||||
)
|
|
||||||
transfer_results = tigerbeetle_client.create_transfer(
|
|
||||||
transfer.id,
|
|
||||||
transfer.debit_account_id,
|
|
||||||
transfer.credit_account_id,
|
|
||||||
transfer.amount,
|
|
||||||
transfer.ledger,
|
|
||||||
transfer.code,
|
|
||||||
transfer.flags
|
|
||||||
)
|
|
||||||
if transfer_results:
|
|
||||||
print(f"Transaction {i} failed. Raw results: {transfer_results}")
|
|
||||||
else:
|
|
||||||
print(f"Transaction {i} (Amount: {transfer_amount}) completed. Transfer ID: {transfer.id}")
|
|
||||||
all_transfer_ids.append(transfer_id.int) # Store ID
|
|
||||||
|
|
||||||
# 4. Query detailed account information from TigerBeetle
|
|
||||||
print("\n--- Raw Account Details (from TigerBeetle) ---")
|
|
||||||
account_ids_to_lookup = [source_account_id.int, destination_account_id.int, bank_account_id.int]
|
|
||||||
detailed_accounts = tigerbeetle_client.lookup_accounts(account_ids_to_lookup)
|
|
||||||
|
|
||||||
for account in detailed_accounts:
|
|
||||||
if isinstance(account, tb.Account):
|
|
||||||
print(f"Account ID: {uuid.UUID(int=account.id)}")
|
|
||||||
print(f" User Data 128: {account.user_data_128}")
|
|
||||||
print(f" User Data 64: {account.user_data_64}")
|
|
||||||
print(f" User Data 32: {account.user_data_32}")
|
|
||||||
print(f" Ledger: {account.ledger}")
|
|
||||||
print(f" Code: {account.code}")
|
|
||||||
print(f" Flags: {account.flags}")
|
|
||||||
print(f" Timestamp: {account.timestamp}")
|
|
||||||
print(f" Debits Posted: {account.debits_posted}")
|
|
||||||
print(f" Credits Posted: {account.credits_posted}")
|
|
||||||
print(f" Debits Pending: {account.debits_pending}")
|
|
||||||
print(f" Credits Pending: {account.credits_pending}")
|
|
||||||
print(f" Net Balance: {account.credits_posted - account.debits_posted}")
|
|
||||||
print("-" * 30)
|
|
||||||
else:
|
|
||||||
print(f"Could not retrieve details for account ID: {account.id if hasattr(account, 'id') else 'Unknown'}")
|
|
||||||
|
|
||||||
# 5. Query ALL transfer information
|
|
||||||
print("\n--- Raw Transfer Details (from TigerBeetle) ---")
|
|
||||||
detailed_transfers = tigerbeetle_client._client.lookup_transfers(all_transfer_ids)
|
|
||||||
|
|
||||||
for transfer_detail in detailed_transfers:
|
|
||||||
if isinstance(transfer_detail, tb.Transfer):
|
|
||||||
print(f"Transfer ID: {uuid.UUID(int=transfer_detail.id)}")
|
|
||||||
print(f" Debit Account ID: {uuid.UUID(int=transfer_detail.debit_account_id)}")
|
|
||||||
print(f" Credit Account ID: {uuid.UUID(int=transfer_detail.credit_account_id)}")
|
|
||||||
print(f" Amount: {transfer_detail.amount}")
|
|
||||||
print(f" Pending ID: {transfer_detail.pending_id}")
|
|
||||||
print(f" User Data 128: {transfer_detail.user_data_128}")
|
|
||||||
print(f" User Data 64: {transfer_detail.user_data_64}")
|
|
||||||
print(f" User Data 32: {transfer_detail.user_data_32}")
|
|
||||||
print(f" Timeout: {transfer_detail.timeout}")
|
|
||||||
print(f" Ledger: {transfer_detail.ledger}")
|
|
||||||
print(f" Code: {transfer_detail.code}")
|
|
||||||
print(f" Flags: {transfer_detail.flags}")
|
|
||||||
print(f" Timestamp: {transfer_detail.timestamp}")
|
|
||||||
print("-" * 30)
|
|
||||||
else:
|
|
||||||
print(f"Could not retrieve details for transfer ID: {transfer_detail.id if hasattr(transfer_detail, 'id') else 'Unknown'}")
|
|
||||||
|
|
||||||
print("\n--- Raw Data Display Complete ---")
|
|
||||||
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