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