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:
Ruslan Bakiev
2026-01-03 13:20:48 +07:00
parent 3ebc3fceb3
commit 2aa6d11db3
16 changed files with 591 additions and 179 deletions

View File

@@ -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
] ]

View File

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

View File

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

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

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

View File

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

View File

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

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

View File

@@ -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,
},
}, },
} }

View File

@@ -0,0 +1,8 @@
query GetTeamBalance {
teamBalance {
balance
creditsPosted
debitsPosted
exists
}
}

View File

@@ -0,0 +1,11 @@
query GetTeamTransactions($limit: Int, $offset: Int) {
teamTransactions(limit: $limit, offset: $offset) {
uuid
amount
state
reasonName
direction
createdAt
updatedAt
}
}

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

View File

@@ -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",

View 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": "Статус"
}
}
}

View File

@@ -2,6 +2,7 @@
"cabinetNav": { "cabinetNav": {
"orders": "Мои заказы", "orders": "Мои заказы",
"addresses": "Мои адреса", "addresses": "Мои адреса",
"billing": "Баланс",
"profile": "Профиль", "profile": "Профиль",
"team": "Компания", "team": "Компания",
"offers": "Мои предложения", "offers": "Мои предложения",

View File

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