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

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)