Initial commit from monorepo

This commit is contained in:
Ruslan Bakiev
2026-01-07 09:17:45 +07:00
commit 72db63f956
38 changed files with 3012 additions and 0 deletions

24
Dockerfile Normal file
View 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
View 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
View File

16
billing/asgi.py Normal file
View 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
View 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
View 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
View 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
View 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
View File

Binary file not shown.

23
billing_app/admin.py Normal file
View 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
View 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
View 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"),
)

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

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

View File

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

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

View File

78
billing_app/models.py Normal file
View 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"

View 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

View File

@@ -0,0 +1 @@
# Billing schemas

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

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

View File

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

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

File diff suppressed because it is too large Load Diff

29
pyproject.toml Normal file
View 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
View 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
View 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 ---")