feat(integration): prepaid billing, engine UI, credits console, direct tasks, v2 final

- Prepaid credit system: org wallet, debit on usage, chat gate, admin credit console
- Engine UI: /manager, /tasks, /tasks/[id], /workforce, /dashboard, /traces pages
- Direct task creation (HTTP POST /tasks, MCP tasks_create) at pending_manager_review
- Pause/resume/cancel/answer actions in engine API and UI
- Employee cap (7 per org default), review pipeline guard (active reviewer required)
- Scheduler: wide window prevents org starvation; archived employees excluded from cap
- Tenant isolation: project-id cross-org 404, engine never uses another tenant's sandbox
- Order route fix: manager built with pipeline states + employee profiles
- Contract extension: ModelGrantPort.get_org_policy with DefaultOrgPolicy
- Engine event types in @agy/contracts (task.step, task.gate, run.log, run.tool, team.changed)
- api-engine.ts typed client; api-credit.ts client
- Alembic migration c4d5e6f7a8b9: prepaid credit + engine UI tables
- 859 tests passed, 1 skipped; ruff/mypy/tsc/eslint/vitest all green

Refs: PLAN-v2.md §9 (T1–T43), todo-v2-final.md
This commit is contained in:
mjalmoudy94
2026-10-14 23:19:05 +03:30
parent fef5a9deab
commit d532929189
117 changed files with 13119 additions and 640 deletions
@@ -209,6 +209,7 @@ def _build_manager(
RateCardRepository,
)
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.engine import (
EmployeeProfileRepository,
PipelineStateRepository,
@@ -235,6 +236,7 @@ def _build_manager(
profiles=EmployeeProfileRepository(session),
states=PipelineStateRepository(session),
runner=runner,
wallets=CreditWalletRepository(session),
)
+31 -1
View File
@@ -28,6 +28,7 @@ from app.application.ports.realtime import (
task_channel,
)
from app.core.config import settings
from app.domain.workforce.pipeline import PipelineKey, Stage, coarse_status
@dataclass(slots=True, kw_only=True)
@@ -204,4 +205,33 @@ async def emit_team_changed(
)
__all__ = ["RunFeed", "emit_team_changed"]
async def emit_task_step(
*,
task_id: str,
organization_id: str,
from_stage: Stage,
to_stage: Stage,
pipeline: PipelineKey,
) -> None:
"""A task moved one stage: the task page's stepper follows it live.
The payload carries stage names and the coarse status only, never a model
or an employee name.
"""
await publish(
RealtimeEvent(
type="task.step",
channels=(task_channel(task_id), org_channel(organization_id)),
organization_id=organization_id,
data={
"task_id": task_id,
"from": str(from_stage),
"to": str(to_stage),
"pipeline": str(pipeline),
"status": coarse_status(to_stage).value,
},
)
)
__all__ = ["RunFeed", "emit_task_step", "emit_team_changed"]
@@ -88,6 +88,7 @@ from app.infrastructure.repositories.ai_control import (
RateCardRepository,
)
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.engine import (
EmployeeProfileRepository,
PipelineStateRepository,
@@ -109,6 +110,11 @@ CHAT_MAX_ATTEMPTS = 2
#: The message a new organization sees when the owner has granted nothing yet.
NO_MODEL_GRANTED_MESSAGE = "منتظر تخصیص مدل توسط مالک سامانه"
#: The reply a zero-credit organization gets instead of a model call (prepaid, D-B).
CHAT_NO_CREDIT_MESSAGE = (
"اعتبار سازمان صفر است؛ مدیر پس از افزودن اعتبار توسط مالک سامانه پاسخ می‌دهد."
)
#: Bootstrap tiers per role, in the plan's order (strongest / balanced / fast).
BOOTSTRAP_TIERS: dict[RoleKey, str] = {
RoleKey.MANAGER: "strong",
@@ -223,6 +229,7 @@ class ManagerTurns:
states: PipelineStateRepository | None = None,
runner: TurnRunner | None = None,
feed=None,
wallets: CreditWalletRepository | None = None,
) -> None:
self.provider = provider
self.employees = employees
@@ -239,6 +246,7 @@ class ManagerTurns:
self.states = states
self.runner = runner
self.feed = feed
self.wallets = wallets
# ------------------------------------------------------------------ #
# 3.6 bootstrap #
@@ -404,6 +412,17 @@ class ManagerTurns:
user_message = await self.messages.create(
Message(thread_id=thread.id, role=MessageRole.USER, content=content)
)
# Prepaid: a reply costs a model call, so an org with no credit gets the
# note instead. No model is called and no tag is executed.
if await self._out_of_credit(organization_id):
note = await self.messages.create(
Message(
thread_id=thread.id,
role=MessageRole.ASSISTANT,
content=CHAT_NO_CREDIT_MESSAGE,
)
)
return user_message, note
employee = await self._manager_of(organization_id, employee_id)
extra = "\n\n".join(
part
@@ -492,6 +511,13 @@ class ManagerTurns:
)
return user_message, assistant_message
async def _out_of_credit(self, organization_id: str) -> bool:
"""True when a wired wallet shows no credit left (a missing wallet counts as none)."""
if self.wallets is None:
return False
wallet = await self.wallets.get_for_org(organization_id)
return wallet is None or wallet.balance <= 0
def _chat_context(
self,
*,
@@ -1083,6 +1109,7 @@ class ManagerTurns:
__all__ = [
"BOOTSTRAP_TIERS",
"CHAT_NO_CREDIT_MESSAGE",
"CHAT_TURN_TIMEOUT_SECONDS",
"HIRABLE_ROLES",
"NO_MODEL_GRANTED_MESSAGE",
+100 -12
View File
@@ -23,6 +23,7 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Literal
from app.application.engine.gates import GateOutcome, GateRunner
from app.application.engine.manager import ManagerOutcome, ManagerTurns, _pick_granted_model
@@ -32,10 +33,11 @@ from app.application.engine.state import advance, park_dependents
from app.application.ports.delivery_hooks import DeliveryHooks, NullDeliveryHooks
from app.application.ports.memory import MemoryPort, NullMemory, TaskOutcome
from app.application.ports.model_grants import OrgPolicyView
from app.application.ports.realtime import emit_task_status
from app.application.ports.repo_workspace import RepoWorkspacePort, WorktreeRef
from app.application.ports.sandbox_provider import SandboxProviderPort
from app.core.config import settings
from app.core.errors import ConflictError
from app.core.errors import ConflictError, NotFoundError, ValidationError
from app.domain.base import utcnow
from app.domain.workforce.engine import (
MAX_TRANSIENT_RETRIES,
@@ -43,8 +45,18 @@ from app.domain.workforce.engine import (
GateName,
is_transient,
)
from app.domain.workforce.models import Employee, RoleKey, Run, RunStatus, Task, TaskStatus
from app.domain.workforce.models import (
Employee,
Message,
MessageRole,
RoleKey,
Run,
RunStatus,
Task,
TaskStatus,
)
from app.domain.workforce.pipeline import (
TERMINAL_STAGES,
Outcome,
Stage,
TaskPipelineState,
@@ -85,8 +97,10 @@ MERGE_STAGES: tuple[Stage, ...] = (Stage.MERGE_REQUEST,)
#: address; the *name* is the employee's, which is what a reviewer reads.
ENGINE_COMMIT_EMAIL = "platform@smart-org.local"
#: Persian reason shown when work pauses because the money ran out.
BUDGET_PAUSED_MESSAGE = "اعتبار سازمان برای ادامهٔ کار کافی نیست؛ کار موقتاً متوقف شد."
#: Persian reason shown when work pauses because the credit ran out (prepaid, D-B).
BUDGET_PAUSED_MESSAGE = (
"اعتبار سازمان تمام شده است؛ کار تا افزودن اعتبار توسط مالک سامانه متوقف شد."
)
#: The stage a turn has to be at for each manager mode.
_MANAGER_STAGES: frozenset[Stage] = frozenset(
@@ -426,7 +440,14 @@ class Scheduler:
same_count=state.same_diff_count,
diff_text=diff.text,
)
await self._patch(state, last_diff_hash=new_hash, same_diff_count=same_count)
# One write for the hash, the counter and the diff the task page shows
# (capped at 20,000 chars). Only a diff that passed the guard is stored.
await self._patch(
state,
last_diff_hash=new_hash,
same_diff_count=same_count,
last_diff=diff.text[:20000],
)
if not progress.ok:
await self._send_back(state, task, progress.detail)
report.failed += 1
@@ -530,6 +551,7 @@ class Scheduler:
return
final_sha = pushed.sha or merged.sha or sha
await self._patch(state, merged_sha=final_sha)
await self._move(state, task, Stage.COMPLETED)
await self.repo_workspace.remove_worktree(worktree=worktree)
if task.project_id:
@@ -622,16 +644,14 @@ class Scheduler:
async def _budget_reason(self, task: Task) -> str | None:
"""Why this task may not spend a turn now, or ``None`` when it may.
Usage is post-paid: the ledger feeds invoices and nothing debits a wallet
from usage. A zero balance is therefore the normal starting state of every
org, not "out of money", so only an overdrawn wallet (below zero) pauses
work. The owner's spend lever is the org's monthly credit budget.
Billing is prepaid: the platform owner credits the wallet, and every usage
row debits it. Zero means "no credit left", so a zero, negative or missing
wallet pauses work before a turn is spent. The per-task cap and the org's
monthly budget still apply on top of that.
"""
if self.wallets is not None:
wallet = await self.wallets.get_for_org(task.organization_id)
# No wallet means no budget was ever set: a fresh org must not be
# frozen out of its first task. A zero balance is not a pause either.
if wallet is not None and wallet.balance < 0:
if wallet is None or wallet.balance <= 0:
return BUDGET_PAUSED_MESSAGE
if self.spend is not None and settings.engine_max_task_credits > 0:
used = await self.spend.spend_for_task(task.id)
@@ -648,6 +668,74 @@ class Scheduler:
return "سقف اعتبار ماهانهٔ سازمان پر شده است؛ کار متوقف شد."
return None
# ------------------------------------------------------------------ #
# the person's controls #
# ------------------------------------------------------------------ #
async def user_action(
self,
*,
organization_id: str,
task_id: str,
action: Literal["pause", "resume", "cancel", "answer"],
user_id: str,
answer: str = "",
) -> TaskPipelineState:
"""Apply one control a person pressed on a task, and return its new state.
The task is looked up inside the caller's organization, so a foreign id is
a 404. A task outside the engine, or one already closed, is a conflict.
Resume is refused while the budget is spent, so a task paused for credit
stays paused until credit is added and resume is pressed again. ``user_id``
names the person who pressed the control; the engine keeps no actor column,
so the route writes the audit row.
"""
task = await self.tasks.get_for_org(organization_id, task_id)
if task is None:
raise NotFoundError("کار یافت نشد.")
state = await self.states.get(task_id)
if state is None:
raise ConflictError("این کار در موتور خودکار نیست.")
if state.stage in TERMINAL_STAGES:
raise ConflictError("این کار بسته شده است.")
if action == "pause":
await self._move(state, task, Stage.PAUSED)
elif action == "resume":
if state.stage is not Stage.PAUSED:
raise ConflictError("فقط کار متوقف‌شده را می‌توان ادامه داد.")
reason = await self._budget_reason(task)
if reason is not None:
raise ConflictError(reason)
await self._move(state, task, Stage.PENDING_MANAGER_REVIEW)
elif action == "cancel":
await self._move(state, task, Stage.CANCELLED)
else:
await self._answer(state, task, answer)
updated = await self.states.get(task_id)
if updated is None: # pragma: no cover - the row was read above
raise NotFoundError("این کار وضعیت موتور ندارد.")
await emit_task_status(
task_id=task_id,
organization_id=organization_id,
status=coarse_status(updated.stage).value,
)
return updated
async def _answer(self, state: TaskPipelineState, task: Task, answer: str) -> None:
"""A person answers the decision a task waits on; triage reads it next."""
content = answer.strip()
if not 1 <= len(content) <= 4000:
raise ValidationError("پاسخ باید بین ۱ تا ۴۰۰۰ نویسه باشد.")
if state.stage is not Stage.PENDING_USER_DECISION:
raise ConflictError("این کار منتظر پاسخ شما نیست.")
# The move comes first, so a lost race raises before anything is written.
await self._move(state, task, Stage.PENDING_MANAGER_REVIEW)
await self.tasks.append_description(task.id, "\n\nپاسخ کاربر: " + content)
if task.thread_id:
await self.manager.messages.create(
Message(thread_id=task.thread_id, role=MessageRole.USER, content=content)
)
async def _retry_or_fail(
self, state: TaskPipelineState, task: Task, message: str, kind: str
) -> None:
+12 -2
View File
@@ -19,6 +19,7 @@ truth, the status column is the summary existing APIs keep reading.
from __future__ import annotations
from app.application.engine.events import emit_task_step
from app.core.errors import ConflictError, NotFoundError
from app.domain.workforce.pipeline import (
SIDE_STAGES,
@@ -46,14 +47,14 @@ async def advance(
Raises ``ConflictError`` when the transition is illegal or when another
worker moved the task first, and ``NotFoundError`` when the task has no
engine state row at all.
engine state row at all. A move that lands publishes one ``task.step``.
"""
if not can_transition_stage(expected_from, to):
raise ConflictError(
f"گذار نابجا از {expected_from.value} به {to.value}."
)
try:
return await states.advance(task_id=task_id, expected_from=expected_from, to=to)
moved = await states.advance(task_id=task_id, expected_from=expected_from, to=to)
except UnknownTaskError as exc:
raise NotFoundError("این کار وضعیت موتور ندارد.") from exc
except InvalidTransitionError as exc:
@@ -62,6 +63,15 @@ async def advance(
raise ConflictError(
"این کار هم‌اکنون توسط موتور دیگری در حرکت است؛ دوباره تلاش کنید."
) from exc
# The publisher never raises, so a broken bus cannot undo the move.
await emit_task_step(
task_id=moved.task_id,
organization_id=moved.organization_id,
from_stage=expected_from,
to_stage=to,
pipeline=moved.pipeline,
)
return moved
async def park_dependents(
@@ -17,6 +17,7 @@ from app.application.ports import PaymentProviderPort
from app.application.ports.realtime import emit_wallet_changed
from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError
from app.domain.billing.models import (
CreditKind,
CreditWallet,
Invoice,
Payment,
@@ -31,6 +32,7 @@ from app.infrastructure.repositories.billing import (
PlanRepository,
SubscriptionRepository,
)
from app.infrastructure.repositories.credit import CreditLedgerRepository
#: A tenant may not buy an arbitrary amount of credit in one action.
MIN_TOPUP = 1.0
@@ -56,6 +58,8 @@ class BillingService:
self.subscriptions = subscriptions
self.ledger = ledger
self.payment_provider = payment_provider
# Settled payments are credited through the credit ledger, in the wallets' session.
self.credit_ledger = CreditLedgerRepository(wallets.session)
# -- reads ---------------------------------------------------------------
@@ -107,6 +111,8 @@ class BillingService:
) -> CreditWallet:
"""Grant (or, for a platform owner, claw back) credit.
Superseded by the owner-only ``CreditService``, which writes the credit ledger.
``allow_negative`` is reserved for the platform owner console; an org
admin may only ever add credit.
"""
@@ -171,7 +177,9 @@ class BillingService:
This is the only path that turns money into credit, so it is idempotent
with respect to status: a payment that is already final is returned
untouched rather than credited twice.
untouched rather than credited twice. The credit is one ``adjustment`` row in
the credit ledger, written in this session, so it shows in the owner's credit
history and updates ``last_credit_at``.
"""
payment = next(
(p for p in await self.payments.list_for_org(organization_id) if p.id == payment_id),
@@ -183,12 +191,15 @@ class BillingService:
return payment
if succeeded:
wallet = await self.wallets.adjust(
organization_id=organization_id, delta=payment.amount
credit = await self.credit_ledger.apply(
organization_id=organization_id,
amount=payment.amount,
kind=CreditKind.ADJUSTMENT,
reason="billing/settle-payment",
)
final = await self.payments.set_status(payment_id, PaymentStatus.SUCCEEDED)
await emit_wallet_changed(
organization_id=organization_id, balance=wallet.balance
organization_id=organization_id, balance=credit.balance_after
)
else:
final = await self.payments.set_status(payment_id, PaymentStatus.FAILED)
@@ -0,0 +1,136 @@
"""Prepaid credit: the platform owner's top-ups, the history and the balances.
An organization's wallet is only ever moved through ``CreditLedgerRepository``, so every
change has a history row. This service is the owner's side of that ledger: it credits one
organization by hand, reads its manual history, lists every organization's balance for the
owner's credit page, and grants the optional starting credit to a new organization. Usage
debits are not written here; ``UsageLedgerRepository.record`` writes them.
Only the platform owner may call the write methods and the cross-organization ``balances``.
The routers enforce that; this service does not look at who is calling.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports.realtime import emit_wallet_changed
from app.core.config import settings
from app.core.errors import NotFoundError, ValidationError
from app.domain.billing.models import CreditKind, CreditTransaction
from app.domain.workforce.pipeline import Stage
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.credit import CreditLedgerRepository
from app.infrastructure.repositories.engine import PipelineStateRepository
from app.infrastructure.repositories.identity import OrganizationRepository
#: The most one owner action may credit at once. A bigger top-up is made in steps.
MAX_OWNER_CREDIT = 100_000.0
#: The reason is required and stored with the history row (at most 500 characters).
_REASON_MIN = 3
_REASON_MAX = 500
#: The entries a history page shows: what someone did to the wallet, not usage debits.
_MANUAL_KINDS = (CreditKind.OWNER_CREDIT, CreditKind.STARTING_CREDIT, CreditKind.ADJUSTMENT)
@dataclass(slots=True, kw_only=True)
class OrgBalance:
"""One organization's row on the platform owner's credit page."""
organization_id: str
name: str
slug: str
balance: float
currency: str
paused_tasks: int
last_credit_at: datetime | None
class CreditService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.ledger = CreditLedgerRepository(session)
self.wallets = CreditWalletRepository(session)
self.orgs = OrganizationRepository(session)
self.states = PipelineStateRepository(session)
async def owner_credit(
self,
*,
organization_id: str,
amount: float,
reason: str,
actor_user_id: str,
) -> CreditTransaction:
"""Credit one organization's wallet on the platform owner's word."""
if await self.orgs.get(organization_id) is None:
raise NotFoundError("سازمان یافت نشد.")
if not 0 < amount <= MAX_OWNER_CREDIT:
raise ValidationError(
f"مقدار اعتبار باید بیشتر از صفر و حداکثر {MAX_OWNER_CREDIT:g} باشد."
)
clean_reason = reason.strip()
if not _REASON_MIN <= len(clean_reason) <= _REASON_MAX:
raise ValidationError("دلیل افزودن اعتبار لازم است.")
transaction = await self.ledger.apply(
organization_id=organization_id,
amount=amount,
kind=CreditKind.OWNER_CREDIT,
reason=clean_reason,
actor_user_id=actor_user_id,
)
# Live push: the sidebar credit badge follows the wallet as it moves.
await emit_wallet_changed(
organization_id=organization_id, balance=transaction.balance_after
)
return transaction
async def history(self, organization_id: str, *, limit: int = 50) -> list[CreditTransaction]:
"""One organization's manual credit history, newest first. Usage debits are left out."""
return await self.ledger.history(organization_id, kinds=_MANUAL_KINDS, limit=limit)
async def balances(self) -> list[OrgBalance]:
"""Every organization's balance, paused task count and last credit date.
Cross-organization on purpose: only the platform owner's credit page may call it.
A missing wallet is shown as zero.
"""
paused = await self.states.count_at_by_org(Stage.PAUSED)
last_credit = await self.ledger.last_credit_at_by_org()
rows: list[OrgBalance] = []
for org in await self.orgs.list_all():
wallet = await self.wallets.get_for_org(org.id)
rows.append(
OrgBalance(
organization_id=org.id,
name=org.name,
slug=org.slug,
balance=wallet.balance if wallet else 0.0,
currency=wallet.currency if wallet else "USD",
paused_tasks=paused.get(org.id, 0),
last_credit_at=last_credit.get(org.id),
)
)
return rows
async def grant_starting_credit(self, organization_id: str) -> CreditTransaction | None:
"""Give a new organization the configured starting credit, if one is set.
``BILLING_STARTING_CREDIT`` defaults to 0, so by default nothing is granted and the
owner credits new organizations by hand.
"""
amount = float(getattr(settings, "billing_starting_credit", 0.0))
if amount <= 0:
return None
return await self.ledger.apply(
organization_id=organization_id,
amount=amount,
kind=CreditKind.STARTING_CREDIT,
reason="اعتبار آغازین",
)
@@ -45,8 +45,9 @@ TIER_LABELS: dict[str, str] = {
#: Policy defaults for an organization that has never been configured. Kept in
#: code (not only in the row default) so ``get_policy`` can answer for any org
#: without writing a row first.
DEFAULT_MAX_EMPLOYEES = 5
#: without writing a row first. D-A: the default cap is 7 employees per
#: organization; ``settings.engine_max_employees`` stays the upper bound.
DEFAULT_MAX_EMPLOYEES = 7
@dataclass(slots=True)
@@ -221,6 +221,11 @@ class OrgSetupService:
)
)
self.session.add(CreditWalletRow(organization_id=org.id, balance=0.0, currency="USD"))
# Prepaid: the optional starting credit (0 by default) goes through the ledger.
await self.session.flush()
from app.application.services.credit_service import CreditService
await CreditService(self.session).grant_starting_credit(org.id)
# -- create the single app -------------------------------------------
project_row = await self._create_single_app(org.id, user.id, payload, catalog_defaults)
@@ -32,6 +32,7 @@ from app.application.ports.realtime import (
emit_task_created,
emit_task_status,
emit_usage_recorded,
emit_wallet_changed,
)
from app.core.errors import (
ConflictError,
@@ -61,6 +62,13 @@ from app.domain.workforce.models import (
ToolCallStatus,
can_transition_run,
)
from app.domain.workforce.pipeline import (
Risk,
Stage,
TaskKind,
TaskPipelineState,
resolve_pipeline,
)
from app.domain.workforce.tools import (
TOOL_CATALOG,
ToolSpec,
@@ -74,6 +82,7 @@ from app.infrastructure.repositories.ai_control import (
RoutingPolicyRepository,
)
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.engine import PipelineStateRepository
from app.infrastructure.repositories.projects import ProjectRepository
from app.infrastructure.repositories.workforce import (
AgentTemplateRepository,
@@ -94,6 +103,12 @@ from app.infrastructure.repositories.workforce import (
#: non-zero placeholder and mark it in the usage metadata.
_UNPRICED = 0.0000001
#: Said when the legacy "run" button is pressed on a task the engine owns.
_ENGINE_OWNS_TASK = (
"این کار را موتور خودکار اجرا می‌کند؛ "
"برای توقف، ادامه یا لغو از دکمه‌های همین کار استفاده کنید."
)
_SYSTEM_PROMPT = (
"تو دستیار هوش مصنوعی سازمان هستی. پاسخ‌ها را کوتاه، دقیق و عملی بده. "
"هیچ‌گاه نام مدل، ارائه‌دهنده یا جزئیات زیرساخت را فاش نکن."
@@ -126,7 +141,7 @@ async def record_model_usage(
card = await rate_cards.get_by_unit(unit)
unit_price = card.customer_price if card else _UNPRICED
internal_cost = card.internal_cost_estimate if card else 0.0
await ledger.record(
balance = await ledger.record(
usage=UsageRecord(
organization_id=organization_id,
project_id=project_id,
@@ -141,6 +156,9 @@ async def record_model_usage(
metadata_json={"priced": card is not None},
)
)
if balance is not None:
# The debit moved the wallet: the sidebar badge follows it live.
await emit_wallet_changed(organization_id=organization_id, balance=balance)
# Live push: the billing page and the sidebar credit badge react to
# consumption as it happens instead of at the next page load.
await emit_usage_recorded(
@@ -406,6 +424,7 @@ class WorkforceService:
"""
from app.application.container_org import build_memory, build_model_grants
from app.application.engine.manager import ManagerTurns
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.engine import (
EmployeeProfileRepository,
PipelineStateRepository,
@@ -426,6 +445,7 @@ class WorkforceService:
memory=build_memory(session),
profiles=EmployeeProfileRepository(session),
states=PipelineStateRepository(session),
wallets=CreditWalletRepository(session),
)
# ------------------------------------------------------------------ #
@@ -443,6 +463,13 @@ class WorkforceService:
thread_id: str | None = None,
assigned_employee_id: str | None = None,
) -> Task:
"""A user order to the manager: the task enters the engine at manager review.
The task gets a pipeline state at ``pending_manager_review``, so the scheduler's
triage turn takes it from there and picks the real pipeline and assignee (D-D).
Without a state the legacy one-shot drain would run it outside the engine. MCP
``tasks_create`` calls this method, so its tasks enter the engine too.
"""
if not title.strip():
raise ValidationError("عنوان کار نمی‌تواند خالی باشد.")
await self._own_project_or_404(organization_id=organization_id, project_id=project_id)
@@ -451,6 +478,7 @@ class WorkforceService:
await self.get_employee_or_404(
organization_id=organization_id, employee_id=assigned_employee_id
)
project_id = project_id or await self._project_id_for(organization_id)
task = await self.tasks.create(
Task(
organization_id=organization_id,
@@ -462,6 +490,21 @@ class WorkforceService:
created_by_user_id=created_by_user_id,
)
)
await PipelineStateRepository(self.tasks.session).create(
TaskPipelineState(
task_id=task.id,
organization_id=organization_id,
project_id=project_id,
pipeline=resolve_pipeline(
manager_choice=None,
task_kind=TaskKind.FEATURE,
has_active_reviewer=False,
),
kind=TaskKind.FEATURE,
risk=Risk.MEDIUM,
stage=Stage.PENDING_MANAGER_REVIEW,
)
)
# Live push: the tasks list and the manager-chat sidebar update the
# moment the task exists, whoever created it and from where.
await emit_task_created(
@@ -472,6 +515,16 @@ class WorkforceService:
)
return task
async def _project_id_for(self, organization_id: str) -> str | None:
"""The project a project-less order lands in: the one its employees work in.
Mirrors ``ManagerTurns._project_id_for``, so a task made here and a task the
manager makes from a chat get the same project.
"""
for employee in await self.employees.list_for_org(organization_id, limit=1):
return employee.project_id
return None
async def list_tasks(
self,
organization_id: str,
@@ -506,6 +559,9 @@ class WorkforceService:
employee_id: str | None = None,
) -> Run:
task = await self.get_task_or_404(organization_id=organization_id, task_id=task_id)
if await PipelineStateRepository(self.tasks.session).get(task.id) is not None:
# The engine owns this task; its own buttons (pause, resume, cancel, answer) drive it.
raise ConflictError(_ENGINE_OWNS_TASK)
if task.status in (TaskStatus.DONE, TaskStatus.CANCELLED):
raise ConflictError("این کار قبلاً بسته شده است.")
+6
View File
@@ -194,6 +194,12 @@ class Settings(BaseSettings):
git_allow_file_protocol: bool = False
# <<< agent-3
# >>> integration (billing): add settings ONLY between these markers
#: Credit given to every new organization at creation (prepaid). 0 means the
#: platform owner credits new organizations by hand.
billing_starting_credit: float = 0.0
# <<< integration
@property
def cors_origin_list(self) -> list[str]:
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
+3
View File
@@ -63,6 +63,9 @@ class AuditAction:
PROJECT_CREATE = "project.create"
PROJECT_DELETE = "project.delete"
TASK_CREATE = "task.create"
#: A person paused, resumed, cancelled or answered an engine task. The metadata
#: names the action only; an answer's text is never written to the audit trail.
TASK_CONTROL = "task.control"
RUN_CANCEL = "run.cancel"
#: A platform owner triaged an error in the console. Auditable so "who
#: decided this bug was fixed?" has an answer.
+28
View File
@@ -83,6 +83,34 @@ class CreditWallet:
self.updated_at = utcnow()
class CreditKind(StrEnum):
"""Why a wallet's balance moved."""
OWNER_CREDIT = "owner_credit"
STARTING_CREDIT = "starting_credit"
USAGE = "usage"
ADJUSTMENT = "adjustment"
@dataclass(slots=True, kw_only=True)
class CreditTransaction:
"""One change to a wallet's balance, with the balance it left behind.
``amount`` is signed: a credit is positive, a debit is negative.
``usage_event_id`` is set on usage debits only, so one usage row debits once.
"""
organization_id: str
kind: CreditKind
amount: float
balance_after: float
reason: str = ""
actor_user_id: str | None = None
usage_event_id: str | None = None
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class UsageEvent:
"""One billable unit of work. Append-only."""
@@ -441,6 +441,10 @@ class TaskPipelineState:
same_diff_count: int = 0
retry_count: int = 0
waiver_reason: str = ""
#: The latest accepted diff (the writer caps it); the task page shows it.
last_diff: str = ""
#: The sha the merge landed on, once the task is COMPLETED.
merged_sha: str = ""
parent_task_id: str | None = None
depends_on: list[str] = field(default_factory=list)
version: int = 0
+1
View File
@@ -1023,6 +1023,7 @@ class QualityMetricRow(Base):
# parallel agents never edit the same file). Imported last so every table is
# registered on ``Base.metadata`` before ``create_all`` / alembic autogenerate.
# ---------------------------------------------------------------------------
from app.infrastructure.db import models_billing as _models_billing # noqa: E402,F401
from app.infrastructure.db import models_delivery as _models_delivery # noqa: E402,F401
from app.infrastructure.db import models_engine as _models_engine # noqa: E402,F401
from app.infrastructure.db import models_org as _models_org # noqa: E402,F401
@@ -0,0 +1,50 @@
"""ORM tables of the prepaid credit ledger (v2).
Use ``from app.core.database import Base`` and the helpers in
``app.infrastructure.db.columns`` (``pk()``, ``created()``). Do NOT import from
``app.infrastructure.db.models`` here: that module imports this one (import cycle).
Foreign keys to existing tables are written as plain strings, e.g.
``ForeignKey("organizations.id", ondelete="CASCADE")``.
``credit_transactions`` is the history of an organization's prepaid wallet
(``credit_wallets.balance``). Every change to the balance writes one row here, in
the same transaction, with the balance it left behind:
* ``owner_credit``, ``starting_credit`` and ``adjustment`` move the balance by a
signed amount (credit positive, debit negative);
* ``usage`` is the debit for one usage row. ``usage_event_id`` is unique, so one
usage row debits the wallet at most once, even when it is written twice.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Float, ForeignKey, Index, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.infrastructure.db.columns import created, pk
class CreditTransactionRow(Base):
__tablename__ = "credit_transactions"
__table_args__ = (
Index("ix_credit_tx_org_created", "organization_id", "created_at"),
UniqueConstraint("usage_event_id", name="uq_credit_transactions_usage_event_id"),
)
id: Mapped[str] = pk()
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
#: owner_credit | starting_credit | usage | adjustment (see ``CreditKind``).
kind: Mapped[str] = mapped_column(String(16), nullable=False)
#: Signed: a credit is positive, a debit is negative.
amount: Mapped[float] = mapped_column(Float, nullable=False)
balance_after: Mapped[float] = mapped_column(Float, nullable=False)
reason: Mapped[str] = mapped_column(String(500), default="", nullable=False)
actor_user_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
#: The usage row this debit paid for. Unique: the idempotency of a usage debit.
usage_event_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
created_at: Mapped[datetime] = created()
@@ -82,6 +82,11 @@ class TaskPipelineStateRow(Base):
#: A waived test pass must always be explainable: the reason is stored,
#: never inferred (PLAN-v2 §3, green gate).
waiver_reason: Mapped[str] = mapped_column(Text, default="", nullable=False)
#: The latest accepted diff (the writer caps it). The worktree is gone after the
#: merge, so the task page reads the diff from here.
last_diff: Mapped[str] = mapped_column(Text, default="", nullable=False)
#: The sha the merge landed on, once the state is COMPLETED.
merged_sha: Mapped[str] = mapped_column(String(64), default="", nullable=False)
parent_task_id: Mapped[str | None] = mapped_column(
ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
)
+1 -1
View File
@@ -143,7 +143,7 @@ class OrgPolicyRow(Base):
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), primary_key=True
)
max_employees: Mapped[int] = mapped_column(Integer, default=5, nullable=False)
max_employees: Mapped[int] = mapped_column(Integer, default=7, nullable=False)
#: ``None`` means "no explicit budget" (an empty/unlimited wallet view).
monthly_credit_budget: Mapped[float | None] = mapped_column(Float, nullable=True)
automation_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports.audit import UsageAggregate, UsageBreakdown, UsageRecord
from app.domain.ai_control.models import RateUnit
from app.domain.audit.models import AuditEvent, ErrorEvent, QualityMetric, Span, Trace
from app.domain.base import new_id
from app.infrastructure.db.models import (
AuditEventRow,
ErrorEventRow,
@@ -18,6 +19,7 @@ from app.infrastructure.db.models import (
TraceRow,
UsageEventRow,
)
from app.infrastructure.repositories.credit import CreditLedgerRepository
def _audit_to_domain(row: AuditEventRow) -> AuditEvent:
@@ -107,14 +109,26 @@ class AuditRepository:
class UsageLedgerRepository:
"""Concrete ``UsageLedgerPort``. The ledger is the billing source of truth."""
"""Concrete ``UsageLedgerPort``. The ledger is the billing source of truth.
Prepaid: every usage row also debits its organization's wallet, once, in the
same transaction (the debit is keyed on the usage row's id). The ledger stays
the source of truth; the wallet is the balance it leaves behind.
"""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def record(self, *, usage: UsageRecord) -> None:
async def record(self, *, usage: UsageRecord) -> float | None:
"""Append one usage row, debit the wallet for it, return the new balance.
Both writes share the caller's transaction, so a rolled-back request leaves
neither behind. Returns ``None`` when the row cost nothing and nothing was
debited.
"""
total = usage.total_price or (usage.quantity * usage.unit_price)
row = UsageEventRow(
id=new_id(),
organization_id=usage.organization_id,
project_id=usage.project_id,
user_id=usage.user_id,
@@ -129,6 +143,12 @@ class UsageLedgerRepository:
)
self.session.add(row)
await self.session.flush()
debit = await CreditLedgerRepository(self.session).debit_for_usage(
organization_id=usage.organization_id,
usage_event_id=row.id,
amount=total,
)
return debit.balance_after if debit is not None else None
async def breakdown(
self,
@@ -0,0 +1,148 @@
"""The prepaid credit ledger: every balance change is one atomic update and one history row.
``credit_wallets.balance`` is the number the engine reads; ``credit_transactions``
is the history that explains it. Both change in the caller's transaction, so a
rolled-back request leaves neither behind.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.base import utcnow
from app.domain.billing.models import CreditKind, CreditTransaction
from app.infrastructure.db.models import CreditWalletRow
from app.infrastructure.db.models_billing import CreditTransactionRow
from app.infrastructure.repositories.billing import CreditWalletRepository
#: Longest history page one call may return; the UI never needs more at once.
_MAX_HISTORY = 200
def _transaction(row: CreditTransactionRow) -> CreditTransaction:
return CreditTransaction(
id=row.id,
organization_id=row.organization_id,
kind=CreditKind(row.kind),
amount=float(row.amount),
balance_after=float(row.balance_after),
reason=row.reason,
actor_user_id=row.actor_user_id,
usage_event_id=row.usage_event_id,
created_at=row.created_at,
)
class CreditLedgerRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def apply(
self,
*,
organization_id: str,
amount: float,
kind: CreditKind,
reason: str = "",
actor_user_id: str | None = None,
usage_event_id: str | None = None,
) -> CreditTransaction:
"""Move the wallet by a signed ``amount`` and record the move.
The balance changes in one SQL ``UPDATE`` (``balance = balance + amount``),
never as a Python read-modify-write, so two writers cannot lose each
other's change. The history row records the balance that update left.
"""
await CreditWalletRepository(self.session).ensure(organization_id)
await self.session.execute(
update(CreditWalletRow)
.where(CreditWalletRow.organization_id == organization_id)
.values(balance=CreditWalletRow.balance + amount, updated_at=utcnow())
.execution_options(synchronize_session="fetch")
)
balance_after = (
await self.session.execute(
select(CreditWalletRow.balance).where(
CreditWalletRow.organization_id == organization_id
)
)
).scalar_one()
row = CreditTransactionRow(
organization_id=organization_id,
kind=str(kind),
amount=amount,
balance_after=float(balance_after),
reason=reason[:500],
actor_user_id=actor_user_id,
usage_event_id=usage_event_id,
)
self.session.add(row)
await self.session.flush()
return _transaction(row)
async def debit_for_usage(
self, *, organization_id: str, usage_event_id: str, amount: float
) -> CreditTransaction | None:
"""Debit the wallet for one usage row, at most once.
Returns ``None`` when the usage costs nothing or was already debited. The
debit runs in a savepoint: when two writers race on the same usage row, the
loser's insert is rolled back and it returns ``None`` instead of poisoning
the caller's transaction. Any other integrity failure is re-raised.
"""
if amount <= 0 or await self._debited(organization_id, usage_event_id):
return None
try:
async with self.session.begin_nested():
return await self.apply(
organization_id=organization_id,
amount=-amount,
kind=CreditKind.USAGE,
usage_event_id=usage_event_id,
)
except IntegrityError:
if await self._debited(organization_id, usage_event_id):
return None
raise
async def history(
self,
organization_id: str,
*,
kinds: tuple[CreditKind, ...] | None = None,
limit: int = 50,
) -> list[CreditTransaction]:
"""One organization's history, newest first, optionally one kind of entry."""
stmt = select(CreditTransactionRow).where(
CreditTransactionRow.organization_id == organization_id
)
if kinds:
stmt = stmt.where(CreditTransactionRow.kind.in_([str(k) for k in kinds]))
stmt = stmt.order_by(CreditTransactionRow.created_at.desc()).limit(
min(limit, _MAX_HISTORY)
)
return [_transaction(r) for r in (await self.session.execute(stmt)).scalars()]
async def last_credit_at_by_org(self) -> dict[str, datetime]:
"""When each organization last received credit (usage debits do not count).
Cross-organization on purpose: only the platform owner's views may use it.
"""
stmt = (
select(CreditTransactionRow.organization_id, func.max(CreditTransactionRow.created_at))
.where(CreditTransactionRow.kind != str(CreditKind.USAGE))
.group_by(CreditTransactionRow.organization_id)
)
result = await self.session.execute(stmt)
return dict(result.tuples().all())
async def _debited(self, organization_id: str, usage_event_id: str) -> bool:
stmt = select(CreditTransactionRow.id).where(
CreditTransactionRow.organization_id == organization_id,
CreditTransactionRow.usage_event_id == usage_event_id,
)
return (await self.session.execute(stmt)).first() is not None
@@ -36,7 +36,7 @@ from app.domain.workforce.pipeline import (
TaskKind,
TaskPipelineState,
)
from app.infrastructure.db.models import RunRow, UsageEventRow
from app.infrastructure.db.models import RunRow, TaskRow, UsageEventRow
from app.infrastructure.db.models_engine import (
EmployeeProfileRow,
EngineTurnRow,
@@ -75,6 +75,8 @@ _PATCHABLE = frozenset(
"same_diff_count",
"retry_count",
"waiver_reason",
"last_diff",
"merged_sha",
"parent_task_id",
"depends_on",
}
@@ -97,6 +99,8 @@ def _state(row: TaskPipelineStateRow) -> TaskPipelineState:
same_diff_count=row.same_diff_count,
retry_count=row.retry_count,
waiver_reason=row.waiver_reason,
last_diff=row.last_diff,
merged_sha=row.merged_sha,
parent_task_id=row.parent_task_id,
depends_on=list(row.depends_on or []),
version=row.version,
@@ -185,6 +189,8 @@ class PipelineStateRepository:
same_diff_count=state.same_diff_count,
retry_count=state.retry_count,
waiver_reason=state.waiver_reason,
last_diff=state.last_diff,
merged_sha=state.merged_sha,
parent_task_id=state.parent_task_id,
depends_on=list(state.depends_on),
version=state.version,
@@ -268,6 +274,27 @@ class PipelineStateRepository:
)
return int((await self.session.execute(stmt)).scalar_one())
async def count_at_by_org(self, stage: Stage) -> dict[str, int]:
"""How many tasks sit at one stage, per organization.
Cross-organization on purpose: only the platform owner's views may use it.
"""
stmt = (
select(TaskPipelineStateRow.organization_id, func.count())
.where(TaskPipelineStateRow.stage == str(stage))
.group_by(TaskPipelineStateRow.organization_id)
)
return {org: int(n) for org, n in (await self.session.execute(stmt)).all()}
async def stage_counts(self, organization_id: str) -> dict[str, int]:
"""How many of one organization's tasks sit at each stage."""
stmt = (
select(TaskPipelineStateRow.stage, func.count())
.where(TaskPipelineStateRow.organization_id == organization_id)
.group_by(TaskPipelineStateRow.stage)
)
return {stage: int(n) for stage, n in (await self.session.execute(stmt)).all()}
# ---------------------------------------------------------------------------
# employee_profiles
@@ -410,6 +437,21 @@ class EngineTurnRepository:
)
return [_turn(r) for r in (await self.session.execute(stmt)).scalars()]
async def list_for_org(self, organization_id: str, *, limit: int = 50) -> list[EngineTurn]:
"""The newest turns across one organization's tasks.
Turns carry no organization column, so the tenant filter joins through
the task: another organization's turn can never match.
"""
stmt = (
select(EngineTurnRow)
.join(TaskRow, EngineTurnRow.task_id == TaskRow.id)
.where(TaskRow.organization_id == organization_id)
.order_by(EngineTurnRow.started_at.desc())
.limit(min(limit, 200))
)
return [_turn(r) for r in (await self.session.execute(stmt)).scalars()]
async def finish(
self,
turn_id: str,
@@ -39,7 +39,7 @@ class DefaultGrantRecord:
@dataclass(slots=True, kw_only=True)
class OrgPolicyRecord:
organization_id: str
max_employees: int = 5
max_employees: int = 7
monthly_credit_budget: float | None = None
automation_enabled: bool = True
mcp_enabled: bool = True
@@ -510,6 +510,15 @@ class TaskRepository:
await self.session.flush()
return _task(row)
async def append_description(self, task_id: str, text: str) -> Task | None:
"""Add text to the end of a task's description (a person's answer, for triage)."""
row = await self.session.get(TaskRow, task_id)
if row is None:
return None
row.description = f"{row.description or ''}{text}"
await self.session.flush()
return _task(row)
class RunRepository:
def __init__(self, session: AsyncSession) -> None:
@@ -0,0 +1,144 @@
"""Owner credit console: ``/api/admin/credits`` and ``/api/admin/orgs/{org}/credit``.
Prepaid billing (D-B): the platform owner is the only one who puts credit into an
organization's wallet. Each credit writes one history row (``CreditService``), one audit row
and one live ``billing.wallet`` push, so the organization's credit badge follows the balance.
Owner-only at the router level, like ``admin_grants.py``. Nothing here names a model or a
provider, and no secret is returned. Organization members read their own balance through
``/api/organizations/{org}/billing``.
"""
from __future__ import annotations
from dataclasses import asdict
from datetime import datetime
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, ConfigDict
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.application.services.credit_service import CreditService, OrgBalance
from app.core.database import get_session
from app.core.errors import NotFoundError
from app.domain.audit.models import AuditAction
from app.domain.billing.models import CreditTransaction
from app.domain.identity.models import User
from app.infrastructure.repositories.identity import OrganizationRepository
from app.interfaces.http.deps import client_meta, require_platform_owner
router = APIRouter(
prefix="/admin",
tags=["admin-credits"],
# Deny-by-default, exactly like the rest of the console.
dependencies=[Depends(require_platform_owner)],
)
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class OrgBalanceOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
organization_id: str
name: str
slug: str
balance: float
currency: str
paused_tasks: int
last_credit_at: datetime | None = None
class CreditRequest(BaseModel):
#: Checked by ``CreditService`` (a zero amount or a blank reason gets a Persian message).
amount: float
reason: str
class CreditTransactionOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
kind: str
amount: float
balance_after: float
reason: str
created_at: datetime
def _balance_out(view: OrgBalance) -> OrgBalanceOut:
return OrgBalanceOut(**asdict(view))
def _transaction_out(transaction: CreditTransaction) -> CreditTransactionOut:
return CreditTransactionOut(
id=transaction.id,
kind=str(transaction.kind),
amount=transaction.amount,
balance_after=transaction.balance_after,
reason=transaction.reason,
created_at=transaction.created_at,
)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/credits", response_model=list[OrgBalanceOut])
async def list_balances(session: AsyncSession = Depends(get_session)) -> list[OrgBalanceOut]:
"""Every organization's balance, paused task count and last credit date."""
return [_balance_out(view) for view in await CreditService(session).balances()]
@router.post("/orgs/{org_id}/credit", response_model=CreditTransactionOut)
async def credit_org(
org_id: str,
payload: CreditRequest,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> CreditTransactionOut:
"""Add credit to one organization's wallet; the history and audit rows commit together."""
transaction = await CreditService(session).owner_credit(
organization_id=org_id,
amount=payload.amount,
reason=payload.reason,
actor_user_id=owner.id,
)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.BILLING_CREDIT,
organization_id=org_id,
actor_user_id=owner.id,
target_type="credit_wallet",
# One wallet per organization, so the organization id names the wallet.
target_id=org_id,
metadata={
"amount": payload.amount,
"reason": transaction.reason,
"balance": transaction.balance_after,
},
ip=ip,
user_agent=ua,
)
return _transaction_out(transaction)
@router.get("/orgs/{org_id}/credit-history", response_model=list[CreditTransactionOut])
async def credit_history(
org_id: str,
limit: int = Query(default=50, ge=1, le=200),
session: AsyncSession = Depends(get_session),
) -> list[CreditTransactionOut]:
"""One organization's manual credit history, newest first (usage debits are not listed)."""
if await OrganizationRepository(session).get(org_id) is None:
raise NotFoundError("سازمان یافت نشد.")
history = await CreditService(session).history(org_id, limit=limit)
return [_transaction_out(transaction) for transaction in history]
+45 -20
View File
@@ -1,8 +1,10 @@
"""Billing endpoints: summary, usage ledger, invoices, credit and checkout.
"""Billing endpoints: summary, usage ledger, invoices, payments and checkout.
Reading billing requires ``billing.view``; changing it requires
``billing.manage``. A tenant can therefore never see — or move — another
organization's money.
Billing is prepaid (D-B): an organization spends the credit in its wallet, and only the
platform owner adds credit, with ``add-credit`` or by settling a payment. Reading billing
requires ``billing.view``; ``checkout``, which only creates a pending payment, requires
``billing.manage``. A tenant can therefore never see or move another organization's money,
and an organization admin cannot give its own wallet credit.
"""
from __future__ import annotations
@@ -13,11 +15,16 @@ from fastapi import APIRouter, Depends, Query, Request, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports.realtime import emit_wallet_changed
from app.application.services.audit_service import record_audit
from app.application.services.billing_service import BillingService
from app.core.database import get_session
from app.core.errors import PermissionDeniedError, ValidationError
from app.domain.audit.models import AuditAction
from app.domain.billing.models import CreditKind
from app.domain.identity.models import CAN_MANAGE_BILLING, CAN_VIEW_BILLING
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.credit import CreditLedgerRepository
from app.interfaces.http.deps import (
OrgContext,
client_meta,
@@ -27,6 +34,9 @@ from app.interfaces.http.deps import (
router = APIRouter(prefix="/organizations/{org_id}/billing", tags=["billing"])
#: Said to anyone who is not the platform owner and tries to move an organization's credit.
OWNER_ONLY_CREDIT = "اعتبار سازمان را فقط مالک سامانه افزایش می‌دهد."
# ---------------------------------------------------------------------------
# Schemas
@@ -88,7 +98,9 @@ class PaymentOut(BaseModel):
class AddCreditRequest(BaseModel):
amount: float = Field(description="Positive to top up; negative is platform-owner only.")
amount: float = Field(
description="Platform owner only. Positive tops up; negative claws back."
)
class WalletOut(BaseModel):
@@ -210,35 +222,47 @@ async def add_credit(
payload: AddCreditRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
service: BillingService = Depends(get_billing_service),
session: AsyncSession = Depends(get_session),
) -> WalletOut:
# A negative delta is a platform-owner-only clawback.
if payload.amount < 0:
if not context.user.is_platform_owner:
context.require(CAN_MANAGE_BILLING) # will raise PermissionDeniedError
from app.core.errors import PermissionDeniedError
"""Add (or claw back) credit on the platform owner's word. Nobody else may (D-B).
raise PermissionDeniedError("کسر اعتبار فقط توسط مالک سامانه انجام می‌شود.")
else:
context.require(CAN_MANAGE_BILLING)
The change is one history row of kind ``adjustment``, so it shows in the owner's
credit history for this organization.
"""
if not context.user.is_platform_owner:
raise PermissionDeniedError(OWNER_ONLY_CREDIT)
if payload.amount == 0:
raise ValidationError("مقدار اعتبار نمی‌تواند صفر باشد.")
wallet = await service.add_credit(
transaction = await CreditLedgerRepository(session).apply(
organization_id=context.org_id,
amount=payload.amount,
allow_negative=context.user.is_platform_owner,
kind=CreditKind.ADJUSTMENT,
reason="billing/add-credit",
actor_user_id=context.user.id,
)
if payload.amount < 0 and transaction.balance_after < 0:
# Refuse to leave the wallet in debt. The request's session rolls back on this
# error, so the balance change and its history row are both undone.
raise ValidationError("اعتبار کافی برای این کسر وجود ندارد.")
wallet = await CreditWalletRepository(session).ensure(context.org_id)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.BILLING_CREDIT, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="credit_wallet",
target_id=wallet.id,
metadata={"amount": payload.amount, "balance": wallet.balance},
metadata={"amount": payload.amount, "balance": transaction.balance_after},
ip=ip, user_agent=ua,
)
# Live push: the sidebar credit badge follows the wallet, whoever moved it.
await emit_wallet_changed(
organization_id=context.org_id, balance=transaction.balance_after
)
return WalletOut(
balance=wallet.balance, currency=wallet.currency, low_balance=wallet.is_low
balance=transaction.balance_after,
currency=wallet.currency,
low_balance=transaction.balance_after <= wallet.low_balance_threshold,
)
@@ -269,12 +293,13 @@ async def settle_payment(
service: BillingService = Depends(get_billing_service),
session: AsyncSession = Depends(get_session),
) -> PaymentOut:
"""Finalise a payment.
"""Finalise a payment. Only the platform owner may: a succeeded payment credits the wallet.
In production the payment provider calls a webhook; this endpoint is the
manual/dev equivalent. It is the only path that converts money into credit.
"""
context.require(CAN_MANAGE_BILLING)
if not context.user.is_platform_owner:
raise PermissionDeniedError(OWNER_ONLY_CREDIT)
payment = await service.settle_payment(
organization_id=context.org_id, payment_id=payment_id, succeeded=payload.succeeded
)
@@ -0,0 +1,670 @@
"""The AI workforce engine's API: status, task board, task detail and recent turns.
Every route resolves its tenant through ``get_org_context``. The reads are open to
every member of the organization, so the credit notice and the board work for
every role. The four actions on a task (pause, resume, cancel, answer) need the
``tasks.run`` capability and each writes one audit row. The team (view, hire by role
and tier, fire) is in this router too; hire and fire need ``employees.manage``.
Three things are load-bearing here:
* **Model identity never leaves.** No schema in this module has a field that can
hold a model id or name, a provider, an account, a container or a port. A turn
shows the employee's *name*, the role, the mode and the stage, never the model
that ran it. A future refactor cannot leak one without adding such a field.
* **Every query is tenant-scoped.** Tasks, states, turns, gates and runs are read
through repositories that filter by organization, and a task id that belongs to
another organization is a 404 (the same answer a missing id gets).
* **Bounded reads.** The board scans at most the newest 500 tasks of the
organization when it filters by stage; a turn excerpt is capped at 1,000 characters.
"""
from __future__ import annotations
from datetime import datetime
from typing import Literal
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.container_org import build_model_grants
from app.application.ports.model_grants import GrantedModel
from app.application.services.audit_service import record_audit
from app.core.database import get_session
from app.core.errors import NotFoundError
from app.domain.audit.models import AuditAction
from app.domain.identity.models import CAN_MANAGE_EMPLOYEES, CAN_RUN_TASKS
from app.domain.workforce.engine import EngineTurn
from app.domain.workforce.models import Employee, EmployeeStatus, RoleKey, Run, Task
from app.domain.workforce.pipeline import Stage, TaskPipelineState, pipeline_stages
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.engine import (
EmployeeProfileRepository,
EngineTurnRepository,
GateResultRepository,
PipelineStateRepository,
)
from app.infrastructure.repositories.workforce import (
EmployeeRepository,
RunRepository,
TaskRepository,
)
from app.interfaces.http.deps import OrgContext, client_meta, get_org_context
router = APIRouter(prefix="/organizations/{org_id}/engine", tags=["engine"])
#: The most tasks a board reads, newest first (a stage filter looks only among these).
_BOARD_SCAN_LIMIT = 500
#: The most pipeline states read in one listing; the rest are looked up one by one.
_STATE_SCAN_LIMIT = 1000
#: The longest reply excerpt a turn carries. The full reply is not shown here.
_EXCERPT_CHARS = 1000
#: The most turns one task detail carries, and the most gate verdicts.
_DETAIL_LIMIT = 200
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class EngineStatusOut(BaseModel):
"""Whether the organization's engine can work, and how much of the team is in use."""
balance: float
currency: str
credit_ok: bool
automation_enabled: bool
max_employees: int
team_size: int
stage_counts: dict[str, int]
class EngineTaskOut(BaseModel):
"""One task on the board.
``stage``, ``pipeline`` and ``kind`` are null for a task the engine does not
own (a legacy task). ``cycle`` and ``max_cycles`` are 0 for those tasks too.
"""
id: str
title: str
status: str
stage: str | None
pipeline: str | None
kind: str | None
cycle: int
max_cycles: int
thread_id: str | None
assigned_employee_id: str | None
assignee_name: str | None
created_at: datetime
updated_at: datetime
class EngineTurnOut(BaseModel):
"""One employee turn: who ran, in which role and stage, and how it ended."""
id: str
run_id: str
role: str
mode: str
stage: str
employee_name: str | None
ok: bool
error_kind: str
tool_calls: int
reply_excerpt: str
started_at: datetime
finished_at: datetime | None
class EngineOrgTurnOut(EngineTurnOut):
"""A turn in the organization's recent list, with the task it belongs to."""
task_id: str
task_title: str
class EngineGateOut(BaseModel):
id: str
gate: str
ok: bool
detail: str
created_at: datetime
class EngineRunOut(BaseModel):
id: str
status: str
started_at: datetime | None
finished_at: datetime | None
class EngineTaskDetailOut(EngineTaskOut):
"""One task with its pipeline, its turns, its gate verdicts and its last diff."""
description: str
#: The stages of the task's pipeline in order; empty for a legacy task.
stages: list[str]
waiver_reason: str
branch: str
depends_on: list[str]
#: The sha the merge landed on, once the task is completed.
merged_sha: str
#: The last accepted diff (capped by the writer at 20,000 characters).
diff: str
turns: list[EngineTurnOut]
gates: list[EngineGateOut]
runs: list[EngineRunOut]
class EngineAnswerRequest(BaseModel):
"""A person's answer to the question a task is waiting on."""
content: str = Field(min_length=1, max_length=4000)
class TierOut(BaseModel):
tier: str
label: str
class TeamMemberOut(BaseModel):
"""One team member. ``tier`` is neutral: no model, provider or model id is shown."""
id: str
name: str
role_key: str
status: str
#: ``fast``, ``balanced``, ``strong``, or ``unknown`` when the model is not granted.
tier: str
busy_task_id: str | None
busy_task_title: str | None
hired_by: str
class TeamOut(BaseModel):
employees: list[TeamMemberOut]
#: The tiers the organization has granted models for, cheapest first.
tiers: list[TierOut]
#: The owner's cap on the headcount for this organization.
max_employees: int
count: int
class HireRequest(BaseModel):
"""A hire asks for a role and a tier. The model is chosen by the platform."""
role: RoleKey
tier: str = Field(min_length=1, max_length=20)
name: str = Field(default="", max_length=120)
# ---------------------------------------------------------------------------
# Mapping
# ---------------------------------------------------------------------------
#: Neutral tier labels, cheapest first: the order the team view lists them in.
_TIER_LABELS: dict[str, str] = {"fast": "سریع", "balanced": "متعادل", "strong": "قوی"}
def _task_out(
task: Task, state: TaskPipelineState | None, names: dict[str, str]
) -> EngineTaskOut:
return EngineTaskOut(
id=task.id,
title=task.title,
status=str(task.status),
stage=str(state.stage) if state else None,
pipeline=str(state.pipeline) if state else None,
kind=str(state.kind) if state else None,
cycle=state.cycle if state else 0,
max_cycles=state.max_cycles if state else 0,
thread_id=task.thread_id,
assigned_employee_id=task.assigned_employee_id,
assignee_name=names.get(task.assigned_employee_id or ""),
created_at=task.created_at,
updated_at=task.updated_at,
)
def _turn_out(turn: EngineTurn, names: dict[str, str]) -> EngineTurnOut:
return EngineTurnOut(
id=turn.id,
run_id=turn.run_id,
role=str(turn.role),
mode=str(turn.mode),
stage=str(turn.stage),
employee_name=names.get(turn.employee_id or ""),
ok=turn.ok,
error_kind=turn.error_kind,
tool_calls=turn.tool_calls,
reply_excerpt=turn.reply_excerpt[:_EXCERPT_CHARS],
started_at=turn.started_at,
finished_at=turn.finished_at,
)
async def _employee_names(
session: AsyncSession, organization_id: str, employee_ids: list[str | None]
) -> dict[str, str]:
"""Names of the employees a list refers to, read inside the organization only.
One listing covers the team in the common case; an id outside that listing is
looked up on its own, still scoped to the organization.
"""
wanted = {eid for eid in employee_ids if eid}
repo = EmployeeRepository(session)
employees: list[Employee] = await repo.list_for_org(organization_id, limit=500)
names = {e.id: e.name for e in employees if e.id in wanted}
for employee_id in wanted:
if employee_id not in names:
employee = await repo.get_for_org(organization_id, employee_id)
if employee is not None:
names[employee_id] = employee.name
return names
async def _task_titles(
session: AsyncSession, organization_id: str, task_ids: list[str]
) -> dict[str, str]:
"""Titles of the tasks a list refers to, read inside the organization only."""
wanted = set(task_ids)
repo = TaskRepository(session)
tasks: list[Task] = await repo.list_for_org(organization_id, limit=_BOARD_SCAN_LIMIT)
titles = {t.id: t.title for t in tasks if t.id in wanted}
for task_id in wanted:
if task_id not in titles:
task = await repo.get_for_org(organization_id, task_id)
if task is not None:
titles[task_id] = task.title
return titles
async def _granted_tiers(
session: AsyncSession, organization_id: str
) -> tuple[list[GrantedModel], dict[str, str]]:
"""The organization's granted models, and each one's neutral tier by model id."""
granted = await build_model_grants(session).granted_models(organization_id=organization_id)
return granted, {model.ai_model_id: model.tier for model in granted}
def _tier_list(granted: list[GrantedModel]) -> list[TierOut]:
present = {model.tier for model in granted}
return [
TierOut(tier=tier, label=label) for tier, label in _TIER_LABELS.items() if tier in present
]
async def _member_out(
session: AsyncSession,
organization_id: str,
employee: Employee,
tiers: dict[str, str],
) -> TeamMemberOut:
profile = await EmployeeProfileRepository(session).get(employee.id)
tier = "unknown"
busy_task_id: str | None = None
busy_task_title: str | None = None
hired_by = ""
if profile is not None:
tier = tiers.get(profile.ai_model_id or "", "unknown")
busy_task_id = profile.busy_task_id
hired_by = profile.hired_by
if busy_task_id:
busy = await TaskRepository(session).get_for_org(organization_id, busy_task_id)
busy_task_title = busy.title if busy is not None else None
return TeamMemberOut(
id=employee.id,
name=employee.name,
role_key=str(employee.role_key),
status=str(employee.status),
tier=tier,
busy_task_id=busy_task_id,
busy_task_title=busy_task_title,
hired_by=hired_by,
)
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@router.get("/status", response_model=EngineStatusOut)
async def engine_status(
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> EngineStatusOut:
"""The credit balance, the owner's policy, the team size and the stage counts.
``credit_ok`` is false for a missing wallet as well as for a zero balance,
because prepaid work needs credit to run (decision D-B).
"""
organization_id = context.org_id
wallet = await CreditWalletRepository(session).get_for_org(organization_id)
policy = await build_model_grants(session).get_org_policy(organization_id)
return EngineStatusOut(
balance=wallet.balance if wallet is not None else 0.0,
currency=wallet.currency if wallet is not None else "USD",
credit_ok=wallet is not None and wallet.balance > 0,
automation_enabled=policy.automation_enabled,
max_employees=policy.max_employees,
team_size=await EmployeeRepository(session).count_for_org(organization_id),
stage_counts=await PipelineStateRepository(session).stage_counts(organization_id),
)
@router.get("/tasks", response_model=list[EngineTaskOut])
async def list_engine_tasks(
stage: Stage | None = Query(default=None),
limit: int = Query(default=100, ge=1, le=500),
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> list[EngineTaskOut]:
"""The organization's tasks, newest first, with their stage when the engine owns them.
With ``stage`` only the tasks at that stage are returned, found among the newest
500 tasks of the organization.
"""
organization_id = context.org_id
states_repo = PipelineStateRepository(session)
states = {
s.task_id: s
for s in await states_repo.list_for_org(organization_id, limit=_STATE_SCAN_LIMIT)
}
rows: list[tuple[Task, TaskPipelineState | None]] = []
for task in await TaskRepository(session).list_for_org(
organization_id, limit=_BOARD_SCAN_LIMIT
):
state = states.get(task.id)
if state is None:
# A legacy task, or an engine task older than the newest states listed above.
state = await states_repo.get(task.id)
if stage is not None and (state is None or state.stage is not stage):
continue
rows.append((task, state))
if len(rows) >= limit:
break
names = await _employee_names(
session, organization_id, [task.assigned_employee_id for task, _ in rows]
)
return [_task_out(task, state, names) for task, state in rows]
@router.get("/tasks/{task_id}", response_model=EngineTaskDetailOut)
async def get_engine_task(
task_id: str,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> EngineTaskDetailOut:
"""One task with its stages, turns, gate verdicts, runs, diff and merged sha."""
organization_id = context.org_id
task = await TaskRepository(session).get_for_org(organization_id, task_id)
if task is None:
raise NotFoundError("کار یافت نشد.")
state = await PipelineStateRepository(session).get(task.id)
turns = await EngineTurnRepository(session).list_for_task(task.id, limit=_DETAIL_LIMIT)
gates = await GateResultRepository(session).list_for_task(task.id, limit=_DETAIL_LIMIT)
runs: list[Run] = await RunRepository(session).list_for_task(task.id)
names = await _employee_names(
session,
organization_id,
[task.assigned_employee_id, *(turn.employee_id for turn in turns)],
)
base = _task_out(task, state, names)
return EngineTaskDetailOut(
**base.model_dump(),
description=task.description,
stages=[str(stage) for stage in pipeline_stages(state.pipeline)] if state else [],
waiver_reason=state.waiver_reason if state else "",
branch=state.branch if state else "",
depends_on=list(state.depends_on) if state else [],
merged_sha=state.merged_sha if state else "",
diff=state.last_diff if state else "",
turns=[_turn_out(turn, names) for turn in turns],
gates=[
EngineGateOut(
id=gate.id,
gate=str(gate.gate),
ok=gate.ok,
detail=gate.detail,
created_at=gate.created_at,
)
for gate in gates
],
runs=[
EngineRunOut(
id=run.id,
status=str(run.status),
started_at=run.started_at,
finished_at=run.finished_at,
)
for run in runs
],
)
@router.get("/turns", response_model=list[EngineOrgTurnOut])
async def list_recent_turns(
limit: int = Query(default=50, ge=1, le=200),
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> list[EngineOrgTurnOut]:
"""The organization's newest turns across all its tasks, with each task's title."""
organization_id = context.org_id
turns = await EngineTurnRepository(session).list_for_org(organization_id, limit=limit)
names = await _employee_names(session, organization_id, [turn.employee_id for turn in turns])
titles = await _task_titles(session, organization_id, [turn.task_id for turn in turns])
return [
EngineOrgTurnOut(
**_turn_out(turn, names).model_dump(),
task_id=turn.task_id,
task_title=titles.get(turn.task_id, ""),
)
for turn in turns
]
# ---------------------------------------------------------------------------
# Actions on one task
# ---------------------------------------------------------------------------
async def _task_view(session: AsyncSession, organization_id: str, task_id: str) -> EngineTaskOut:
task = await TaskRepository(session).get_for_org(organization_id, task_id)
if task is None:
raise NotFoundError("کار یافت نشد.")
state = await PipelineStateRepository(session).get(task.id)
names = await _employee_names(session, organization_id, [task.assigned_employee_id])
return _task_out(task, state, names)
async def _control(
*,
action: Literal["pause", "resume", "cancel", "answer"],
task_id: str,
request: Request,
context: OrgContext,
session: AsyncSession,
answer: str = "",
) -> EngineTaskOut:
context.require(CAN_RUN_TASKS)
# Imported inside the handler: the container wires the whole engine, and the read
# routes above do not need it.
from app.application.container import build_scheduler
await build_scheduler(session).user_action(
organization_id=context.org_id,
task_id=task_id,
action=action,
user_id=context.user.id,
answer=answer,
)
ip, ua = client_meta(request)
# Only the action is written: an answer's text stays out of the audit trail.
await record_audit(
session,
action=AuditAction.TASK_CONTROL,
organization_id=context.org_id,
actor_user_id=context.user.id,
target_type="task",
target_id=task_id,
metadata={"action": action},
ip=ip,
user_agent=ua,
)
return await _task_view(session, context.org_id, task_id)
@router.post("/tasks/{task_id}/pause", response_model=EngineTaskOut)
async def pause_task(
task_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> EngineTaskOut:
"""Stop a task before its next turn. Its stage is kept, so resume picks it up."""
return await _control(
action="pause", task_id=task_id, request=request, context=context, session=session
)
@router.post("/tasks/{task_id}/resume", response_model=EngineTaskOut)
async def resume_task(
task_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> EngineTaskOut:
"""Send a paused task back to triage. Refused while the organization has no credit."""
return await _control(
action="resume", task_id=task_id, request=request, context=context, session=session
)
@router.post("/tasks/{task_id}/cancel", response_model=EngineTaskOut)
async def cancel_task(
task_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> EngineTaskOut:
"""End a task. Its worktree is released and tasks waiting on it are parked."""
return await _control(
action="cancel", task_id=task_id, request=request, context=context, session=session
)
@router.post("/tasks/{task_id}/answer", response_model=EngineTaskOut)
async def answer_task(
task_id: str,
body: EngineAnswerRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> EngineTaskOut:
"""Answer the question a task waits on. Only a task at a decision accepts one."""
return await _control(
action="answer",
task_id=task_id,
request=request,
context=context,
session=session,
answer=body.content,
)
# ---------------------------------------------------------------------------
# Team: the view, hire by role and tier, fire
# ---------------------------------------------------------------------------
@router.get("/team", response_model=TeamOut)
async def get_team(
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> TeamOut:
"""The team, without the archived members, with each member's neutral tier."""
organization_id = context.org_id
granted, tiers = await _granted_tiers(session, organization_id)
policy = await build_model_grants(session).get_org_policy(organization_id)
employees = [
employee
for employee in await EmployeeRepository(session).list_for_org(organization_id, limit=500)
if employee.status is not EmployeeStatus.ARCHIVED
]
members = [await _member_out(session, organization_id, e, tiers) for e in employees]
return TeamOut(
employees=members,
tiers=_tier_list(granted),
max_employees=policy.max_employees,
count=len(members),
)
@router.post("/team/hire", response_model=TeamMemberOut, status_code=201)
async def hire_member(
body: HireRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> TeamMemberOut:
"""Hire one employee for a role on a granted model of the requested tier."""
context.require(CAN_MANAGE_EMPLOYEES)
# Imported inside the handler, as for the task actions above.
from app.application.container import build_scheduler
employee = await build_scheduler(session).manager.hire(
organization_id=context.org_id,
role=body.role,
tier=body.tier,
name=body.name,
project_id=None,
)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.EMPLOYEE_CREATE,
organization_id=context.org_id,
actor_user_id=context.user.id,
target_type="employee",
target_id=employee.id,
metadata={"role_key": str(body.role), "tier": body.tier},
ip=ip,
user_agent=ua,
)
_, tiers = await _granted_tiers(session, context.org_id)
return await _member_out(session, context.org_id, employee, tiers)
@router.post("/team/{employee_id}/fire", response_model=TeamMemberOut)
async def fire_member(
employee_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> TeamMemberOut:
"""Archive one employee. The last active manager cannot be fired."""
context.require(CAN_MANAGE_EMPLOYEES)
from app.application.container import build_scheduler
employee = await build_scheduler(session).manager.fire(
organization_id=context.org_id, employee_id=employee_id
)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.EMPLOYEE_UPDATE,
organization_id=context.org_id,
actor_user_id=context.user.id,
target_type="employee",
target_id=employee.id,
metadata={"action": "fire"},
ip=ip,
user_agent=ua,
)
_, tiers = await _granted_tiers(session, context.org_id)
return await _member_out(session, context.org_id, employee, tiers)
@@ -145,6 +145,11 @@ async def create_organization(
)
# Every organization gets a wallet up front so billing views are never empty.
session.add(CreditWalletRow(organization_id=org.id, balance=0.0, currency="USD"))
# Prepaid: the optional starting credit (0 by default) goes through the ledger.
await session.flush()
from app.application.services.credit_service import CreditService
await CreditService(session).grant_starting_credit(org.id)
ip, ua = client_meta(request)
await record_audit(
@@ -23,6 +23,7 @@ from app.interfaces.http.errors import register_exception_handlers
from app.interfaces.http.request_context import RequestContextMiddleware
from app.interfaces.http.routers import (
admin,
admin_credits,
admin_domains,
admin_grants,
admin_users,
@@ -31,6 +32,7 @@ from app.interfaces.http.routers import (
auth,
billing,
delivery,
engine,
health,
invites,
knowledge,
@@ -82,6 +84,8 @@ def build_rpc_app() -> FastAPI:
application.include_router(memory.router, prefix="/api")
application.include_router(mcp_tokens.router, prefix="/api")
application.include_router(admin_grants.router, prefix="/api")
application.include_router(admin_credits.router, prefix="/api")
application.include_router(engine.router, prefix="/api")
application.include_router(delivery.router, prefix="/api")
application.include_router(admin_domains.router, prefix="/api")
application.include_router(sandbox.admin_router, prefix="/api")