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.urls import path
|
||||
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.schema import schema as billing_schema # Use 'schema' from billing_app
|
||||
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/public/', csrf_exempt(PublicGraphQLView.as_view(graphiql=True, schema=billing_schema))),
|
||||
path('graphql/m2m/', csrf_exempt(M2MGraphQLView.as_view(graphiql=False, schema=billing_schema))), # M2M GraphQL endpoint
|
||||
# Add other GraphQL endpoints (user, team, etc.) as needed for billing service
|
||||
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))),
|
||||
]
|
||||
|
||||
@@ -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 .graphql_middleware import M2MNoAuthMiddleware, PublicNoAuthMiddleware
|
||||
from .schemas import schema
|
||||
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)
|
||||
|
||||
# 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" />
|
||||
</NuxtLink>
|
||||
</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">
|
||||
<li>
|
||||
@@ -202,6 +212,12 @@
|
||||
{{ t('cabinetNav.addresses') }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink :to="localePath('/clientarea/billing')" :class="{ active: isActive('/clientarea/billing') }">
|
||||
<Icon name="lucide:wallet" size="18" />
|
||||
{{ t('cabinetNav.billing') }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</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,
|
||||
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": {
|
||||
"orders": "My orders",
|
||||
"addresses": "My addresses",
|
||||
"billing": "Balance",
|
||||
"profile": "Profile",
|
||||
"team": "Company",
|
||||
"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": {
|
||||
"orders": "Мои заказы",
|
||||
"addresses": "Мои адреса",
|
||||
"billing": "Баланс",
|
||||
"profile": "Профиль",
|
||||
"team": "Компания",
|
||||
"offers": "Мои предложения",
|
||||
|
||||
@@ -28,6 +28,7 @@ export default defineNuxtConfig({
|
||||
'ru/about.json',
|
||||
'ru/aiAssistants.json',
|
||||
'ru/auth.json',
|
||||
'ru/billing.json',
|
||||
'ru/breadcrumbs.json',
|
||||
'ru/cabinetNav.json',
|
||||
'ru/catalogAddress.json',
|
||||
@@ -88,6 +89,7 @@ export default defineNuxtConfig({
|
||||
'en/about.json',
|
||||
'en/aiAssistants.json',
|
||||
'en/auth.json',
|
||||
'en/billing.json',
|
||||
'en/breadcrumbs.json',
|
||||
'en/cabinetNav.json',
|
||||
'en/catalogAddress.json',
|
||||
|
||||
Reference in New Issue
Block a user