Add Billing API with M2M/Team schema separation and frontend
Backend changes: - Create separate schemas: m2m_schema.py (internal) and team_schema.py (teams:member scope) - Add teamBalance query (TigerBeetle lookup) and teamTransactions query - Add TeamGraphQLView with BillingJWTMiddleware - Add /graphql/team/ endpoint, remove old schema.py Frontend changes: - Add Billing to Sidebar navigation - Create billing page with balance display and transactions list - Add GraphQL operations for billing - Add i18n keys for billing (ru/en) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,12 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from billing_app.views import PublicGraphQLView, M2MGraphQLView # Assuming we'll have more views later
|
from billing_app.views import PublicGraphQLView, M2MGraphQLView, TeamGraphQLView
|
||||||
from billing_app.schema import schema as billing_schema # Use 'schema' from billing_app
|
from billing_app.schemas.m2m_schema import m2m_schema
|
||||||
|
from billing_app.schemas.team_schema import team_schema
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
path('graphql/public/', csrf_exempt(PublicGraphQLView.as_view(graphiql=True, schema=billing_schema))),
|
path('graphql/m2m/', csrf_exempt(M2MGraphQLView.as_view(graphiql=False, schema=m2m_schema))),
|
||||||
path('graphql/m2m/', csrf_exempt(M2MGraphQLView.as_view(graphiql=False, schema=billing_schema))), # M2M GraphQL endpoint
|
path('graphql/team/', csrf_exempt(TeamGraphQLView.as_view(graphiql=True, schema=team_schema))),
|
||||||
# Add other GraphQL endpoints (user, team, etc.) as needed for billing service
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
import graphene
|
|
||||||
import uuid
|
|
||||||
import logging
|
|
||||||
from graphene_django import DjangoObjectType
|
|
||||||
from graphql import GraphQLError # Added for clarity in errors
|
|
||||||
|
|
||||||
import tigerbeetle as tb
|
|
||||||
from .tigerbeetle_client import tigerbeetle_client
|
|
||||||
from .models import Account as AccountModel, TransactionReason as TransactionReasonModel, Operation as OperationModel
|
|
||||||
from .permissions import require_scopes # Added
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
def get_or_create_local_accounts(account_ids_str: list[str]) -> (list[AccountModel], list[str]):
|
|
||||||
"""
|
|
||||||
Checks for local account existence, returns a list of existing accounts
|
|
||||||
and a list of IDs for accounts that need to be created.
|
|
||||||
"""
|
|
||||||
existing_accounts = list(AccountModel.objects.filter(uuid__in=[uuid.UUID(id_str) for id_str in account_ids_str]))
|
|
||||||
existing_ids = {str(acc.uuid) for acc in existing_accounts}
|
|
||||||
new_ids = [id_str for id_str in account_ids_str if id_str not in existing_ids]
|
|
||||||
return existing_accounts, new_ids
|
|
||||||
|
|
||||||
# --- GraphQL Types for our models ---
|
|
||||||
class AccountType(DjangoObjectType):
|
|
||||||
class Meta:
|
|
||||||
model = AccountModel
|
|
||||||
fields = "__all__" # Expose all fields of the Account model
|
|
||||||
|
|
||||||
class TransactionReasonType(DjangoObjectType):
|
|
||||||
class Meta:
|
|
||||||
model = TransactionReasonModel
|
|
||||||
fields = "__all__" # Expose all fields of the TransactionReason model
|
|
||||||
|
|
||||||
class OperationType(DjangoObjectType):
|
|
||||||
class Meta:
|
|
||||||
model = OperationModel
|
|
||||||
fields = "__all__" # Expose all fields of the Operation model
|
|
||||||
|
|
||||||
|
|
||||||
class CreateTransfer(graphene.Mutation):
|
|
||||||
"""
|
|
||||||
Creates a financial transfer between two accounts.
|
|
||||||
If the accounts do not exist in TigerBeetle, they will be created automatically.
|
|
||||||
"""
|
|
||||||
class Arguments:
|
|
||||||
debitAccountId = graphene.String(required=True)
|
|
||||||
creditAccountId = graphene.String(required=True)
|
|
||||||
amount = graphene.Int(required=True)
|
|
||||||
ledger = graphene.Int(required=True, description="The ledger for the accounts and transfer.")
|
|
||||||
code = graphene.Int(required=True, description="A user-defined code for the transfer.")
|
|
||||||
|
|
||||||
success = graphene.Boolean()
|
|
||||||
message = graphene.String()
|
|
||||||
transfer_id = graphene.String()
|
|
||||||
|
|
||||||
@require_scopes("billing:write:transfers") # Protect with scope
|
|
||||||
def mutate(self, info, debitAccountId, creditAccountId, amount, ledger, code):
|
|
||||||
# 1. Validate UUIDs
|
|
||||||
try:
|
|
||||||
debit_uuid = uuid.UUID(debitAccountId)
|
|
||||||
credit_uuid = uuid.UUID(creditAccountId)
|
|
||||||
except ValueError:
|
|
||||||
return CreateTransfer(success=False, message="Invalid account ID format. Must be a valid UUID.")
|
|
||||||
|
|
||||||
# 2. Check for local account existence and identify new accounts
|
|
||||||
all_ids_str = [debitAccountId, creditAccountId]
|
|
||||||
_, new_account_ids_str = get_or_create_local_accounts(all_ids_str)
|
|
||||||
|
|
||||||
accounts_to_create = []
|
|
||||||
if new_account_ids_str:
|
|
||||||
logger.info(f"Accounts to create in TigerBeetle: {new_account_ids_str}")
|
|
||||||
for id_str in new_account_ids_str:
|
|
||||||
# Create in local DB first
|
|
||||||
AccountModel.objects.create(uuid=uuid.UUID(id_str))
|
|
||||||
# Prepare for TigerBeetle
|
|
||||||
accounts_to_create.append(
|
|
||||||
tb.Account(
|
|
||||||
id=(uuid.UUID(id_str)).int,
|
|
||||||
ledger=ledger,
|
|
||||||
code=100, # Default code for a team/user account
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3. Create accounts in TigerBeetle if any are new
|
|
||||||
if accounts_to_create:
|
|
||||||
account_errors = tigerbeetle_client.create_accounts(accounts_to_create)
|
|
||||||
if account_errors:
|
|
||||||
error_msg = f"Failed to create new accounts in TigerBeetle: {[e.code.name for e in account_errors]}"
|
|
||||||
logger.error(error_msg)
|
|
||||||
# Note: In a real scenario, you might want to roll back the local DB creation
|
|
||||||
return CreateTransfer(success=False, message=error_msg)
|
|
||||||
|
|
||||||
# 4. Prepare and execute the transfer
|
|
||||||
transfer_id_obj = uuid.uuid4()
|
|
||||||
transfer = tb.Transfer(
|
|
||||||
id=transfer_id_obj.int,
|
|
||||||
debit_account_id=debit_uuid.int,
|
|
||||||
credit_account_id=credit_uuid.int,
|
|
||||||
amount=amount,
|
|
||||||
ledger=ledger,
|
|
||||||
code=code,
|
|
||||||
)
|
|
||||||
|
|
||||||
transfer_errors = tigerbeetle_client.create_transfers([transfer])
|
|
||||||
if transfer_errors:
|
|
||||||
error_msg = f"Transfer failed: {[e.code.name for e in transfer_errors]}"
|
|
||||||
logger.error(error_msg)
|
|
||||||
return CreateTransfer(success=False, message=error_msg)
|
|
||||||
|
|
||||||
logger.info(f"Transfer {transfer_id_obj} completed successfully.")
|
|
||||||
return CreateTransfer(
|
|
||||||
success=True,
|
|
||||||
message="Transfer completed successfully.",
|
|
||||||
transfer_id=str(transfer_id_obj)
|
|
||||||
)
|
|
||||||
|
|
||||||
class Mutation(graphene.ObjectType):
|
|
||||||
createTransfer = CreateTransfer.Field()
|
|
||||||
|
|
||||||
class Query(graphene.ObjectType):
|
|
||||||
hello = graphene.String(name=graphene.String(default_value="World"))
|
|
||||||
|
|
||||||
# --- New Queries for billing models ---
|
|
||||||
accounts = graphene.List(AccountType)
|
|
||||||
account = graphene.Field(AccountType, uuid=graphene.UUID(required=True))
|
|
||||||
|
|
||||||
transaction_reasons = graphene.List(TransactionReasonType)
|
|
||||||
transaction_reason = graphene.Field(TransactionReasonType, uuid=graphene.UUID(required=True)) # TransactionReason uses 'id' not 'uuid'
|
|
||||||
|
|
||||||
operations = graphene.List(OperationType)
|
|
||||||
operation = graphene.Field(OperationType, uuid=graphene.UUID(required=True))
|
|
||||||
|
|
||||||
def resolve_hello(self, info, name):
|
|
||||||
return f"Hello {name}!"
|
|
||||||
|
|
||||||
@require_scopes("billing:read:accounts") # Protect with scope
|
|
||||||
def resolve_accounts(self, info, **kwargs):
|
|
||||||
return AccountModel.objects.all()
|
|
||||||
|
|
||||||
@require_scopes("billing:read:accounts") # Protect with scope
|
|
||||||
def resolve_account(self, info, uuid):
|
|
||||||
try:
|
|
||||||
return AccountModel.objects.get(uuid=uuid)
|
|
||||||
except AccountModel.DoesNotExist:
|
|
||||||
return None
|
|
||||||
|
|
||||||
@require_scopes("billing:read:transaction_reasons") # Protect with scope
|
|
||||||
def resolve_transaction_reasons(self, info, **kwargs):
|
|
||||||
return TransactionReasonModel.objects.all()
|
|
||||||
|
|
||||||
@require_scopes("billing:read:transaction_reasons") # Protect with scope
|
|
||||||
def resolve_transaction_reason(self, info, uuid):
|
|
||||||
try:
|
|
||||||
# Assuming TransactionReason will use default id as primary key
|
|
||||||
return TransactionReasonModel.objects.get(pk=uuid)
|
|
||||||
except TransactionReasonModel.DoesNotExist:
|
|
||||||
return None
|
|
||||||
|
|
||||||
@require_scopes("billing:read:operations") # Protect with scope
|
|
||||||
def resolve_operations(self, info, **kwargs):
|
|
||||||
return OperationModel.objects.all()
|
|
||||||
|
|
||||||
@require_scopes("billing:read:operations") # Protect with scope
|
|
||||||
def resolve_operation(self, info, uuid):
|
|
||||||
try:
|
|
||||||
return OperationModel.objects.get(uuid=uuid)
|
|
||||||
except OperationModel.DoesNotExist:
|
|
||||||
return None
|
|
||||||
|
|
||||||
schema = graphene.Schema(query=Query, mutation=Mutation)
|
|
||||||
1
backends/billing/billing_app/schemas/__init__.py
Normal file
1
backends/billing/billing_app/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Billing schemas
|
||||||
142
backends/billing/billing_app/schemas/m2m_schema.py
Normal file
142
backends/billing/billing_app/schemas/m2m_schema.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"""
|
||||||
|
M2M (Machine-to-Machine) GraphQL schema for Billing service.
|
||||||
|
Used by internal services (Temporal workflows, etc.) without user authentication.
|
||||||
|
"""
|
||||||
|
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, TransactionReason as TransactionReasonModel, Operation as OperationModel
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_local_accounts(account_ids_str: list[str]) -> tuple[list[AccountModel], list[str]]:
|
||||||
|
"""
|
||||||
|
Checks for local account existence, returns a list of existing accounts
|
||||||
|
and a list of IDs for accounts that need to be created.
|
||||||
|
"""
|
||||||
|
existing_accounts = list(AccountModel.objects.filter(uuid__in=[uuid.UUID(id_str) for id_str in account_ids_str]))
|
||||||
|
existing_ids = {str(acc.uuid) for acc in existing_accounts}
|
||||||
|
new_ids = [id_str for id_str in account_ids_str if id_str not in existing_ids]
|
||||||
|
return existing_accounts, new_ids
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionReasonType(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = TransactionReasonModel
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class OperationType(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = OperationModel
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class CreateTransferInput(graphene.InputObjectType):
|
||||||
|
debitAccountId = graphene.String(required=True)
|
||||||
|
creditAccountId = graphene.String(required=True)
|
||||||
|
amount = graphene.Int(required=True)
|
||||||
|
ledger = graphene.Int(required=True, description="The ledger for the accounts and transfer.")
|
||||||
|
code = graphene.Int(required=True, description="A user-defined code for the transfer.")
|
||||||
|
|
||||||
|
|
||||||
|
class CreateTransfer(graphene.Mutation):
|
||||||
|
"""
|
||||||
|
Creates a financial transfer between two accounts.
|
||||||
|
If the accounts do not exist in TigerBeetle, they will be created automatically.
|
||||||
|
"""
|
||||||
|
class Arguments:
|
||||||
|
input = CreateTransferInput(required=True)
|
||||||
|
|
||||||
|
success = graphene.Boolean()
|
||||||
|
message = graphene.String()
|
||||||
|
transferId = graphene.String()
|
||||||
|
|
||||||
|
def mutate(self, info, input):
|
||||||
|
# 1. Validate UUIDs
|
||||||
|
try:
|
||||||
|
debit_uuid = uuid.UUID(input.debitAccountId)
|
||||||
|
credit_uuid = uuid.UUID(input.creditAccountId)
|
||||||
|
except ValueError:
|
||||||
|
return CreateTransfer(success=False, message="Invalid account ID format. Must be a valid UUID.")
|
||||||
|
|
||||||
|
# 2. Check for local account existence and identify new accounts
|
||||||
|
all_ids_str = [input.debitAccountId, input.creditAccountId]
|
||||||
|
_, new_account_ids_str = get_or_create_local_accounts(all_ids_str)
|
||||||
|
|
||||||
|
accounts_to_create = []
|
||||||
|
if new_account_ids_str:
|
||||||
|
logger.info(f"Accounts to create in TigerBeetle: {new_account_ids_str}")
|
||||||
|
for id_str in new_account_ids_str:
|
||||||
|
# Create in local DB first
|
||||||
|
AccountModel.objects.create(uuid=uuid.UUID(id_str))
|
||||||
|
# Prepare for TigerBeetle
|
||||||
|
accounts_to_create.append(
|
||||||
|
tb.Account(
|
||||||
|
id=(uuid.UUID(id_str)).int,
|
||||||
|
ledger=input.ledger,
|
||||||
|
code=100, # Default code for a team/user account
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Create accounts in TigerBeetle if any are new
|
||||||
|
if accounts_to_create:
|
||||||
|
account_errors = tigerbeetle_client.create_accounts(accounts_to_create)
|
||||||
|
if account_errors:
|
||||||
|
error_msg = f"Failed to create new accounts in TigerBeetle: {[e.code.name for e in account_errors]}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return CreateTransfer(success=False, message=error_msg)
|
||||||
|
|
||||||
|
# 4. Prepare and execute the transfer
|
||||||
|
transfer_id_obj = uuid.uuid4()
|
||||||
|
transfer = tb.Transfer(
|
||||||
|
id=transfer_id_obj.int,
|
||||||
|
debit_account_id=debit_uuid.int,
|
||||||
|
credit_account_id=credit_uuid.int,
|
||||||
|
amount=input.amount,
|
||||||
|
ledger=input.ledger,
|
||||||
|
code=input.code,
|
||||||
|
)
|
||||||
|
|
||||||
|
transfer_errors = tigerbeetle_client.create_transfers([transfer])
|
||||||
|
if transfer_errors:
|
||||||
|
error_msg = f"Transfer failed: {[e.code.name for e in transfer_errors]}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return CreateTransfer(success=False, message=error_msg)
|
||||||
|
|
||||||
|
logger.info(f"Transfer {transfer_id_obj} completed successfully.")
|
||||||
|
return CreateTransfer(
|
||||||
|
success=True,
|
||||||
|
message="Transfer completed successfully.",
|
||||||
|
transferId=str(transfer_id_obj)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class M2MQuery(graphene.ObjectType):
|
||||||
|
transactionReasons = graphene.List(TransactionReasonType)
|
||||||
|
transactionReason = graphene.Field(TransactionReasonType, uuid=graphene.UUID(required=True))
|
||||||
|
operationTypes = graphene.List(OperationType)
|
||||||
|
|
||||||
|
def resolve_transactionReasons(self, info, **kwargs):
|
||||||
|
return TransactionReasonModel.objects.all()
|
||||||
|
|
||||||
|
def resolve_transactionReason(self, info, uuid):
|
||||||
|
try:
|
||||||
|
return TransactionReasonModel.objects.get(pk=uuid)
|
||||||
|
except TransactionReasonModel.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def resolve_operationTypes(self, info, **kwargs):
|
||||||
|
return OperationModel.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class M2MMutation(graphene.ObjectType):
|
||||||
|
createTransfer = CreateTransfer.Field()
|
||||||
|
|
||||||
|
|
||||||
|
m2m_schema = graphene.Schema(query=M2MQuery, mutation=M2MMutation)
|
||||||
135
backends/billing/billing_app/schemas/team_schema.py
Normal file
135
backends/billing/billing_app/schemas/team_schema.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"""
|
||||||
|
Team-level GraphQL schema for Billing service.
|
||||||
|
Requires teams:member scope (verified by middleware).
|
||||||
|
Provides teamBalance and teamTransactions for the authenticated team.
|
||||||
|
"""
|
||||||
|
import graphene
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
from django.db.models import Q
|
||||||
|
from graphene_django import DjangoObjectType
|
||||||
|
from graphql import GraphQLError
|
||||||
|
|
||||||
|
from ..tigerbeetle_client import tigerbeetle_client
|
||||||
|
from ..models import Account as AccountModel, Operation as OperationModel, TransactionReason as TransactionReasonModel
|
||||||
|
from ..permissions import require_scopes
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TeamBalance(graphene.ObjectType):
|
||||||
|
"""Balance information for a team's account."""
|
||||||
|
balance = graphene.Float(required=True, description="Current balance (credits_posted - debits_posted)")
|
||||||
|
creditsPosted = graphene.Float(required=True, description="Total credits posted to account")
|
||||||
|
debitsPosted = graphene.Float(required=True, description="Total debits posted from account")
|
||||||
|
exists = graphene.Boolean(required=True, description="Whether the account exists in TigerBeetle")
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionReasonType(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = TransactionReasonModel
|
||||||
|
fields = ('id', 'name', 'description')
|
||||||
|
|
||||||
|
|
||||||
|
class TeamTransaction(DjangoObjectType):
|
||||||
|
"""Transaction/operation involving the team's account."""
|
||||||
|
reasonName = graphene.String()
|
||||||
|
direction = graphene.String(description="'credit' or 'debit' relative to team account")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = OperationModel
|
||||||
|
fields = ('uuid', 'amount', 'state', 'created_at', 'updated_at')
|
||||||
|
|
||||||
|
def resolve_reasonName(self, info):
|
||||||
|
return self.reason.name if self.reason else None
|
||||||
|
|
||||||
|
|
||||||
|
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),
|
||||||
|
offset=graphene.Int(default_value=0),
|
||||||
|
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:
|
||||||
|
# Convert team UUID to int for TigerBeetle lookup
|
||||||
|
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, offset=0):
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Find the local account record for this team
|
||||||
|
try:
|
||||||
|
account = AccountModel.objects.get(uuid=team_uuid_obj)
|
||||||
|
except AccountModel.DoesNotExist:
|
||||||
|
# No account yet - return empty list
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Get all operations where this account is source or destination
|
||||||
|
operations = OperationModel.objects.filter(
|
||||||
|
Q(source_account=account) | Q(destination_account=account)
|
||||||
|
).select_related('reason', 'source_account', 'destination_account').order_by('-created_at')[offset:offset + limit]
|
||||||
|
|
||||||
|
# Add direction to each operation
|
||||||
|
result = []
|
||||||
|
for op in operations:
|
||||||
|
op.direction = 'debit' if op.source_account == account else 'credit'
|
||||||
|
result.append(op)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(f"Invalid team UUID format: {team_uuid} - {e}")
|
||||||
|
raise GraphQLError("Invalid team UUID format")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching team transactions: {e}")
|
||||||
|
raise GraphQLError("Failed to fetch team transactions")
|
||||||
|
|
||||||
|
|
||||||
|
team_schema = graphene.Schema(query=TeamQuery)
|
||||||
@@ -1,16 +1,30 @@
|
|||||||
from graphene_django.views import GraphQLView
|
from graphene_django.views import GraphQLView
|
||||||
from .graphql_middleware import M2MNoAuthMiddleware, PublicNoAuthMiddleware
|
from .graphql_middleware import M2MNoAuthMiddleware, PublicNoAuthMiddleware, BillingJWTMiddleware
|
||||||
from .schemas import schema
|
|
||||||
|
|
||||||
class PublicGraphQLView(GraphQLView):
|
class PublicGraphQLView(GraphQLView):
|
||||||
|
"""GraphQL view for public operations (no authentication)."""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
kwargs['middleware'] = [PublicNoAuthMiddleware()]
|
kwargs['middleware'] = [PublicNoAuthMiddleware()]
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class M2MGraphQLView(GraphQLView):
|
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):
|
def __init__(self, *args, **kwargs):
|
||||||
kwargs['middleware'] = [M2MNoAuthMiddleware()]
|
kwargs['middleware'] = [M2MNoAuthMiddleware()]
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
# You can add other views here as needed
|
|
||||||
|
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)
|
||||||
|
|||||||
@@ -90,6 +90,16 @@
|
|||||||
<Icon name="lucide:map-pin" size="18" />
|
<Icon name="lucide:map-pin" size="18" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath('/clientarea/billing')"
|
||||||
|
:class="{ active: isActive('/clientarea/billing') }"
|
||||||
|
class="tooltip tooltip-right"
|
||||||
|
:data-tip="t('cabinetNav.billing')"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:wallet" size="18" />
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
|
||||||
<template v-if="isSeller">
|
<template v-if="isSeller">
|
||||||
<li>
|
<li>
|
||||||
@@ -202,6 +212,12 @@
|
|||||||
{{ t('cabinetNav.addresses') }}
|
{{ t('cabinetNav.addresses') }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink :to="localePath('/clientarea/billing')" :class="{ active: isActive('/clientarea/billing') }">
|
||||||
|
<Icon name="lucide:wallet" size="18" />
|
||||||
|
{{ t('cabinetNav.billing') }}
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
190
webapp/app/pages/clientarea/billing/index.vue
Normal file
190
webapp/app/pages/clientarea/billing/index.vue
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<template>
|
||||||
|
<Section variant="plain" paddingY="md">
|
||||||
|
<Stack gap="6">
|
||||||
|
<PageHeader :title="t('billing.header.title')" />
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<Card v-if="isLoading" padding="lg">
|
||||||
|
<Stack align="center" gap="3">
|
||||||
|
<Spinner />
|
||||||
|
<Text tone="muted">{{ t('billing.states.loading') }}</Text>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<Alert v-else-if="error" variant="error">
|
||||||
|
<Stack gap="2">
|
||||||
|
<Heading :level="4" weight="semibold">{{ t('billing.errors.title') }}</Heading>
|
||||||
|
<Text tone="muted">{{ error }}</Text>
|
||||||
|
<Button @click="loadBalance">{{ t('billing.errors.retry') }}</Button>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<!-- Balance card -->
|
||||||
|
<template v-else>
|
||||||
|
<Card padding="lg">
|
||||||
|
<Stack gap="4">
|
||||||
|
<Stack direction="row" gap="4" align="center" justify="between">
|
||||||
|
<Stack gap="1">
|
||||||
|
<Text tone="muted" size="sm">{{ t('billing.balance.label') }}</Text>
|
||||||
|
<Heading :level="2" weight="bold">
|
||||||
|
{{ formatCurrency(balance.balance) }}
|
||||||
|
</Heading>
|
||||||
|
</Stack>
|
||||||
|
<IconCircle tone="primary" size="lg">
|
||||||
|
<Icon name="lucide:wallet" size="24" />
|
||||||
|
</IconCircle>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<div class="divider my-0"></div>
|
||||||
|
|
||||||
|
<Grid :cols="2" :gap="4">
|
||||||
|
<Stack gap="1">
|
||||||
|
<Text tone="muted" size="sm">{{ t('billing.balance.credits') }}</Text>
|
||||||
|
<Text weight="semibold" class="text-success">
|
||||||
|
+{{ formatCurrency(balance.creditsPosted) }}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap="1">
|
||||||
|
<Text tone="muted" size="sm">{{ t('billing.balance.debits') }}</Text>
|
||||||
|
<Text weight="semibold" class="text-error">
|
||||||
|
-{{ formatCurrency(balance.debitsPosted) }}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Grid>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Transactions section -->
|
||||||
|
<Stack gap="3">
|
||||||
|
<Heading :level="3">{{ t('billing.transactions.title') }}</Heading>
|
||||||
|
|
||||||
|
<Card v-if="transactions.length === 0" padding="lg" tone="muted">
|
||||||
|
<Stack align="center" gap="2">
|
||||||
|
<Icon name="lucide:receipt" size="32" class="opacity-50" />
|
||||||
|
<Text tone="muted">{{ t('billing.transactions.empty') }}</Text>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card v-else padding="none">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('billing.transactions.date') }}</th>
|
||||||
|
<th>{{ t('billing.transactions.reason') }}</th>
|
||||||
|
<th>{{ t('billing.transactions.amount') }}</th>
|
||||||
|
<th>{{ t('billing.transactions.status') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="tx in transactions" :key="tx.uuid">
|
||||||
|
<td>{{ formatDate(tx.createdAt) }}</td>
|
||||||
|
<td>{{ tx.reasonName || '—' }}</td>
|
||||||
|
<td :class="tx.direction === 'credit' ? 'text-success' : 'text-error'">
|
||||||
|
{{ tx.direction === 'credit' ? '+' : '-' }}{{ formatCurrency(tx.amount) }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Pill
|
||||||
|
:variant="tx.state === 'COMPLETED' ? 'primary' : 'outline'"
|
||||||
|
:tone="getStateTone(tx.state)"
|
||||||
|
>
|
||||||
|
{{ tx.state }}
|
||||||
|
</Pill>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</template>
|
||||||
|
</Stack>
|
||||||
|
</Section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: ['auth-oidc']
|
||||||
|
})
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const balance = ref({
|
||||||
|
balance: 0,
|
||||||
|
creditsPosted: 0,
|
||||||
|
debitsPosted: 0,
|
||||||
|
exists: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const transactions = ref<any[]>([])
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'RUB',
|
||||||
|
minimumFractionDigits: 2
|
||||||
|
}).format(amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
if (!dateStr) return '—'
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleDateString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStateTone = (state: string) => {
|
||||||
|
switch (state) {
|
||||||
|
case 'COMPLETED': return 'success'
|
||||||
|
case 'PENDING': return 'warning'
|
||||||
|
case 'FAILED': return 'error'
|
||||||
|
default: return 'neutral'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadBalance = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Import will work after codegen runs
|
||||||
|
const { GetTeamBalanceDocument } = await import('~/composables/graphql/team/billing-generated')
|
||||||
|
const { data, error: balanceError } = await useServerQuery('team-balance', GetTeamBalanceDocument, {}, 'team', 'billing')
|
||||||
|
|
||||||
|
if (balanceError.value) throw balanceError.value
|
||||||
|
|
||||||
|
if (data.value?.teamBalance) {
|
||||||
|
balance.value = data.value.teamBalance
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message || t('billing.errors.load_failed')
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadTransactions = async () => {
|
||||||
|
try {
|
||||||
|
const { GetTeamTransactionsDocument } = await import('~/composables/graphql/team/billing-generated')
|
||||||
|
const { data, error: txError } = await useServerQuery('team-transactions', GetTeamTransactionsDocument, { limit: 50, offset: 0 }, 'team', 'billing')
|
||||||
|
|
||||||
|
if (txError.value) throw txError.value
|
||||||
|
|
||||||
|
transactions.value = data.value?.teamTransactions || []
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load transactions', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadBalance()
|
||||||
|
await loadTransactions()
|
||||||
|
</script>
|
||||||
@@ -63,6 +63,12 @@ const config: CodegenConfig = {
|
|||||||
plugins,
|
plugins,
|
||||||
config: pluginConfig,
|
config: pluginConfig,
|
||||||
},
|
},
|
||||||
|
'./app/composables/graphql/team/billing-generated.ts': {
|
||||||
|
schema: 'https://billing.optovia.ru/graphql/team/',
|
||||||
|
documents: './graphql/operations/team/billing/*.graphql',
|
||||||
|
plugins,
|
||||||
|
config: pluginConfig,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
query GetTeamBalance {
|
||||||
|
teamBalance {
|
||||||
|
balance
|
||||||
|
creditsPosted
|
||||||
|
debitsPosted
|
||||||
|
exists
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
query GetTeamTransactions($limit: Int, $offset: Int) {
|
||||||
|
teamTransactions(limit: $limit, offset: $offset) {
|
||||||
|
uuid
|
||||||
|
amount
|
||||||
|
state
|
||||||
|
reasonName
|
||||||
|
direction
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
28
webapp/i18n/locales/en/billing.json
Normal file
28
webapp/i18n/locales/en/billing.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"billing": {
|
||||||
|
"header": {
|
||||||
|
"title": "Balance"
|
||||||
|
},
|
||||||
|
"states": {
|
||||||
|
"loading": "Loading balance..."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"title": "Loading error",
|
||||||
|
"load_failed": "Failed to load balance",
|
||||||
|
"retry": "Retry"
|
||||||
|
},
|
||||||
|
"balance": {
|
||||||
|
"label": "Current balance",
|
||||||
|
"credits": "Credits",
|
||||||
|
"debits": "Debits"
|
||||||
|
},
|
||||||
|
"transactions": {
|
||||||
|
"title": "Transaction history",
|
||||||
|
"empty": "No transactions yet",
|
||||||
|
"date": "Date",
|
||||||
|
"reason": "Reason",
|
||||||
|
"amount": "Amount",
|
||||||
|
"status": "Status"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
"cabinetNav": {
|
"cabinetNav": {
|
||||||
"orders": "My orders",
|
"orders": "My orders",
|
||||||
"addresses": "My addresses",
|
"addresses": "My addresses",
|
||||||
|
"billing": "Balance",
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"team": "Company",
|
"team": "Company",
|
||||||
"offers": "My offers",
|
"offers": "My offers",
|
||||||
|
|||||||
28
webapp/i18n/locales/ru/billing.json
Normal file
28
webapp/i18n/locales/ru/billing.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"billing": {
|
||||||
|
"header": {
|
||||||
|
"title": "Баланс"
|
||||||
|
},
|
||||||
|
"states": {
|
||||||
|
"loading": "Загрузка баланса..."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"title": "Ошибка загрузки",
|
||||||
|
"load_failed": "Не удалось загрузить баланс",
|
||||||
|
"retry": "Повторить"
|
||||||
|
},
|
||||||
|
"balance": {
|
||||||
|
"label": "Текущий баланс",
|
||||||
|
"credits": "Пополнения",
|
||||||
|
"debits": "Списания"
|
||||||
|
},
|
||||||
|
"transactions": {
|
||||||
|
"title": "История операций",
|
||||||
|
"empty": "Операций пока нет",
|
||||||
|
"date": "Дата",
|
||||||
|
"reason": "Причина",
|
||||||
|
"amount": "Сумма",
|
||||||
|
"status": "Статус"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
"cabinetNav": {
|
"cabinetNav": {
|
||||||
"orders": "Мои заказы",
|
"orders": "Мои заказы",
|
||||||
"addresses": "Мои адреса",
|
"addresses": "Мои адреса",
|
||||||
|
"billing": "Баланс",
|
||||||
"profile": "Профиль",
|
"profile": "Профиль",
|
||||||
"team": "Компания",
|
"team": "Компания",
|
||||||
"offers": "Мои предложения",
|
"offers": "Мои предложения",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export default defineNuxtConfig({
|
|||||||
'ru/about.json',
|
'ru/about.json',
|
||||||
'ru/aiAssistants.json',
|
'ru/aiAssistants.json',
|
||||||
'ru/auth.json',
|
'ru/auth.json',
|
||||||
|
'ru/billing.json',
|
||||||
'ru/breadcrumbs.json',
|
'ru/breadcrumbs.json',
|
||||||
'ru/cabinetNav.json',
|
'ru/cabinetNav.json',
|
||||||
'ru/catalogAddress.json',
|
'ru/catalogAddress.json',
|
||||||
@@ -88,6 +89,7 @@ export default defineNuxtConfig({
|
|||||||
'en/about.json',
|
'en/about.json',
|
||||||
'en/aiAssistants.json',
|
'en/aiAssistants.json',
|
||||||
'en/auth.json',
|
'en/auth.json',
|
||||||
|
'en/billing.json',
|
||||||
'en/breadcrumbs.json',
|
'en/breadcrumbs.json',
|
||||||
'en/cabinetNav.json',
|
'en/cabinetNav.json',
|
||||||
'en/catalogAddress.json',
|
'en/catalogAddress.json',
|
||||||
|
|||||||
Reference in New Issue
Block a user