Initial commit from monorepo
This commit is contained in:
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
NIXPACKS_POETRY_VERSION=2.2.1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends build-essential curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN python -m venv --copies /opt/venv
|
||||||
|
ENV VIRTUAL_ENV=/opt/venv
|
||||||
|
ENV PATH="/opt/venv/bin:$PATH"
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir poetry==$NIXPACKS_POETRY_VERSION \
|
||||||
|
&& poetry install --no-interaction --no-ansi
|
||||||
|
|
||||||
|
ENV PORT=8000
|
||||||
|
|
||||||
|
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}"]
|
||||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Billing Service
|
||||||
|
|
||||||
|
This service is responsible for handling all billing-related logic, including integration with TigerBeetle for double-entry accounting.
|
||||||
0
billing/__init__.py
Normal file
0
billing/__init__.py
Normal file
16
billing/asgi.py
Normal file
16
billing/asgi.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
164
billing/settings.py
Normal file
164
billing/settings.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
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')
|
||||||
134
billing/settings_local.py
Normal file
134
billing/settings_local.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
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')
|
||||||
12
billing/urls.py
Normal file
12
billing/urls.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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))),
|
||||||
|
]
|
||||||
16
billing/wsgi.py
Normal file
16
billing/wsgi.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
0
billing_app/__init__.py
Normal file
0
billing_app/__init__.py
Normal file
BIN
billing_app/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
billing_app/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
23
billing_app/admin.py
Normal file
23
billing_app/admin.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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')
|
||||||
6
billing_app/apps.py
Normal file
6
billing_app/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class BillingAppConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'billing_app'
|
||||||
70
billing_app/auth.py
Normal file
70
billing_app/auth.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
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"),
|
||||||
|
)
|
||||||
74
billing_app/graphql_middleware.py
Normal file
74
billing_app/graphql_middleware.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""
|
||||||
|
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)
|
||||||
65
billing_app/migrations/0001_initial.py
Normal file
65
billing_app/migrations/0001_initial.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# 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',
|
||||||
|
),
|
||||||
|
]
|
||||||
78
billing_app/migrations/0003_refactor_models.py
Normal file
78
billing_app/migrations/0003_refactor_models.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# 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',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
billing_app/migrations/__init__.py
Normal file
0
billing_app/migrations/__init__.py
Normal file
78
billing_app/models.py
Normal file
78
billing_app/models.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
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"
|
||||||
74
billing_app/permissions.py
Normal file
74
billing_app/permissions.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""
|
||||||
|
Декоратор для проверки 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
billing_app/schemas/__init__.py
Normal file
1
billing_app/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Billing schemas
|
||||||
BIN
billing_app/schemas/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
billing_app/schemas/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
billing_app/schemas/__pycache__/m2m_schema.cpython-313.pyc
Normal file
BIN
billing_app/schemas/__pycache__/m2m_schema.cpython-313.pyc
Normal file
Binary file not shown.
BIN
billing_app/schemas/__pycache__/team_schema.cpython-313.pyc
Normal file
BIN
billing_app/schemas/__pycache__/team_schema.cpython-313.pyc
Normal file
Binary file not shown.
150
billing_app/schemas/m2m_schema.py
Normal file
150
billing_app/schemas/m2m_schema.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"""
|
||||||
|
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)
|
||||||
134
billing_app/schemas/team_schema.py
Normal file
134
billing_app/schemas/team_schema.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""
|
||||||
|
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)
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
{% 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 %}
|
||||||
179
billing_app/tests.py
Normal file
179
billing_app/tests.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
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.')
|
||||||
|
|
||||||
146
billing_app/tigerbeetle_client.py
Normal file
146
billing_app/tigerbeetle_client.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
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()
|
||||||
30
billing_app/views.py
Normal file
30
billing_app/views.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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)
|
||||||
11
create_accounts.py
Normal file
11
create_accounts.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
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.")
|
||||||
43
docker-compose.yml
Normal file
43
docker-compose.yml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
services:
|
||||||
|
billing:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: billing
|
||||||
|
restart: unless-stopped
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
|
environment:
|
||||||
|
- PORT=8000
|
||||||
|
- TB_CLUSTER_ID=0
|
||||||
|
- TB_ADDRESS=tigerbeetle:3000
|
||||||
|
security_opt:
|
||||||
|
- seccomp=unconfined
|
||||||
|
depends_on:
|
||||||
|
- tigerbeetle
|
||||||
|
networks:
|
||||||
|
dokploy-network:
|
||||||
|
aliases:
|
||||||
|
- billing
|
||||||
|
|
||||||
|
tigerbeetle:
|
||||||
|
image: ghcr.io/tigerbeetle/tigerbeetle:latest
|
||||||
|
container_name: tigerbeetle
|
||||||
|
privileged: true
|
||||||
|
command: ["start", "--addresses=0.0.0.0:3000", "--development", "/var/lib/tigerbeetle/0_0.tigerbeetle"]
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
volumes:
|
||||||
|
- tigerbeetle_data:/var/lib/tigerbeetle
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
dokploy-network:
|
||||||
|
aliases:
|
||||||
|
- tigerbeetle
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
tigerbeetle_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dokploy-network:
|
||||||
|
external: true
|
||||||
22
manage.py
Executable file
22
manage.py
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/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()
|
||||||
18
nixpacks.toml
Normal file
18
nixpacks.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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"
|
||||||
1016
poetry.lock
generated
Normal file
1016
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
pyproject.toml
Normal file
29
pyproject.toml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
[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"
|
||||||
53
test_tb_connection.py
Normal file
53
test_tb_connection.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
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}")
|
||||||
183
transactions_script.py
Normal file
183
transactions_script.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
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 ---")
|
||||||
Reference in New Issue
Block a user