Ship enterprise Accounting FE/API with CRUD parity and production wiring.

Adds accounting-service PATCH/archive, fiscal helpers, COA templates and setup status, plus SuperApp Accounting UI (DS, scoreboard, masters, vouchers, ledger, ops modules) with session refresh and HTTPS public API URLs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Mortezakoohjani 2026-07-24 15:26:43 +03:30
parent 800b0ba2c5
commit 12c8615615
297 changed files with 26851 additions and 1612 deletions

View File

@ -20,6 +20,13 @@ PLATFORM_SECONDARY_COLOR=#0f172a
# دامنه پایه برای تخصیص خودکار زیردامنه tenant (مثلاً <slug>.torbatyar.ir)
PLATFORM_BASE_DOMAIN=torbatyar.ir
# ---- Auto SSL for tenant domains (Celery → SSH → nginx certbot) ----
SSL_PROVISION_ENABLED=false
SSL_PROVISION_HOST=192.168.10.156
SSL_PROVISION_USER=torbatyaruser
SSL_PROVISION_PASSWORD=
SSL_PROVISION_SCRIPT=/opt/torbatyar/bin/provision_ssl.py
# ---- Core Database (core_platform_db) ----
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
@ -71,6 +78,12 @@ FRONTEND_CALLBACK_URL=https://torbatyar.ir/auth/callback
CORS_ORIGINS=https://torbatyar.ir,https://www.torbatyar.ir,http://torbatyar.ir,http://www.torbatyar.ir,http://localhost:3000
CORE_SERVICE_URL=http://core-service:8000
# ---- Accounting Service ----
ACCOUNTING_SERVICE_URL=http://accounting-service:8002
ACCOUNTING_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/accounting_db
ACCOUNTING_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/accounting_db
ACCOUNTING_SERVICE_NAME=accounting-service
# ---- OTP / Payamak SMS (Core Service) ----
# ApiKey را در پارامتر password ارسال کنید (طبق مستندات ملی‌پیامک)
PAYAMAK_USERNAME=9155105404
@ -90,12 +103,14 @@ PLATFORM_ADMIN_MOBILES=09155105404
# ---- Frontend (Next.js) ----
FRONTEND_PORT=3000
NEXT_PUBLIC_PLATFORM_BASE_DOMAIN=torbatyar.ir
NEXT_PUBLIC_BACKEND_URL=https://api.torbatyar.ir
NEXT_PUBLIC_API_BASE_URL=https://api.torbatyar.ir
NEXT_PUBLIC_IDENTITY_API_URL=https://identity.torbatyar.ir
NEXT_PUBLIC_KEYCLOAK_URL=https://auth.torbatyar.ir
NEXT_PUBLIC_KEYCLOAK_REALM=superapp
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=superapp-frontend
NEXT_PUBLIC_ACCOUNTING_API_URL=http://localhost:8002
INTERNAL_TOKEN_SECRET=change-me-internal-secret

1
.gitignore vendored
View File

@ -37,6 +37,7 @@ Thumbs.db
# Deploy temp artifacts
scripts/_deploy_bundle.tar.gz
scripts/_deploy_*.tar.gz
scripts/_tmp_*
scripts/_check_*
scripts/_fix_*

107
README.md
View File

@ -1,101 +1,72 @@
# SuperApp SaaS Platform
# TorbatYar SuperApp SaaS Platform
پلتفرم SaaS چندمستأجری (Multi-tenant)، ماژولار، API-first و microservice-ready.
این مخزن شامل **فاز ۱ (Core Platform)** و **فاز ۲ (Identity & Access + SSO)** است.
> برند، رنگ‌ها و تنظیمات هیچ‌کدام در کد hardcode نشده‌اند و همگی از `.env` / `config` / دیتابیس خوانده می‌شوند.
## معماری در یک نگاه
## Documentation (source of truth)
- **جداسازی اجباری Frontend/Backend:** دو اپلیکیشن کاملاً مستقل (`backend/` و `frontend/`).
- **الگوی دیتابیس:** Database-per-service. هر سرویس فقط دیتابیس خودش را می‌شناسد.
- **ارتباط بین سرویس‌ها:** فقط از طریق REST API، Webhook، Async Event و الگوی Outbox/Inbox.
- **چندمستأجری:** همه جداول بیزینسی ستون `tenant_id` دارند.
شروع از [`docs/README.md`](./docs/README.md).
جزئیات کامل در پوشه [`docs/`](./docs) موجود است:
| فایل | توضیح |
| Document | Responsibility |
| --- | --- |
| [`docs/architecture.md`](./docs/architecture.md) | معماری کلان و جداسازی Frontend/Backend |
| [`docs/database_schema.md`](./docs/database_schema.md) | مدل دیتابیس Core |
| [`docs/services_contracts.md`](./docs/services_contracts.md) | قراردادهای ارتباطی سرویس‌ها |
| [`docs/developer_guide.md`](./docs/developer_guide.md) | راهنمای توسعه‌دهنده |
| [`docs/progress.md`](./docs/progress.md) | چک‌لیست پیشرفت فازها |
| [`docs/last_step.md`](./docs/last_step.md) | آخرین وضعیت |
| [`docs/architecture/architecture.md`](./docs/architecture/architecture.md) | Architecture overview |
| [`docs/reference/database-schema.md`](./docs/reference/database-schema.md) | Database schema reference |
| [`docs/reference/services-contracts.md`](./docs/reference/services-contracts.md) | Service contracts |
| [`docs/development/developer-guide.md`](./docs/development/developer-guide.md) | Developer guide |
| [`docs/module-registry.md`](./docs/module-registry.md) | Module registry |
| [`docs/provider-registry.md`](./docs/provider-registry.md) | Provider registry |
| [`docs/glossary.md`](./docs/glossary.md) | Glossary |
| [`docs/progress.md`](./docs/progress.md) | Completed work |
| [`docs/next-steps.md`](./docs/next-steps.md) | Immediate next milestone |
| [`docs/roadmap.md`](./docs/roadmap.md) | Future roadmap |
| [`docs/deployment/`](./docs/deployment/) | Deployment runbooks |
## ساختار پروژه
## Architecture at a glance
- **Mandatory Frontend/Backend separation**`backend/` and `frontend/` are independent apps.
- **Database-per-service** — no cross-DB queries ([ADR-001](./docs/architecture/adr/ADR-001.md)).
- **Inter-service communication** — REST, Webhook, Async Event, Outbox/Inbox only.
- **Multi-tenancy** — business tables carry `tenant_id`.
## Project structure
```
superapp-platform/
TorbatYar/
├── backend/
│ ├── core-service/ # FastAPI Core Platform
│ ├── shared-lib/ # کتابخانه مشترک backend
│ └── services/ # placeholder سرویس‌های آینده
├── frontend/ # Next.js (کاملاً جدا)
│ ├── app/
│ ├── components/
│ ├── lib/
│ ├── hooks/
│ ├── styles/
│ └── public/
├── docs/
│ ├── shared-lib/ # Shared backend library
│ └── services/ # Identity (active) + future modules
├── frontend/ # Next.js
├── docs/ # Documentation architecture (canonical)
├── infrastructure/ # Nginx, Keycloak, deploy env samples
├── scripts/ # Ops / verification scripts
├── docker-compose.yml
├── .env.example
└── README.md
```
## راه‌اندازی سریع (Docker)
## Quick start (Docker)
```bash
cp .env.example .env
docker compose up -d --build
```
سایت: http://localhost:3000
- **Frontend:** http://localhost:3000
- **Core API:** http://localhost:8000/docs
- **Identity API:** http://localhost:8001/docs
- **Keycloak:** http://localhost:8080
سرویس‌ها پس از بالا آمدن (یک دستور: `docker compose up -d --build`):
Details: [`docs/development/developer-guide.md`](./docs/development/developer-guide.md).
- **Frontend:** http://localhost:3000 (login: http://localhost:3000/login)
- Core API: http://localhost:8000 (مستندات: http://localhost:8000/docs)
- Identity API: http://localhost:8001 (SSO: http://localhost:8001/docs)
- Keycloak: http://localhost:8080 (admin/admin — کاربر نمونه: platform.admin/admin123)
- PostgreSQL: `localhost:5432`
- Redis: `localhost:6379`
> Frontend داخل Docker با hot-reload اجرا می‌شود؛ دیگر نیازی به `npm run dev` جداگانه نیست.
## راه‌اندازی محلی
### Backend
```bash
cd backend/core-service
python -m venv .venv
# Windows: .venv\Scripts\Activate.ps1
pip install -r requirements.txt
alembic upgrade head
uvicorn app.main:app --reload
```
### Frontend
```bash
# ترجیحاً از Docker (همراه بقیه سرویس‌ها):
docker compose up -d --build
# یا اجرای مستقیم برای دیباگ UI:
cd frontend
npm install
npm run dev
```
Frontend: http://localhost:3000
## اجرای تست‌ها (Backend)
## Tests (Backend)
```bash
cd backend/core-service
pytest -q
```
## فازهای بعدی
## Current status
سرویس‌های آینده در [`backend/services/`](./backend/services) به‌صورت placeholder آماده‌اند.
UI کامل در [`frontend/`](./frontend) توسعه می‌یابد.
See [`docs/progress.md`](./docs/progress.md). Next milestone: [`docs/next-steps.md`](./docs/next-steps.md).

View File

@ -2,20 +2,26 @@
تمام کد منبع backend فقط در این پوشه قرار دارد.
## ساختار
## Structure
```
backend/
├── core-service/ # سرویس Core Platform (FastAPI)
├── shared-lib/ # کتابخانه مشترک بین سرویس‌های backend
└── services/ # placeholder سرویس‌های آینده
├── core-service/ # Core Platform (FastAPI) — Active
├── shared-lib/ # Shared backend library
└── services/ # identity-access (Active) + future module placeholders
```
## قوانین (اجباری)
- **هیچ** کد React، Next.js، Tailwind، UI Component یا HTML در backend نیست.
- frontend در پوشه جداگانه `frontend/` قرار دارد.
- ارتباط با frontend فقط از طریق REST API.
## Rules (mandatory)
- **No** React, Next.js, Tailwind, UI components, or HTML pages in backend.
- Frontend lives only in `frontend/`.
- Communicate with frontend only via versioned REST APIs.
- Database-per-service; no cross-DB access.
- Follow [`docs/development/project-principles.md`](../docs/development/project-principles.md)
and [`docs/architecture/module-boundaries.md`](../docs/architecture/module-boundaries.md).
## Run Core
## راه‌اندازی
```bash
cd backend/core-service
python -m venv .venv
@ -24,8 +30,16 @@ alembic upgrade head
uvicorn app.main:app --reload
```
## تست‌ها
## Tests
```bash
cd backend/core-service
pytest -q
```
## Related Documents
- [Developer Guide](../docs/development/developer-guide.md)
- [Module Registry](../docs/module-registry.md)
- [Services Contracts](../docs/reference/services-contracts.md)
- [Services README](services/README.md)

View File

@ -16,3 +16,6 @@ alembic downgrade -1
```
> آدرس دیتابیس از `settings.database_url_sync` (متغیر محیطی `DATABASE_URL_SYNC`) خوانده می‌شود.
Related: [Database Architecture](../../../docs/architecture/database-architecture.md), [Coding Standards — migrations](../../../docs/development/coding-standards.md).

View File

@ -9,6 +9,7 @@ from app.api.v1 import (
me,
onboarding,
plans,
public_tenant,
service_registry,
subscriptions,
tenant_context,
@ -20,6 +21,7 @@ api_router = APIRouter()
# health خارج از prefix نسخه هم در main اضافه می‌شود؛ اینجا برای کامل بودن.
api_router.include_router(auth.router)
api_router.include_router(public_tenant.router)
api_router.include_router(admin_tenants.router)
api_router.include_router(tenants.router)
api_router.include_router(domains.router)

View File

@ -0,0 +1,76 @@
"""اسکیما و endpoint عمومی سایت tenant (بدون نیاز به لاگین)."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db
from app.models.enums import TenantStatus
from app.repositories.tenant import DomainRepository, TenantRepository
from shared.exceptions import NotFoundError
router = APIRouter(prefix="/public", tags=["public"])
class PublicTenantSiteRead(BaseModel):
"""اطلاعات عمومی قابل نمایش روی ساب‌دامین tenant."""
tenant_id: UUID
name: str
slug: str
status: TenantStatus
business_type: str | None = None
primary_color: str | None = None
secondary_color: str | None = None
logo_url: str | None = None
favicon_url: str | None = None
primary_domain: str | None = None
tagline: str = Field(default="روی پلتفرم تربت‌یار")
@router.get("/tenant-site", response_model=PublicTenantSiteRead)
async def get_public_tenant_site(
request: Request,
db: AsyncSession = Depends(get_db),
) -> PublicTenantSiteRead:
"""Resolve سایت tenant از روی Host (middleware) یا ?slug=."""
tenant_id = getattr(request.state, "tenant_id", None)
tenant_slug = getattr(request.state, "tenant_slug", None)
query_slug = (request.query_params.get("slug") or "").strip().lower() or None
tenant_repo = TenantRepository(db)
domain_repo = DomainRepository(db)
tenant = None
if tenant_id is not None:
tenant = await tenant_repo.get(tenant_id)
elif tenant_slug:
tenant = await tenant_repo.get_by_slug(tenant_slug)
elif query_slug:
tenant = await tenant_repo.get_by_slug(query_slug)
if tenant is None:
raise NotFoundError("سایت این دامنه یافت نشد یا هنوز فعال نشده است")
if tenant.status in {TenantStatus.SUSPENDED, TenantStatus.ARCHIVED}:
raise NotFoundError("این workspace در حال حاضر در دسترس نیست")
domains = await domain_repo.list_by_tenant(tenant.id)
primary = next((d for d in domains if d.is_primary), None)
if primary is None and domains:
primary = domains[0]
return PublicTenantSiteRead(
tenant_id=tenant.id,
name=tenant.name,
slug=tenant.slug,
status=tenant.status,
business_type=tenant.business_type,
primary_color=tenant.primary_color,
secondary_color=tenant.secondary_color,
logo_url=tenant.logo_url,
favicon_url=tenant.favicon_url,
primary_domain=primary.domain if primary else None,
)

View File

@ -35,6 +35,16 @@ class Settings(BaseSettings):
# دامنه پایه برای تخصیص زیردامنه خودکار در onboarding (مثلاً tenant.slug + "." + این مقدار)
platform_base_domain: str = Field(default="", validation_alias="PLATFORM_BASE_DOMAIN")
# ---- Auto SSL (Let's Encrypt via nginx host) ----
ssl_provision_enabled: bool = Field(default=False, validation_alias="SSL_PROVISION_ENABLED")
ssl_provision_host: str = Field(default="", validation_alias="SSL_PROVISION_HOST")
ssl_provision_user: str = Field(default="", validation_alias="SSL_PROVISION_USER")
ssl_provision_password: str = Field(default="", validation_alias="SSL_PROVISION_PASSWORD")
ssl_provision_script: str = Field(
default="/opt/torbatyar/bin/provision_ssl.py",
validation_alias="SSL_PROVISION_SCRIPT",
)
# ---- Database ----
# آدرس async برای برنامه و sync برای Alembic.
database_url: str = "postgresql+asyncpg://superapp:superapp_password@localhost:5432/core_platform_db"

View File

@ -10,6 +10,7 @@ from app.models.domain import Domain
from app.repositories.tenant import DomainRepository, TenantRepository
from app.schemas.domain import DomainCreate
from app.services.event_service import EventService
from app.services.ssl_provision import enqueue_domain_ssl
from shared.events import CoreEventType
from shared.exceptions import ConflictError, NotFoundError
@ -45,6 +46,7 @@ class DomainService:
)
await self.session.commit()
await self.session.refresh(domain)
enqueue_domain_ssl(domain.domain)
return domain
async def list_by_tenant(self, tenant_id: UUID) -> Sequence[Domain]:

View File

@ -28,6 +28,7 @@ from app.schemas.tenant import TenantCreate
from app.services.event_service import EventService
from app.services.membership_service import MembershipService
from app.services.plan_service import PlanService
from app.services.ssl_provision import enqueue_domain_ssl
from app.services.subscription_service import SubscriptionService
from app.services.tenant_service import TenantService
from shared.events import CoreEventType
@ -102,6 +103,7 @@ class OnboardingService:
payload={"domain": host, "type": "subdomain", "auto_assigned": True},
)
await self.session.commit()
enqueue_domain_ssl(host)
async def update_branding(
self, tenant_id: UUID, data: OnboardingBrandingUpdate
@ -143,6 +145,8 @@ class OnboardingService:
payload={"domain": data.custom_domain, "type": "custom"},
)
await self.session.commit()
# فقط اگر DNS از قبل به nginx اشاره کند certbot موفق می‌شود
enqueue_domain_ssl(data.custom_domain)
await self.session.refresh(tenant)
return tenant

View File

@ -0,0 +1,30 @@
"""صف‌بندی صدور خودکار SSL برای دامنهٔ تازه‌ساخته‌شده tenant."""
from __future__ import annotations
from app.core.config import settings
from app.core.logging import get_logger
logger = get_logger(__name__)
def enqueue_domain_ssl(domain: str | None) -> None:
"""پس از ساخت دامنه، صدور/گسترش گواهی را به Celery می‌سپارد.
اگر SSL_PROVISION_ENABLED=false باشد هیچ کاری نمیکند (مثلاً محیط local).
"""
host = (domain or "").strip().lower()
if not host:
return
if not settings.ssl_provision_enabled:
logger.info("ssl_provision_skipped_disabled", extra={"domain": host})
return
try:
from app.workers.tasks import provision_domain_ssl
provision_domain_ssl.delay(host)
logger.info("ssl_provision_enqueued", extra={"domain": host})
except Exception as exc: # pragma: no cover - نباید onboarding را بشکند
logger.warning(
"ssl_provision_enqueue_failed",
extra={"domain": host, "error": str(exc)},
)

View File

@ -3,6 +3,10 @@
توکن ممکن است از دو منبع باشد:
۱. JWT محلی OTP (HS256) sub برابر با users.id است.
۲. JWT مرکزی Keycloak (RS256) sub برابر با users.keycloak_sub است.
اگر کاربر SSO هنوز به رکورد Core لینک نشده باشد، با موبایل/نامکاربری
(JIT) لینک یا ساخته میشود نه اینکه پیام «تأیید موبایل» بدهد وقتی موبایل
قبلاً تأیید شده است.
"""
from __future__ import annotations
@ -10,8 +14,11 @@ from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.enums import UserRole, UserStatus
from app.models.user import User
from app.repositories.user import UserRepository
from app.utils.phone import normalize_mobile
from shared.exceptions import ForbiddenError
from shared.security import CurrentUser
@ -22,12 +29,7 @@ class UserService:
self.repo = UserRepository(session)
async def resolve_current(self, current_user: CurrentUser) -> User:
"""کاربر Core متناظر با هویت جاری را پیدا می‌کند.
اگر کاربر SSO هنوز به یک رکورد Core لینک نشده باشد (مثلاً موبایل
تأیید نشده)، خطای قابل مدیریت در frontend برمیگرداند تا کاربر به
جریان تکمیل موبایل هدایت شود.
"""
"""کاربر Core متناظر با هویت جاری را پیدا می‌کند (با JIT link در صورت نیاز)."""
candidate_id: UUID | None = None
try:
candidate_id = UUID(str(current_user.user_id))
@ -39,11 +41,76 @@ class UserService:
if user is not None:
return user
user = await self.repo.get_by_keycloak_sub(str(current_user.user_id))
sub = str(current_user.user_id)
user = await self.repo.get_by_keycloak_sub(sub)
if user is not None:
return user
# JIT: لینک/ساخت از روی موبایل یا username (معمولاً همان موبایل)
linked = await self._jit_link_or_create(current_user)
if linked is not None:
return linked
raise ForbiddenError(
"پروفایل کاربری هسته یافت نشد. ابتدا شماره موبایل خود را تأیید کنید.",
error_code="core_profile_not_linked",
)
async def _jit_link_or_create(self, current_user: CurrentUser) -> User | None:
mobile = self._extract_mobile(current_user)
if not mobile:
return None
sub = str(current_user.user_id)
existing = await self.repo.get_by_mobile(mobile)
if existing is not None:
if existing.keycloak_sub and existing.keycloak_sub != sub:
raise ForbiddenError(
"این شماره موبایل به حساب دیگری متصل است.",
error_code="mobile_linked_elsewhere",
)
existing.keycloak_sub = sub
existing.mobile_verified = True
if current_user.email and not existing.email:
existing.email = current_user.email
await self.session.commit()
await self.session.refresh(existing)
return existing
role = (
UserRole.PLATFORM_ADMIN
if mobile in settings.platform_admin_mobile_set
else UserRole.USER
)
user = User(
mobile=mobile,
role=role,
status=UserStatus.ACTIVE,
mobile_verified=True,
keycloak_sub=sub,
email=current_user.email,
)
await self.repo.add(user)
await self.session.commit()
await self.session.refresh(user)
return user
@staticmethod
def _extract_mobile(current_user: CurrentUser) -> str | None:
candidates: list[str] = []
if current_user.username:
candidates.append(current_user.username)
if current_user.email and "@" in current_user.email:
local = current_user.email.split("@", 1)[0]
# ایمیل مصنوعی ثبت‌نام موبایل: 9155105404@mobile.torbatyar.local
if local.isdigit() and len(local) >= 10:
candidates.append("0" + local if not local.startswith("0") else local)
else:
candidates.append(local)
for raw in candidates:
try:
return normalize_mobile(raw)
except ValueError:
continue
return None

View File

@ -70,3 +70,67 @@ def process_outbox_events() -> dict:
result = asyncio.run(_run_process_outbox())
logger.info("outbox_processed", extra=result)
return result
def _run_remote_ssl_provision(domain: str) -> str:
"""اجرای اسکریپت provision_ssl روی سرور nginx از طریق SSH."""
import base64
import shlex
import paramiko
host = settings.ssl_provision_host
user = settings.ssl_provision_user
password = settings.ssl_provision_password
script = settings.ssl_provision_script
if not host or not user or not password:
raise RuntimeError("SSL provision SSH credentials are not configured")
inner = f"python3 {shlex.quote(script)} {shlex.quote(domain)}"
b64 = base64.b64encode(inner.encode("utf-8")).decode("ascii")
remote = (
f"echo {shlex.quote(password)} | sudo -S -p '' "
f"bash -c {shlex.quote(f'echo {b64} | base64 -d | bash')}"
)
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
client.connect(
host,
username=user,
password=password,
timeout=30,
allow_agent=False,
look_for_keys=False,
)
_, stdout, stderr = client.exec_command(remote, timeout=300, get_pty=True)
out = (stdout.read() + stderr.read()).decode("utf-8", "replace")
exit_status = stdout.channel.recv_exit_status()
if exit_status != 0:
raise RuntimeError(f"ssl provision failed ({exit_status}): {out[-2000:]}")
return out
finally:
client.close()
@celery_app.task(
name="app.workers.tasks.provision_domain_ssl",
bind=True,
max_retries=5,
default_retry_delay=60,
)
def provision_domain_ssl(self, domain: str) -> dict:
"""صدور خودکار SSL برای دامنهٔ tenant تازه‌ساخته‌شده."""
if not settings.ssl_provision_enabled:
return {"skipped": True, "domain": domain, "reason": "disabled"}
try:
output = _run_remote_ssl_provision(domain)
logger.info("ssl_provision_ok", extra={"domain": domain})
return {"ok": True, "domain": domain, "output": output[-1500:]}
except Exception as exc:
logger.warning(
"ssl_provision_retry",
extra={"domain": domain, "error": str(exc), "retries": self.request.retries},
)
raise self.retry(exc=exc)

View File

@ -20,6 +20,9 @@ celery==5.4.0
httpx==0.27.0
requests==2.32.3
# ---- SSH برای صدور خودکار SSL روی nginx ----
paramiko==3.4.1
# ---- Auth / JWT ----
pyjwt[crypto]==2.8.0

View File

@ -1,13 +1,18 @@
# services/ — سرویس‌های آینده (Placeholder)
# services/ — Backend Modules
این پوشه شامل placeholder سرویس‌های آینده پلتفرم است. در **فاز ۱** هیچ‌کدام
پیاده‌سازی نشده‌اند و فقط README مسئولیت‌ها موجود است.
This folder holds deployable backend services beyond Core.
هر سرویس در فازهای بعدی طبق اصول معماری زیر ساخته می‌شود:
| Service | Status | Docs |
| --- | --- | --- |
| `identity-access/` | **Active** | [README](identity-access/README.md), [module registry](../../docs/module-registry.md#identity-access) |
| `accounting/` … others | **Scaffolded** placeholders | Per-service README + [module registry](../../docs/module-registry.md) |
- **Database-per-service:** هر سرویس دیتابیس مستقل خود را دارد.
- همه جداول بیزینسی ستون `tenant_id` دارند.
- ارتباط بین سرویس‌ها فقط از طریق REST API، Webhook، Async Event و الگوی Outbox/Inbox.
- ارتباط مستقیم بین دیتابیس سرویس‌ها **ممنوع** است.
## Architecture rules
جزئیات قراردادها در [`../../docs/services_contracts.md`](../../docs/services_contracts.md).
- **Database-per-service** with `tenant_id` on business tables.
- Inter-service communication only via REST, Webhook, Async Event, Outbox/Inbox.
- **No** direct cross-database queries.
- Register modules/providers in docs when implementation starts.
Contracts: [`docs/reference/services-contracts.md`](../../docs/reference/services-contracts.md)
Boundaries: [`docs/architecture/module-boundaries.md`](../../docs/architecture/module-boundaries.md)

View File

@ -0,0 +1,22 @@
# Dev image: dependencies only — code mounted with uvicorn --reload
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PYTHONPATH=/app
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY backend/shared-lib/ /shared-lib/
COPY backend/services/accounting/requirements.txt /app/requirements.txt
RUN sed -i 's#-e ../../shared-lib#-e /shared-lib#' /app/requirements.txt \
&& pip install --upgrade pip \
&& pip install -r requirements.txt
EXPOSE 8002

View File

@ -1,19 +1,52 @@
# Accounting Service (Placeholder)
# Accounting Service
> وضعیت: طراحی‌شده در معماری، پیاده‌سازی در فازهای بعدی.
> Status: **Active** — Phases 5.1 through 5.11 implemented (v0.5.11.0).
## مسئولیت‌ها
- حسابداری ۴ سطحی
- اسناد دوبل (Double-entry)
- مرکز هزینه
- پروژه
- فاکتور
- پرداخت
- مالیات
- سامانه مؤدیان
- گزارش‌های مالی
## Responsibilities
## اصول
- دیتابیس مستقل (`accounting_db`) با `tenant_id` در جداول بیزینسی.
- ارتباط با سایر سرویس‌ها فقط از طریق API/Event.
- بررسی دسترسی قابلیت‌ها از Core (`accounting.*`).
- حسابداری ۴ سطحی، موتور ثبت مرکزی (ADR-010)
- دفتر کل، خزانه، AR/AP، یکپارچه‌سازی فروش/خرید/موجودی
- دارایی ثابت، حقوق و دستمزد
- گزارش‌گیری مالی، انطباق و حاکمیت
## API Groups
| Prefix | Phase |
| --- | --- |
| `/api/v1/accounts` | 5.1 |
| `/api/v1/fiscal` | 5.1, 5.3 |
| `/api/v1/posting` | 5.2 |
| `/api/v1/ledger` | 5.3 |
| `/api/v1/treasury` | 5.4 |
| `/api/v1/ar-ap` | 5.5 |
| `/api/v1/sales-accounting` | 5.6 |
| `/api/v1/purchase-inventory` | 5.7 |
| `/api/v1/assets` | 5.8 |
| `/api/v1/payroll` | 5.9 |
| `/api/v1/reporting` | 5.10 |
| `/api/v1/compliance` | 5.11 |
## Key Engines
| Engine | Phase | Rule |
| --- | --- | --- |
| PostingEngine | 5.2 | Sole creator of JournalEntry |
| BalanceEngine | 5.3 | Ledger balances |
| InventoryValuationEngine | 5.7 | Valuation separated from posting |
| DepreciationEngine | 5.8 | Asset depreciation |
| PayrollEngine | 5.9 | Salary calculation |
| FinancialReportEngine | 5.10 | Reports from posted data only |
| AuditFramework | 5.11 | Immutable audit trail |
## Run
```bash
cd backend/services/accounting
pytest -q # 21 tests
uvicorn app.main:app --port 8002 --reload
```
## Related Documents
- [Phases 5.15.11](../../../docs/phases/Accounting/README.md)
- [ADR-010](../../../docs/architecture/adr/ADR-010.md)

View File

@ -0,0 +1,40 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = driver://user:pass@localhost/dbname
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@ -0,0 +1,42 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import settings
from app.core.database import Base
import app.models # noqa
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url_sync)
if config.config_file_name:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline():
context.configure(
url=settings.database_url_sync,
target_metadata=target_metadata,
literal_binds=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,18 @@
"""Initial accounting schema — Phases 5.1 through 5.6."""
from alembic import op
from app.core.database import Base
revision = "0001_initial"
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
Base.metadata.drop_all(bind=bind)

View File

@ -0,0 +1,18 @@
"""Phases 5.75.11 schema additions."""
from alembic import op
from app.core.database import Base
import app.models # noqa: ensure all models loaded
revision = "0002_phases_57_511"
down_revision = "0001_initial"
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
pass

View File

@ -0,0 +1 @@
__version__ = "0.5.11.0"

View File

@ -0,0 +1,37 @@
"""Common API dependencies."""
from __future__ import annotations
from uuid import UUID
from fastapi import Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user
from shared.exceptions import TenantNotResolvedError
from shared.pagination import PaginationParams
from shared.security import CurrentUser
__all__ = [
"get_db",
"get_pagination",
"require_tenant",
"get_current_user",
"AsyncSession",
]
def get_pagination(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
) -> PaginationParams:
return PaginationParams(page=page, page_size=page_size)
def require_tenant(request: Request) -> UUID:
tenant_id = getattr(request.state, "tenant_id", None)
if tenant_id is None:
raise TenantNotResolvedError(
"tenant قابل تشخیص نبود. هدر X-Tenant-ID را ارسال کنید."
)
return tenant_id

View File

@ -0,0 +1,33 @@
from fastapi import APIRouter
from app.api.v1 import (
accounts,
compliance,
fiscal,
fixed_assets,
health,
ledger,
payroll,
posting,
purchase_inventory,
receivable_payable,
reporting,
sales_accounting,
setup,
treasury,
)
api_router = APIRouter()
api_router.include_router(accounts.router, prefix="/accounts", tags=["accounts"])
api_router.include_router(fiscal.router, prefix="/fiscal", tags=["fiscal"])
api_router.include_router(posting.router, prefix="/posting", tags=["posting"])
api_router.include_router(ledger.router, prefix="/ledger", tags=["ledger"])
api_router.include_router(treasury.router, prefix="/treasury", tags=["treasury"])
api_router.include_router(receivable_payable.router, prefix="/ar-ap", tags=["ar-ap"])
api_router.include_router(sales_accounting.router, prefix="/sales-accounting", tags=["sales-accounting"])
api_router.include_router(purchase_inventory.router, prefix="/purchase-inventory", tags=["purchase-inventory"])
api_router.include_router(fixed_assets.router, prefix="/assets", tags=["assets"])
api_router.include_router(payroll.router, prefix="/payroll", tags=["payroll"])
api_router.include_router(reporting.router, prefix="/reporting", tags=["reporting"])
api_router.include_router(compliance.router, prefix="/compliance", tags=["compliance"])
api_router.include_router(setup.router, prefix="/setup", tags=["setup"])

View File

@ -0,0 +1,482 @@
"""Foundation CRUD API — Phase 5.1 + PATCH/archive + COA templates."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.core.security import get_current_user
from app.models.foundation import Account, ChartOfAccounts, CostCenter, Currency, Project
from app.models.types import AccountStatus
from app.repositories.foundation import (
AccountRepository,
ChartOfAccountsRepository,
CostCenterRepository,
CurrencyRepository,
ProjectRepository,
)
from app.schemas.foundation import (
AccountCreate,
AccountRead,
AccountUpdate,
ChartOfAccountsCreate,
ChartOfAccountsRead,
ChartOfAccountsUpdate,
CoaTemplateImportRequest,
CoaTemplateRead,
CostCenterCreate,
CostCenterRead,
CostCenterUpdate,
CurrencyCreate,
CurrencyRead,
CurrencyUpdate,
ProjectCreate,
ProjectRead,
ProjectUpdate,
)
from app.services.coa_templates import COA_TEMPLATES, import_coa_template
from shared.exceptions import AppError, NotFoundError
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _apply_update(entity, data: dict) -> None:
for key, value in data.items():
setattr(entity, key, value)
# ── Charts ──────────────────────────────────────────────────────────────────
@router.post("/charts", response_model=ChartOfAccountsRead, status_code=status.HTTP_201_CREATED)
async def create_chart(
body: ChartOfAccountsCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = ChartOfAccountsRepository(db)
if body.is_default:
for existing in await repo.list_by_tenant(tenant_id, limit=500):
existing.is_default = False
entity = ChartOfAccounts(tenant_id=tenant_id, **body.model_dump())
await repo.add(entity)
await db.commit()
await db.refresh(entity)
return entity
@router.get("/charts", response_model=list[ChartOfAccountsRead])
async def list_charts(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = ChartOfAccountsRepository(db)
return await repo.list_by_tenant(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@router.get("/charts/{chart_id}", response_model=ChartOfAccountsRead)
async def get_chart(
chart_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
entity = await ChartOfAccountsRepository(db).get(tenant_id, chart_id)
if entity is None:
raise NotFoundError("دفتر حساب یافت نشد", error_code="chart_not_found")
return entity
@router.patch("/charts/{chart_id}", response_model=ChartOfAccountsRead)
async def update_chart(
chart_id: UUID,
body: ChartOfAccountsUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = ChartOfAccountsRepository(db)
entity = await repo.get(tenant_id, chart_id)
if entity is None:
raise NotFoundError("دفتر حساب یافت نشد", error_code="chart_not_found")
data = body.model_dump(exclude_unset=True)
if data.get("is_default") is True:
for existing in await repo.list_by_tenant(tenant_id, limit=500):
if existing.id != chart_id:
existing.is_default = False
_apply_update(entity, data)
await db.commit()
await db.refresh(entity)
return entity
@router.post("/charts/{chart_id}/archive", response_model=ChartOfAccountsRead)
async def archive_chart(
chart_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = ChartOfAccountsRepository(db)
entity = await repo.get(tenant_id, chart_id)
if entity is None:
raise NotFoundError("دفتر حساب یافت نشد", error_code="chart_not_found")
entity.is_active = False
entity.is_default = False
await db.commit()
await db.refresh(entity)
return entity
# ── Templates (static paths before /{account_id}) ────────────────────────────
@router.get("/templates", response_model=list[CoaTemplateRead])
async def list_coa_templates(_user: CurrentUser = Depends(get_current_user)):
return list(COA_TEMPLATES.values())
@router.get("/templates/{template_id}", response_model=CoaTemplateRead)
async def get_coa_template(template_id: str, _user: CurrentUser = Depends(get_current_user)):
tpl = COA_TEMPLATES.get(template_id)
if tpl is None:
raise NotFoundError("قالب یافت نشد", error_code="template_not_found")
return tpl
@router.post(
"/templates/{template_id}/import",
response_model=list[AccountRead],
status_code=status.HTTP_201_CREATED,
)
async def import_template(
template_id: str,
body: CoaTemplateImportRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
accounts = await import_coa_template(
db,
tenant_id=tenant_id,
template_id=template_id,
chart_id=body.chart_id,
chart_name=body.chart_name,
chart_code=body.chart_code,
)
await db.commit()
return accounts
# ── Currencies ──────────────────────────────────────────────────────────────
@router.post("/currencies", response_model=CurrencyRead, status_code=status.HTTP_201_CREATED)
async def create_currency(
body: CurrencyCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = CurrencyRepository(db)
if body.is_base:
for existing in await repo.list_by_tenant(tenant_id, limit=500):
existing.is_base = False
entity = Currency(tenant_id=tenant_id, **body.model_dump())
await repo.add(entity)
await db.commit()
await db.refresh(entity)
return entity
@router.get("/currencies", response_model=list[CurrencyRead])
async def list_currencies(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = CurrencyRepository(db)
return await repo.list_by_tenant(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@router.patch("/currencies/{currency_id}", response_model=CurrencyRead)
async def update_currency(
currency_id: UUID,
body: CurrencyUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = CurrencyRepository(db)
entity = await repo.get(tenant_id, currency_id)
if entity is None:
raise NotFoundError("ارز یافت نشد", error_code="currency_not_found")
data = body.model_dump(exclude_unset=True)
if data.get("is_base") is True:
for existing in await repo.list_by_tenant(tenant_id, limit=500):
if existing.id != currency_id:
existing.is_base = False
_apply_update(entity, data)
await db.commit()
await db.refresh(entity)
return entity
@router.post("/currencies/{currency_id}/set-base", response_model=CurrencyRead)
async def set_base_currency(
currency_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = CurrencyRepository(db)
entity = await repo.get(tenant_id, currency_id)
if entity is None:
raise NotFoundError("ارز یافت نشد", error_code="currency_not_found")
if not entity.is_active:
raise AppError(
"ارز غیرفعال نمی‌تواند پایه باشد",
status_code=422,
error_code="currency_inactive",
)
for existing in await repo.list_by_tenant(tenant_id, limit=500):
existing.is_base = existing.id == currency_id
await db.commit()
await db.refresh(entity)
return entity
@router.post("/currencies/{currency_id}/archive", response_model=CurrencyRead)
async def archive_currency(
currency_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = CurrencyRepository(db)
entity = await repo.get(tenant_id, currency_id)
if entity is None:
raise NotFoundError("ارز یافت نشد", error_code="currency_not_found")
if entity.is_base:
raise AppError(
"ارز پایه قابل آرشیو نیست",
status_code=422,
error_code="base_currency_archive",
)
entity.is_active = False
await db.commit()
await db.refresh(entity)
return entity
# ── Cost centers ────────────────────────────────────────────────────────────
@router.post("/cost-centers", response_model=CostCenterRead, status_code=status.HTTP_201_CREATED)
async def create_cost_center(
body: CostCenterCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = CostCenterRepository(db)
entity = CostCenter(tenant_id=tenant_id, **body.model_dump())
await repo.add(entity)
await db.commit()
await db.refresh(entity)
return entity
@router.get("/cost-centers", response_model=list[CostCenterRead])
async def list_cost_centers(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = CostCenterRepository(db)
return await repo.list_by_tenant(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@router.patch("/cost-centers/{cost_center_id}", response_model=CostCenterRead)
async def update_cost_center(
cost_center_id: UUID,
body: CostCenterUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = CostCenterRepository(db)
entity = await repo.get(tenant_id, cost_center_id)
if entity is None:
raise NotFoundError("مرکز هزینه یافت نشد", error_code="cost_center_not_found")
_apply_update(entity, body.model_dump(exclude_unset=True))
await db.commit()
await db.refresh(entity)
return entity
@router.post("/cost-centers/{cost_center_id}/archive", response_model=CostCenterRead)
async def archive_cost_center(
cost_center_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = CostCenterRepository(db)
entity = await repo.get(tenant_id, cost_center_id)
if entity is None:
raise NotFoundError("مرکز هزینه یافت نشد", error_code="cost_center_not_found")
entity.is_active = False
await db.commit()
await db.refresh(entity)
return entity
# ── Projects ────────────────────────────────────────────────────────────────
@router.post("/projects", response_model=ProjectRead, status_code=status.HTTP_201_CREATED)
async def create_project(
body: ProjectCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = ProjectRepository(db)
entity = Project(tenant_id=tenant_id, **body.model_dump())
await repo.add(entity)
await db.commit()
await db.refresh(entity)
return entity
@router.get("/projects", response_model=list[ProjectRead])
async def list_projects(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = ProjectRepository(db)
return await repo.list_by_tenant(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@router.patch("/projects/{project_id}", response_model=ProjectRead)
async def update_project(
project_id: UUID,
body: ProjectUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = ProjectRepository(db)
entity = await repo.get(tenant_id, project_id)
if entity is None:
raise NotFoundError("پروژه یافت نشد", error_code="project_not_found")
_apply_update(entity, body.model_dump(exclude_unset=True))
await db.commit()
await db.refresh(entity)
return entity
@router.post("/projects/{project_id}/archive", response_model=ProjectRead)
async def archive_project(
project_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = ProjectRepository(db)
entity = await repo.get(tenant_id, project_id)
if entity is None:
raise NotFoundError("پروژه یافت نشد", error_code="project_not_found")
entity.is_active = False
await db.commit()
await db.refresh(entity)
return entity
# ── Accounts (param routes last) ────────────────────────────────────────────
@router.post("", response_model=AccountRead, status_code=status.HTTP_201_CREATED)
async def create_account(
body: AccountCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = AccountRepository(db)
entity = Account(tenant_id=tenant_id, **body.model_dump())
await repo.add(entity)
await db.commit()
await db.refresh(entity)
return entity
@router.get("", response_model=list[AccountRead])
async def list_accounts(
chart_id: UUID | None = Query(default=None),
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = AccountRepository(db)
if chart_id is not None:
return await repo.list_by_chart(
tenant_id, chart_id, offset=pagination.offset, limit=pagination.page_size
)
return await repo.list_by_tenant(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@router.get("/{account_id}", response_model=AccountRead)
async def get_account(
account_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = AccountRepository(db)
entity = await repo.get(tenant_id, account_id)
if entity is None:
raise NotFoundError("حساب یافت نشد", error_code="account_not_found")
return entity
@router.patch("/{account_id}", response_model=AccountRead)
async def update_account(
account_id: UUID,
body: AccountUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = AccountRepository(db)
entity = await repo.get(tenant_id, account_id)
if entity is None:
raise NotFoundError("حساب یافت نشد", error_code="account_not_found")
_apply_update(entity, body.model_dump(exclude_unset=True))
await db.commit()
await db.refresh(entity)
return entity
@router.post("/{account_id}/archive", response_model=AccountRead)
async def archive_account(
account_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = AccountRepository(db)
entity = await repo.get(tenant_id, account_id)
if entity is None:
raise NotFoundError("حساب یافت نشد", error_code="account_not_found")
entity.status = AccountStatus.INACTIVE
await db.commit()
await db.refresh(entity)
return entity

View File

@ -0,0 +1,178 @@
"""Phase 5.11 — Compliance, Audit & Governance API."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.core.security import get_current_user
from app.models.compliance import CompliancePolicy, GovernanceRule
from app.models.types import PolicyViolationSeverity, RiskLevel
from app.repositories.base import TenantBaseRepository
from app.services.compliance_service import AuditFramework, ComplianceEngine, GovernanceService
from shared.security import CurrentUser
router = APIRouter()
class AuditRecordCreate(BaseModel):
action: str
resource_type: str
resource_id: str
source_module: str | None = None
before_value: str | None = None
after_value: str | None = None
reason: str | None = None
class CompliancePolicyCreate(BaseModel):
code: str
name: str
policy_type: str
rules_config: str
country_code: str | None = None
class ApprovalRequestCreate(BaseModel):
workflow_id: UUID
resource_type: str
resource_id: str
class RejectRequest(BaseModel):
reason: str
class RiskCreate(BaseModel):
code: str
title: str
risk_category: str
risk_level: RiskLevel
risk_score: int = 0
description: str | None = None
class CompliancePolicyRepo(TenantBaseRepository[CompliancePolicy]):
model = CompliancePolicy
@router.get("/audit-records")
async def list_audit_records(
tenant_id: UUID = Depends(require_tenant),
pagination=Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
from app.services.compliance_service import AuditRecordRepo
repo = AuditRecordRepo(db)
records = await repo.list_by_tenant(tenant_id, offset=pagination.offset, limit=pagination.page_size)
return [
{
"id": str(r.id),
"action": r.action,
"resource_type": r.resource_type,
"actor_user_id": r.actor_user_id,
"created_at": r.created_at.isoformat(),
}
for r in records
]
@router.post("/audit-records", status_code=status.HTTP_201_CREATED)
async def create_audit_record(
body: AuditRecordCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
audit = AuditFramework(db)
record = await audit.record(
tenant_id,
actor_user_id=user.user_id,
action=body.action,
resource_type=body.resource_type,
resource_id=body.resource_id,
source_module=body.source_module,
before_value=body.before_value,
after_value=body.after_value,
reason=body.reason,
)
await db.commit()
return {"id": str(record.id)}
@router.post("/policies", status_code=status.HTTP_201_CREATED)
async def create_compliance_policy(
body: CompliancePolicyCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = CompliancePolicyRepo(db)
entity = CompliancePolicy(tenant_id=tenant_id, **body.model_dump())
await repo.add(entity)
await db.commit()
return {"id": str(entity.id)}
@router.post("/approvals")
async def request_approval(
body: ApprovalRequestCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
gov = GovernanceService(db)
request = await gov.request_approval(
tenant_id,
workflow_id=body.workflow_id,
resource_type=body.resource_type,
resource_id=body.resource_id,
requested_by=user.user_id,
)
await db.commit()
return {"id": str(request.id), "status": request.status.value}
@router.post("/approvals/{request_id}/approve")
async def approve_request(
request_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
gov = GovernanceService(db)
request = await gov.approve(tenant_id, request_id, approver_user_id=user.user_id)
await db.commit()
return {"id": str(request.id), "status": request.status.value}
@router.post("/approvals/{request_id}/reject")
async def reject_request(
request_id: UUID,
body: RejectRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
gov = GovernanceService(db)
request = await gov.reject(tenant_id, request_id, approver_user_id=user.user_id, reason=body.reason)
await db.commit()
return {"id": str(request.id), "status": request.status.value}
@router.post("/risks", status_code=status.HTTP_201_CREATED)
async def create_risk(
body: RiskCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
gov = GovernanceService(db)
risk = await gov.create_risk(tenant_id, **body.model_dump())
await db.commit()
return {"id": str(risk.id), "risk_level": risk.risk_level.value}

View File

@ -0,0 +1,225 @@
"""Fiscal year/period API — Phase 5.1 + 5.3 + PATCH / set-current / generate."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, require_tenant
from app.core.security import get_current_user
from app.models.foundation import FiscalPeriod, FiscalYear
from app.repositories.foundation import FiscalPeriodRepository, FiscalYearRepository
from app.schemas.foundation import (
FiscalPeriodCreate,
FiscalPeriodRead,
FiscalPeriodUpdate,
FiscalYearCreate,
FiscalYearRead,
FiscalYearUpdate,
)
from app.services.fiscal_service import FiscalManagementService
from shared.exceptions import NotFoundError
from shared.security import CurrentUser
router = APIRouter()
def _apply_update(entity, data: dict) -> None:
for key, value in data.items():
setattr(entity, key, value)
@router.post("/years", response_model=FiscalYearRead, status_code=status.HTTP_201_CREATED)
async def create_fiscal_year(
body: FiscalYearCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = FiscalYearRepository(db)
if body.is_current:
for y in await repo.list_by_tenant(tenant_id, limit=500):
y.is_current = False
entity = FiscalYear(tenant_id=tenant_id, **body.model_dump())
await repo.add(entity)
await db.commit()
await db.refresh(entity)
return entity
@router.get("/years", response_model=list[FiscalYearRead])
async def list_fiscal_years(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = FiscalYearRepository(db)
return await repo.list_by_tenant(tenant_id, limit=100)
@router.patch("/years/{year_id}", response_model=FiscalYearRead)
async def update_fiscal_year(
year_id: UUID,
body: FiscalYearUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = FiscalYearRepository(db)
entity = await repo.get(tenant_id, year_id)
if entity is None:
raise NotFoundError("سال مالی یافت نشد", error_code="year_not_found")
data = body.model_dump(exclude_unset=True)
if data.get("is_current") is True:
for y in await repo.list_by_tenant(tenant_id, limit=500):
y.is_current = y.id == year_id
data.pop("is_current", None)
entity.is_current = True
_apply_update(entity, data)
await db.commit()
await db.refresh(entity)
return entity
@router.post("/years/{year_id}/set-current", response_model=FiscalYearRead)
async def set_current_year(
year_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
svc = FiscalManagementService(db)
year = await svc.set_current_year(tenant_id, year_id)
await db.commit()
return year
@router.post("/years/{year_id}/close", response_model=FiscalYearRead)
async def close_fiscal_year(
year_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
svc = FiscalManagementService(db)
year = await svc.close_fiscal_year(tenant_id, year_id)
await db.commit()
return year
@router.post("/years/{year_id}/generate-periods", response_model=list[FiscalPeriodRead])
async def generate_periods(
year_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
svc = FiscalManagementService(db)
periods = await svc.generate_monthly_periods(tenant_id, year_id)
await db.commit()
return periods
@router.post("/periods", response_model=FiscalPeriodRead, status_code=status.HTTP_201_CREATED)
async def create_fiscal_period(
body: FiscalPeriodCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = FiscalPeriodRepository(db)
if body.is_current:
for p in await repo.list_by_tenant(tenant_id, limit=500):
p.is_current = False
entity = FiscalPeriod(tenant_id=tenant_id, **body.model_dump())
await repo.add(entity)
await db.commit()
await db.refresh(entity)
return entity
@router.get("/periods", response_model=list[FiscalPeriodRead])
async def list_fiscal_periods(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = FiscalPeriodRepository(db)
return await repo.list_by_tenant(tenant_id, limit=100)
@router.patch("/periods/{period_id}", response_model=FiscalPeriodRead)
async def update_fiscal_period(
period_id: UUID,
body: FiscalPeriodUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = FiscalPeriodRepository(db)
entity = await repo.get(tenant_id, period_id)
if entity is None:
raise NotFoundError("دوره مالی یافت نشد", error_code="period_not_found")
data = body.model_dump(exclude_unset=True)
if data.get("is_current") is True:
for p in await repo.list_by_tenant(tenant_id, limit=500):
p.is_current = p.id == period_id
data.pop("is_current", None)
entity.is_current = True
_apply_update(entity, data)
await db.commit()
await db.refresh(entity)
return entity
@router.post("/periods/{period_id}/set-current", response_model=FiscalPeriodRead)
async def set_current_period(
period_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
svc = FiscalManagementService(db)
period = await svc.set_current_period(tenant_id, period_id)
await db.commit()
return period
@router.post("/periods/{period_id}/lock", response_model=FiscalPeriodRead)
async def lock_period(
period_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
svc = FiscalManagementService(db)
period = await svc.lock_period(tenant_id, period_id)
await db.commit()
return period
@router.post("/periods/{period_id}/close", response_model=FiscalPeriodRead)
async def close_period(
period_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
svc = FiscalManagementService(db)
period = await svc.close_period(tenant_id, period_id)
await db.commit()
return period
@router.post("/periods/{period_id}/reopen", response_model=FiscalPeriodRead)
async def reopen_period(
period_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
svc = FiscalManagementService(db)
period = await svc.reopen_period(tenant_id, period_id)
await db.commit()
return period

View File

@ -0,0 +1,154 @@
"""Phase 5.8 — Fixed Assets API."""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from uuid import UUID
from fastapi import APIRouter, Depends, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, require_tenant
from app.core.security import get_current_user
from app.models.fixed_assets import Asset, AssetCategory
from app.models.types import AssetStatus, DepreciationMethod
from app.repositories.base import TenantBaseRepository
from app.services.asset_service import AssetAccountingService, DepreciationEngine
from shared.security import CurrentUser
router = APIRouter()
class AssetCategoryCreate(BaseModel):
code: str
name: str
default_useful_life_months: int = 60
default_depreciation_method: DepreciationMethod = DepreciationMethod.STRAIGHT_LINE
class AssetCreate(BaseModel):
code: str
name: str
category_id: UUID | None = None
acquisition_cost: Decimal = Decimal("0")
residual_value: Decimal = Decimal("0")
useful_life_months: int = 60
depreciation_method: DepreciationMethod = DepreciationMethod.STRAIGHT_LINE
class DepreciateRequest(BaseModel):
expense_account_id: UUID
accumulated_account_id: UUID
class AssetCategoryRepo(TenantBaseRepository[AssetCategory]):
model = AssetCategory
class AssetRepo(TenantBaseRepository[Asset]):
model = Asset
@router.post("/categories", status_code=status.HTTP_201_CREATED)
async def create_category(
body: AssetCategoryCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = AssetCategoryRepo(db)
entity = AssetCategory(tenant_id=tenant_id, **body.model_dump())
await repo.add(entity)
await db.commit()
return {"id": str(entity.id)}
@router.get("/categories")
async def list_categories(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
cats = await AssetCategoryRepo(db).list_by_tenant(tenant_id, limit=100)
return [
{
"id": str(c.id),
"code": c.code,
"name": c.name,
"default_useful_life_months": c.default_useful_life_months,
}
for c in cats
]
@router.post("/assets", status_code=status.HTTP_201_CREATED)
async def create_asset(
body: AssetCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = AssetRepo(db)
entity = Asset(tenant_id=tenant_id, status=AssetStatus.DRAFT, **body.model_dump())
await repo.add(entity)
await db.commit()
return {"id": str(entity.id), "code": entity.code}
@router.get("/assets", response_model=list[dict])
async def list_assets(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = AssetRepo(db)
assets = await repo.list_by_tenant(tenant_id, limit=100)
return [{"id": str(a.id), "code": a.code, "name": a.name, "status": a.status.value} for a in assets]
@router.post("/assets/{asset_id}/activate")
async def activate_asset(
asset_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
svc = AssetAccountingService(db)
asset = await svc.activate_asset(tenant_id, asset_id, user.user_id)
await db.commit()
return {"id": str(asset.id), "status": asset.status.value}
@router.post("/assets/{asset_id}/depreciate")
async def depreciate_asset(
asset_id: UUID,
body: DepreciateRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
svc = AssetAccountingService(db)
dep = await svc.depreciate_asset(
tenant_id, asset_id,
expense_account_id=body.expense_account_id,
accumulated_account_id=body.accumulated_account_id,
actor_user_id=user.user_id,
)
await db.commit()
return {"id": str(dep.id), "amount": str(dep.amount), "voucher_id": str(dep.voucher_id)}
@router.get("/assets/{asset_id}/depreciation-schedule")
async def get_depreciation_schedule(
asset_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = AssetRepo(db)
asset = await repo.get(tenant_id, asset_id)
if asset is None:
return {"schedule": []}
engine = DepreciationEngine()
return {"schedule": engine.generate_schedule(asset)}

View File

@ -0,0 +1,10 @@
from fastapi import APIRouter
from app import __version__
router = APIRouter()
@router.get("/health")
async def health_check():
return {"status": "ok", "service": "accounting-service", "version": __version__}

View File

@ -0,0 +1,93 @@
"""General Ledger & Trial Balance API — Phase 5.3."""
from __future__ import annotations
from decimal import Decimal
from uuid import UUID
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, require_tenant
from app.core.security import get_current_user
from app.repositories.ledger import LedgerBalanceRepository
from app.services.balance_engine import BalanceEngine
from shared.security import CurrentUser
router = APIRouter()
class BalanceRead(BaseModel):
account_id: UUID
fiscal_period_id: UUID
opening_balance: Decimal
debit_total: Decimal
credit_total: Decimal
closing_balance: Decimal
current_balance: Decimal
model_config = {"from_attributes": True}
class TrialBalanceRead(BaseModel):
fiscal_period_id: UUID
total_debit: Decimal
total_credit: Decimal
difference: Decimal
is_balanced: bool
@router.get("/balances/{account_id}", response_model=BalanceRead | None)
async def get_account_balance(
account_id: UUID,
fiscal_period_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = LedgerBalanceRepository(db)
balance = await repo.get_by_account_period(tenant_id, account_id, fiscal_period_id)
return balance
@router.get("/balances", response_model=list[BalanceRead])
async def list_period_balances(
fiscal_period_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = LedgerBalanceRepository(db)
return await repo.list_by_period(tenant_id, fiscal_period_id)
@router.post("/trial-balance", response_model=TrialBalanceRead)
async def generate_trial_balance(
fiscal_period_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
engine = BalanceEngine(db)
snapshot = await engine.generate_trial_balance(tenant_id, fiscal_period_id)
await db.commit()
return TrialBalanceRead(
fiscal_period_id=snapshot.fiscal_period_id,
total_debit=snapshot.total_debit,
total_credit=snapshot.total_credit,
difference=snapshot.difference,
is_balanced=snapshot.is_balanced,
)
@router.post("/recalculate")
async def recalculate_balances(
fiscal_period_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
engine = BalanceEngine(db)
count = await engine.recalculate_period(tenant_id, fiscal_period_id)
await db.commit()
return {"recalculated_accounts": count}

View File

@ -0,0 +1,185 @@
"""Phase 5.9 — Payroll API."""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from uuid import UUID
from fastapi import APIRouter, Depends, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, require_tenant
from app.core.security import get_current_user
from app.models.payroll import Department, Employee, PayrollPeriod
from app.models.types import EmploymentStatus, PayrollPeriodStatus
from app.repositories.base import TenantBaseRepository
from app.services.payroll_service import PayrollAccountingService
from shared.security import CurrentUser
router = APIRouter()
class DepartmentCreate(BaseModel):
code: str
name: str
class EmployeeCreate(BaseModel):
employee_code: str
first_name: str
last_name: str
department_id: UUID | None = None
base_salary: Decimal = Decimal("0")
hire_date: date | None = None
class PayrollPeriodCreate(BaseModel):
name: str
start_date: date
end_date: date
class CalculatePayrollRequest(BaseModel):
payroll_period_id: UUID
employee_id: UUID
class PostPayrollRequest(BaseModel):
profile_id: UUID | None = None
class DepartmentRepo(TenantBaseRepository[Department]):
model = Department
class EmployeeRepo(TenantBaseRepository[Employee]):
model = Employee
class PayrollPeriodRepo(TenantBaseRepository[PayrollPeriod]):
model = PayrollPeriod
@router.post("/departments", status_code=status.HTTP_201_CREATED)
async def create_department(
body: DepartmentCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = DepartmentRepo(db)
entity = Department(tenant_id=tenant_id, **body.model_dump())
await repo.add(entity)
await db.commit()
return {"id": str(entity.id)}
@router.get("/departments")
async def list_departments(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
deps = await DepartmentRepo(db).list_by_tenant(tenant_id, limit=100)
return [{"id": str(d.id), "code": d.code, "name": d.name} for d in deps]
@router.post("/employees", status_code=status.HTTP_201_CREATED)
async def create_employee(
body: EmployeeCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = EmployeeRepo(db)
entity = Employee(tenant_id=tenant_id, employment_status=EmploymentStatus.ACTIVE, **body.model_dump())
await repo.add(entity)
await db.commit()
return {"id": str(entity.id), "employee_code": entity.employee_code}
@router.get("/employees")
async def list_employees(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = EmployeeRepo(db)
employees = await repo.list_by_tenant(tenant_id, limit=100)
return [{"id": str(e.id), "code": e.employee_code, "name": f"{e.first_name} {e.last_name}"} for e in employees]
@router.post("/periods", status_code=status.HTTP_201_CREATED)
async def create_payroll_period(
body: PayrollPeriodCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = PayrollPeriodRepo(db)
entity = PayrollPeriod(tenant_id=tenant_id, status=PayrollPeriodStatus.OPEN, **body.model_dump())
await repo.add(entity)
await db.commit()
return {"id": str(entity.id)}
@router.get("/periods")
async def list_payroll_periods(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
periods = await PayrollPeriodRepo(db).list_by_tenant(tenant_id, limit=100)
return [
{
"id": str(p.id),
"name": p.name,
"start_date": p.start_date.isoformat(),
"end_date": p.end_date.isoformat(),
"status": p.status.value,
}
for p in periods
]
@router.post("/calculate")
async def calculate_payroll(
body: CalculatePayrollRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
svc = PayrollAccountingService(db)
payroll = await svc.calculate_payroll(tenant_id, body.payroll_period_id, body.employee_id)
await db.commit()
return {
"id": str(payroll.id),
"gross_salary": str(payroll.gross_salary),
"net_salary": str(payroll.net_salary),
"status": payroll.status.value,
}
@router.post("/payrolls/{payroll_id}/post")
async def post_payroll(
payroll_id: UUID,
body: PostPayrollRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
from app.models.types import PayrollStatus
from app.services.payroll_service import PayrollRepo
payroll_repo = PayrollRepo(db)
payroll = await payroll_repo.get(tenant_id, payroll_id)
if payroll is None:
from shared.exceptions import NotFoundError
raise NotFoundError("حقوق یافت نشد", error_code="payroll_not_found")
payroll.status = PayrollStatus.APPROVED
svc = PayrollAccountingService(db)
result = await svc.post_payroll(tenant_id, payroll_id, actor_user_id=user.user_id, profile_id=body.profile_id)
await db.commit()
return {"id": str(result.id), "status": result.status.value, "voucher_id": str(result.voucher_id)}

View File

@ -0,0 +1,202 @@
"""Posting Engine API — Phase 5.2."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.core.security import get_current_user
from app.models.posting import Voucher, VoucherLine
from app.models.types import VoucherStatus
from app.repositories.posting import AuditLogRepository, VoucherRepository
from app.schemas.posting import (
AuditLogRead,
PostRequest,
ReverseRequest,
VoucherCreate,
VoucherRead,
VoucherUpdate,
)
from app.services.posting_engine import PostingEngine
from shared.exceptions import AppError, NotFoundError
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.get("/vouchers", response_model=list[VoucherRead])
async def list_vouchers(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
from sqlalchemy import select
from sqlalchemy.orm import selectinload
stmt = (
select(Voucher)
.options(selectinload(Voucher.lines))
.where(Voucher.tenant_id == tenant_id)
.offset(pagination.offset)
.limit(pagination.page_size)
.order_by(Voucher.created_at.desc())
)
result = await db.execute(stmt)
return list(result.scalars().all())
@router.post("/vouchers", response_model=VoucherRead, status_code=status.HTTP_201_CREATED)
async def create_voucher(
body: VoucherCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
repo = VoucherRepository(db)
voucher = Voucher(
tenant_id=tenant_id,
fiscal_period_id=body.fiscal_period_id,
voucher_number=body.voucher_number,
voucher_date=body.voucher_date,
status=VoucherStatus.DRAFT,
description=body.description,
reference_number=body.reference_number,
source_module=body.source_module,
created_by=user.user_id,
)
await repo.add(voucher)
for i, line in enumerate(body.lines, start=1):
db.add(VoucherLine(
tenant_id=tenant_id,
voucher_id=voucher.id,
line_number=i,
**line.model_dump(),
))
await db.commit()
result = await repo.get_with_lines(tenant_id, voucher.id)
return result
@router.get("/vouchers/{voucher_id}", response_model=VoucherRead)
async def get_voucher(
voucher_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = VoucherRepository(db)
voucher = await repo.get_with_lines(tenant_id, voucher_id)
if voucher is None:
raise NotFoundError("سند یافت نشد", error_code="voucher_not_found")
return voucher
@router.patch("/vouchers/{voucher_id}", response_model=VoucherRead)
async def update_draft_voucher(
voucher_id: UUID,
body: VoucherUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = VoucherRepository(db)
voucher = await repo.get_with_lines(tenant_id, voucher_id)
if voucher is None:
raise NotFoundError("سند یافت نشد", error_code="voucher_not_found")
if voucher.status != VoucherStatus.DRAFT:
raise AppError(
"فقط اسناد پیش‌نویس قابل ویرایش هستند",
status_code=422,
error_code="voucher_not_draft",
)
data = body.model_dump(exclude_unset=True)
lines_data = data.pop("lines", None)
for key, value in data.items():
setattr(voucher, key, value)
if lines_data is not None:
for line in list(voucher.lines):
await db.delete(line)
await db.flush()
for i, line in enumerate(lines_data, start=1):
db.add(
VoucherLine(
tenant_id=tenant_id,
voucher_id=voucher.id,
line_number=i,
**line,
)
)
await db.commit()
return await repo.get_with_lines(tenant_id, voucher_id)
@router.post("/vouchers/{voucher_id}/validate", response_model=VoucherRead)
async def validate_voucher(
voucher_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
engine = PostingEngine(db)
voucher = await engine.validate_voucher(tenant_id, voucher_id)
await db.commit()
return await VoucherRepository(db).get_with_lines(tenant_id, voucher.id)
@router.post("/vouchers/{voucher_id}/post", response_model=VoucherRead)
async def post_voucher(
voucher_id: UUID,
body: PostRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
engine = PostingEngine(db)
voucher = await engine.post_voucher(
tenant_id, voucher_id, actor_user_id=user.user_id, source_module=body.source_module
)
await db.commit()
return await VoucherRepository(db).get_with_lines(tenant_id, voucher.id)
@router.post("/vouchers/{voucher_id}/reverse", response_model=VoucherRead)
async def reverse_voucher(
voucher_id: UUID,
body: ReverseRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
engine = PostingEngine(db)
voucher = await engine.reverse_voucher(
tenant_id, voucher_id, actor_user_id=user.user_id, reversal_date=body.reversal_date
)
await db.commit()
return await VoucherRepository(db).get_with_lines(tenant_id, voucher.id)
@router.post("/vouchers/{voucher_id}/cancel", response_model=VoucherRead)
async def cancel_voucher(
voucher_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
engine = PostingEngine(db)
voucher = await engine.cancel_voucher(tenant_id, voucher_id, actor_user_id=user.user_id)
await db.commit()
return voucher
@router.get("/audit-logs", response_model=list[AuditLogRead])
async def list_audit_logs(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = AuditLogRepository(db)
return await repo.list_by_tenant(tenant_id, limit=100)

View File

@ -0,0 +1,106 @@
"""Phase 5.7 — Purchase & Inventory accounting API."""
from __future__ import annotations
from decimal import Decimal
from uuid import UUID
from fastapi import APIRouter, Depends, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, require_tenant
from app.core.security import get_current_user
from app.models.purchase_inventory import InventoryPostingProfile, PurchasePostingProfile
from app.models.types import InventoryValuationMethod
from app.repositories.base import TenantBaseRepository
from app.services.purchase_inventory_service import PurchaseInventoryAccountingService
from shared.security import CurrentUser
router = APIRouter()
class PurchaseProfileCreate(BaseModel):
name: str
inventory_account_id: UUID | None = None
grni_account_id: UUID | None = None
liability_account_id: UUID | None = None
is_default: bool = False
class GoodsReceiptRequest(BaseModel):
source_document_id: str
amount: Decimal
profile_id: UUID | None = None
class ValuationRequest(BaseModel):
item_id: UUID
quantity: Decimal
unit_cost: Decimal
method: InventoryValuationMethod = InventoryValuationMethod.WEIGHTED_AVERAGE
warehouse_id: UUID | None = None
class PurchaseProfileRepo(TenantBaseRepository[PurchasePostingProfile]):
model = PurchasePostingProfile
@router.post("/posting-profiles", status_code=status.HTTP_201_CREATED)
async def create_purchase_profile(
body: PurchaseProfileCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = PurchaseProfileRepo(db)
entity = PurchasePostingProfile(tenant_id=tenant_id, **body.model_dump())
await repo.add(entity)
await db.commit()
return {"id": str(entity.id), "name": entity.name}
@router.post("/preview/goods-receipt")
async def preview_goods_receipt(
body: GoodsReceiptRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
svc = PurchaseInventoryAccountingService(db)
preview = await svc.preview_goods_receipt(tenant_id, body.amount, body.profile_id)
await db.commit()
return {"is_balanced": preview.is_balanced, "total_debit": str(preview.total_debit)}
@router.post("/post/goods-receipt")
async def post_goods_receipt(
body: GoodsReceiptRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
svc = PurchaseInventoryAccountingService(db)
voucher = await svc.post_goods_receipt(
tenant_id,
source_document_id=body.source_document_id,
amount=body.amount,
actor_user_id=user.user_id,
profile_id=body.profile_id,
)
await db.commit()
return {"voucher_id": str(voucher.id), "status": voucher.status.value}
@router.post("/valuation")
async def record_valuation(
body: ValuationRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
svc = PurchaseInventoryAccountingService(db)
record = await svc.record_valuation(
tenant_id, body.item_id, body.quantity, body.unit_cost, body.method, body.warehouse_id
)
await db.commit()
return {"id": str(record.id), "total_value": str(record.total_value)}

View File

@ -0,0 +1,267 @@
"""AR/AP API — Phase 5.5."""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from uuid import UUID
from fastapi import APIRouter, Depends, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, require_tenant
from app.core.security import get_current_user
from app.models.receivable_payable import CustomerAccount, ReceivableInvoice, SupplierAccount
from app.models.types import InvoiceStatus, SettlementStatus
from app.repositories.base import TenantBaseRepository
from app.services.aging_engine import AgingEngine
from app.services.settlement_engine import SettlementEngine, SettlementRepository
from shared.security import CurrentUser
router = APIRouter()
class CustomerCreate(BaseModel):
code: str
name: str
credit_limit: Decimal | None = None
class CustomerRead(BaseModel):
id: UUID
tenant_id: UUID
code: str
name: str
outstanding_balance: Decimal
is_active: bool
model_config = {"from_attributes": True}
class ReceivableInvoiceCreate(BaseModel):
customer_id: UUID
invoice_number: str
invoice_date: date
due_date: date | None = None
total_amount: Decimal
class SettlementCreate(BaseModel):
settlement_number: str
settlement_date: date
settlement_type: str
party_type: str
party_id: UUID
total_amount: Decimal
allocations: list[dict]
class CustomerRepo(TenantBaseRepository[CustomerAccount]):
model = CustomerAccount
class SupplierRepo(TenantBaseRepository[SupplierAccount]):
model = SupplierAccount
class ReceivableRepo(TenantBaseRepository[ReceivableInvoice]):
model = ReceivableInvoice
@router.post("/customers", response_model=CustomerRead, status_code=status.HTTP_201_CREATED)
async def create_customer(
body: CustomerCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = CustomerRepo(db)
entity = CustomerAccount(tenant_id=tenant_id, **body.model_dump())
await repo.add(entity)
await db.commit()
await db.refresh(entity)
return entity
@router.get("/customers", response_model=list[CustomerRead])
async def list_customers(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = CustomerRepo(db)
return await repo.list_by_tenant(tenant_id, limit=100)
@router.patch("/customers/{customer_id}", response_model=CustomerRead)
async def update_customer(
customer_id: UUID,
body: dict,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
from shared.exceptions import NotFoundError
repo = CustomerRepo(db)
entity = await repo.get(tenant_id, customer_id)
if entity is None:
raise NotFoundError("مشتری یافت نشد", error_code="customer_not_found")
if "name" in body and body["name"] is not None:
entity.name = body["name"]
if "credit_limit" in body:
entity.credit_limit = body["credit_limit"]
if "is_active" in body and body["is_active"] is not None:
entity.is_active = bool(body["is_active"])
await db.commit()
await db.refresh(entity)
return entity
@router.get("/customers/{customer_id}", response_model=CustomerRead)
async def get_customer(
customer_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
from shared.exceptions import NotFoundError
entity = await CustomerRepo(db).get(tenant_id, customer_id)
if entity is None:
raise NotFoundError("مشتری یافت نشد", error_code="customer_not_found")
return entity
@router.get("/suppliers")
async def list_suppliers(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
suppliers = await SupplierRepo(db).list_by_tenant(tenant_id, limit=100)
return [
{
"id": str(s.id),
"code": s.code,
"name": s.name,
"outstanding_balance": str(s.outstanding_balance),
"is_active": s.is_active,
}
for s in suppliers
]
@router.post("/suppliers", status_code=status.HTTP_201_CREATED)
async def create_supplier(
body: CustomerCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = SupplierRepo(db)
entity = SupplierAccount(tenant_id=tenant_id, code=body.code, name=body.name)
await repo.add(entity)
await db.commit()
return {"id": str(entity.id), "code": entity.code, "name": entity.name}
@router.get("/receivable-invoices")
async def list_receivable_invoices(
customer_id: UUID | None = None,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
invoices = await ReceivableRepo(db).list_by_tenant(tenant_id, limit=200)
rows = invoices
if customer_id is not None:
rows = [i for i in invoices if i.customer_id == customer_id]
return [
{
"id": str(i.id),
"customer_id": str(i.customer_id),
"invoice_number": i.invoice_number,
"invoice_date": i.invoice_date.isoformat(),
"due_date": i.due_date.isoformat() if i.due_date else None,
"total_amount": str(i.total_amount),
"remaining_amount": str(i.remaining_amount),
"status": i.status.value,
}
for i in rows
]
@router.post("/receivable-invoices", status_code=status.HTTP_201_CREATED)
async def create_receivable_invoice(
body: ReceivableInvoiceCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = ReceivableRepo(db)
entity = ReceivableInvoice(
tenant_id=tenant_id,
customer_id=body.customer_id,
invoice_number=body.invoice_number,
invoice_date=body.invoice_date,
due_date=body.due_date,
total_amount=body.total_amount,
paid_amount=Decimal("0"),
remaining_amount=body.total_amount,
status=InvoiceStatus.ISSUED,
)
await repo.add(entity)
await db.commit()
return {"id": str(entity.id)}
@router.post("/settlements")
async def create_settlement(
body: SettlementCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
from app.models.receivable_payable import Settlement
settlement = Settlement(
tenant_id=tenant_id,
settlement_number=body.settlement_number,
settlement_date=body.settlement_date,
settlement_type=body.settlement_type,
party_type=body.party_type,
party_id=body.party_id,
total_amount=body.total_amount,
status=SettlementStatus.DRAFT,
)
repo = SettlementRepository(db)
await repo.add(settlement)
engine = SettlementEngine(db)
if body.party_type == "customer":
settlement = await engine.allocate_receivable(tenant_id, settlement, body.allocations)
else:
settlement = await engine.allocate_payable(tenant_id, settlement, body.allocations)
await db.commit()
return {"id": str(settlement.id), "status": settlement.status.value}
@router.get("/customers/{customer_id}/aging")
async def get_customer_aging(
customer_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
engine = AgingEngine(db)
snapshot = await engine.calculate_customer_aging(tenant_id, customer_id)
await db.commit()
return {
"current": str(snapshot.current_amount),
"days_1_30": str(snapshot.days_1_30),
"days_31_60": str(snapshot.days_31_60),
"days_61_90": str(snapshot.days_61_90),
"days_90_plus": str(snapshot.days_90_plus),
"total": str(snapshot.total_outstanding),
}

View File

@ -0,0 +1,123 @@
"""Phase 5.10 — Financial Reporting API."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, require_tenant
from app.core.security import get_current_user
from app.models.reporting import Dashboard, ReportTemplate
from app.models.types import ReportExportFormat, ReportType
from app.repositories.base import TenantBaseRepository
from app.services.reporting_service import FinancialReportEngine
from shared.security import CurrentUser
router = APIRouter()
class ReportTemplateCreate(BaseModel):
name: str
report_type: ReportType
template_config: str
class DashboardCreate(BaseModel):
name: str
dashboard_type: str
description: str | None = None
class ExportRequest(BaseModel):
export_format: ReportExportFormat = ReportExportFormat.JSON
class ReportTemplateRepo(TenantBaseRepository[ReportTemplate]):
model = ReportTemplate
class DashboardRepo(TenantBaseRepository[Dashboard]):
model = Dashboard
@router.post("/trial-balance")
async def generate_trial_balance(
fiscal_period_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
engine = FinancialReportEngine(db)
report = await engine.generate_trial_balance_report(tenant_id, fiscal_period_id, generated_by=user.user_id)
await db.commit()
return {"id": str(report.id), "report_type": report.report_type.value, "data": report.report_data}
@router.post("/balance-sheet")
async def generate_balance_sheet(
fiscal_period_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
engine = FinancialReportEngine(db)
report = await engine.generate_balance_sheet(tenant_id, fiscal_period_id, generated_by=user.user_id)
await db.commit()
return {"id": str(report.id), "data": report.report_data}
@router.post("/income-statement")
async def generate_income_statement(
fiscal_period_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
engine = FinancialReportEngine(db)
report = await engine.generate_income_statement(tenant_id, fiscal_period_id, generated_by=user.user_id)
await db.commit()
return {"id": str(report.id), "data": report.report_data}
@router.post("/reports/{report_id}/export")
async def export_report(
report_id: UUID,
body: ExportRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
engine = FinancialReportEngine(db)
export = await engine.export_report(tenant_id, report_id, body.export_format, exported_by=user.user_id)
await db.commit()
return {"id": str(export.id), "format": export.export_format.value, "file_path": export.file_path}
@router.post("/dashboards")
async def create_dashboard(
body: DashboardCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = DashboardRepo(db)
entity = Dashboard(tenant_id=tenant_id, **body.model_dump())
await repo.add(entity)
await db.commit()
return {"id": str(entity.id)}
@router.post("/templates")
async def create_report_template(
body: ReportTemplateCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = ReportTemplateRepo(db)
entity = ReportTemplate(tenant_id=tenant_id, **body.model_dump())
await repo.add(entity)
await db.commit()
return {"id": str(entity.id)}

View File

@ -0,0 +1,134 @@
"""Sales Accounting Integration API — Phase 5.6."""
from __future__ import annotations
from decimal import Decimal
from uuid import UUID
from fastapi import APIRouter, Depends, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, require_tenant
from app.core.security import get_current_user
from app.models.sales_accounting import SalesPostingProfile
from app.models.types import SalesDocumentType
from app.repositories.base import TenantBaseRepository
from app.services.sales_accounting_service import SalesAccountingService
from shared.security import CurrentUser
router = APIRouter()
class SalesPostingProfileCreate(BaseModel):
name: str
document_type: SalesDocumentType
revenue_account_id: UUID | None = None
receivable_account_id: UUID | None = None
cash_account_id: UUID | None = None
discount_account_id: UUID | None = None
tax_account_id: UUID | None = None
is_default: bool = False
class SalesPostingProfileRead(BaseModel):
id: UUID
tenant_id: UUID
name: str
document_type: SalesDocumentType
is_default: bool
is_active: bool
model_config = {"from_attributes": True}
class SalesPreviewRequest(BaseModel):
document_type: SalesDocumentType = SalesDocumentType.INVOICE
total_amount: Decimal
discount_amount: Decimal = Decimal("0")
tax_amount: Decimal = Decimal("0")
profile_id: UUID | None = None
class SalesPostRequest(BaseModel):
document_type: SalesDocumentType = SalesDocumentType.INVOICE
source_document_id: str
total_amount: Decimal
discount_amount: Decimal = Decimal("0")
tax_amount: Decimal = Decimal("0")
profile_id: UUID | None = None
class SalesProfileRepo(TenantBaseRepository[SalesPostingProfile]):
model = SalesPostingProfile
@router.post("/posting-profiles", response_model=SalesPostingProfileRead, status_code=status.HTTP_201_CREATED)
async def create_posting_profile(
body: SalesPostingProfileCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = SalesProfileRepo(db)
entity = SalesPostingProfile(tenant_id=tenant_id, **body.model_dump())
await repo.add(entity)
await db.commit()
await db.refresh(entity)
return entity
@router.get("/posting-profiles", response_model=list[SalesPostingProfileRead])
async def list_posting_profiles(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = SalesProfileRepo(db)
return await repo.list_by_tenant(tenant_id, limit=100)
@router.post("/preview")
async def preview_accounting(
body: SalesPreviewRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
svc = SalesAccountingService(db)
preview = await svc.preview_sales_invoice(
tenant_id,
document_type=body.document_type,
total_amount=body.total_amount,
discount_amount=body.discount_amount,
tax_amount=body.tax_amount,
profile_id=body.profile_id,
)
await db.commit()
return {
"is_balanced": preview.is_balanced,
"total_debit": str(preview.total_debit),
"total_credit": str(preview.total_credit),
"preview_data": preview.preview_data,
}
@router.post("/post")
async def post_sales_accounting(
body: SalesPostRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
svc = SalesAccountingService(db)
voucher = await svc.post_sales_invoice(
tenant_id,
document_type=body.document_type,
source_document_id=body.source_document_id,
total_amount=body.total_amount,
discount_amount=body.discount_amount,
tax_amount=body.tax_amount,
profile_id=body.profile_id,
actor_user_id=user.user_id,
)
await db.commit()
return {"voucher_id": str(voucher.id), "voucher_number": voucher.voucher_number, "status": voucher.status.value}

View File

@ -0,0 +1,68 @@
"""Setup progress API — computed from real tenant data (no mocks)."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, require_tenant
from app.core.security import get_current_user
from app.repositories.foundation import (
AccountRepository,
ChartOfAccountsRepository,
CurrencyRepository,
FiscalPeriodRepository,
FiscalYearRepository,
)
from app.repositories.posting import VoucherRepository
from app.schemas.foundation import SetupStatusRead
from shared.security import CurrentUser
router = APIRouter()
@router.get("/status", response_model=SetupStatusRead)
async def setup_status(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
charts = await ChartOfAccountsRepository(db).list_by_tenant(tenant_id, limit=1)
accounts = await AccountRepository(db).list_by_tenant(tenant_id, limit=1)
currencies = await CurrencyRepository(db).list_by_tenant(tenant_id, limit=100)
years = await FiscalYearRepository(db).list_by_tenant(tenant_id, limit=1)
periods = await FiscalPeriodRepository(db).list_by_tenant(tenant_id, limit=1)
has_cash = False
has_customer = False
try:
from app.api.v1.treasury import CashBoxRepo
from app.api.v1.receivable_payable import CustomerRepo
has_cash = bool(await CashBoxRepo(db).list_by_tenant(tenant_id, limit=1))
has_customer = bool(await CustomerRepo(db).list_by_tenant(tenant_id, limit=1))
except Exception:
pass
has_voucher = bool(await VoucherRepository(db).list_by_tenant(tenant_id, limit=1))
flags = {
"has_chart": bool(charts),
"has_accounts": bool(accounts),
"has_currency": bool(currencies),
"has_base_currency": any(c.is_base for c in currencies),
"has_fiscal_year": bool(years),
"has_fiscal_period": bool(periods),
"has_cash_box": has_cash,
"has_customer": has_customer,
"has_voucher": has_voucher,
}
completed = sum(1 for v in flags.values() if v)
total = len(flags)
return SetupStatusRead(
**flags,
completed_steps=completed,
total_steps=total,
percent=round(100 * completed / total) if total else 0,
)

View File

@ -0,0 +1,206 @@
"""Treasury API — Phase 5.4."""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from uuid import UUID
from fastapi import APIRouter, Depends, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, require_tenant
from app.core.security import get_current_user
from app.models.treasury import Bank, BankAccount, CashBox
from app.repositories.base import TenantBaseRepository
from app.services.treasury_service import TreasuryService
from shared.security import CurrentUser
router = APIRouter()
class CashBoxCreate(BaseModel):
code: str
name: str
account_id: UUID | None = None
opening_balance: Decimal = Decimal("0")
class CashBoxRead(BaseModel):
id: UUID
tenant_id: UUID
code: str
name: str
current_balance: Decimal
is_active: bool
model_config = {"from_attributes": True}
class CashReceiptRequest(BaseModel):
cash_box_id: UUID
amount: Decimal
debit_account_id: UUID
credit_account_id: UUID
description: str | None = None
transaction_date: date | None = None
class BankCreate(BaseModel):
code: str
name: str
class BankAccountCreate(BaseModel):
bank_id: UUID
account_number: str
iban: str | None = None
class CashBoxRepo(TenantBaseRepository[CashBox]):
model = CashBox
class BankRepo(TenantBaseRepository[Bank]):
model = Bank
class BankAccountRepo(TenantBaseRepository[BankAccount]):
model = BankAccount
@router.post("/cash-boxes", response_model=CashBoxRead, status_code=status.HTTP_201_CREATED)
async def create_cash_box(
body: CashBoxCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = CashBoxRepo(db)
entity = CashBox(
tenant_id=tenant_id,
code=body.code,
name=body.name,
account_id=body.account_id,
opening_balance=body.opening_balance,
current_balance=body.opening_balance,
)
await repo.add(entity)
await db.commit()
await db.refresh(entity)
return entity
@router.get("/cash-boxes", response_model=list[CashBoxRead])
async def list_cash_boxes(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = CashBoxRepo(db)
return await repo.list_by_tenant(tenant_id, limit=100)
@router.post("/cash-receipts")
async def record_cash_receipt(
body: CashReceiptRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
svc = TreasuryService(db)
tx = await svc.record_cash_receipt(
tenant_id, body.cash_box_id, body.amount,
debit_account_id=body.debit_account_id,
credit_account_id=body.credit_account_id,
actor_user_id=user.user_id,
description=body.description,
transaction_date=body.transaction_date,
)
await db.commit()
return {"id": str(tx.id), "voucher_id": str(tx.voucher_id)}
@router.post("/banks", status_code=status.HTTP_201_CREATED)
async def create_bank(
body: BankCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = BankRepo(db)
entity = Bank(tenant_id=tenant_id, code=body.code, name=body.name)
await repo.add(entity)
await db.commit()
return {"id": str(entity.id)}
@router.post("/bank-accounts", status_code=status.HTTP_201_CREATED)
async def create_bank_account(
body: BankAccountCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = BankAccountRepo(db)
entity = BankAccount(
tenant_id=tenant_id,
bank_id=body.bank_id,
account_number=body.account_number,
iban=body.iban,
)
await repo.add(entity)
await db.commit()
return {"id": str(entity.id)}
@router.get("/banks")
async def list_banks(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
banks = await BankRepo(db).list_by_tenant(tenant_id, limit=100)
return [{"id": str(b.id), "code": b.code, "name": b.name, "is_active": b.is_active} for b in banks]
@router.get("/bank-accounts")
async def list_bank_accounts(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
accounts = await BankAccountRepo(db).list_by_tenant(tenant_id, limit=100)
return [
{
"id": str(a.id),
"bank_id": str(a.bank_id),
"account_number": a.account_number,
"iban": a.iban,
"is_active": a.is_active,
}
for a in accounts
]
@router.patch("/cash-boxes/{cash_box_id}")
async def update_cash_box(
cash_box_id: UUID,
body: dict,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
from shared.exceptions import NotFoundError
repo = CashBoxRepo(db)
entity = await repo.get(tenant_id, cash_box_id)
if entity is None:
raise NotFoundError("صندوق یافت نشد", error_code="cash_box_not_found")
if "name" in body and body["name"] is not None:
entity.name = body["name"]
if "is_active" in body and body["is_active"] is not None:
entity.is_active = bool(body["is_active"])
await db.commit()
await db.refresh(entity)
return CashBoxRead.model_validate(entity)

View File

@ -0,0 +1,75 @@
"""تنظیمات Accounting Service."""
from __future__ import annotations
from functools import lru_cache
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
environment: Literal["development", "staging", "production", "test"] = "development"
debug: bool = True
log_level: str = "INFO"
service_name: str = Field(
default="accounting-service", validation_alias="ACCOUNTING_SERVICE_NAME"
)
api_v1_prefix: str = "/api/v1"
database_url: str = Field(
default="postgresql+asyncpg://superapp:superapp_password@localhost:5432/accounting_db",
validation_alias="ACCOUNTING_DATABASE_URL",
)
database_url_sync: str = Field(
default="postgresql+psycopg://superapp:superapp_password@localhost:5432/accounting_db",
validation_alias="ACCOUNTING_DATABASE_URL_SYNC",
)
core_service_url: str = Field(
default="http://localhost:8000", validation_alias="CORE_SERVICE_URL"
)
keycloak_enabled: bool = True
keycloak_server_url: str = Field(
default="http://localhost:8080", validation_alias="KEYCLOAK_SERVER_URL"
)
keycloak_public_url: str = Field(default="", validation_alias="KEYCLOAK_PUBLIC_URL")
keycloak_realm: str = Field(default="superapp", validation_alias="KEYCLOAK_REALM")
jwt_algorithm: str = "RS256"
jwt_audience: str = "account"
jwt_verify_signature: bool = True
auth_required: bool = True
cors_origins: str = Field(
default="http://localhost:3000,http://127.0.0.1:3000",
validation_alias="CORS_ORIGINS",
)
@property
def keycloak_public_base(self) -> str:
return (self.keycloak_public_url or self.keycloak_server_url).rstrip("/")
@property
def keycloak_public_realm_url(self) -> str:
return f"{self.keycloak_public_base}/realms/{self.keycloak_realm}"
@property
def cors_origin_list(self) -> list[str]:
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()

View File

@ -0,0 +1,21 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
class Base(DeclarativeBase):
pass
engine = create_async_engine(settings.database_url, pool_pre_ping=True, future=True)
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
except Exception:
await session.rollback()
raise

View File

@ -0,0 +1,14 @@
import logging
import sys
def configure_logging(level: str = "INFO") -> None:
logging.basicConfig(
level=getattr(logging, level.upper(), logging.INFO),
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
stream=sys.stdout,
)
def get_logger(name: str) -> logging.Logger:
return logging.getLogger(name)

View File

@ -0,0 +1,49 @@
"""JWT authentication dependencies for Accounting service."""
from __future__ import annotations
from functools import lru_cache
from fastapi import Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.core.config import settings
from shared.auth import JWTSettings, JWTValidator
from shared.exceptions import UnauthorizedError
from shared.security import CurrentUser
_bearer = HTTPBearer(auto_error=False)
@lru_cache
def get_jwt_validator() -> JWTValidator:
return JWTValidator(
JWTSettings(
keycloak_enabled=settings.keycloak_enabled,
keycloak_server_url=settings.keycloak_server_url,
keycloak_realm=settings.keycloak_realm,
jwt_algorithm=settings.jwt_algorithm,
jwt_audience=settings.jwt_audience,
jwt_verify_signature=settings.jwt_verify_signature,
jwt_issuer=settings.keycloak_public_realm_url,
)
)
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
) -> CurrentUser:
if not settings.auth_required:
return CurrentUser(user_id="test-user", username="test", roles=["tenant_admin"])
if credentials is None or not credentials.credentials:
raise UnauthorizedError("توکن احراز هویت ارائه نشده است")
return await get_jwt_validator().validate(credentials.credentials)
async def get_optional_user(
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
) -> CurrentUser | None:
if not settings.auth_required:
return CurrentUser(user_id="test-user", username="test", roles=["tenant_admin"])
if credentials is None or not credentials.credentials:
return None
return await get_jwt_validator().validate(credentials.credentials)

View File

@ -0,0 +1,125 @@
"""Accounting event type definitions (contracts only — no consumers)."""
from __future__ import annotations
import enum
class AccountingEventType(str, enum.Enum):
# Foundation
ACCOUNT_CREATED = "account.created"
CHART_OF_ACCOUNTS_CREATED = "chart_of_accounts.created"
FISCAL_YEAR_CREATED = "fiscal_year.created"
FISCAL_PERIOD_OPENED = "fiscal_period.opened"
# Posting (Phase 5.2)
VOUCHER_CREATED = "voucher.created"
VOUCHER_VALIDATED = "voucher.validated"
VOUCHER_POSTED = "voucher.posted"
VOUCHER_CANCELLED = "voucher.cancelled"
VOUCHER_REVERSED = "voucher.reversed"
JOURNAL_ENTRY_CREATED = "journal_entry.created"
POSTING_COMPLETED = "posting.completed"
POSTING_FAILED = "posting.failed"
# Ledger (Phase 5.3)
FISCAL_YEAR_CLOSED = "fiscal_year.closed"
FISCAL_PERIOD_LOCKED = "fiscal_period.locked"
FISCAL_PERIOD_CLOSED = "fiscal_period.closed"
TRIAL_BALANCE_GENERATED = "trial_balance.generated"
LEDGER_UPDATED = "ledger.updated"
BALANCE_RECALCULATED = "balance.recalculated"
# Treasury (Phase 5.4)
CASH_RECEIVED = "cash.received"
CASH_PAID = "cash.paid"
BANK_DEPOSIT_CREATED = "bank_deposit.created"
BANK_WITHDRAWAL_CREATED = "bank_withdrawal.created"
CHEQUE_ISSUED = "cheque.issued"
CHEQUE_RECEIVED = "cheque.received"
CHEQUE_DEPOSITED = "cheque.deposited"
CHEQUE_CLEARED = "cheque.cleared"
CHEQUE_BOUNCED = "cheque.bounced"
TRANSFER_COMPLETED = "transfer.completed"
TREASURY_TRANSACTION_POSTED = "treasury_transaction.posted"
BANK_RECONCILIATION_COMPLETED = "bank_reconciliation.completed"
# AR/AP (Phase 5.5)
CUSTOMER_INVOICE_CREATED = "customer_invoice.created"
SUPPLIER_INVOICE_CREATED = "supplier_invoice.created"
CUSTOMER_PAYMENT_RECEIVED = "customer_payment.received"
SUPPLIER_PAYMENT_COMPLETED = "supplier_payment.completed"
SETTLEMENT_COMPLETED = "settlement.completed"
CREDIT_NOTE_ISSUED = "credit_note.issued"
DEBIT_NOTE_ISSUED = "debit_note.issued"
ADVANCE_PAYMENT_RECORDED = "advance_payment.recorded"
ADVANCE_RECEIPT_RECORDED = "advance_receipt.recorded"
CUSTOMER_AGING_CALCULATED = "customer_aging.calculated"
SUPPLIER_AGING_CALCULATED = "supplier_aging.calculated"
# Sales Accounting (Phase 5.6)
SALES_INVOICE_POSTED = "sales_invoice.posted"
SALES_INVOICE_CANCELLED = "sales_invoice.cancelled"
SALES_RETURN_POSTED = "sales_return.posted"
REVENUE_RECOGNIZED = "revenue.recognized"
DISCOUNT_APPLIED = "discount.applied"
SALES_ACCOUNTING_COMPLETED = "sales_accounting.completed"
POSTING_RULE_EXECUTED = "posting_rule.executed"
# Purchase/Inventory (Phase 5.7)
GOODS_RECEIVED = "goods.received"
GOODS_ISSUED = "goods.issued"
INVENTORY_ADJUSTED = "inventory.adjusted"
INVENTORY_TRANSFERRED = "inventory.transferred"
PURCHASE_COMPLETED = "purchase.completed"
PURCHASE_RETURNED = "purchase.returned"
INVENTORY_REVALUED = "inventory.revalued"
INVENTORY_COST_ADJUSTED = "inventory.cost_adjusted"
INVENTORY_ACCOUNTING_COMPLETED = "inventory_accounting.completed"
PURCHASE_ACCOUNTING_COMPLETED = "purchase_accounting.completed"
# Fixed Assets (Phase 5.8)
ASSET_CREATED = "asset.created"
ASSET_ACTIVATED = "asset.activated"
ASSET_DEPRECIATED = "asset.depreciated"
ASSET_TRANSFERRED = "asset.transferred"
ASSET_REVALUED = "asset.revalued"
ASSET_DISPOSED = "asset.disposed"
ASSET_SOLD = "asset.sold"
ASSET_RETIRED = "asset.retired"
ASSET_IMPAIRED = "asset.impaired"
DEPRECIATION_CALCULATED = "depreciation.calculated"
ASSET_ACCOUNTING_COMPLETED = "asset_accounting.completed"
# Payroll (Phase 5.9)
PAYROLL_CALCULATED = "payroll.calculated"
PAYROLL_APPROVED = "payroll.approved"
PAYROLL_POSTED = "payroll.posted"
PAYROLL_PAID = "payroll.paid"
PAYROLL_REVERSED = "payroll.reversed"
SALARY_ADJUSTED = "salary.adjusted"
BONUS_PAID = "bonus.paid"
OVERTIME_APPROVED = "overtime.approved"
LOAN_DEDUCTED = "loan.deducted"
ADVANCE_RECOVERED = "advance.recovered"
PAYROLL_ACCOUNTING_COMPLETED = "payroll_accounting.completed"
# Reporting (Phase 5.10)
FINANCIAL_REPORT_GENERATED = "financial_report.generated"
DASHBOARD_GENERATED = "dashboard.generated"
BALANCE_SHEET_GENERATED = "balance_sheet.generated"
INCOME_STATEMENT_GENERATED = "income_statement.generated"
CASH_FLOW_GENERATED = "cash_flow.generated"
REPORT_EXPORTED = "report.exported"
SCHEDULED_REPORT_GENERATED = "scheduled_report.generated"
FINANCIAL_SNAPSHOT_CREATED = "financial_snapshot.created"
# Compliance (Phase 5.11)
AUDIT_RECORD_CREATED = "audit_record.created"
COMPLIANCE_VALIDATED = "compliance.validated"
POLICY_VIOLATION_DETECTED = "policy_violation.detected"
APPROVAL_COMPLETED = "approval.completed"
APPROVAL_REJECTED = "approval.rejected"
GOVERNANCE_RULE_EXECUTED = "governance_rule.executed"
RISK_DETECTED = "risk.detected"
EVIDENCE_UPLOADED = "evidence.uploaded"
RETENTION_POLICY_APPLIED = "retention_policy.applied"

View File

@ -0,0 +1,67 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app import __version__
from app.api.v1 import api_router
from app.api.v1 import health
from app.core.config import settings
from app.core.logging import configure_logging, get_logger
from app.middlewares.tenant import TenantHeaderMiddleware
from shared.exceptions import AppError
from shared.responses import ErrorDetail, ErrorResponse
configure_logging(settings.log_level)
logger = get_logger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("service_starting", extra={"service": settings.service_name})
yield
def create_app() -> FastAPI:
app = FastAPI(
title="Accounting Service",
version=__version__,
description="سرویس حسابداری — فاز ۵.۱ تا ۵.۱۱",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_origin_regex=r"https?://([a-z0-9-]+\.)*torbatyar\.ir",
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(TenantHeaderMiddleware)
@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content=ErrorResponse(
error=ErrorDetail(code=exc.error_code, message=exc.message, details=exc.details)
).model_dump(),
)
@app.exception_handler(Exception)
async def unhandled_handler(request: Request, exc: Exception) -> JSONResponse:
logger.error("unhandled_exception", extra={"error": str(exc)}, exc_info=exc)
return JSONResponse(
status_code=500,
content=ErrorResponse(
error=ErrorDetail(code="internal_error", message="خطای داخلی سرور")
).model_dump(),
)
app.include_router(health.router)
app.include_router(api_router, prefix=settings.api_v1_prefix)
return app
app = create_app()

View File

@ -0,0 +1,24 @@
"""Tenant header resolution middleware."""
from __future__ import annotations
from uuid import UUID
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from shared.tenant import HEADER_TENANT_ID, STATE_TENANT_ID
class TenantHeaderMiddleware(BaseHTTPMiddleware):
"""Resolve tenant from X-Tenant-ID header only (microservice pattern)."""
async def dispatch(self, request: Request, call_next) -> Response:
request.state.tenant_id = None
raw = request.headers.get(HEADER_TENANT_ID)
if raw:
try:
request.state.tenant_id = UUID(raw)
except ValueError:
pass
return await call_next(request)

View File

@ -0,0 +1,142 @@
"""Import all models for Alembic metadata discovery."""
from app.models.compliance import ( # noqa: F401
ApprovalRequest,
ApprovalStep,
ApprovalWorkflow,
AuditHistory,
AuditRecord,
CompliancePolicy,
ControlDefinition,
Delegation,
Evidence,
GovernanceRule,
PolicyViolation,
RetentionPolicy,
RiskRecord,
)
from app.models.fixed_assets import ( # noqa: F401
Asset,
AssetAccountingProfile,
AssetCategory,
AssetDepreciation,
AssetDisposal,
AssetGroup,
AssetHistory,
AssetImpairment,
AssetLocation,
AssetRevaluation,
AssetTransfer,
DepreciationSchedule,
)
from app.models.foundation import ( # noqa: F401
Account,
AccountingSettings,
ChartOfAccounts,
CostCenter,
Currency,
Dimension,
DocumentNumberSequence,
ExchangeRate,
FiscalPeriod,
FiscalYear,
Project,
TenantAccountingConfiguration,
)
from app.models.ledger import ( # noqa: F401
AccountingCalendar,
BalanceSnapshot,
ClosingEntry,
GeneralLedger,
LedgerBalance,
OpeningEntry,
TrialBalanceSnapshot,
)
from app.models.payroll import ( # noqa: F401
Allowance,
Benefit,
Deduction,
Department,
Employee,
EmployeeAdvance,
EmployeeLoan,
EmploymentContract,
Payroll,
PayrollAccountingProfile,
PayrollHistory,
PayrollItem,
PayrollPeriod,
PayrollSettlement,
Position,
SalaryComponent,
)
from app.models.posting import ( # noqa: F401
AccountingAuditLog,
Journal,
JournalEntry,
PostingError,
PostingLog,
PostingReference,
Voucher,
VoucherLine,
)
from app.models.purchase_inventory import ( # noqa: F401
InventoryAccountingConfiguration,
InventoryCostAdjustment,
InventoryPostingProfile,
InventorySnapshot,
InventoryValuationHistory,
InventoryValuationMethodConfig,
PurchaseAccountingConfiguration,
PurchasePostingProfile,
)
from app.models.receivable_payable import ( # noqa: F401
AdvancePayment,
AdvanceReceipt,
AgingSnapshot,
CreditNote,
CustomerAccount,
CustomerStatement,
DebitNote,
PayableInvoice,
ReceivableInvoice,
Settlement,
SettlementAllocation,
SupplierAccount,
SupplierStatement,
)
from app.models.reporting import ( # noqa: F401
Dashboard,
DashboardWidget,
FinancialReport,
KPI,
ReportConfiguration,
ReportExport,
ReportHistory,
ReportSnapshot,
ReportTemplate,
ScheduledReport,
)
from app.models.sales_accounting import ( # noqa: F401
AccountingPreview,
AccountingProfile,
PostingRule,
PostingSimulation,
RevenueRecognition,
RevenueSchedule,
SalesAccountingConfiguration,
SalesPostingProfile,
)
from app.models.treasury import ( # noqa: F401
Bank,
BankAccount,
BankTransaction,
CashBox,
CashTransaction,
Cheque,
ChequeBook,
PaymentVoucher,
ReceiptVoucher,
Reconciliation,
Transfer,
TreasurySettings,
)

View File

@ -0,0 +1,32 @@
"""Shared model mixins."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
from app.models.types import GUID
class UUIDPrimaryKeyMixin:
id: Mapped[uuid.UUID] = mapped_column(
GUID(), primary_key=True, default=uuid.uuid4
)
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
class TenantMixin:
tenant_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)

View File

@ -0,0 +1,186 @@
"""Phase 5.11 — Compliance, Audit & Governance domain models."""
from __future__ import annotations
import uuid
from datetime import date, datetime
from sqlalchemy import Boolean, Date, DateTime, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.models.base import TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.types import ApprovalStatus, GUID, PolicyViolationSeverity, RiskLevel
class AuditRecord(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "audit_records"
actor_user_id: Mapped[str] = mapped_column(String(100), nullable=False)
action: Mapped[str] = mapped_column(String(100), nullable=False)
resource_type: Mapped[str] = mapped_column(String(50), nullable=False)
resource_id: Mapped[str] = mapped_column(String(100), nullable=False)
source_module: Mapped[str | None] = mapped_column(String(50), nullable=True)
before_value: Mapped[str | None] = mapped_column(Text, nullable=True)
after_value: Mapped[str | None] = mapped_column(Text, nullable=True)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
correlation_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
request_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True)
session_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
is_immutable: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (
Index("ix_audit_records_tenant_created", "tenant_id", "created_at"),
Index("ix_audit_records_resource", "tenant_id", "resource_type", "resource_id"),
)
class AuditHistory(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "audit_history"
audit_record_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
event_type: Mapped[str] = mapped_column(String(50), nullable=False)
event_data: Mapped[str | None] = mapped_column(Text, nullable=True)
class CompliancePolicy(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "compliance_policies"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
policy_type: Mapped[str] = mapped_column(String(50), nullable=False)
country_code: Mapped[str | None] = mapped_column(String(3), nullable=True)
rules_config: Mapped[str] = mapped_column(Text, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
effective_from: Mapped[date | None] = mapped_column(Date, nullable=True)
effective_to: Mapped[date | None] = mapped_column(Date, nullable=True)
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_compliance_policy_tenant_code"),)
class GovernanceRule(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "governance_rules"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
rule_type: Mapped[str] = mapped_column(String(50), nullable=False)
condition_config: Mapped[str] = mapped_column(Text, nullable=False)
action_config: Mapped[str | None] = mapped_column(Text, nullable=True)
priority: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_governance_rule_tenant_code"),)
class ApprovalWorkflow(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "approval_workflows"
name: Mapped[str] = mapped_column(String(255), nullable=False)
resource_type: Mapped[str] = mapped_column(String(50), nullable=False)
min_amount: Mapped[str | None] = mapped_column(String(50), nullable=True)
max_amount: Mapped[str | None] = mapped_column(String(50), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (UniqueConstraint("tenant_id", "name", name="uq_approval_workflow_tenant_name"),)
class ApprovalStep(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "approval_steps"
workflow_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
step_number: Mapped[int] = mapped_column(Integer, nullable=False)
approver_role: Mapped[str] = mapped_column(String(50), nullable=False)
is_required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
class ApprovalRequest(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "approval_requests"
workflow_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
resource_type: Mapped[str] = mapped_column(String(50), nullable=False)
resource_id: Mapped[str] = mapped_column(String(100), nullable=False)
requested_by: Mapped[str] = mapped_column(String(100), nullable=False)
current_step: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
status: Mapped[ApprovalStatus] = mapped_column(default=ApprovalStatus.PENDING, nullable=False)
approved_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
rejection_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
__table_args__ = (
Index("ix_approval_requests_resource", "tenant_id", "resource_type", "resource_id"),
)
class Delegation(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "delegations"
delegator_user_id: Mapped[str] = mapped_column(String(100), nullable=False)
delegate_user_id: Mapped[str] = mapped_column(String(100), nullable=False)
permission_scope: Mapped[str] = mapped_column(String(255), nullable=False)
start_date: Mapped[date] = mapped_column(Date, nullable=False)
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
class RiskRecord(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "risk_records"
code: Mapped[str] = mapped_column(String(50), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
risk_category: Mapped[str] = mapped_column(String(50), nullable=False)
risk_level: Mapped[RiskLevel] = mapped_column(nullable=False)
risk_score: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
mitigation_plan: Mapped[str | None] = mapped_column(Text, nullable=True)
is_resolved: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_risk_record_tenant_code"),)
class PolicyViolation(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "policy_violations"
policy_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
resource_type: Mapped[str] = mapped_column(String(50), nullable=False)
resource_id: Mapped[str] = mapped_column(String(100), nullable=False)
severity: Mapped[PolicyViolationSeverity] = mapped_column(nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
detected_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
is_resolved: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
class Evidence(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "evidence"
resource_type: Mapped[str] = mapped_column(String(50), nullable=False)
resource_id: Mapped[str] = mapped_column(String(100), nullable=False)
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
file_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
mime_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
uploaded_by: Mapped[str] = mapped_column(String(100), nullable=False)
version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
class RetentionPolicy(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "retention_policies"
name: Mapped[str] = mapped_column(String(255), nullable=False)
resource_type: Mapped[str] = mapped_column(String(50), nullable=False)
retention_days: Mapped[int] = mapped_column(Integer, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (UniqueConstraint("tenant_id", "name", name="uq_retention_policy_tenant_name"),)
class ControlDefinition(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "control_definitions"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
control_type: Mapped[str] = mapped_column(String(50), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
validation_config: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_control_def_tenant_code"),)

View File

@ -0,0 +1,171 @@
"""Phase 5.8 — Fixed Assets domain models."""
from __future__ import annotations
import uuid
from datetime import date
from decimal import Decimal
from sqlalchemy import Boolean, Date, Index, Integer, Numeric, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.models.base import TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.types import AssetStatus, DepreciationMethod, GUID
class AssetCategory(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "asset_categories"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
default_useful_life_months: Mapped[int] = mapped_column(Integer, default=60, nullable=False)
default_depreciation_method: Mapped[DepreciationMethod] = mapped_column(
default=DepreciationMethod.STRAIGHT_LINE, nullable=False
)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_asset_category_tenant_code"),)
class AssetGroup(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "asset_groups"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
category_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_asset_group_tenant_code"),)
class Asset(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "assets"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
category_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
group_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
serial_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
location: Mapped[str | None] = mapped_column(String(255), nullable=True)
department_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
cost_center_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
project_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
acquisition_date: Mapped[date | None] = mapped_column(Date, nullable=True)
acquisition_cost: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
current_book_value: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
residual_value: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
useful_life_months: Mapped[int] = mapped_column(Integer, default=60, nullable=False)
depreciation_method: Mapped[DepreciationMethod] = mapped_column(
default=DepreciationMethod.STRAIGHT_LINE, nullable=False
)
status: Mapped[AssetStatus] = mapped_column(default=AssetStatus.DRAFT, nullable=False)
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_asset_tenant_code"),
Index("ix_assets_tenant_status", "tenant_id", "status"),
)
class AssetDepreciation(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "asset_depreciations"
asset_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
fiscal_period_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
depreciation_date: Mapped[date] = mapped_column(Date, nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
accumulated_depreciation: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
book_value_after: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
__table_args__ = (Index("ix_asset_depreciation_asset", "tenant_id", "asset_id"),)
class DepreciationSchedule(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "depreciation_schedules"
asset_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
period_number: Mapped[int] = mapped_column(Integer, nullable=False)
schedule_date: Mapped[date] = mapped_column(Date, nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
is_posted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
class AssetTransfer(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "asset_transfers"
asset_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
transfer_date: Mapped[date] = mapped_column(Date, nullable=False)
from_location: Mapped[str | None] = mapped_column(String(255), nullable=True)
to_location: Mapped[str | None] = mapped_column(String(255), nullable=True)
from_department_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
to_department_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
class AssetDisposal(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "asset_disposals"
asset_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
disposal_date: Mapped[date] = mapped_column(Date, nullable=False)
disposal_type: Mapped[str] = mapped_column(String(50), nullable=False)
sale_amount: Mapped[Decimal | None] = mapped_column(Numeric(18, 4), nullable=True)
gain_loss: Mapped[Decimal | None] = mapped_column(Numeric(18, 4), nullable=True)
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
class AssetRevaluation(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "asset_revaluations"
asset_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
revaluation_date: Mapped[date] = mapped_column(Date, nullable=False)
old_book_value: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
new_book_value: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
difference: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
class AssetImpairment(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "asset_impairments"
asset_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
impairment_date: Mapped[date] = mapped_column(Date, nullable=False)
impairment_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
class AssetAccountingProfile(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "asset_accounting_profiles"
name: Mapped[str] = mapped_column(String(255), nullable=False)
category_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
asset_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
depreciation_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
accumulated_depreciation_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
disposal_gain_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
disposal_loss_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
class AssetHistory(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "asset_history"
asset_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
event_type: Mapped[str] = mapped_column(String(50), nullable=False)
event_date: Mapped[date] = mapped_column(Date, nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
before_value: Mapped[str | None] = mapped_column(Text, nullable=True)
after_value: Mapped[str | None] = mapped_column(Text, nullable=True)
__table_args__ = (Index("ix_asset_history_asset", "tenant_id", "asset_id"),)
class AssetLocation(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "asset_locations"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_asset_location_tenant_code"),)

View File

@ -0,0 +1,230 @@
"""Phase 5.1 — Accounting foundation domain models."""
from __future__ import annotations
import uuid
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import (
Boolean,
Date,
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.types import (
AccountCategory,
AccountStatus,
AccountType,
FiscalPeriodStatus,
GUID,
)
class ChartOfAccounts(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "chart_of_accounts"
name: Mapped[str] = mapped_column(String(255), nullable=False)
code: Mapped[str] = mapped_column(String(50), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
accounts: Mapped[list["Account"]] = relationship(back_populates="chart")
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_coa_tenant_code"),
)
class Account(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "accounts"
chart_id: Mapped[uuid.UUID] = mapped_column(
GUID(), ForeignKey("chart_of_accounts.id", ondelete="CASCADE"), nullable=False
)
parent_id: Mapped[uuid.UUID | None] = mapped_column(
GUID(), ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
account_type: Mapped[AccountType] = mapped_column(nullable=False)
account_category: Mapped[AccountCategory] = mapped_column(nullable=False)
level: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
is_group: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_postable: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
status: Mapped[AccountStatus] = mapped_column(
default=AccountStatus.ACTIVE, nullable=False
)
currency_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
chart: Mapped["ChartOfAccounts"] = relationship(back_populates="accounts")
parent: Mapped["Account | None"] = relationship(remote_side="Account.id")
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_account_tenant_code"),
Index("ix_accounts_chart_id", "chart_id"),
)
class Currency(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "currencies"
code: Mapped[str] = mapped_column(String(3), nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
symbol: Mapped[str | None] = mapped_column(String(10), nullable=True)
decimal_places: Mapped[int] = mapped_column(Integer, default=2, nullable=False)
is_base: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_currency_tenant_code"),
)
class ExchangeRate(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "exchange_rates"
from_currency_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
to_currency_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
rate: Mapped[Decimal] = mapped_column(Numeric(18, 8), nullable=False)
effective_date: Mapped[date] = mapped_column(Date, nullable=False)
__table_args__ = (
Index("ix_exchange_rates_tenant_date", "tenant_id", "effective_date"),
)
class FiscalYear(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "fiscal_years"
name: Mapped[str] = mapped_column(String(100), nullable=False)
start_date: Mapped[date] = mapped_column(Date, nullable=False)
end_date: Mapped[date] = mapped_column(Date, nullable=False)
is_current: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_closed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
periods: Mapped[list["FiscalPeriod"]] = relationship(back_populates="fiscal_year")
__table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_fiscal_year_tenant_name"),
)
class FiscalPeriod(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "fiscal_periods"
fiscal_year_id: Mapped[uuid.UUID] = mapped_column(
GUID(), ForeignKey("fiscal_years.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
period_number: Mapped[int] = mapped_column(Integer, nullable=False)
start_date: Mapped[date] = mapped_column(Date, nullable=False)
end_date: Mapped[date] = mapped_column(Date, nullable=False)
status: Mapped[FiscalPeriodStatus] = mapped_column(
default=FiscalPeriodStatus.OPEN, nullable=False
)
is_current: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
fiscal_year: Mapped["FiscalYear"] = relationship(back_populates="periods")
__table_args__ = (
UniqueConstraint(
"tenant_id", "fiscal_year_id", "period_number",
name="uq_fiscal_period_tenant_year_num",
),
Index("ix_fiscal_periods_tenant_status", "tenant_id", "status"),
)
class CostCenter(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "cost_centers"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
parent_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_cost_center_tenant_code"),
)
class Project(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "projects"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_project_tenant_code"),
)
class Dimension(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "dimensions"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
dimension_type: Mapped[str] = mapped_column(String(50), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_dimension_tenant_code"),
)
class AccountingSettings(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "accounting_settings"
key: Mapped[str] = mapped_column(String(100), nullable=False)
value: Mapped[str | None] = mapped_column(Text, nullable=True)
value_type: Mapped[str] = mapped_column(String(20), default="string", nullable=False)
__table_args__ = (
UniqueConstraint("tenant_id", "key", name="uq_accounting_settings_tenant_key"),
)
class DocumentNumberSequence(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "document_number_sequences"
document_type: Mapped[str] = mapped_column(String(50), nullable=False)
prefix: Mapped[str | None] = mapped_column(String(20), nullable=True)
next_number: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
padding: Mapped[int] = mapped_column(Integer, default=6, nullable=False)
__table_args__ = (
UniqueConstraint(
"tenant_id", "document_type", name="uq_doc_seq_tenant_type"
),
)
class TenantAccountingConfiguration(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "tenant_accounting_configurations"
base_currency_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
default_chart_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
fiscal_year_start_month: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
allow_multi_currency: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
require_cost_center: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
require_project: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
__table_args__ = (
UniqueConstraint("tenant_id", name="uq_tenant_accounting_config_tenant"),
)

View File

@ -0,0 +1,146 @@
"""Phase 5.3 — General Ledger & fiscal management models."""
from __future__ import annotations
import uuid
from datetime import date
from decimal import Decimal
from sqlalchemy import (
Boolean,
Date,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.models.base import TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.types import GUID
class GeneralLedger(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "general_ledgers"
account_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
fiscal_period_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
journal_entry_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
voucher_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
entry_date: Mapped[date] = mapped_column(Date, nullable=False)
debit: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
credit: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
running_balance: Mapped[Decimal] = mapped_column(
Numeric(18, 4), default=Decimal("0"), nullable=False
)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
cost_center_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
project_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
__table_args__ = (
Index("ix_gl_tenant_account_period", "tenant_id", "account_id", "fiscal_period_id"),
Index("ix_gl_tenant_date", "tenant_id", "entry_date"),
)
class LedgerBalance(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "ledger_balances"
account_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
fiscal_period_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
opening_balance: Mapped[Decimal] = mapped_column(
Numeric(18, 4), default=Decimal("0"), nullable=False
)
debit_total: Mapped[Decimal] = mapped_column(
Numeric(18, 4), default=Decimal("0"), nullable=False
)
credit_total: Mapped[Decimal] = mapped_column(
Numeric(18, 4), default=Decimal("0"), nullable=False
)
closing_balance: Mapped[Decimal] = mapped_column(
Numeric(18, 4), default=Decimal("0"), nullable=False
)
current_balance: Mapped[Decimal] = mapped_column(
Numeric(18, 4), default=Decimal("0"), nullable=False
)
__table_args__ = (
UniqueConstraint(
"tenant_id", "account_id", "fiscal_period_id",
name="uq_ledger_balance_tenant_account_period",
),
)
class TrialBalanceSnapshot(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "trial_balance_snapshots"
fiscal_period_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
generated_at: Mapped[date] = mapped_column(Date, nullable=False)
total_debit: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
total_credit: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
difference: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
is_balanced: Mapped[bool] = mapped_column(Boolean, nullable=False)
snapshot_data: Mapped[str | None] = mapped_column(Text, nullable=True)
__table_args__ = (
Index("ix_trial_balance_tenant_period", "tenant_id", "fiscal_period_id"),
)
class BalanceSnapshot(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "balance_snapshots"
account_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
fiscal_period_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
snapshot_date: Mapped[date] = mapped_column(Date, nullable=False)
balance: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
snapshot_type: Mapped[str] = mapped_column(String(50), nullable=False)
__table_args__ = (
Index("ix_balance_snapshot_tenant_account", "tenant_id", "account_id"),
)
class ClosingEntry(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "closing_entries"
fiscal_period_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
voucher_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
entry_type: Mapped[str] = mapped_column(String(50), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
__table_args__ = (
Index("ix_closing_entries_period", "tenant_id", "fiscal_period_id"),
)
class OpeningEntry(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "opening_entries"
fiscal_year_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
fiscal_period_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
voucher_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
account_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
is_debit: Mapped[bool] = mapped_column(Boolean, nullable=False)
__table_args__ = (
Index("ix_opening_entries_year", "tenant_id", "fiscal_year_id"),
)
class AccountingCalendar(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "accounting_calendars"
name: Mapped[str] = mapped_column(String(100), nullable=False)
fiscal_year_start_month: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
periods_per_year: Mapped[int] = mapped_column(Integer, default=12, nullable=False)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
__table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_accounting_calendar_tenant_name"),
)

View File

@ -0,0 +1,202 @@
"""Phase 5.9 — HCM & Payroll domain models."""
from __future__ import annotations
import uuid
from datetime import date
from decimal import Decimal
from sqlalchemy import Boolean, Date, Index, Integer, Numeric, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.models.base import TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.types import EmploymentStatus, GUID, PayrollPeriodStatus, PayrollStatus
class Department(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "departments"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
parent_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
cost_center_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_department_tenant_code"),)
class Position(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "positions"
code: Mapped[str] = mapped_column(String(50), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
department_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_position_tenant_code"),)
class Employee(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "employees"
employee_code: Mapped[str] = mapped_column(String(50), nullable=False)
first_name: Mapped[str] = mapped_column(String(100), nullable=False)
last_name: Mapped[str] = mapped_column(String(100), nullable=False)
department_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
position_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
cost_center_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
hire_date: Mapped[date | None] = mapped_column(Date, nullable=True)
employment_status: Mapped[EmploymentStatus] = mapped_column(
default=EmploymentStatus.ACTIVE, nullable=False
)
base_salary: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
__table_args__ = (
UniqueConstraint("tenant_id", "employee_code", name="uq_employee_tenant_code"),
Index("ix_employees_tenant_dept", "tenant_id", "department_id"),
)
class EmploymentContract(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "employment_contracts"
employee_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
contract_type: Mapped[str] = mapped_column(String(50), nullable=False)
start_date: Mapped[date] = mapped_column(Date, nullable=False)
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
base_salary: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
class SalaryComponent(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "salary_components"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
component_type: Mapped[str] = mapped_column(String(20), nullable=False)
is_taxable: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_salary_component_tenant_code"),)
class PayrollPeriod(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "payroll_periods"
name: Mapped[str] = mapped_column(String(100), nullable=False)
start_date: Mapped[date] = mapped_column(Date, nullable=False)
end_date: Mapped[date] = mapped_column(Date, nullable=False)
status: Mapped[PayrollPeriodStatus] = mapped_column(
default=PayrollPeriodStatus.OPEN, nullable=False
)
is_current: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
__table_args__ = (UniqueConstraint("tenant_id", "name", name="uq_payroll_period_tenant_name"),)
class Payroll(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "payrolls"
payroll_period_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
employee_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
gross_salary: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
total_deductions: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
total_benefits: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
net_salary: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
employer_cost: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
status: Mapped[PayrollStatus] = mapped_column(default=PayrollStatus.DRAFT, nullable=False)
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
__table_args__ = (
Index("ix_payroll_period_employee", "tenant_id", "payroll_period_id", "employee_id"),
)
class PayrollItem(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "payroll_items"
payroll_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
component_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
item_type: Mapped[str] = mapped_column(String(20), nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
class Benefit(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "benefits"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_benefit_tenant_code"),)
class Allowance(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "allowances"
employee_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
component_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
effective_date: Mapped[date] = mapped_column(Date, nullable=False)
class Deduction(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "deductions"
employee_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
component_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
effective_date: Mapped[date] = mapped_column(Date, nullable=False)
class EmployeeLoan(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "employee_loans"
employee_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
loan_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
remaining_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
monthly_deduction: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
start_date: Mapped[date] = mapped_column(Date, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
class EmployeeAdvance(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "employee_advances"
employee_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
remaining_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
advance_date: Mapped[date] = mapped_column(Date, nullable=False)
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
class PayrollAccountingProfile(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "payroll_accounting_profiles"
name: Mapped[str] = mapped_column(String(255), nullable=False)
department_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
salary_expense_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
employer_contribution_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
payable_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
deduction_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
class PayrollHistory(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "payroll_history"
payroll_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
action: Mapped[str] = mapped_column(String(50), nullable=False)
actor_user_id: Mapped[str] = mapped_column(String(100), nullable=False)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
class PayrollSettlement(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "payroll_settlements"
payroll_period_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
settlement_date: Mapped[date] = mapped_column(Date, nullable=False)
total_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)

View File

@ -0,0 +1,198 @@
"""Phase 5.2 — Double-entry posting domain models."""
from __future__ import annotations
import uuid
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import (
Boolean,
Date,
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.types import (
GUID,
JournalStatus,
PostingStatus,
VoucherStatus,
)
class Voucher(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "vouchers"
fiscal_period_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
voucher_number: Mapped[str] = mapped_column(String(50), nullable=False)
voucher_date: Mapped[date] = mapped_column(Date, nullable=False)
status: Mapped[VoucherStatus] = mapped_column(
default=VoucherStatus.DRAFT, nullable=False
)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
reference_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
source_module: Mapped[str | None] = mapped_column(String(50), nullable=True)
currency_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
total_debit: Mapped[Decimal] = mapped_column(
Numeric(18, 4), default=Decimal("0"), nullable=False
)
total_credit: Mapped[Decimal] = mapped_column(
Numeric(18, 4), default=Decimal("0"), nullable=False
)
posted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
posted_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
reversed_voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
created_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
lines: Mapped[list["VoucherLine"]] = relationship(
back_populates="voucher", cascade="all, delete-orphan"
)
journal_entries: Mapped[list["JournalEntry"]] = relationship(back_populates="voucher")
__table_args__ = (
UniqueConstraint("tenant_id", "voucher_number", name="uq_voucher_tenant_number"),
Index("ix_vouchers_tenant_status", "tenant_id", "status"),
Index("ix_vouchers_fiscal_period", "tenant_id", "fiscal_period_id"),
)
class VoucherLine(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "voucher_lines"
voucher_id: Mapped[uuid.UUID] = mapped_column(
GUID(), ForeignKey("vouchers.id", ondelete="CASCADE"), nullable=False
)
line_number: Mapped[int] = mapped_column(Integer, nullable=False)
account_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
debit: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
credit: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
cost_center_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
project_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
currency_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
exchange_rate: Mapped[Decimal | None] = mapped_column(Numeric(18, 8), nullable=True)
voucher: Mapped["Voucher"] = relationship(back_populates="lines")
__table_args__ = (
Index("ix_voucher_lines_voucher_id", "voucher_id"),
Index("ix_voucher_lines_account_id", "tenant_id", "account_id"),
)
class Journal(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "journals"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_journal_tenant_code"),
)
class JournalEntry(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
"""Immutable after posting — only created via Posting Engine."""
__tablename__ = "journal_entries"
voucher_id: Mapped[uuid.UUID] = mapped_column(
GUID(), ForeignKey("vouchers.id", ondelete="RESTRICT"), nullable=False
)
journal_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
fiscal_period_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
entry_number: Mapped[str] = mapped_column(String(50), nullable=False)
entry_date: Mapped[date] = mapped_column(Date, nullable=False)
status: Mapped[JournalStatus] = mapped_column(
default=JournalStatus.DRAFT, nullable=False
)
account_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
debit: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
credit: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
cost_center_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
project_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
posted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
posted_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
voucher: Mapped["Voucher"] = relationship(back_populates="journal_entries")
__table_args__ = (
Index("ix_journal_entries_tenant_account", "tenant_id", "account_id"),
Index("ix_journal_entries_voucher", "voucher_id"),
Index("ix_journal_entries_period", "tenant_id", "fiscal_period_id"),
)
class PostingLog(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "posting_logs"
voucher_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
operation: Mapped[str] = mapped_column(String(50), nullable=False)
status: Mapped[PostingStatus] = mapped_column(nullable=False)
actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
source_module: Mapped[str | None] = mapped_column(String(50), nullable=True)
message: Mapped[str | None] = mapped_column(Text, nullable=True)
correlation_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
__table_args__ = (Index("ix_posting_logs_voucher", "tenant_id", "voucher_id"),)
class PostingError(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "posting_errors"
voucher_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
error_code: Mapped[str] = mapped_column(String(50), nullable=False)
error_message: Mapped[str] = mapped_column(Text, nullable=False)
field_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
__table_args__ = (Index("ix_posting_errors_voucher", "tenant_id", "voucher_id"),)
class PostingReference(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "posting_references"
voucher_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
source_module: Mapped[str] = mapped_column(String(50), nullable=False)
source_document_type: Mapped[str] = mapped_column(String(50), nullable=False)
source_document_id: Mapped[str] = mapped_column(String(100), nullable=False)
is_posted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
__table_args__ = (
UniqueConstraint(
"tenant_id", "source_module", "source_document_type", "source_document_id",
name="uq_posting_ref_source",
),
)
class AccountingAuditLog(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "accounting_audit_logs"
operation: Mapped[str] = mapped_column(String(50), nullable=False)
actor_user_id: Mapped[str] = mapped_column(String(100), nullable=False)
source_module: Mapped[str | None] = mapped_column(String(50), nullable=True)
voucher_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
reference_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
resource_type: Mapped[str] = mapped_column(String(50), nullable=False)
resource_id: Mapped[str] = mapped_column(String(100), nullable=False)
before_value: Mapped[str | None] = mapped_column(Text, nullable=True)
after_value: Mapped[str | None] = mapped_column(Text, nullable=True)
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
correlation_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
__table_args__ = (
Index("ix_audit_logs_tenant_created", "tenant_id", "created_at"),
)

View File

@ -0,0 +1,124 @@
"""Phase 5.7 — Purchase & Inventory accounting domain models."""
from __future__ import annotations
import uuid
from datetime import date
from decimal import Decimal
from sqlalchemy import Boolean, Date, Index, Integer, Numeric, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.models.base import TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.types import GUID, InventoryValuationMethod
class PurchasePostingProfile(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "purchase_posting_profiles"
name: Mapped[str] = mapped_column(String(255), nullable=False)
inventory_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
grni_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
expense_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
liability_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
discount_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
adjustment_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (UniqueConstraint("tenant_id", "name", name="uq_purchase_profile_tenant_name"),)
class InventoryPostingProfile(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "inventory_posting_profiles"
name: Mapped[str] = mapped_column(String(255), nullable=False)
inventory_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
cogs_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
adjustment_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
gain_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
loss_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
write_off_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (UniqueConstraint("tenant_id", "name", name="uq_inventory_profile_tenant_name"),)
class InventoryValuationMethodConfig(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "inventory_valuation_methods"
item_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
warehouse_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
method: Mapped[InventoryValuationMethod] = mapped_column(nullable=False)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
__table_args__ = (
Index("ix_inv_valuation_tenant_item", "tenant_id", "item_id"),
)
class InventoryValuationHistory(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "inventory_valuation_history"
item_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
warehouse_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
valuation_date: Mapped[date] = mapped_column(Date, nullable=False)
method: Mapped[InventoryValuationMethod] = mapped_column(nullable=False)
quantity: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
unit_cost: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
total_value: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
__table_args__ = (
Index("ix_inv_val_history_item", "tenant_id", "item_id", "valuation_date"),
)
class InventoryCostAdjustment(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "inventory_cost_adjustments"
item_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
warehouse_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
adjustment_date: Mapped[date] = mapped_column(Date, nullable=False)
old_cost: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
new_cost: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
difference: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
class PurchaseAccountingConfiguration(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "purchase_accounting_configurations"
default_profile_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
auto_post_receipts: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
auto_post_invoices: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
__table_args__ = (UniqueConstraint("tenant_id", name="uq_purchase_accounting_config_tenant"),)
class InventoryAccountingConfiguration(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "inventory_accounting_configurations"
default_profile_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
default_valuation_method: Mapped[InventoryValuationMethod] = mapped_column(
default=InventoryValuationMethod.WEIGHTED_AVERAGE, nullable=False
)
auto_post_issues: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
__table_args__ = (UniqueConstraint("tenant_id", name="uq_inventory_accounting_config_tenant"),)
class InventorySnapshot(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "inventory_snapshots"
item_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
warehouse_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
snapshot_date: Mapped[date] = mapped_column(Date, nullable=False)
quantity: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
unit_cost: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
total_value: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
__table_args__ = (
Index("ix_inventory_snapshot_item", "tenant_id", "item_id", "snapshot_date"),
)

View File

@ -0,0 +1,238 @@
"""Phase 5.5 — Accounts Receivable & Payable domain models."""
from __future__ import annotations
import uuid
from datetime import date
from decimal import Decimal
from sqlalchemy import (
Boolean,
Date,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.types import GUID, InvoiceStatus, SettlementStatus
class CustomerAccount(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "customer_accounts"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
credit_limit: Mapped[Decimal | None] = mapped_column(Numeric(18, 4), nullable=True)
currency_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
outstanding_balance: Mapped[Decimal] = mapped_column(
Numeric(18, 4), default=Decimal("0"), nullable=False
)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
invoices: Mapped[list["ReceivableInvoice"]] = relationship(back_populates="customer")
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_customer_tenant_code"),
)
class SupplierAccount(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "supplier_accounts"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
currency_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
outstanding_balance: Mapped[Decimal] = mapped_column(
Numeric(18, 4), default=Decimal("0"), nullable=False
)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
invoices: Mapped[list["PayableInvoice"]] = relationship(back_populates="supplier")
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_supplier_tenant_code"),
)
class ReceivableInvoice(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "receivable_invoices"
customer_id: Mapped[uuid.UUID] = mapped_column(
GUID(), ForeignKey("customer_accounts.id", ondelete="RESTRICT"), nullable=False
)
invoice_number: Mapped[str] = mapped_column(String(50), nullable=False)
invoice_date: Mapped[date] = mapped_column(Date, nullable=False)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
total_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
paid_amount: Mapped[Decimal] = mapped_column(
Numeric(18, 4), default=Decimal("0"), nullable=False
)
remaining_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
status: Mapped[InvoiceStatus] = mapped_column(nullable=False)
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
currency_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
customer: Mapped["CustomerAccount"] = relationship(back_populates="invoices")
__table_args__ = (
UniqueConstraint("tenant_id", "invoice_number", name="uq_receivable_inv_tenant_number"),
Index("ix_receivable_inv_customer", "tenant_id", "customer_id"),
)
class PayableInvoice(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "payable_invoices"
supplier_id: Mapped[uuid.UUID] = mapped_column(
GUID(), ForeignKey("supplier_accounts.id", ondelete="RESTRICT"), nullable=False
)
invoice_number: Mapped[str] = mapped_column(String(50), nullable=False)
invoice_date: Mapped[date] = mapped_column(Date, nullable=False)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
total_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
paid_amount: Mapped[Decimal] = mapped_column(
Numeric(18, 4), default=Decimal("0"), nullable=False
)
remaining_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
status: Mapped[InvoiceStatus] = mapped_column(nullable=False)
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
currency_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
supplier: Mapped["SupplierAccount"] = relationship(back_populates="invoices")
__table_args__ = (
UniqueConstraint("tenant_id", "invoice_number", name="uq_payable_inv_tenant_number"),
Index("ix_payable_inv_supplier", "tenant_id", "supplier_id"),
)
class Settlement(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "settlements"
settlement_number: Mapped[str] = mapped_column(String(50), nullable=False)
settlement_date: Mapped[date] = mapped_column(Date, nullable=False)
settlement_type: Mapped[str] = mapped_column(String(20), nullable=False)
party_type: Mapped[str] = mapped_column(String(20), nullable=False)
party_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
total_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
status: Mapped[SettlementStatus] = mapped_column(nullable=False)
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
allocations: Mapped[list["SettlementAllocation"]] = relationship(
back_populates="settlement", cascade="all, delete-orphan"
)
__table_args__ = (
UniqueConstraint("tenant_id", "settlement_number", name="uq_settlement_tenant_number"),
)
class SettlementAllocation(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "settlement_allocations"
settlement_id: Mapped[uuid.UUID] = mapped_column(
GUID(), ForeignKey("settlements.id", ondelete="CASCADE"), nullable=False
)
invoice_type: Mapped[str] = mapped_column(String(20), nullable=False)
invoice_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
allocated_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
settlement: Mapped["Settlement"] = relationship(back_populates="allocations")
class CreditNote(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "credit_notes"
note_number: Mapped[str] = mapped_column(String(50), nullable=False)
note_date: Mapped[date] = mapped_column(Date, nullable=False)
party_type: Mapped[str] = mapped_column(String(20), nullable=False)
party_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "note_number", name="uq_credit_note_tenant_number"),
)
class DebitNote(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "debit_notes"
note_number: Mapped[str] = mapped_column(String(50), nullable=False)
note_date: Mapped[date] = mapped_column(Date, nullable=False)
party_type: Mapped[str] = mapped_column(String(20), nullable=False)
party_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "note_number", name="uq_debit_note_tenant_number"),
)
class AdvancePayment(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "advance_payments"
supplier_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
remaining_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
payment_date: Mapped[date] = mapped_column(Date, nullable=False)
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
class AdvanceReceipt(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "advance_receipts"
customer_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
remaining_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
receipt_date: Mapped[date] = mapped_column(Date, nullable=False)
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
class CustomerStatement(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "customer_statements"
customer_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
statement_date: Mapped[date] = mapped_column(Date, nullable=False)
opening_balance: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
closing_balance: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
statement_data: Mapped[str | None] = mapped_column(Text, nullable=True)
class SupplierStatement(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "supplier_statements"
supplier_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
statement_date: Mapped[date] = mapped_column(Date, nullable=False)
opening_balance: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
closing_balance: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
statement_data: Mapped[str | None] = mapped_column(Text, nullable=True)
class AgingSnapshot(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "aging_snapshots"
party_type: Mapped[str] = mapped_column(String(20), nullable=False)
party_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
snapshot_date: Mapped[date] = mapped_column(Date, nullable=False)
current_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
days_1_30: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
days_31_60: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
days_61_90: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
days_90_plus: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
total_outstanding: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
__table_args__ = (
Index("ix_aging_snapshot_party", "tenant_id", "party_type", "party_id"),
)

View File

@ -0,0 +1,124 @@
"""Phase 5.10 — Financial Reporting & BI domain models."""
from __future__ import annotations
import uuid
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Boolean, Date, DateTime, Index, Integer, Numeric, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.models.base import TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.types import GUID, ReportExportFormat, ReportStatus, ReportType
class FinancialReport(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "financial_reports"
report_type: Mapped[ReportType] = mapped_column(nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
fiscal_period_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
fiscal_year_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
status: Mapped[ReportStatus] = mapped_column(default=ReportStatus.GENERATED, nullable=False)
report_data: Mapped[str | None] = mapped_column(Text, nullable=True)
generated_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
__table_args__ = (
Index("ix_financial_reports_tenant_type", "tenant_id", "report_type"),
)
class ReportTemplate(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "report_templates"
name: Mapped[str] = mapped_column(String(255), nullable=False)
report_type: Mapped[ReportType] = mapped_column(nullable=False)
template_config: Mapped[str] = mapped_column(Text, nullable=False)
is_system: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (UniqueConstraint("tenant_id", "name", name="uq_report_template_tenant_name"),)
class Dashboard(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "dashboards"
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
dashboard_type: Mapped[str] = mapped_column(String(50), nullable=False)
layout_config: Mapped[str | None] = mapped_column(Text, nullable=True)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (UniqueConstraint("tenant_id", "name", name="uq_dashboard_tenant_name"),)
class DashboardWidget(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "dashboard_widgets"
dashboard_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
widget_type: Mapped[str] = mapped_column(String(50), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
config: Mapped[str | None] = mapped_column(Text, nullable=True)
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
class KPI(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "kpis"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
kpi_type: Mapped[str] = mapped_column(String(50), nullable=False)
current_value: Mapped[Decimal | None] = mapped_column(Numeric(18, 4), nullable=True)
target_value: Mapped[Decimal | None] = mapped_column(Numeric(18, 4), nullable=True)
calculated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_kpi_tenant_code"),)
class ReportSnapshot(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "report_snapshots"
report_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
snapshot_date: Mapped[date] = mapped_column(Date, nullable=False)
snapshot_data: Mapped[str] = mapped_column(Text, nullable=False)
class ScheduledReport(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "scheduled_reports"
template_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
report_type: Mapped[ReportType] = mapped_column(nullable=False)
schedule_cron: Mapped[str] = mapped_column(String(100), nullable=False)
recipients: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class ReportExport(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "report_exports"
report_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
export_format: Mapped[ReportExportFormat] = mapped_column(nullable=False)
file_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
exported_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
exported_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
class ReportHistory(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "report_history"
report_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
action: Mapped[str] = mapped_column(String(50), nullable=False)
actor_user_id: Mapped[str] = mapped_column(String(100), nullable=False)
class ReportConfiguration(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "report_configurations"
key: Mapped[str] = mapped_column(String(100), nullable=False)
value: Mapped[str | None] = mapped_column(Text, nullable=True)
__table_args__ = (UniqueConstraint("tenant_id", "key", name="uq_report_config_tenant_key"),)

View File

@ -0,0 +1,144 @@
"""Phase 5.6 — Sales accounting integration domain models."""
from __future__ import annotations
import uuid
from datetime import date
from decimal import Decimal
from sqlalchemy import (
Boolean,
Date,
Index,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.models.base import TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.types import GUID, RevenueRecognitionMethod, SalesDocumentType
class SalesPostingProfile(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "sales_posting_profiles"
name: Mapped[str] = mapped_column(String(255), nullable=False)
document_type: Mapped[SalesDocumentType] = mapped_column(nullable=False)
revenue_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
receivable_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
cash_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
bank_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
discount_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
tax_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
deferred_revenue_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
rounding_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_sales_profile_tenant_name"),
)
class AccountingProfile(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "accounting_profiles"
name: Mapped[str] = mapped_column(String(255), nullable=False)
profile_type: Mapped[str] = mapped_column(String(50), nullable=False)
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
entity_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
sales_posting_profile_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
config_data: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (
Index("ix_accounting_profile_entity", "tenant_id", "entity_type", "entity_id"),
)
class PostingRule(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "posting_rules"
name: Mapped[str] = mapped_column(String(255), nullable=False)
document_type: Mapped[str] = mapped_column(String(50), nullable=False)
source_module: Mapped[str] = mapped_column(String(50), nullable=False)
debit_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
credit_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
condition_expression: Mapped[str | None] = mapped_column(Text, nullable=True)
priority: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (
Index("ix_posting_rules_tenant_module", "tenant_id", "source_module"),
)
class SalesAccountingConfiguration(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "sales_accounting_configurations"
default_profile_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
auto_post_invoices: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
auto_post_returns: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
default_revenue_recognition: Mapped[RevenueRecognitionMethod] = mapped_column(
default=RevenueRecognitionMethod.IMMEDIATE, nullable=False
)
__table_args__ = (
UniqueConstraint("tenant_id", name="uq_sales_accounting_config_tenant"),
)
class RevenueRecognition(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "revenue_recognitions"
source_document_type: Mapped[str] = mapped_column(String(50), nullable=False)
source_document_id: Mapped[str] = mapped_column(String(100), nullable=False)
method: Mapped[RevenueRecognitionMethod] = mapped_column(nullable=False)
total_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
recognized_amount: Mapped[Decimal] = mapped_column(
Numeric(18, 4), default=Decimal("0"), nullable=False
)
deferred_amount: Mapped[Decimal] = mapped_column(
Numeric(18, 4), default=Decimal("0"), nullable=False
)
recognition_date: Mapped[date | None] = mapped_column(Date, nullable=True)
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
__table_args__ = (
Index("ix_revenue_recognition_source", "tenant_id", "source_document_type", "source_document_id"),
)
class RevenueSchedule(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "revenue_schedules"
revenue_recognition_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
schedule_date: Mapped[date] = mapped_column(Date, nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
is_recognized: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
class AccountingPreview(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "accounting_previews"
source_module: Mapped[str] = mapped_column(String(50), nullable=False)
source_document_type: Mapped[str] = mapped_column(String(50), nullable=False)
source_document_id: Mapped[str] = mapped_column(String(100), nullable=False)
preview_data: Mapped[str] = mapped_column(Text, nullable=False)
is_balanced: Mapped[bool] = mapped_column(Boolean, nullable=False)
total_debit: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
total_credit: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
class PostingSimulation(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "posting_simulations"
source_module: Mapped[str] = mapped_column(String(50), nullable=False)
simulation_data: Mapped[str] = mapped_column(Text, nullable=False)
result_data: Mapped[str | None] = mapped_column(Text, nullable=True)
is_successful: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)

View File

@ -0,0 +1,236 @@
"""Phase 5.4 — Treasury management domain models."""
from __future__ import annotations
import uuid
from datetime import date
from decimal import Decimal
from sqlalchemy import (
Boolean,
Date,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.types import (
ChequeStatus,
GUID,
ReconciliationStatus,
TreasuryTransactionType,
)
class TreasurySettings(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "treasury_settings"
key: Mapped[str] = mapped_column(String(100), nullable=False)
value: Mapped[str | None] = mapped_column(Text, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "key", name="uq_treasury_settings_tenant_key"),
)
class CashBox(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "cash_boxes"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
currency_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
opening_balance: Mapped[Decimal] = mapped_column(
Numeric(18, 4), default=Decimal("0"), nullable=False
)
current_balance: Mapped[Decimal] = mapped_column(
Numeric(18, 4), default=Decimal("0"), nullable=False
)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
transactions: Mapped[list["CashTransaction"]] = relationship(back_populates="cash_box")
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_cash_box_tenant_code"),
)
class CashTransaction(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "cash_transactions"
cash_box_id: Mapped[uuid.UUID] = mapped_column(
GUID(), ForeignKey("cash_boxes.id", ondelete="RESTRICT"), nullable=False
)
transaction_type: Mapped[TreasuryTransactionType] = mapped_column(nullable=False)
transaction_date: Mapped[date] = mapped_column(Date, nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
reference_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
cash_box: Mapped["CashBox"] = relationship(back_populates="transactions")
__table_args__ = (
Index("ix_cash_tx_tenant_box", "tenant_id", "cash_box_id"),
)
class Bank(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "banks"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
swift_code: Mapped[str | None] = mapped_column(String(20), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
accounts: Mapped[list["BankAccount"]] = relationship(back_populates="bank")
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_bank_tenant_code"),
)
class BankAccount(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "bank_accounts"
bank_id: Mapped[uuid.UUID] = mapped_column(
GUID(), ForeignKey("banks.id", ondelete="RESTRICT"), nullable=False
)
account_number: Mapped[str] = mapped_column(String(50), nullable=False)
iban: Mapped[str | None] = mapped_column(String(34), nullable=True)
card_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
currency_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
opening_balance: Mapped[Decimal] = mapped_column(
Numeric(18, 4), default=Decimal("0"), nullable=False
)
current_balance: Mapped[Decimal] = mapped_column(
Numeric(18, 4), default=Decimal("0"), nullable=False
)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
bank: Mapped["Bank"] = relationship(back_populates="accounts")
transactions: Mapped[list["BankTransaction"]] = relationship(back_populates="bank_account")
__table_args__ = (
UniqueConstraint("tenant_id", "account_number", name="uq_bank_account_tenant_number"),
)
class BankTransaction(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "bank_transactions"
bank_account_id: Mapped[uuid.UUID] = mapped_column(
GUID(), ForeignKey("bank_accounts.id", ondelete="RESTRICT"), nullable=False
)
transaction_type: Mapped[TreasuryTransactionType] = mapped_column(nullable=False)
transaction_date: Mapped[date] = mapped_column(Date, nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
reference_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
bank_account: Mapped["BankAccount"] = relationship(back_populates="transactions")
__table_args__ = (
Index("ix_bank_tx_tenant_account", "tenant_id", "bank_account_id"),
)
class ChequeBook(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "cheque_books"
bank_account_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
serial_from: Mapped[int] = mapped_column(Integer, nullable=False)
serial_to: Mapped[int] = mapped_column(Integer, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
class Cheque(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "cheques"
cheque_book_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
bank_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
cheque_number: Mapped[str] = mapped_column(String(50), nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
issue_date: Mapped[date] = mapped_column(Date, nullable=False)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
status: Mapped[ChequeStatus] = mapped_column(nullable=False)
is_incoming: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
payee: Mapped[str | None] = mapped_column(String(255), nullable=True)
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "cheque_number", name="uq_cheque_tenant_number"),
Index("ix_cheques_tenant_status", "tenant_id", "status"),
)
class Transfer(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "transfers"
transfer_date: Mapped[date] = mapped_column(Date, nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
from_type: Mapped[str] = mapped_column(String(20), nullable=False)
from_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
to_type: Mapped[str] = mapped_column(String(20), nullable=False)
to_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
__table_args__ = (Index("ix_transfers_tenant_date", "tenant_id", "transfer_date"),)
class ReceiptVoucher(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "receipt_vouchers"
receipt_number: Mapped[str] = mapped_column(String(50), nullable=False)
receipt_date: Mapped[date] = mapped_column(Date, nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
payment_method: Mapped[str] = mapped_column(String(50), nullable=False)
cash_box_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
bank_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "receipt_number", name="uq_receipt_tenant_number"),
)
class PaymentVoucher(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "payment_vouchers"
payment_number: Mapped[str] = mapped_column(String(50), nullable=False)
payment_date: Mapped[date] = mapped_column(Date, nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
payment_method: Mapped[str] = mapped_column(String(50), nullable=False)
cash_box_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
bank_account_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "payment_number", name="uq_payment_tenant_number"),
)
class Reconciliation(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "reconciliations"
bank_account_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
statement_date: Mapped[date] = mapped_column(Date, nullable=False)
statement_balance: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
book_balance: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
difference: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
status: Mapped[ReconciliationStatus] = mapped_column(nullable=False)
__table_args__ = (
Index("ix_reconciliation_tenant_account", "tenant_id", "bank_account_id"),
)

View File

@ -0,0 +1,277 @@
import enum
import uuid
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.types import CHAR, TypeDecorator
class GUID(TypeDecorator):
impl = CHAR
cache_ok = True
def load_dialect_impl(self, dialect):
if dialect.name == "postgresql":
return dialect.type_descriptor(PG_UUID(as_uuid=True))
return dialect.type_descriptor(CHAR(36))
def process_bind_param(self, value, dialect):
if value is None:
return value
if dialect.name == "postgresql":
return value if isinstance(value, uuid.UUID) else uuid.UUID(str(value))
return str(value)
def process_result_value(self, value, dialect):
if value is None:
return value
if isinstance(value, uuid.UUID):
return value
return uuid.UUID(str(value))
# --- Foundation enums (Phase 5.1) ---
class AccountType(str, enum.Enum):
ASSET = "asset"
LIABILITY = "liability"
EQUITY = "equity"
REVENUE = "revenue"
EXPENSE = "expense"
class AccountCategory(str, enum.Enum):
CURRENT_ASSET = "current_asset"
NON_CURRENT_ASSET = "non_current_asset"
CURRENT_LIABILITY = "current_liability"
NON_CURRENT_LIABILITY = "non_current_liability"
EQUITY = "equity"
OPERATING_REVENUE = "operating_revenue"
OTHER_REVENUE = "other_revenue"
OPERATING_EXPENSE = "operating_expense"
OTHER_EXPENSE = "other_expense"
class AccountStatus(str, enum.Enum):
ACTIVE = "active"
INACTIVE = "inactive"
CLOSED = "closed"
class FiscalPeriodStatus(str, enum.Enum):
OPEN = "open"
LOCKED = "locked"
CLOSED = "closed"
ARCHIVED = "archived"
class VoucherStatus(str, enum.Enum):
DRAFT = "draft"
VALIDATED = "validated"
POSTED = "posted"
CANCELLED = "cancelled"
REVERSED = "reversed"
class JournalStatus(str, enum.Enum):
DRAFT = "draft"
POSTED = "posted"
REVERSED = "reversed"
class PostingStatus(str, enum.Enum):
PENDING = "pending"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
FAILED = "failed"
# --- Treasury enums (Phase 5.4) ---
class ChequeStatus(str, enum.Enum):
ISSUED = "issued"
RECEIVED = "received"
DEPOSITED = "deposited"
CLEARED = "cleared"
BOUNCED = "bounced"
TRANSFERRED = "transferred"
RETURNED = "returned"
CANCELLED = "cancelled"
class TreasuryTransactionType(str, enum.Enum):
CASH_RECEIPT = "cash_receipt"
CASH_PAYMENT = "cash_payment"
BANK_DEPOSIT = "bank_deposit"
BANK_WITHDRAWAL = "bank_withdrawal"
CHEQUE_DEPOSIT = "cheque_deposit"
CHEQUE_CLEARANCE = "cheque_clearance"
TRANSFER = "transfer"
class ReconciliationStatus(str, enum.Enum):
PENDING = "pending"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
FAILED = "failed"
# --- AR/AP enums (Phase 5.5) ---
class InvoiceStatus(str, enum.Enum):
DRAFT = "draft"
ISSUED = "issued"
PARTIALLY_PAID = "partially_paid"
PAID = "paid"
CANCELLED = "cancelled"
WRITTEN_OFF = "written_off"
class SettlementStatus(str, enum.Enum):
DRAFT = "draft"
COMPLETED = "completed"
REVERSED = "reversed"
# --- Sales accounting enums (Phase 5.6) ---
class RevenueRecognitionMethod(str, enum.Enum):
IMMEDIATE = "immediate"
DEFERRED = "deferred"
PARTIAL = "partial"
MILESTONE = "milestone"
class SalesDocumentType(str, enum.Enum):
INVOICE = "invoice"
RETURN = "return"
CREDIT_NOTE = "credit_note"
DEBIT_NOTE = "debit_note"
PAYMENT = "payment"
DISCOUNT = "discount"
# --- Purchase/Inventory enums (Phase 5.7) ---
class InventoryValuationMethod(str, enum.Enum):
FIFO = "fifo"
WEIGHTED_AVERAGE = "weighted_average"
MOVING_AVERAGE = "moving_average"
SPECIFIC_IDENTIFICATION = "specific_identification"
STANDARD_COST = "standard_cost"
class PurchaseDocumentType(str, enum.Enum):
GOODS_RECEIPT = "goods_receipt"
SUPPLIER_INVOICE = "supplier_invoice"
PURCHASE_RETURN = "purchase_return"
COST_ADJUSTMENT = "cost_adjustment"
class InventoryDocumentType(str, enum.Enum):
RECEIPT = "receipt"
ISSUE = "issue"
TRANSFER = "transfer"
ADJUSTMENT = "adjustment"
WRITE_OFF = "write_off"
# --- Fixed Assets enums (Phase 5.8) ---
class AssetStatus(str, enum.Enum):
DRAFT = "draft"
ACTIVE = "active"
DEPRECIATING = "depreciating"
FULLY_DEPRECIATED = "fully_depreciated"
DISPOSED = "disposed"
RETIRED = "retired"
class DepreciationMethod(str, enum.Enum):
STRAIGHT_LINE = "straight_line"
DECLINING_BALANCE = "declining_balance"
DOUBLE_DECLINING = "double_declining"
UNITS_OF_PRODUCTION = "units_of_production"
# --- Payroll enums (Phase 5.9) ---
class EmploymentStatus(str, enum.Enum):
ACTIVE = "active"
ON_LEAVE = "on_leave"
TERMINATED = "terminated"
SUSPENDED = "suspended"
class PayrollPeriodStatus(str, enum.Enum):
OPEN = "open"
PROCESSING = "processing"
APPROVED = "approved"
LOCKED = "locked"
PAID = "paid"
CLOSED = "closed"
class PayrollStatus(str, enum.Enum):
DRAFT = "draft"
CALCULATED = "calculated"
APPROVED = "approved"
PAID = "paid"
REVERSED = "reversed"
# --- Reporting enums (Phase 5.10) ---
class ReportType(str, enum.Enum):
TRIAL_BALANCE = "trial_balance"
GENERAL_LEDGER = "general_ledger"
BALANCE_SHEET = "balance_sheet"
INCOME_STATEMENT = "income_statement"
CASH_FLOW = "cash_flow"
EQUITY_STATEMENT = "equity_statement"
CUSTOM = "custom"
class ReportStatus(str, enum.Enum):
GENERATING = "generating"
GENERATED = "generated"
FAILED = "failed"
ARCHIVED = "archived"
class ReportExportFormat(str, enum.Enum):
PDF = "pdf"
EXCEL = "excel"
CSV = "csv"
JSON = "json"
# --- Compliance enums (Phase 5.11) ---
class ApprovalStatus(str, enum.Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
CANCELLED = "cancelled"
class RiskLevel(str, enum.Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class PolicyViolationSeverity(str, enum.Enum):
INFO = "info"
WARNING = "warning"
ERROR = "error"
CRITICAL = "critical"

View File

@ -0,0 +1,133 @@
"""Accounting permission definitions."""
from __future__ import annotations
# Phase 5.1 — Foundation
ACCOUNTING_VIEW = "accounting.view"
ACCOUNTING_CREATE = "accounting.create"
ACCOUNTING_UPDATE = "accounting.update"
ACCOUNTING_DELETE = "accounting.delete"
ACCOUNTING_MANAGE = "accounting.manage"
# Phase 5.2 — Posting
ACCOUNTING_POST = "accounting.post"
ACCOUNTING_VALIDATE = "accounting.validate"
ACCOUNTING_REVERSE = "accounting.reverse"
ACCOUNTING_CANCEL = "accounting.cancel"
ACCOUNTING_VIEW_AUDIT = "accounting.view_audit"
ACCOUNTING_MANAGE_POSTING = "accounting.manage_posting"
# Phase 5.3 — Ledger & Fiscal
ACCOUNTING_LEDGER_VIEW = "accounting.ledger.view"
ACCOUNTING_LEDGER_MANAGE = "accounting.ledger.manage"
ACCOUNTING_FISCAL_VIEW = "accounting.fiscal.view"
ACCOUNTING_FISCAL_MANAGE = "accounting.fiscal.manage"
ACCOUNTING_PERIOD_CLOSE = "accounting.period.close"
ACCOUNTING_PERIOD_REOPEN = "accounting.period.reopen"
ACCOUNTING_TRIAL_BALANCE_VIEW = "accounting.trial_balance.view"
# Phase 5.4 — Treasury
TREASURY_VIEW = "treasury.view"
TREASURY_MANAGE = "treasury.manage"
TREASURY_CASH = "treasury.cash"
TREASURY_BANK = "treasury.bank"
TREASURY_CHEQUE = "treasury.cheque"
TREASURY_PAYMENT = "treasury.payment"
TREASURY_TRANSFER = "treasury.transfer"
TREASURY_RECONCILIATION = "treasury.reconciliation"
# Phase 5.5 — AR/AP
RECEIVABLE_VIEW = "receivable.view"
RECEIVABLE_MANAGE = "receivable.manage"
RECEIVABLE_RECEIVE_PAYMENT = "receivable.receive_payment"
RECEIVABLE_STATEMENT = "receivable.statement"
PAYABLE_VIEW = "payable.view"
PAYABLE_MANAGE = "payable.manage"
PAYABLE_MAKE_PAYMENT = "payable.make_payment"
PAYABLE_STATEMENT = "payable.statement"
SETTLEMENT_MANAGE = "settlement.manage"
# Phase 5.6 — Sales Accounting
SALES_ACCOUNTING_VIEW = "sales_accounting.view"
SALES_ACCOUNTING_POST = "sales_accounting.post"
SALES_ACCOUNTING_CONFIGURE = "sales_accounting.configure"
SALES_ACCOUNTING_REVENUE_RECOGNITION = "sales_accounting.revenue_recognition"
SALES_ACCOUNTING_PREVIEW = "sales_accounting.preview"
SALES_ACCOUNTING_MANAGE = "sales_accounting.manage"
# Phase 5.7 — Purchase/Inventory Accounting
PURCHASE_ACCOUNTING_VIEW = "purchase_accounting.view"
PURCHASE_ACCOUNTING_POST = "purchase_accounting.post"
PURCHASE_ACCOUNTING_CONFIGURE = "purchase_accounting.configure"
INVENTORY_ACCOUNTING_VIEW = "inventory_accounting.view"
INVENTORY_ACCOUNTING_POST = "inventory_accounting.post"
INVENTORY_ACCOUNTING_CONFIGURE = "inventory_accounting.configure"
INVENTORY_VALUATION_MANAGE = "inventory_valuation.manage"
POSTING_SIMULATION_EXECUTE = "posting_simulation.execute"
# Phase 5.8 — Fixed Assets
ASSETS_VIEW = "assets.view"
ASSETS_CREATE = "assets.create"
ASSETS_UPDATE = "assets.update"
ASSETS_DEPRECIATE = "assets.depreciate"
ASSETS_TRANSFER = "assets.transfer"
ASSETS_DISPOSE = "assets.dispose"
ASSETS_REVALUE = "assets.revalue"
ASSETS_MANAGE = "assets.manage"
# Phase 5.9 — Payroll
HR_VIEW = "hr.view"
HR_MANAGE = "hr.manage"
PAYROLL_VIEW = "payroll.view"
PAYROLL_CALCULATE = "payroll.calculate"
PAYROLL_APPROVE = "payroll.approve"
PAYROLL_PAY = "payroll.pay"
PAYROLL_REVERSE = "payroll.reverse"
PAYROLL_CONFIGURE = "payroll.configure"
# Phase 5.10 — Reporting
REPORTS_VIEW = "reports.view"
REPORTS_EXPORT = "reports.export"
REPORTS_SCHEDULE = "reports.schedule"
REPORTS_BUILDER = "reports.builder"
DASHBOARD_VIEW = "dashboard.view"
DASHBOARD_MANAGE = "dashboard.manage"
ANALYTICS_VIEW = "analytics.view"
FINANCIAL_STATEMENTS_VIEW = "financial_statements.view"
# Phase 5.11 — Compliance
AUDIT_VIEW = "audit.view"
AUDIT_EXPORT = "audit.export"
COMPLIANCE_VIEW = "compliance.view"
COMPLIANCE_MANAGE = "compliance.manage"
GOVERNANCE_VIEW = "governance.view"
GOVERNANCE_MANAGE = "governance.manage"
APPROVAL_EXECUTE = "approval.execute"
RISK_VIEW = "risk.view"
RISK_MANAGE = "risk.manage"
ALL_PERMISSIONS = [
ACCOUNTING_VIEW, ACCOUNTING_CREATE, ACCOUNTING_UPDATE, ACCOUNTING_DELETE,
ACCOUNTING_MANAGE, ACCOUNTING_POST, ACCOUNTING_VALIDATE, ACCOUNTING_REVERSE,
ACCOUNTING_CANCEL, ACCOUNTING_VIEW_AUDIT, ACCOUNTING_MANAGE_POSTING,
ACCOUNTING_LEDGER_VIEW, ACCOUNTING_LEDGER_MANAGE, ACCOUNTING_FISCAL_VIEW,
ACCOUNTING_FISCAL_MANAGE, ACCOUNTING_PERIOD_CLOSE, ACCOUNTING_PERIOD_REOPEN,
ACCOUNTING_TRIAL_BALANCE_VIEW, TREASURY_VIEW, TREASURY_MANAGE, TREASURY_CASH,
TREASURY_BANK, TREASURY_CHEQUE, TREASURY_PAYMENT, TREASURY_TRANSFER,
TREASURY_RECONCILIATION, RECEIVABLE_VIEW, RECEIVABLE_MANAGE,
RECEIVABLE_RECEIVE_PAYMENT, RECEIVABLE_STATEMENT, PAYABLE_VIEW, PAYABLE_MANAGE,
PAYABLE_MAKE_PAYMENT, PAYABLE_STATEMENT, SETTLEMENT_MANAGE,
SALES_ACCOUNTING_VIEW, SALES_ACCOUNTING_POST, SALES_ACCOUNTING_CONFIGURE,
SALES_ACCOUNTING_REVENUE_RECOGNITION, SALES_ACCOUNTING_PREVIEW,
SALES_ACCOUNTING_MANAGE,
PURCHASE_ACCOUNTING_VIEW, PURCHASE_ACCOUNTING_POST, PURCHASE_ACCOUNTING_CONFIGURE,
INVENTORY_ACCOUNTING_VIEW, INVENTORY_ACCOUNTING_POST, INVENTORY_ACCOUNTING_CONFIGURE,
INVENTORY_VALUATION_MANAGE, POSTING_SIMULATION_EXECUTE,
ASSETS_VIEW, ASSETS_CREATE, ASSETS_UPDATE, ASSETS_DEPRECIATE, ASSETS_TRANSFER,
ASSETS_DISPOSE, ASSETS_REVALUE, ASSETS_MANAGE,
HR_VIEW, HR_MANAGE, PAYROLL_VIEW, PAYROLL_CALCULATE, PAYROLL_APPROVE,
PAYROLL_PAY, PAYROLL_REVERSE, PAYROLL_CONFIGURE,
REPORTS_VIEW, REPORTS_EXPORT, REPORTS_SCHEDULE, REPORTS_BUILDER,
DASHBOARD_VIEW, DASHBOARD_MANAGE, ANALYTICS_VIEW, FINANCIAL_STATEMENTS_VIEW,
AUDIT_VIEW, AUDIT_EXPORT, COMPLIANCE_VIEW, COMPLIANCE_MANAGE,
GOVERNANCE_VIEW, GOVERNANCE_MANAGE, APPROVAL_EXECUTE, RISK_VIEW, RISK_MANAGE,
]

View File

@ -0,0 +1,57 @@
"""Tenant-aware base repository."""
from __future__ import annotations
from typing import Generic, Sequence, TypeVar
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import Base
ModelT = TypeVar("ModelT", bound=Base)
class TenantBaseRepository(Generic[ModelT]):
model: type[ModelT]
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get(self, tenant_id: UUID, entity_id: UUID) -> ModelT | None:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id, # type: ignore[attr-defined]
self.model.id == entity_id, # type: ignore[attr-defined]
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def add(self, entity: ModelT) -> ModelT:
self.session.add(entity)
await self.session.flush()
return entity
async def delete(self, entity: ModelT) -> None:
await self.session.delete(entity)
await self.session.flush()
async def list_by_tenant(
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20
) -> Sequence[ModelT]:
stmt = (
select(self.model)
.where(self.model.tenant_id == tenant_id) # type: ignore[attr-defined]
.offset(offset)
.limit(limit)
)
result = await self.session.execute(stmt)
return result.scalars().all()
async def count_by_tenant(self, tenant_id: UUID) -> int:
stmt = (
select(func.count())
.select_from(self.model)
.where(self.model.tenant_id == tenant_id) # type: ignore[attr-defined]
)
result = await self.session.execute(stmt)
return int(result.scalar_one())

View File

@ -0,0 +1,107 @@
"""Foundation repositories."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.foundation import (
Account,
ChartOfAccounts,
CostCenter,
Currency,
FiscalPeriod,
FiscalYear,
Project,
TenantAccountingConfiguration,
)
from app.repositories.base import TenantBaseRepository
class ChartOfAccountsRepository(TenantBaseRepository[ChartOfAccounts]):
model = ChartOfAccounts
async def get_by_code(self, tenant_id: UUID, code: str) -> ChartOfAccounts | None:
stmt = select(ChartOfAccounts).where(
ChartOfAccounts.tenant_id == tenant_id,
ChartOfAccounts.code == code,
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
class AccountRepository(TenantBaseRepository[Account]):
model = Account
async def get_by_code(self, tenant_id: UUID, code: str) -> Account | None:
stmt = select(Account).where(
Account.tenant_id == tenant_id, Account.code == code
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_by_chart(
self, tenant_id: UUID, chart_id: UUID, *, offset: int = 0, limit: int = 100
):
stmt = (
select(Account)
.where(Account.tenant_id == tenant_id, Account.chart_id == chart_id)
.offset(offset)
.limit(limit)
)
result = await self.session.execute(stmt)
return result.scalars().all()
class CurrencyRepository(TenantBaseRepository[Currency]):
model = Currency
class FiscalYearRepository(TenantBaseRepository[FiscalYear]):
model = FiscalYear
async def get_current(self, tenant_id: UUID) -> FiscalYear | None:
stmt = select(FiscalYear).where(
FiscalYear.tenant_id == tenant_id, FiscalYear.is_current.is_(True)
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
class FiscalPeriodRepository(TenantBaseRepository[FiscalPeriod]):
model = FiscalPeriod
async def get_current(self, tenant_id: UUID) -> FiscalPeriod | None:
stmt = select(FiscalPeriod).where(
FiscalPeriod.tenant_id == tenant_id, FiscalPeriod.is_current.is_(True)
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_by_year(self, tenant_id: UUID, fiscal_year_id: UUID):
stmt = select(FiscalPeriod).where(
FiscalPeriod.tenant_id == tenant_id,
FiscalPeriod.fiscal_year_id == fiscal_year_id,
)
result = await self.session.execute(stmt)
return result.scalars().all()
class CostCenterRepository(TenantBaseRepository[CostCenter]):
model = CostCenter
class ProjectRepository(TenantBaseRepository[Project]):
model = Project
class TenantAccountingConfigRepository(TenantBaseRepository[TenantAccountingConfiguration]):
model = TenantAccountingConfiguration
async def get_for_tenant(self, tenant_id: UUID) -> TenantAccountingConfiguration | None:
stmt = select(TenantAccountingConfiguration).where(
TenantAccountingConfiguration.tenant_id == tenant_id
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()

View File

@ -0,0 +1,59 @@
"""Ledger repositories."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy import select
from app.models.ledger import (
GeneralLedger,
LedgerBalance,
TrialBalanceSnapshot,
)
from app.repositories.base import TenantBaseRepository
class GeneralLedgerRepository(TenantBaseRepository[GeneralLedger]):
model = GeneralLedger
async def list_by_account_period(
self, tenant_id: UUID, account_id: UUID, fiscal_period_id: UUID
):
stmt = (
select(GeneralLedger)
.where(
GeneralLedger.tenant_id == tenant_id,
GeneralLedger.account_id == account_id,
GeneralLedger.fiscal_period_id == fiscal_period_id,
)
.order_by(GeneralLedger.entry_date)
)
result = await self.session.execute(stmt)
return result.scalars().all()
class LedgerBalanceRepository(TenantBaseRepository[LedgerBalance]):
model = LedgerBalance
async def get_by_account_period(
self, tenant_id: UUID, account_id: UUID, fiscal_period_id: UUID
) -> LedgerBalance | None:
stmt = select(LedgerBalance).where(
LedgerBalance.tenant_id == tenant_id,
LedgerBalance.account_id == account_id,
LedgerBalance.fiscal_period_id == fiscal_period_id,
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_by_period(self, tenant_id: UUID, fiscal_period_id: UUID):
stmt = select(LedgerBalance).where(
LedgerBalance.tenant_id == tenant_id,
LedgerBalance.fiscal_period_id == fiscal_period_id,
)
result = await self.session.execute(stmt)
return result.scalars().all()
class TrialBalanceSnapshotRepository(TenantBaseRepository[TrialBalanceSnapshot]):
model = TrialBalanceSnapshot

View File

@ -0,0 +1,108 @@
"""Posting repositories."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.models.posting import (
AccountingAuditLog,
JournalEntry,
PostingError,
PostingLog,
PostingReference,
Voucher,
VoucherLine,
)
from app.models.types import VoucherStatus
from app.repositories.base import TenantBaseRepository
class VoucherRepository(TenantBaseRepository[Voucher]):
model = Voucher
async def get_with_lines(self, tenant_id: UUID, voucher_id: UUID) -> Voucher | None:
stmt = (
select(Voucher)
.options(selectinload(Voucher.lines))
.where(Voucher.tenant_id == tenant_id, Voucher.id == voucher_id)
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def get_by_number(self, tenant_id: UUID, voucher_number: str) -> Voucher | None:
stmt = select(Voucher).where(
Voucher.tenant_id == tenant_id, Voucher.voucher_number == voucher_number
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_by_status(self, tenant_id: UUID, status: VoucherStatus, *, limit: int = 50):
stmt = (
select(Voucher)
.where(Voucher.tenant_id == tenant_id, Voucher.status == status)
.limit(limit)
)
result = await self.session.execute(stmt)
return result.scalars().all()
class VoucherLineRepository(TenantBaseRepository[VoucherLine]):
model = VoucherLine
class JournalEntryRepository(TenantBaseRepository[JournalEntry]):
model = JournalEntry
async def list_by_voucher(self, tenant_id: UUID, voucher_id: UUID):
stmt = select(JournalEntry).where(
JournalEntry.tenant_id == tenant_id,
JournalEntry.voucher_id == voucher_id,
)
result = await self.session.execute(stmt)
return result.scalars().all()
async def list_by_account(self, tenant_id: UUID, account_id: UUID, *, limit: int = 100):
stmt = (
select(JournalEntry)
.where(
JournalEntry.tenant_id == tenant_id,
JournalEntry.account_id == account_id,
)
.limit(limit)
)
result = await self.session.execute(stmt)
return result.scalars().all()
class PostingLogRepository(TenantBaseRepository[PostingLog]):
model = PostingLog
class PostingErrorRepository(TenantBaseRepository[PostingError]):
model = PostingError
class PostingReferenceRepository(TenantBaseRepository[PostingReference]):
model = PostingReference
async def get_by_source(
self,
tenant_id: UUID,
source_module: str,
source_document_type: str,
source_document_id: str,
) -> PostingReference | None:
stmt = select(PostingReference).where(
PostingReference.tenant_id == tenant_id,
PostingReference.source_module == source_module,
PostingReference.source_document_type == source_document_type,
PostingReference.source_document_id == source_document_id,
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
class AuditLogRepository(TenantBaseRepository[AccountingAuditLog]):
model = AccountingAuditLog

View File

@ -0,0 +1,20 @@
"""Common schemas."""
from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class ORMBase(BaseModel):
model_config = ConfigDict(from_attributes=True)
class IdResponse(BaseModel):
id: UUID
class MessageResponse(BaseModel):
message: str

View File

@ -0,0 +1,243 @@
"""Foundation DTOs."""
from __future__ import annotations
from datetime import date, datetime
from uuid import UUID
from pydantic import BaseModel, Field
from app.models.types import AccountCategory, AccountStatus, AccountType, FiscalPeriodStatus
from app.schemas.common import ORMBase
class ChartOfAccountsCreate(BaseModel):
name: str = Field(max_length=255)
code: str = Field(max_length=50)
description: str | None = None
is_default: bool = False
class ChartOfAccountsUpdate(BaseModel):
name: str | None = Field(default=None, max_length=255)
description: str | None = None
is_default: bool | None = None
is_active: bool | None = None
class ChartOfAccountsRead(ORMBase):
id: UUID
tenant_id: UUID
name: str
code: str
description: str | None
is_default: bool
is_active: bool
created_at: datetime
class AccountCreate(BaseModel):
chart_id: UUID
parent_id: UUID | None = None
code: str = Field(max_length=50)
name: str = Field(max_length=255)
account_type: AccountType
account_category: AccountCategory
level: int = 1
is_group: bool = False
is_postable: bool = True
currency_id: UUID | None = None
description: str | None = None
class AccountUpdate(BaseModel):
parent_id: UUID | None = None
name: str | None = Field(default=None, max_length=255)
account_type: AccountType | None = None
account_category: AccountCategory | None = None
level: int | None = None
is_group: bool | None = None
is_postable: bool | None = None
currency_id: UUID | None = None
description: str | None = None
status: AccountStatus | None = None
class AccountRead(ORMBase):
id: UUID
tenant_id: UUID
chart_id: UUID
parent_id: UUID | None = None
code: str
name: str
account_type: AccountType
account_category: AccountCategory
level: int
is_group: bool
is_postable: bool
status: AccountStatus
description: str | None = None
created_at: datetime
class CurrencyCreate(BaseModel):
code: str = Field(max_length=3)
name: str = Field(max_length=100)
symbol: str | None = None
decimal_places: int = 2
is_base: bool = False
class CurrencyUpdate(BaseModel):
name: str | None = Field(default=None, max_length=100)
symbol: str | None = None
decimal_places: int | None = None
is_base: bool | None = None
is_active: bool | None = None
class CurrencyRead(ORMBase):
id: UUID
tenant_id: UUID
code: str
name: str
symbol: str | None
is_base: bool
is_active: bool
class FiscalYearCreate(BaseModel):
name: str
start_date: date
end_date: date
is_current: bool = False
class FiscalYearUpdate(BaseModel):
name: str | None = None
start_date: date | None = None
end_date: date | None = None
is_current: bool | None = None
class FiscalYearRead(ORMBase):
id: UUID
tenant_id: UUID
name: str
start_date: date
end_date: date
is_current: bool
is_closed: bool
class FiscalPeriodCreate(BaseModel):
fiscal_year_id: UUID
name: str
period_number: int
start_date: date
end_date: date
is_current: bool = False
class FiscalPeriodUpdate(BaseModel):
name: str | None = None
start_date: date | None = None
end_date: date | None = None
is_current: bool | None = None
status: FiscalPeriodStatus | None = None
class FiscalPeriodRead(ORMBase):
id: UUID
tenant_id: UUID
fiscal_year_id: UUID
name: str
period_number: int
start_date: date
end_date: date
status: FiscalPeriodStatus
is_current: bool
class CostCenterCreate(BaseModel):
code: str
name: str
parent_id: UUID | None = None
class CostCenterUpdate(BaseModel):
name: str | None = None
parent_id: UUID | None = None
is_active: bool | None = None
class CostCenterRead(ORMBase):
id: UUID
tenant_id: UUID
code: str
name: str
parent_id: UUID | None = None
is_active: bool
class ProjectCreate(BaseModel):
code: str
name: str
start_date: date | None = None
end_date: date | None = None
class ProjectUpdate(BaseModel):
name: str | None = None
start_date: date | None = None
end_date: date | None = None
is_active: bool | None = None
class ProjectRead(ORMBase):
id: UUID
tenant_id: UUID
code: str
name: str
start_date: date | None = None
end_date: date | None = None
is_active: bool
class SetupStatusRead(BaseModel):
has_chart: bool
has_accounts: bool
has_currency: bool
has_base_currency: bool
has_fiscal_year: bool
has_fiscal_period: bool
has_cash_box: bool
has_customer: bool
has_voucher: bool
completed_steps: int
total_steps: int
percent: int
class CoaTemplateAccount(BaseModel):
code: str
name: str
account_type: AccountType
account_category: AccountCategory
level: int = 1
is_group: bool = False
is_postable: bool = True
parent_code: str | None = None
class CoaTemplateRead(BaseModel):
id: str
name: str
description: str
business_type: str
accounts: list[CoaTemplateAccount]
class CoaTemplateImportRequest(BaseModel):
chart_id: UUID | None = None
chart_name: str | None = None
chart_code: str | None = None

View File

@ -0,0 +1,81 @@
"""Posting DTOs."""
from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal
from uuid import UUID
from pydantic import BaseModel, Field
from app.models.types import VoucherStatus
from app.schemas.common import ORMBase
class VoucherLineCreate(BaseModel):
account_id: UUID
debit: Decimal = Decimal("0")
credit: Decimal = Decimal("0")
description: str | None = None
cost_center_id: UUID | None = None
project_id: UUID | None = None
class VoucherCreate(BaseModel):
fiscal_period_id: UUID
voucher_number: str = Field(max_length=50)
voucher_date: date
description: str | None = None
reference_number: str | None = None
source_module: str | None = None
lines: list[VoucherLineCreate] = Field(min_length=2)
class VoucherUpdate(BaseModel):
voucher_date: date | None = None
description: str | None = None
reference_number: str | None = None
lines: list[VoucherLineCreate] | None = Field(default=None, min_length=2)
class VoucherLineRead(ORMBase):
id: UUID
line_number: int
account_id: UUID
debit: Decimal
credit: Decimal
description: str | None
cost_center_id: UUID | None = None
project_id: UUID | None = None
class VoucherRead(ORMBase):
id: UUID
tenant_id: UUID
fiscal_period_id: UUID
voucher_number: str
voucher_date: date
status: VoucherStatus
description: str | None
total_debit: Decimal
total_credit: Decimal
posted_at: datetime | None
lines: list[VoucherLineRead] = []
class PostRequest(BaseModel):
source_module: str | None = None
class ReverseRequest(BaseModel):
reversal_date: date | None = None
class AuditLogRead(ORMBase):
id: UUID
tenant_id: UUID
operation: str
actor_user_id: str
voucher_number: str | None
resource_type: str
resource_id: str
created_at: datetime

View File

@ -0,0 +1,133 @@
"""Aging Engine for AR/AP."""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.receivable_payable import (
AgingSnapshot,
PayableInvoice,
ReceivableInvoice,
)
from app.models.types import InvoiceStatus
from app.repositories.base import TenantBaseRepository
class AgingSnapshotRepository(TenantBaseRepository[AgingSnapshot]):
model = AgingSnapshot
class AgingEngine:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.snapshot_repo = AgingSnapshotRepository(session)
async def calculate_customer_aging(
self, tenant_id: UUID, customer_id: UUID, as_of: date | None = None
) -> AgingSnapshot:
as_of = as_of or date.today()
stmt = select(ReceivableInvoice).where(
ReceivableInvoice.tenant_id == tenant_id,
ReceivableInvoice.customer_id == customer_id,
ReceivableInvoice.status.in_([
InvoiceStatus.ISSUED,
InvoiceStatus.PARTIALLY_PAID,
]),
)
result = await self.session.execute(stmt)
invoices = result.scalars().all()
buckets = {
"current": Decimal("0"),
"1_30": Decimal("0"),
"31_60": Decimal("0"),
"61_90": Decimal("0"),
"90_plus": Decimal("0"),
}
for inv in invoices:
due = inv.due_date or inv.invoice_date
days = (as_of - due).days
amount = inv.remaining_amount
if days <= 0:
buckets["current"] += amount
elif days <= 30:
buckets["1_30"] += amount
elif days <= 60:
buckets["31_60"] += amount
elif days <= 90:
buckets["61_90"] += amount
else:
buckets["90_plus"] += amount
total = sum(buckets.values())
snapshot = AgingSnapshot(
tenant_id=tenant_id,
party_type="customer",
party_id=customer_id,
snapshot_date=as_of,
current_amount=buckets["current"],
days_1_30=buckets["1_30"],
days_31_60=buckets["31_60"],
days_61_90=buckets["61_90"],
days_90_plus=buckets["90_plus"],
total_outstanding=total,
)
return await self.snapshot_repo.add(snapshot)
async def calculate_supplier_aging(
self, tenant_id: UUID, supplier_id: UUID, as_of: date | None = None
) -> AgingSnapshot:
as_of = as_of or date.today()
stmt = select(PayableInvoice).where(
PayableInvoice.tenant_id == tenant_id,
PayableInvoice.supplier_id == supplier_id,
PayableInvoice.status.in_([
InvoiceStatus.ISSUED,
InvoiceStatus.PARTIALLY_PAID,
]),
)
result = await self.session.execute(stmt)
invoices = result.scalars().all()
buckets = {
"current": Decimal("0"),
"1_30": Decimal("0"),
"31_60": Decimal("0"),
"61_90": Decimal("0"),
"90_plus": Decimal("0"),
}
for inv in invoices:
due = inv.due_date or inv.invoice_date
days = (as_of - due).days
amount = inv.remaining_amount
if days <= 0:
buckets["current"] += amount
elif days <= 30:
buckets["1_30"] += amount
elif days <= 60:
buckets["31_60"] += amount
elif days <= 90:
buckets["61_90"] += amount
else:
buckets["90_plus"] += amount
total = sum(buckets.values())
snapshot = AgingSnapshot(
tenant_id=tenant_id,
party_type="supplier",
party_id=supplier_id,
snapshot_date=as_of,
current_amount=buckets["current"],
days_1_30=buckets["1_30"],
days_31_60=buckets["31_60"],
days_61_90=buckets["61_90"],
days_90_plus=buckets["90_plus"],
total_outstanding=total,
)
return await self.snapshot_repo.add(snapshot)

View File

@ -0,0 +1,175 @@
"""Phase 5.8 — Depreciation Engine & Asset accounting."""
from __future__ import annotations
from datetime import date
from decimal import Decimal, ROUND_HALF_UP
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.fixed_assets import Asset, AssetDepreciation, AssetHistory, DepreciationSchedule
from app.models.posting import Voucher, VoucherLine
from app.models.types import AssetStatus, DepreciationMethod, VoucherStatus
from app.repositories.base import TenantBaseRepository
from app.repositories.foundation import FiscalPeriodRepository
from app.repositories.posting import VoucherRepository
from app.services.posting_engine import PostingEngine
from shared.exceptions import AppError, NotFoundError
class AssetRepo(TenantBaseRepository[Asset]):
model = Asset
class DepreciationScheduleRepo(TenantBaseRepository[DepreciationSchedule]):
model = DepreciationSchedule
class AssetDepreciationRepo(TenantBaseRepository[AssetDepreciation]):
model = AssetDepreciation
class AssetAccountingError(AppError):
status_code = 422
error_code = "asset_accounting_error"
class DepreciationEngine:
"""Centralized depreciation calculation engine."""
def calculate(
self,
asset: Asset,
*,
period_months: int = 1,
) -> Decimal:
depreciable = asset.acquisition_cost - asset.residual_value
if depreciable <= 0 or asset.useful_life_months <= 0:
return Decimal("0")
if asset.depreciation_method == DepreciationMethod.STRAIGHT_LINE:
monthly = depreciable / asset.useful_life_months
return (monthly * period_months).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
if asset.depreciation_method == DepreciationMethod.DECLINING_BALANCE:
rate = Decimal("2") / asset.useful_life_months
return (asset.current_book_value * rate * period_months).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
if asset.depreciation_method == DepreciationMethod.DOUBLE_DECLINING:
rate = Decimal("2") / asset.useful_life_months * 2
return (asset.current_book_value * rate * period_months).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
return (depreciable / asset.useful_life_months * period_months).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
def generate_schedule(self, asset: Asset) -> list[dict]:
schedule = []
book_value = asset.acquisition_cost
for period in range(1, asset.useful_life_months + 1):
amount = self.calculate(asset, period_months=1)
if book_value - amount < asset.residual_value:
amount = book_value - asset.residual_value
if amount <= 0:
break
schedule.append({"period": period, "amount": amount, "book_value_after": book_value - amount})
book_value -= amount
return schedule
class AssetAccountingService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.asset_repo = AssetRepo(session)
self.schedule_repo = DepreciationScheduleRepo(session)
self.depreciation_repo = AssetDepreciationRepo(session)
self.voucher_repo = VoucherRepository(session)
self.period_repo = FiscalPeriodRepository(session)
self.posting_engine = PostingEngine(session)
self.depreciation_engine = DepreciationEngine()
async def activate_asset(self, tenant_id: UUID, asset_id: UUID, actor_user_id: str) -> Asset:
asset = await self.asset_repo.get(tenant_id, asset_id)
if asset is None:
raise NotFoundError("دارایی یافت نشد", error_code="asset_not_found")
if asset.status != AssetStatus.DRAFT:
raise AssetAccountingError("فقط دارایی‌های پیش‌نویس قابل فعال‌سازی هستند")
asset.status = AssetStatus.ACTIVE
asset.current_book_value = asset.acquisition_cost
self._record_history(tenant_id, asset_id, "activated", asset.acquisition_cost, asset.acquisition_cost)
return asset
async def depreciate_asset(
self,
tenant_id: UUID,
asset_id: UUID,
*,
expense_account_id: UUID,
accumulated_account_id: UUID,
actor_user_id: str,
) -> AssetDepreciation:
asset = await self.asset_repo.get(tenant_id, asset_id)
if asset is None:
raise NotFoundError("دارایی یافت نشد", error_code="asset_not_found")
if asset.status not in (AssetStatus.ACTIVE, AssetStatus.DEPRECIATING):
raise AssetAccountingError("دارایی فعال نیست")
amount = self.depreciation_engine.calculate(asset)
if amount <= 0:
raise AssetAccountingError("مبلغ استهلاک صفر است")
period = await self.period_repo.get_current(tenant_id)
if period is None:
raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found")
voucher = Voucher(
tenant_id=tenant_id,
fiscal_period_id=period.id,
voucher_number=f"DEP-{asset.code}-{date.today().isoformat()}",
voucher_date=date.today(),
status=VoucherStatus.DRAFT,
description=f"Depreciation {asset.code}",
source_module="assets",
reference_number=str(asset_id),
created_by=actor_user_id,
)
await self.voucher_repo.add(voucher)
self.session.add(VoucherLine(
tenant_id=tenant_id, voucher_id=voucher.id, line_number=1,
account_id=expense_account_id, debit=amount, credit=Decimal("0"),
))
self.session.add(VoucherLine(
tenant_id=tenant_id, voucher_id=voucher.id, line_number=2,
account_id=accumulated_account_id, debit=Decimal("0"), credit=amount,
))
await self.session.flush()
posted = await self.posting_engine.post_voucher(
tenant_id, voucher.id, actor_user_id=actor_user_id, source_module="assets"
)
accumulated = asset.acquisition_cost - asset.current_book_value + amount
asset.current_book_value -= amount
asset.status = AssetStatus.DEPRECIATING if asset.current_book_value > asset.residual_value else AssetStatus.FULLY_DEPRECIATED
dep = AssetDepreciation(
tenant_id=tenant_id,
asset_id=asset_id,
fiscal_period_id=period.id,
depreciation_date=date.today(),
amount=amount,
accumulated_depreciation=accumulated,
book_value_after=asset.current_book_value,
voucher_id=posted.id,
)
return await self.depreciation_repo.add(dep)
def _record_history(
self, tenant_id: UUID, asset_id: UUID, event_type: str,
before: Decimal, after: Decimal,
) -> None:
self.session.add(AssetHistory(
tenant_id=tenant_id,
asset_id=asset_id,
event_type=event_type,
event_date=date.today(),
before_value=str(before),
after_value=str(after),
))

View File

@ -0,0 +1,152 @@
"""Balance Engine — centralized balance calculations."""
from __future__ import annotations
from decimal import Decimal
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ledger import GeneralLedger, LedgerBalance, TrialBalanceSnapshot
from app.models.posting import JournalEntry
from app.models.types import JournalStatus
from app.repositories.foundation import AccountRepository
from app.repositories.ledger import (
GeneralLedgerRepository,
LedgerBalanceRepository,
TrialBalanceSnapshotRepository,
)
from app.repositories.posting import JournalEntryRepository
from shared.exceptions import NotFoundError
class BalanceEngine:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.gl_repo = GeneralLedgerRepository(session)
self.balance_repo = LedgerBalanceRepository(session)
self.journal_repo = JournalEntryRepository(session)
self.account_repo = AccountRepository(session)
self.trial_repo = TrialBalanceSnapshotRepository(session)
async def update_ledger_from_posting(
self,
tenant_id: UUID,
voucher_id: UUID,
fiscal_period_id: UUID,
entries: list[JournalEntry],
) -> None:
for entry in entries:
balance = await self.balance_repo.get_by_account_period(
tenant_id, entry.account_id, fiscal_period_id
)
current = balance.current_balance if balance else Decimal("0")
running = current + entry.debit - entry.credit
await self.gl_repo.add(
GeneralLedger(
tenant_id=tenant_id,
account_id=entry.account_id,
fiscal_period_id=fiscal_period_id,
journal_entry_id=entry.id,
voucher_id=voucher_id,
entry_date=entry.entry_date,
debit=entry.debit,
credit=entry.credit,
running_balance=running,
description=entry.description,
cost_center_id=entry.cost_center_id,
project_id=entry.project_id,
)
)
if balance is None:
balance = LedgerBalance(
tenant_id=tenant_id,
account_id=entry.account_id,
fiscal_period_id=fiscal_period_id,
opening_balance=Decimal("0"),
debit_total=entry.debit,
credit_total=entry.credit,
closing_balance=running,
current_balance=running,
)
await self.balance_repo.add(balance)
else:
balance.debit_total += entry.debit
balance.credit_total += entry.credit
balance.current_balance = running
balance.closing_balance = running
async def get_account_balance(
self, tenant_id: UUID, account_id: UUID, fiscal_period_id: UUID
) -> Decimal:
balance = await self.balance_repo.get_by_account_period(
tenant_id, account_id, fiscal_period_id
)
return balance.current_balance if balance else Decimal("0")
async def generate_trial_balance(
self, tenant_id: UUID, fiscal_period_id: UUID
) -> TrialBalanceSnapshot:
balances = await self.balance_repo.list_by_period(tenant_id, fiscal_period_id)
total_debit = sum(b.debit_total for b in balances)
total_credit = sum(b.credit_total for b in balances)
difference = total_debit - total_credit
from datetime import date
snapshot = TrialBalanceSnapshot(
tenant_id=tenant_id,
fiscal_period_id=fiscal_period_id,
generated_at=date.today(),
total_debit=total_debit,
total_credit=total_credit,
difference=difference,
is_balanced=difference == Decimal("0"),
)
return await self.trial_repo.add(snapshot)
async def recalculate_period(
self, tenant_id: UUID, fiscal_period_id: UUID
) -> int:
"""Rebuild ledger balances from posted journal entries."""
entries = await self.journal_repo.list_by_tenant(tenant_id, limit=10000)
posted = [
e for e in entries
if e.fiscal_period_id == fiscal_period_id and e.status == JournalStatus.POSTED
]
account_totals: dict[UUID, dict] = {}
for entry in posted:
if entry.account_id not in account_totals:
account_totals[entry.account_id] = {
"debit": Decimal("0"),
"credit": Decimal("0"),
}
account_totals[entry.account_id]["debit"] += entry.debit
account_totals[entry.account_id]["credit"] += entry.credit
count = 0
for account_id, totals in account_totals.items():
balance = await self.balance_repo.get_by_account_period(
tenant_id, account_id, fiscal_period_id
)
current = totals["debit"] - totals["credit"]
if balance is None:
balance = LedgerBalance(
tenant_id=tenant_id,
account_id=account_id,
fiscal_period_id=fiscal_period_id,
debit_total=totals["debit"],
credit_total=totals["credit"],
current_balance=current,
closing_balance=current,
)
await self.balance_repo.add(balance)
else:
balance.debit_total = totals["debit"]
balance.credit_total = totals["credit"]
balance.current_balance = current
balance.closing_balance = current
count += 1
return count

View File

@ -0,0 +1,303 @@
"""Predefined Chart of Accounts templates + import."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.foundation import Account, ChartOfAccounts
from app.models.types import AccountCategory, AccountStatus, AccountType
from app.repositories.foundation import AccountRepository, ChartOfAccountsRepository
from app.schemas.foundation import CoaTemplateAccount, CoaTemplateRead
from shared.exceptions import AppError, NotFoundError
COA_TEMPLATES: dict[str, CoaTemplateRead] = {
"general-trading": CoaTemplateRead(
id="general-trading",
name="بازرگانی عمومی",
description="چارت استاندارد برای کسب‌وکارهای بازرگانی و خرده‌فروشی",
business_type="trading",
accounts=[
CoaTemplateAccount(
code="1",
name="دارایی‌ها",
account_type=AccountType.ASSET,
account_category=AccountCategory.CURRENT_ASSET,
level=1,
is_group=True,
is_postable=False,
),
CoaTemplateAccount(
code="11",
name="دارایی‌های جاری",
account_type=AccountType.ASSET,
account_category=AccountCategory.CURRENT_ASSET,
level=2,
is_group=True,
is_postable=False,
parent_code="1",
),
CoaTemplateAccount(
code="1101",
name="صندوق",
account_type=AccountType.ASSET,
account_category=AccountCategory.CURRENT_ASSET,
level=3,
parent_code="11",
),
CoaTemplateAccount(
code="1102",
name="بانک",
account_type=AccountType.ASSET,
account_category=AccountCategory.CURRENT_ASSET,
level=3,
parent_code="11",
),
CoaTemplateAccount(
code="1103",
name="حساب‌های دریافتنی",
account_type=AccountType.ASSET,
account_category=AccountCategory.CURRENT_ASSET,
level=3,
parent_code="11",
),
CoaTemplateAccount(
code="2",
name="بدهی‌ها",
account_type=AccountType.LIABILITY,
account_category=AccountCategory.CURRENT_LIABILITY,
level=1,
is_group=True,
is_postable=False,
),
CoaTemplateAccount(
code="21",
name="بدهی‌های جاری",
account_type=AccountType.LIABILITY,
account_category=AccountCategory.CURRENT_LIABILITY,
level=2,
is_group=True,
is_postable=False,
parent_code="2",
),
CoaTemplateAccount(
code="2101",
name="حساب‌های پرداختنی",
account_type=AccountType.LIABILITY,
account_category=AccountCategory.CURRENT_LIABILITY,
level=3,
parent_code="21",
),
CoaTemplateAccount(
code="3",
name="حقوق صاحبان سهام",
account_type=AccountType.EQUITY,
account_category=AccountCategory.EQUITY,
level=1,
is_group=True,
is_postable=False,
),
CoaTemplateAccount(
code="3101",
name="سرمایه",
account_type=AccountType.EQUITY,
account_category=AccountCategory.EQUITY,
level=2,
parent_code="3",
),
CoaTemplateAccount(
code="4",
name="درآمدها",
account_type=AccountType.REVENUE,
account_category=AccountCategory.OPERATING_REVENUE,
level=1,
is_group=True,
is_postable=False,
),
CoaTemplateAccount(
code="4101",
name="فروش کالا",
account_type=AccountType.REVENUE,
account_category=AccountCategory.OPERATING_REVENUE,
level=2,
parent_code="4",
),
CoaTemplateAccount(
code="5",
name="هزینه‌ها",
account_type=AccountType.EXPENSE,
account_category=AccountCategory.OPERATING_EXPENSE,
level=1,
is_group=True,
is_postable=False,
),
CoaTemplateAccount(
code="5101",
name="بهای تمام‌شده کالای فروش‌رفته",
account_type=AccountType.EXPENSE,
account_category=AccountCategory.OPERATING_EXPENSE,
level=2,
parent_code="5",
),
CoaTemplateAccount(
code="5201",
name="هزینه‌های اداری",
account_type=AccountType.EXPENSE,
account_category=AccountCategory.OPERATING_EXPENSE,
level=2,
parent_code="5",
),
],
),
"services": CoaTemplateRead(
id="services",
name="خدمات حرفه‌ای",
description="چارت ساده برای شرکت‌های خدماتی و مشاوره",
business_type="services",
accounts=[
CoaTemplateAccount(
code="1",
name="دارایی‌ها",
account_type=AccountType.ASSET,
account_category=AccountCategory.CURRENT_ASSET,
level=1,
is_group=True,
is_postable=False,
),
CoaTemplateAccount(
code="1101",
name="صندوق و بانک",
account_type=AccountType.ASSET,
account_category=AccountCategory.CURRENT_ASSET,
level=2,
parent_code="1",
),
CoaTemplateAccount(
code="1102",
name="مطالبات مشتریان",
account_type=AccountType.ASSET,
account_category=AccountCategory.CURRENT_ASSET,
level=2,
parent_code="1",
),
CoaTemplateAccount(
code="2",
name="بدهی‌ها",
account_type=AccountType.LIABILITY,
account_category=AccountCategory.CURRENT_LIABILITY,
level=1,
is_group=True,
is_postable=False,
),
CoaTemplateAccount(
code="2101",
name="بدهی‌های جاری",
account_type=AccountType.LIABILITY,
account_category=AccountCategory.CURRENT_LIABILITY,
level=2,
parent_code="2",
),
CoaTemplateAccount(
code="3",
name="حقوق صاحبان سهام",
account_type=AccountType.EQUITY,
account_category=AccountCategory.EQUITY,
level=1,
is_group=True,
is_postable=False,
),
CoaTemplateAccount(
code="3101",
name="سرمایه",
account_type=AccountType.EQUITY,
account_category=AccountCategory.EQUITY,
level=2,
parent_code="3",
),
CoaTemplateAccount(
code="4",
name="درآمد خدمات",
account_type=AccountType.REVENUE,
account_category=AccountCategory.OPERATING_REVENUE,
level=1,
is_group=False,
is_postable=True,
),
CoaTemplateAccount(
code="5",
name="هزینه‌های عملیاتی",
account_type=AccountType.EXPENSE,
account_category=AccountCategory.OPERATING_EXPENSE,
level=1,
is_group=False,
is_postable=True,
),
],
),
}
async def import_coa_template(
session: AsyncSession,
*,
tenant_id: UUID,
template_id: str,
chart_id: UUID | None = None,
chart_name: str | None = None,
chart_code: str | None = None,
) -> list[Account]:
tpl = COA_TEMPLATES.get(template_id)
if tpl is None:
raise NotFoundError("قالب یافت نشد", error_code="template_not_found")
chart_repo = ChartOfAccountsRepository(session)
account_repo = AccountRepository(session)
if chart_id is not None:
chart = await chart_repo.get(tenant_id, chart_id)
if chart is None:
raise NotFoundError("دفتر حساب یافت نشد", error_code="chart_not_found")
else:
code = chart_code or f"TPL-{template_id[:12].upper()}"
existing = await chart_repo.get_by_code(tenant_id, code)
if existing is not None:
raise AppError("کد دفتر حساب تکراری است", status_code=409, error_code="chart_code_exists")
chart = ChartOfAccounts(
tenant_id=tenant_id,
name=chart_name or tpl.name,
code=code,
description=tpl.description,
is_default=False,
is_active=True,
)
await chart_repo.add(chart)
await session.flush()
created: list[Account] = []
code_to_id: dict[str, UUID] = {}
for item in sorted(tpl.accounts, key=lambda a: a.level):
parent_id = code_to_id.get(item.parent_code) if item.parent_code else None
if item.parent_code and parent_id is None:
raise AppError(
f"والد حساب {item.code} یافت نشد",
status_code=422,
error_code="parent_missing",
)
acc = Account(
tenant_id=tenant_id,
chart_id=chart.id,
parent_id=parent_id,
code=item.code,
name=item.name,
account_type=item.account_type,
account_category=item.account_category,
level=item.level,
is_group=item.is_group,
is_postable=item.is_postable,
status=AccountStatus.ACTIVE,
)
await account_repo.add(acc)
await session.flush()
code_to_id[item.code] = acc.id
created.append(acc)
return created

View File

@ -0,0 +1,237 @@
"""Phase 5.11 — Compliance, Audit & Governance services."""
from __future__ import annotations
from datetime import date, datetime, timezone
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.compliance import (
ApprovalRequest,
ApprovalStep,
ApprovalWorkflow,
AuditRecord,
CompliancePolicy,
GovernanceRule,
PolicyViolation,
RiskRecord,
)
from app.models.types import ApprovalStatus, PolicyViolationSeverity, RiskLevel
from app.repositories.base import TenantBaseRepository
from shared.exceptions import AppError, NotFoundError
class ComplianceError(AppError):
status_code = 422
error_code = "compliance_error"
class AuditRecordRepo(TenantBaseRepository[AuditRecord]):
model = AuditRecord
class CompliancePolicyRepo(TenantBaseRepository[CompliancePolicy]):
model = CompliancePolicy
class GovernanceRuleRepo(TenantBaseRepository[GovernanceRule]):
model = GovernanceRule
class ApprovalWorkflowRepo(TenantBaseRepository[ApprovalWorkflow]):
model = ApprovalWorkflow
class ApprovalRequestRepo(TenantBaseRepository[ApprovalRequest]):
model = ApprovalRequest
class RiskRecordRepo(TenantBaseRepository[RiskRecord]):
model = RiskRecord
class AuditFramework:
"""Immutable audit trail framework."""
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.audit_repo = AuditRecordRepo(session)
async def record(
self,
tenant_id: UUID,
*,
actor_user_id: str,
action: str,
resource_type: str,
resource_id: str,
source_module: str | None = None,
before_value: str | None = None,
after_value: str | None = None,
reason: str | None = None,
correlation_id: str | None = None,
ip_address: str | None = None,
) -> AuditRecord:
record = AuditRecord(
tenant_id=tenant_id,
actor_user_id=actor_user_id,
action=action,
resource_type=resource_type,
resource_id=resource_id,
source_module=source_module,
before_value=before_value,
after_value=after_value,
reason=reason,
correlation_id=correlation_id,
ip_address=ip_address,
is_immutable=True,
)
return await self.audit_repo.add(record)
class ComplianceEngine:
"""Configurable compliance validation engine."""
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.policy_repo = CompliancePolicyRepo(session)
async def validate_policy(
self, tenant_id: UUID, policy_code: str, context: dict
) -> tuple[bool, str | None]:
from sqlalchemy import select
stmt = select(CompliancePolicy).where(
CompliancePolicy.tenant_id == tenant_id,
CompliancePolicy.code == policy_code,
CompliancePolicy.is_active.is_(True),
)
result = await self.session.execute(stmt)
policy = result.scalar_one_or_none()
if policy is None:
return True, None
return True, None
class GovernanceService:
"""Approval workflows and segregation of duties."""
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.workflow_repo = ApprovalWorkflowRepo(session)
self.request_repo = ApprovalRequestRepo(session)
self.audit = AuditFramework(session)
async def request_approval(
self,
tenant_id: UUID,
*,
workflow_id: UUID,
resource_type: str,
resource_id: str,
requested_by: str,
) -> ApprovalRequest:
workflow = await self.workflow_repo.get(tenant_id, workflow_id)
if workflow is None:
raise NotFoundError("گردش کار تأیید یافت نشد", error_code="workflow_not_found")
request = ApprovalRequest(
tenant_id=tenant_id,
workflow_id=workflow_id,
resource_type=resource_type,
resource_id=resource_id,
requested_by=requested_by,
status=ApprovalStatus.PENDING,
)
await self.request_repo.add(request)
await self.audit.record(
tenant_id,
actor_user_id=requested_by,
action="approval_requested",
resource_type=resource_type,
resource_id=resource_id,
source_module="governance",
)
return request
async def approve(
self, tenant_id: UUID, request_id: UUID, *, approver_user_id: str
) -> ApprovalRequest:
request = await self.request_repo.get(tenant_id, request_id)
if request is None:
raise NotFoundError("درخواست تأیید یافت نشد", error_code="approval_not_found")
if request.status != ApprovalStatus.PENDING:
raise ComplianceError("درخواست قبلاً پردازش شده است")
if request.requested_by == approver_user_id:
raise ComplianceError("ایجادکننده نمی‌تواند تأییدکننده باشد (SoD)")
request.status = ApprovalStatus.APPROVED
request.approved_by = approver_user_id
request.approved_at = datetime.now(timezone.utc)
await self.audit.record(
tenant_id,
actor_user_id=approver_user_id,
action="approval_completed",
resource_type=request.resource_type,
resource_id=request.resource_id,
source_module="governance",
)
return request
async def reject(
self, tenant_id: UUID, request_id: UUID, *, approver_user_id: str, reason: str
) -> ApprovalRequest:
request = await self.request_repo.get(tenant_id, request_id)
if request is None:
raise NotFoundError("درخواست تأیید یافت نشد", error_code="approval_not_found")
request.status = ApprovalStatus.REJECTED
request.approved_by = approver_user_id
request.approved_at = datetime.now(timezone.utc)
request.rejection_reason = reason
return request
async def record_violation(
self,
tenant_id: UUID,
*,
policy_id: UUID,
resource_type: str,
resource_id: str,
severity: PolicyViolationSeverity,
description: str,
) -> PolicyViolation:
violation = PolicyViolation(
tenant_id=tenant_id,
policy_id=policy_id,
resource_type=resource_type,
resource_id=resource_id,
severity=severity,
description=description,
detected_at=datetime.now(timezone.utc),
)
self.session.add(violation)
await self.session.flush()
return violation
async def create_risk(
self,
tenant_id: UUID,
*,
code: str,
title: str,
risk_category: str,
risk_level: RiskLevel,
risk_score: int,
description: str | None = None,
) -> RiskRecord:
risk = RiskRecord(
tenant_id=tenant_id,
code=code,
title=title,
risk_category=risk_category,
risk_level=risk_level,
risk_score=risk_score,
description=description,
)
repo = RiskRecordRepo(self.session)
return await repo.add(risk)

View File

@ -0,0 +1,128 @@
"""Fiscal period management service."""
from __future__ import annotations
from calendar import monthrange
from datetime import date
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.foundation import FiscalPeriod, FiscalYear
from app.models.types import FiscalPeriodStatus
from app.repositories.foundation import FiscalPeriodRepository, FiscalYearRepository
from shared.exceptions import AppError, NotFoundError
class FiscalManagementError(AppError):
status_code = 422
error_code = "fiscal_management_error"
class FiscalManagementService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.year_repo = FiscalYearRepository(session)
self.period_repo = FiscalPeriodRepository(session)
async def lock_period(self, tenant_id: UUID, period_id: UUID) -> FiscalPeriod:
period = await self.period_repo.get(tenant_id, period_id)
if period is None:
raise NotFoundError("دوره مالی یافت نشد", error_code="period_not_found")
if period.status != FiscalPeriodStatus.OPEN:
raise FiscalManagementError("فقط دوره‌های باز قابل قفل هستند")
period.status = FiscalPeriodStatus.LOCKED
return period
async def close_period(self, tenant_id: UUID, period_id: UUID) -> FiscalPeriod:
period = await self.period_repo.get(tenant_id, period_id)
if period is None:
raise NotFoundError("دوره مالی یافت نشد", error_code="period_not_found")
period.status = FiscalPeriodStatus.CLOSED
period.is_current = False
return period
async def reopen_period(self, tenant_id: UUID, period_id: UUID) -> FiscalPeriod:
period = await self.period_repo.get(tenant_id, period_id)
if period is None:
raise NotFoundError("دوره مالی یافت نشد", error_code="period_not_found")
if period.status == FiscalPeriodStatus.ARCHIVED:
raise FiscalManagementError("دوره بایگانی‌شده قابل بازگشایی نیست")
period.status = FiscalPeriodStatus.OPEN
return period
async def close_fiscal_year(self, tenant_id: UUID, year_id: UUID) -> FiscalYear:
year = await self.year_repo.get(tenant_id, year_id)
if year is None:
raise NotFoundError("سال مالی یافت نشد", error_code="year_not_found")
periods = await self.period_repo.list_by_year(tenant_id, year_id)
for p in periods:
if p.status == FiscalPeriodStatus.OPEN:
p.status = FiscalPeriodStatus.CLOSED
p.is_current = False
year.is_closed = True
year.is_current = False
return year
async def set_current_year(self, tenant_id: UUID, year_id: UUID) -> FiscalYear:
year = await self.year_repo.get(tenant_id, year_id)
if year is None:
raise NotFoundError("سال مالی یافت نشد", error_code="year_not_found")
if year.is_closed:
raise FiscalManagementError("سال بسته‌شده نمی‌تواند جاری باشد")
for y in await self.year_repo.list_by_tenant(tenant_id, limit=500):
y.is_current = y.id == year_id
return year
async def set_current_period(self, tenant_id: UUID, period_id: UUID) -> FiscalPeriod:
period = await self.period_repo.get(tenant_id, period_id)
if period is None:
raise NotFoundError("دوره مالی یافت نشد", error_code="period_not_found")
if period.status == FiscalPeriodStatus.CLOSED:
raise FiscalManagementError("دوره بسته‌شده نمی‌تواند جاری باشد")
for p in await self.period_repo.list_by_tenant(tenant_id, limit=500):
p.is_current = p.id == period_id
return period
async def generate_monthly_periods(
self, tenant_id: UUID, year_id: UUID
) -> list[FiscalPeriod]:
year = await self.year_repo.get(tenant_id, year_id)
if year is None:
raise NotFoundError("سال مالی یافت نشد", error_code="year_not_found")
existing = await self.period_repo.list_by_year(tenant_id, year_id)
if existing:
raise FiscalManagementError("برای این سال قبلاً دوره تعریف شده است")
periods: list[FiscalPeriod] = []
cursor = year.start_date.replace(day=1)
end = year.end_date
num = 1
while cursor <= end:
last_day = monthrange(cursor.year, cursor.month)[1]
period_end = date(cursor.year, cursor.month, last_day)
if period_end > end:
period_end = end
period_start = year.start_date if num == 1 else cursor
if period_start > period_end:
break
entity = FiscalPeriod(
tenant_id=tenant_id,
fiscal_year_id=year_id,
name=f"دوره {num}",
period_number=num,
start_date=period_start,
end_date=period_end,
status=FiscalPeriodStatus.OPEN,
is_current=num == 1 and year.is_current,
)
await self.period_repo.add(entity)
periods.append(entity)
num += 1
if period_end >= end:
break
if cursor.month == 12:
cursor = date(cursor.year + 1, 1, 1)
else:
cursor = date(cursor.year, cursor.month + 1, 1)
await self.session.flush()
return periods

View File

@ -0,0 +1,185 @@
"""Phase 5.9 — Payroll Engine & accounting integration."""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.payroll import (
Employee,
EmployeeAdvance,
EmployeeLoan,
Payroll,
PayrollAccountingProfile,
PayrollItem,
PayrollPeriod,
)
from app.models.posting import Voucher, VoucherLine
from app.models.types import PayrollPeriodStatus, PayrollStatus, VoucherStatus
from app.repositories.base import TenantBaseRepository
from app.repositories.foundation import FiscalPeriodRepository
from app.repositories.posting import VoucherRepository
from app.services.posting_engine import PostingEngine
from shared.exceptions import AppError, NotFoundError
class PayrollError(AppError):
status_code = 422
error_code = "payroll_error"
class EmployeeRepo(TenantBaseRepository[Employee]):
model = Employee
class PayrollPeriodRepo(TenantBaseRepository[PayrollPeriod]):
model = PayrollPeriod
class PayrollRepo(TenantBaseRepository[Payroll]):
model = Payroll
class PayrollProfileRepo(TenantBaseRepository[PayrollAccountingProfile]):
model = PayrollAccountingProfile
class PayrollEngine:
"""Centralized payroll calculation engine."""
def calculate(
self,
employee: Employee,
*,
benefits: Decimal = Decimal("0"),
deductions: Decimal = Decimal("0"),
overtime: Decimal = Decimal("0"),
) -> dict:
gross = employee.base_salary + benefits + overtime
net = gross - deductions
employer_cost = gross + benefits * Decimal("0.23")
return {
"gross_salary": gross,
"total_benefits": benefits,
"total_deductions": deductions,
"net_salary": net,
"employer_cost": employer_cost,
}
class PayrollAccountingService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.employee_repo = EmployeeRepo(session)
self.period_repo = PayrollPeriodRepo(session)
self.payroll_repo = PayrollRepo(session)
self.profile_repo = PayrollProfileRepo(session)
self.fiscal_period_repo = FiscalPeriodRepository(session)
self.voucher_repo = VoucherRepository(session)
self.posting_engine = PostingEngine(session)
self.payroll_engine = PayrollEngine()
async def calculate_payroll(
self, tenant_id: UUID, payroll_period_id: UUID, employee_id: UUID
) -> Payroll:
employee = await self.employee_repo.get(tenant_id, employee_id)
if employee is None:
raise NotFoundError("کارمند یافت نشد", error_code="employee_not_found")
period = await self.period_repo.get(tenant_id, payroll_period_id)
if period is None:
raise NotFoundError("دوره حقوق یافت نشد", error_code="payroll_period_not_found")
if period.status not in (PayrollPeriodStatus.OPEN, PayrollPeriodStatus.PROCESSING):
raise PayrollError("دوره حقوق قابل محاسبه نیست")
calc = self.payroll_engine.calculate(employee)
payroll = Payroll(
tenant_id=tenant_id,
payroll_period_id=payroll_period_id,
employee_id=employee_id,
gross_salary=calc["gross_salary"],
total_deductions=calc["total_deductions"],
total_benefits=calc["total_benefits"],
net_salary=calc["net_salary"],
employer_cost=calc["employer_cost"],
status=PayrollStatus.CALCULATED,
)
return await self.payroll_repo.add(payroll)
async def post_payroll(
self,
tenant_id: UUID,
payroll_id: UUID,
*,
actor_user_id: str,
profile_id: UUID | None = None,
) -> Payroll:
payroll = await self.payroll_repo.get(tenant_id, payroll_id)
if payroll is None:
raise NotFoundError("حقوق یافت نشد", error_code="payroll_not_found")
if payroll.status != PayrollStatus.APPROVED:
raise PayrollError("حقوق باید تأیید شده باشد")
profile = await self._resolve_profile(tenant_id, profile_id)
fiscal_period = await self.fiscal_period_repo.get_current(tenant_id)
if fiscal_period is None:
raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found")
voucher = Voucher(
tenant_id=tenant_id,
fiscal_period_id=fiscal_period.id,
voucher_number=f"PAY-{payroll_id}",
voucher_date=date.today(),
status=VoucherStatus.DRAFT,
description=f"Payroll posting {payroll_id}",
source_module="payroll",
reference_number=str(payroll_id),
created_by=actor_user_id,
)
await self.voucher_repo.add(voucher)
if profile.salary_expense_account_id and profile.payable_account_id:
self.session.add(VoucherLine(
tenant_id=tenant_id, voucher_id=voucher.id, line_number=1,
account_id=profile.salary_expense_account_id,
debit=payroll.gross_salary, credit=Decimal("0"),
))
self.session.add(VoucherLine(
tenant_id=tenant_id, voucher_id=voucher.id, line_number=2,
account_id=profile.payable_account_id,
debit=Decimal("0"), credit=payroll.net_salary,
))
if payroll.total_deductions > 0 and profile.deduction_account_id:
self.session.add(VoucherLine(
tenant_id=tenant_id, voucher_id=voucher.id, line_number=3,
account_id=profile.deduction_account_id,
debit=Decimal("0"), credit=payroll.total_deductions,
))
await self.session.flush()
posted = await self.posting_engine.post_voucher(
tenant_id, voucher.id, actor_user_id=actor_user_id, source_module="payroll"
)
payroll.status = PayrollStatus.PAID
payroll.voucher_id = posted.id
return payroll
async def _resolve_profile(
self, tenant_id: UUID, profile_id: UUID | None
) -> PayrollAccountingProfile:
if profile_id:
profile = await self.profile_repo.get(tenant_id, profile_id)
if profile is None:
raise NotFoundError("پروفایل حقوق یافت نشد", error_code="profile_not_found")
return profile
stmt = select(PayrollAccountingProfile).where(
PayrollAccountingProfile.tenant_id == tenant_id,
PayrollAccountingProfile.is_default.is_(True),
)
result = await self.session.execute(stmt)
profile = result.scalar_one_or_none()
if profile is None:
raise NotFoundError("پروفایل پیش‌فرض حقوق یافت نشد", error_code="profile_not_found")
return profile

View File

@ -0,0 +1,273 @@
"""Centralized Posting Engine — sole creator of Journal Entries (ADR-010)."""
from __future__ import annotations
import uuid
from datetime import date, datetime, timezone
from decimal import Decimal
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.foundation import Account, FiscalPeriod
from app.models.posting import (
AccountingAuditLog,
JournalEntry,
PostingError,
PostingLog,
Voucher,
VoucherLine,
)
from app.models.types import (
AccountStatus,
FiscalPeriodStatus,
JournalStatus,
PostingStatus,
VoucherStatus,
)
from app.repositories.foundation import AccountRepository, FiscalPeriodRepository
from app.repositories.posting import (
AuditLogRepository,
JournalEntryRepository,
PostingErrorRepository,
PostingLogRepository,
VoucherRepository,
)
from shared.exceptions import AppError, NotFoundError
class PostingValidationError(AppError):
status_code = 422
error_code = "posting_validation_failed"
class PostingEngine:
"""The ONLY component allowed to create Journal Entries."""
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.voucher_repo = VoucherRepository(session)
self.account_repo = AccountRepository(session)
self.period_repo = FiscalPeriodRepository(session)
self.journal_repo = JournalEntryRepository(session)
self.log_repo = PostingLogRepository(session)
self.error_repo = PostingErrorRepository(session)
self.audit_repo = AuditLogRepository(session)
async def validate_voucher(self, tenant_id: UUID, voucher_id: UUID) -> Voucher:
voucher = await self.voucher_repo.get_with_lines(tenant_id, voucher_id)
if voucher is None:
raise NotFoundError("سند یافت نشد", error_code="voucher_not_found")
errors: list[PostingError] = []
if voucher.status not in (VoucherStatus.DRAFT, VoucherStatus.VALIDATED):
errors.append(self._make_error(tenant_id, voucher_id, "invalid_status", "وضعیت سند نامعتبر است"))
period = await self.period_repo.get(tenant_id, voucher.fiscal_period_id)
if period is None:
errors.append(self._make_error(tenant_id, voucher_id, "period_not_found", "دوره مالی یافت نشد"))
elif period.status != FiscalPeriodStatus.OPEN:
errors.append(self._make_error(tenant_id, voucher_id, "period_locked", "دوره مالی بسته یا قفل است"))
if not voucher.lines:
errors.append(self._make_error(tenant_id, voucher_id, "no_lines", "سند فاقد سطر است"))
total_debit = Decimal("0")
total_credit = Decimal("0")
for line in voucher.lines:
total_debit += line.debit
total_credit += line.credit
account = await self.account_repo.get(tenant_id, line.account_id)
if account is None:
errors.append(self._make_error(tenant_id, voucher_id, "account_not_found", f"حساب {line.account_id} یافت نشد", "account_id"))
elif account.status != AccountStatus.ACTIVE:
errors.append(self._make_error(tenant_id, voucher_id, "account_inactive", f"حساب {account.code} غیرفعال است", "account_id"))
elif not account.is_postable:
errors.append(self._make_error(tenant_id, voucher_id, "account_not_postable", f"حساب {account.code} قابل ثبت نیست", "account_id"))
if total_debit != total_credit:
errors.append(self._make_error(tenant_id, voucher_id, "unbalanced", f"عدم تراز: بدهکار={total_debit} بستانکار={total_credit}"))
if errors:
for err in errors:
await self.error_repo.add(err)
await self._log(tenant_id, voucher_id, "validate", PostingStatus.FAILED, "Validation failed")
raise PostingValidationError("اعتبارسنجی سند ناموفق بود", details={"errors": len(errors)})
voucher.total_debit = total_debit
voucher.total_credit = total_credit
voucher.status = VoucherStatus.VALIDATED
await self._log(tenant_id, voucher_id, "validate", PostingStatus.COMPLETED, "Validated")
return voucher
async def post_voucher(
self,
tenant_id: UUID,
voucher_id: UUID,
*,
actor_user_id: str,
source_module: str | None = None,
) -> Voucher:
voucher = await self.validate_voucher(tenant_id, voucher_id)
if voucher.status == VoucherStatus.POSTED:
raise PostingValidationError("سند قبلاً ثبت شده است")
entry_num = 0
for line in voucher.lines:
entry_num += 1
entry = JournalEntry(
tenant_id=tenant_id,
voucher_id=voucher.id,
fiscal_period_id=voucher.fiscal_period_id,
entry_number=f"{voucher.voucher_number}-{entry_num}",
entry_date=voucher.voucher_date,
status=JournalStatus.POSTED,
account_id=line.account_id,
debit=line.debit,
credit=line.credit,
description=line.description,
cost_center_id=line.cost_center_id,
project_id=line.project_id,
posted_at=datetime.now(timezone.utc),
posted_by=actor_user_id,
)
await self.journal_repo.add(entry)
now = datetime.now(timezone.utc)
voucher.status = VoucherStatus.POSTED
voucher.posted_at = now
voucher.posted_by = actor_user_id
if source_module:
voucher.source_module = source_module
await self._audit(
tenant_id=tenant_id,
operation="post",
actor_user_id=actor_user_id,
voucher=voucher,
source_module=source_module,
)
await self._log(tenant_id, voucher_id, "post", PostingStatus.COMPLETED, "Posted", actor_user_id)
return voucher
async def reverse_voucher(
self,
tenant_id: UUID,
voucher_id: UUID,
*,
actor_user_id: str,
reversal_date: date | None = None,
) -> Voucher:
original = await self.voucher_repo.get_with_lines(tenant_id, voucher_id)
if original is None:
raise NotFoundError("سند یافت نشد", error_code="voucher_not_found")
if original.status != VoucherStatus.POSTED:
raise PostingValidationError("فقط اسناد ثبت‌شده قابل برگشت هستند")
rev_number = f"REV-{original.voucher_number}"
reversal = Voucher(
tenant_id=tenant_id,
fiscal_period_id=original.fiscal_period_id,
voucher_number=rev_number,
voucher_date=reversal_date or date.today(),
status=VoucherStatus.DRAFT,
description=f"برگشت سند {original.voucher_number}",
reference_number=original.voucher_number,
source_module=original.source_module,
reversed_voucher_id=original.id,
created_by=actor_user_id,
)
await self.voucher_repo.add(reversal)
for i, line in enumerate(original.lines, start=1):
rev_line = VoucherLine(
tenant_id=tenant_id,
voucher_id=reversal.id,
line_number=i,
account_id=line.account_id,
debit=line.credit,
credit=line.debit,
description=f"برگشت: {line.description or ''}",
cost_center_id=line.cost_center_id,
project_id=line.project_id,
)
self.session.add(rev_line)
await self.session.flush()
reversal = await self.voucher_repo.get_with_lines(tenant_id, reversal.id)
await self.post_voucher(tenant_id, reversal.id, actor_user_id=actor_user_id)
original.status = VoucherStatus.REVERSED
await self._audit(
tenant_id=tenant_id,
operation="reverse",
actor_user_id=actor_user_id,
voucher=original,
)
return reversal
async def cancel_voucher(
self, tenant_id: UUID, voucher_id: UUID, *, actor_user_id: str
) -> Voucher:
voucher = await self.voucher_repo.get(tenant_id, voucher_id)
if voucher is None:
raise NotFoundError("سند یافت نشد", error_code="voucher_not_found")
if voucher.status == VoucherStatus.POSTED:
raise PostingValidationError("سند ثبت‌شده قابل لغو نیست — از برگشت استفاده کنید")
voucher.status = VoucherStatus.CANCELLED
await self._audit(tenant_id=tenant_id, operation="cancel", actor_user_id=actor_user_id, voucher=voucher)
return voucher
def _make_error(
self, tenant_id: UUID, voucher_id: UUID, code: str, message: str, field: str | None = None
) -> PostingError:
return PostingError(
tenant_id=tenant_id,
voucher_id=voucher_id,
error_code=code,
error_message=message,
field_name=field,
)
async def _log(
self,
tenant_id: UUID,
voucher_id: UUID,
operation: str,
status: PostingStatus,
message: str,
actor: str | None = None,
) -> None:
await self.log_repo.add(
PostingLog(
tenant_id=tenant_id,
voucher_id=voucher_id,
operation=operation,
status=status,
actor_user_id=actor,
message=message,
)
)
async def _audit(
self,
*,
tenant_id: UUID,
operation: str,
actor_user_id: str,
voucher: Voucher,
source_module: str | None = None,
) -> None:
await self.audit_repo.add(
AccountingAuditLog(
tenant_id=tenant_id,
operation=operation,
actor_user_id=actor_user_id,
source_module=source_module or voucher.source_module,
voucher_number=voucher.voucher_number,
reference_number=voucher.reference_number,
resource_type="voucher",
resource_id=str(voucher.id),
)
)

View File

@ -0,0 +1,188 @@
"""Phase 5.7 — Purchase & Inventory accounting services."""
from __future__ import annotations
import json
from decimal import Decimal
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.posting import Voucher, VoucherLine
from app.models.purchase_inventory import (
InventoryAccountingConfiguration,
InventoryCostAdjustment,
InventoryPostingProfile,
InventoryValuationHistory,
PurchasePostingProfile,
)
from app.models.sales_accounting import AccountingPreview
from app.models.types import InventoryDocumentType, InventoryValuationMethod, PurchaseDocumentType, VoucherStatus
from app.repositories.base import TenantBaseRepository
from app.repositories.foundation import FiscalPeriodRepository
from app.repositories.posting import VoucherRepository
from app.services.posting_engine import PostingEngine
from shared.exceptions import NotFoundError
class PurchaseProfileRepo(TenantBaseRepository[PurchasePostingProfile]):
model = PurchasePostingProfile
class InventoryProfileRepo(TenantBaseRepository[InventoryPostingProfile]):
model = InventoryPostingProfile
class InventoryValuationHistoryRepo(TenantBaseRepository[InventoryValuationHistory]):
model = InventoryValuationHistory
class InventoryCostAdjustmentRepo(TenantBaseRepository[InventoryCostAdjustment]):
model = InventoryCostAdjustment
class InventoryValuationEngine:
"""Valuation calculations separated from Posting Engine."""
def calculate_fifo(self, layers: list[tuple[Decimal, Decimal]], issue_qty: Decimal) -> Decimal:
remaining = issue_qty
total_cost = Decimal("0")
for qty, cost in layers:
if remaining <= 0:
break
take = min(remaining, qty)
total_cost += take * cost
remaining -= take
return total_cost
def calculate_weighted_average(self, total_qty: Decimal, total_value: Decimal, new_qty: Decimal, new_cost: Decimal) -> Decimal:
if total_qty + new_qty == 0:
return Decimal("0")
return (total_value + new_qty * new_cost) / (total_qty + new_qty)
def calculate_moving_average(self, current_avg: Decimal, current_qty: Decimal, new_qty: Decimal, new_cost: Decimal) -> Decimal:
return self.calculate_weighted_average(current_qty, current_avg * current_qty, new_qty, new_cost)
class PurchaseInventoryAccountingService:
"""Purchase and inventory accounting via Posting Engine only."""
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.purchase_profile_repo = PurchaseProfileRepo(session)
self.inventory_profile_repo = InventoryProfileRepo(session)
self.voucher_repo = VoucherRepository(session)
self.period_repo = FiscalPeriodRepository(session)
self.posting_engine = PostingEngine(session)
self.valuation_engine = InventoryValuationEngine()
self.valuation_history_repo = InventoryValuationHistoryRepo(session)
self.cost_adjustment_repo = InventoryCostAdjustmentRepo(session)
async def preview_goods_receipt(
self, tenant_id: UUID, amount: Decimal, profile_id: UUID | None = None
) -> AccountingPreview:
profile = await self._resolve_purchase_profile(tenant_id, profile_id)
lines = []
if profile.inventory_account_id:
lines.append({"account_id": str(profile.inventory_account_id), "debit": str(amount), "credit": "0"})
if profile.grni_account_id:
lines.append({"account_id": str(profile.grni_account_id), "debit": "0", "credit": str(amount)})
total_debit = sum(Decimal(l["debit"]) for l in lines)
total_credit = sum(Decimal(l["credit"]) for l in lines)
preview = AccountingPreview(
tenant_id=tenant_id,
source_module="purchase",
source_document_type=PurchaseDocumentType.GOODS_RECEIPT.value,
source_document_id="preview",
preview_data=json.dumps(lines),
is_balanced=total_debit == total_credit,
total_debit=total_debit,
total_credit=total_credit,
)
self.session.add(preview)
await self.session.flush()
return preview
async def post_goods_receipt(
self,
tenant_id: UUID,
*,
source_document_id: str,
amount: Decimal,
actor_user_id: str,
profile_id: UUID | None = None,
) -> Voucher:
from datetime import date
profile = await self._resolve_purchase_profile(tenant_id, profile_id)
period = await self.period_repo.get_current(tenant_id)
if period is None:
raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found")
voucher = Voucher(
tenant_id=tenant_id,
fiscal_period_id=period.id,
voucher_number=f"GR-{source_document_id}",
voucher_date=date.today(),
status=VoucherStatus.DRAFT,
description=f"Goods receipt {source_document_id}",
source_module="purchase",
reference_number=source_document_id,
created_by=actor_user_id,
)
await self.voucher_repo.add(voucher)
if profile.inventory_account_id and profile.grni_account_id:
self.session.add(VoucherLine(
tenant_id=tenant_id, voucher_id=voucher.id, line_number=1,
account_id=profile.inventory_account_id, debit=amount, credit=Decimal("0"),
))
self.session.add(VoucherLine(
tenant_id=tenant_id, voucher_id=voucher.id, line_number=2,
account_id=profile.grni_account_id, debit=Decimal("0"), credit=amount,
))
await self.session.flush()
return await self.posting_engine.post_voucher(
tenant_id, voucher.id, actor_user_id=actor_user_id, source_module="purchase"
)
async def record_valuation(
self,
tenant_id: UUID,
item_id: UUID,
quantity: Decimal,
unit_cost: Decimal,
method: InventoryValuationMethod,
warehouse_id: UUID | None = None,
) -> InventoryValuationHistory:
from datetime import date
record = InventoryValuationHistory(
tenant_id=tenant_id,
item_id=item_id,
warehouse_id=warehouse_id,
valuation_date=date.today(),
method=method,
quantity=quantity,
unit_cost=unit_cost,
total_value=quantity * unit_cost,
)
return await self.valuation_history_repo.add(record)
async def _resolve_purchase_profile(
self, tenant_id: UUID, profile_id: UUID | None
) -> PurchasePostingProfile:
if profile_id:
profile = await self.purchase_profile_repo.get(tenant_id, profile_id)
if profile is None:
raise NotFoundError("پروفایل خرید یافت نشد", error_code="profile_not_found")
return profile
stmt = select(PurchasePostingProfile).where(
PurchasePostingProfile.tenant_id == tenant_id,
PurchasePostingProfile.is_default.is_(True),
)
result = await self.session.execute(stmt)
profile = result.scalar_one_or_none()
if profile is None:
raise NotFoundError("پروفایل پیش‌فرض خرید یافت نشد", error_code="profile_not_found")
return profile

View File

@ -0,0 +1,117 @@
"""Phase 5.10 — Financial Report Engine."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from decimal import Decimal
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.reporting import FinancialReport, ReportExport, ReportSnapshot
from app.models.types import ReportExportFormat, ReportStatus, ReportType
from app.repositories.base import TenantBaseRepository
from app.repositories.ledger import LedgerBalanceRepository
from app.services.balance_engine import BalanceEngine
from shared.exceptions import NotFoundError
class FinancialReportRepo(TenantBaseRepository[FinancialReport]):
model = FinancialReport
class ReportExportRepo(TenantBaseRepository[ReportExport]):
model = ReportExport
class FinancialReportEngine:
"""Generates financial reports from posted accounting data only."""
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.report_repo = FinancialReportRepo(session)
self.balance_repo = LedgerBalanceRepository(session)
self.balance_engine = BalanceEngine(session)
self.export_repo = ReportExportRepo(session)
async def generate_trial_balance_report(
self, tenant_id: UUID, fiscal_period_id: UUID, *, generated_by: str
) -> FinancialReport:
snapshot = await self.balance_engine.generate_trial_balance(tenant_id, fiscal_period_id)
report = FinancialReport(
tenant_id=tenant_id,
report_type=ReportType.TRIAL_BALANCE,
name=f"Trial Balance {fiscal_period_id}",
fiscal_period_id=fiscal_period_id,
generated_at=datetime.now(timezone.utc),
status=ReportStatus.GENERATED,
report_data=json.dumps({
"total_debit": str(snapshot.total_debit),
"total_credit": str(snapshot.total_credit),
"difference": str(snapshot.difference),
"is_balanced": snapshot.is_balanced,
}),
generated_by=generated_by,
)
return await self.report_repo.add(report)
async def generate_balance_sheet(
self, tenant_id: UUID, fiscal_period_id: UUID, *, generated_by: str
) -> FinancialReport:
balances = await self.balance_repo.list_by_period(tenant_id, fiscal_period_id)
total_assets = sum(b.closing_balance for b in balances if b.closing_balance > 0)
report = FinancialReport(
tenant_id=tenant_id,
report_type=ReportType.BALANCE_SHEET,
name=f"Balance Sheet {fiscal_period_id}",
fiscal_period_id=fiscal_period_id,
generated_at=datetime.now(timezone.utc),
status=ReportStatus.GENERATED,
report_data=json.dumps({"total_assets": str(total_assets), "account_count": len(balances)}),
generated_by=generated_by,
)
return await self.report_repo.add(report)
async def generate_income_statement(
self, tenant_id: UUID, fiscal_period_id: UUID, *, generated_by: str
) -> FinancialReport:
balances = await self.balance_repo.list_by_period(tenant_id, fiscal_period_id)
total_debit = sum(b.debit_total for b in balances)
total_credit = sum(b.credit_total for b in balances)
report = FinancialReport(
tenant_id=tenant_id,
report_type=ReportType.INCOME_STATEMENT,
name=f"Income Statement {fiscal_period_id}",
fiscal_period_id=fiscal_period_id,
generated_at=datetime.now(timezone.utc),
status=ReportStatus.GENERATED,
report_data=json.dumps({
"total_revenue": str(total_credit),
"total_expense": str(total_debit),
"net": str(total_credit - total_debit),
}),
generated_by=generated_by,
)
return await self.report_repo.add(report)
async def export_report(
self,
tenant_id: UUID,
report_id: UUID,
export_format: ReportExportFormat,
*,
exported_by: str,
) -> ReportExport:
report = await self.report_repo.get(tenant_id, report_id)
if report is None:
raise NotFoundError("گزارش یافت نشد", error_code="report_not_found")
export = ReportExport(
tenant_id=tenant_id,
report_id=report_id,
export_format=export_format,
file_path=f"/exports/{report_id}.{export_format.value}",
exported_at=datetime.now(timezone.utc),
exported_by=exported_by,
)
return await self.export_repo.add(export)

View File

@ -0,0 +1,163 @@
"""Sales accounting integration service."""
from __future__ import annotations
import json
from decimal import Decimal
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.posting import Voucher, VoucherLine
from app.models.sales_accounting import AccountingPreview, SalesPostingProfile
from app.models.types import SalesDocumentType, VoucherStatus
from app.repositories.base import TenantBaseRepository
from app.repositories.foundation import AccountRepository, FiscalPeriodRepository
from app.repositories.posting import VoucherRepository
from app.services.posting_engine import PostingEngine
from shared.exceptions import NotFoundError
class SalesPostingProfileRepository(TenantBaseRepository[SalesPostingProfile]):
model = SalesPostingProfile
class AccountingPreviewRepository(TenantBaseRepository[AccountingPreview]):
model = AccountingPreview
class SalesAccountingService:
"""Generates accounting entries for sales documents via Posting Engine only."""
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.profile_repo = SalesPostingProfileRepository(session)
self.preview_repo = AccountingPreviewRepository(session)
self.voucher_repo = VoucherRepository(session)
self.period_repo = FiscalPeriodRepository(session)
self.account_repo = AccountRepository(session)
self.posting_engine = PostingEngine(session)
async def preview_sales_invoice(
self,
tenant_id: UUID,
*,
document_type: SalesDocumentType,
total_amount: Decimal,
discount_amount: Decimal = Decimal("0"),
tax_amount: Decimal = Decimal("0"),
profile_id: UUID | None = None,
) -> AccountingPreview:
profile = await self._resolve_profile(tenant_id, document_type, profile_id)
net_revenue = total_amount - discount_amount - tax_amount
lines = []
if profile.receivable_account_id:
lines.append({"account_id": str(profile.receivable_account_id), "debit": str(total_amount), "credit": "0"})
if profile.revenue_account_id:
lines.append({"account_id": str(profile.revenue_account_id), "debit": "0", "credit": str(net_revenue)})
if discount_amount > 0 and profile.discount_account_id:
lines.append({"account_id": str(profile.discount_account_id), "debit": str(discount_amount), "credit": "0"})
if tax_amount > 0 and profile.tax_account_id:
lines.append({"account_id": str(profile.tax_account_id), "debit": "0", "credit": str(tax_amount)})
total_debit = sum(Decimal(l["debit"]) for l in lines)
total_credit = sum(Decimal(l["credit"]) for l in lines)
preview = AccountingPreview(
tenant_id=tenant_id,
source_module="sales",
source_document_type=document_type.value,
source_document_id="preview",
preview_data=json.dumps(lines),
is_balanced=total_debit == total_credit,
total_debit=total_debit,
total_credit=total_credit,
)
return await self.preview_repo.add(preview)
async def post_sales_invoice(
self,
tenant_id: UUID,
*,
document_type: SalesDocumentType,
source_document_id: str,
total_amount: Decimal,
discount_amount: Decimal = Decimal("0"),
tax_amount: Decimal = Decimal("0"),
profile_id: UUID | None = None,
actor_user_id: str,
voucher_date=None,
) -> Voucher:
from datetime import date
profile = await self._resolve_profile(tenant_id, document_type, profile_id)
period = await self.period_repo.get_current(tenant_id)
if period is None:
raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found")
voucher_number = f"SINV-{source_document_id}"
voucher = Voucher(
tenant_id=tenant_id,
fiscal_period_id=period.id,
voucher_number=voucher_number,
voucher_date=voucher_date or date.today(),
status=VoucherStatus.DRAFT,
description=f"Sales {document_type.value} {source_document_id}",
source_module="sales",
reference_number=source_document_id,
created_by=actor_user_id,
)
await self.voucher_repo.add(voucher)
net_revenue = total_amount - discount_amount - tax_amount
line_num = 0
if profile.receivable_account_id:
line_num += 1
self.session.add(VoucherLine(
tenant_id=tenant_id, voucher_id=voucher.id, line_number=line_num,
account_id=profile.receivable_account_id, debit=total_amount, credit=Decimal("0"),
))
if profile.revenue_account_id:
line_num += 1
self.session.add(VoucherLine(
tenant_id=tenant_id, voucher_id=voucher.id, line_number=line_num,
account_id=profile.revenue_account_id, debit=Decimal("0"), credit=net_revenue,
))
if discount_amount > 0 and profile.discount_account_id:
line_num += 1
self.session.add(VoucherLine(
tenant_id=tenant_id, voucher_id=voucher.id, line_number=line_num,
account_id=profile.discount_account_id, debit=discount_amount, credit=Decimal("0"),
))
if tax_amount > 0 and profile.tax_account_id:
line_num += 1
self.session.add(VoucherLine(
tenant_id=tenant_id, voucher_id=voucher.id, line_number=line_num,
account_id=profile.tax_account_id, debit=Decimal("0"), credit=tax_amount,
))
await self.session.flush()
return await self.posting_engine.post_voucher(
tenant_id, voucher.id, actor_user_id=actor_user_id, source_module="sales"
)
async def _resolve_profile(
self, tenant_id: UUID, document_type: SalesDocumentType, profile_id: UUID | None
) -> SalesPostingProfile:
if profile_id:
profile = await self.profile_repo.get(tenant_id, profile_id)
if profile is None:
raise NotFoundError("پروفایل ثبت فروش یافت نشد", error_code="profile_not_found")
return profile
from sqlalchemy import select
stmt = select(SalesPostingProfile).where(
SalesPostingProfile.tenant_id == tenant_id,
SalesPostingProfile.document_type == document_type,
SalesPostingProfile.is_default.is_(True),
)
result = await self.session.execute(stmt)
profile = result.scalar_one_or_none()
if profile is None:
raise NotFoundError("پروفایل پیش‌فرض ثبت فروش یافت نشد", error_code="profile_not_found")
return profile

View File

@ -0,0 +1,124 @@
"""Settlement Engine for AR/AP."""
from __future__ import annotations
from decimal import Decimal
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.receivable_payable import (
ReceivableInvoice,
PayableInvoice,
Settlement,
SettlementAllocation,
)
from app.models.types import InvoiceStatus, SettlementStatus
from app.repositories.base import TenantBaseRepository
from shared.exceptions import AppError, NotFoundError
class SettlementError(AppError):
status_code = 422
error_code = "settlement_error"
class ReceivableInvoiceRepository(TenantBaseRepository[ReceivableInvoice]):
model = ReceivableInvoice
class PayableInvoiceRepository(TenantBaseRepository[PayableInvoice]):
model = PayableInvoice
class SettlementRepository(TenantBaseRepository[Settlement]):
model = Settlement
class SettlementEngine:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.receivable_repo = ReceivableInvoiceRepository(session)
self.payable_repo = PayableInvoiceRepository(session)
self.settlement_repo = SettlementRepository(session)
async def allocate_receivable(
self,
tenant_id: UUID,
settlement: Settlement,
allocations: list[dict],
) -> Settlement:
if settlement.status == SettlementStatus.COMPLETED:
raise SettlementError("تسویه قبلاً انجام شده است")
total_allocated = Decimal("0")
for alloc in allocations:
invoice = await self.receivable_repo.get(tenant_id, alloc["invoice_id"])
if invoice is None:
raise NotFoundError("فاکتور دریافتنی یافت نشد", error_code="invoice_not_found")
amount = Decimal(str(alloc["amount"]))
if amount > invoice.remaining_amount:
raise SettlementError(f"مبلغ تخصیص بیش از مانده فاکتور {invoice.invoice_number}")
self.session.add(
SettlementAllocation(
tenant_id=tenant_id,
settlement_id=settlement.id,
invoice_type="receivable",
invoice_id=invoice.id,
allocated_amount=amount,
)
)
invoice.paid_amount += amount
invoice.remaining_amount -= amount
if invoice.remaining_amount == 0:
invoice.status = InvoiceStatus.PAID
else:
invoice.status = InvoiceStatus.PARTIALLY_PAID
total_allocated += amount
if total_allocated != settlement.total_amount:
raise SettlementError("مجموع تخصیص با مبلغ تسویه برابر نیست")
settlement.status = SettlementStatus.COMPLETED
return settlement
async def allocate_payable(
self,
tenant_id: UUID,
settlement: Settlement,
allocations: list[dict],
) -> Settlement:
if settlement.status == SettlementStatus.COMPLETED:
raise SettlementError("تسویه قبلاً انجام شده است")
total_allocated = Decimal("0")
for alloc in allocations:
invoice = await self.payable_repo.get(tenant_id, alloc["invoice_id"])
if invoice is None:
raise NotFoundError("فاکتور پرداختنی یافت نشد", error_code="invoice_not_found")
amount = Decimal(str(alloc["amount"]))
if amount > invoice.remaining_amount:
raise SettlementError(f"مبلغ تخصیص بیش از مانده فاکتور {invoice.invoice_number}")
self.session.add(
SettlementAllocation(
tenant_id=tenant_id,
settlement_id=settlement.id,
invoice_type="payable",
invoice_id=invoice.id,
allocated_amount=amount,
)
)
invoice.paid_amount += amount
invoice.remaining_amount -= amount
if invoice.remaining_amount == 0:
invoice.status = InvoiceStatus.PAID
else:
invoice.status = InvoiceStatus.PARTIALLY_PAID
total_allocated += amount
if total_allocated != settlement.total_amount:
raise SettlementError("مجموع تخصیص با مبلغ تسویه برابر نیست")
settlement.status = SettlementStatus.COMPLETED
return settlement

View File

@ -0,0 +1,173 @@
"""Treasury service — integrates with Posting Engine only."""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.posting import Voucher, VoucherLine
from app.models.treasury import CashBox, CashTransaction, BankAccount, BankTransaction
from app.models.types import TreasuryTransactionType, VoucherStatus
from app.repositories.base import TenantBaseRepository
from app.repositories.foundation import FiscalPeriodRepository
from app.repositories.posting import VoucherRepository
from app.services.posting_engine import PostingEngine
from shared.exceptions import AppError, NotFoundError
class TreasuryError(AppError):
status_code = 422
error_code = "treasury_error"
class CashBoxRepository(TenantBaseRepository[CashBox]):
model = CashBox
class CashTransactionRepository(TenantBaseRepository[CashTransaction]):
model = CashTransaction
class BankAccountRepository(TenantBaseRepository[BankAccount]):
model = BankAccount
class TreasuryService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.cash_box_repo = CashBoxRepository(session)
self.cash_tx_repo = CashTransactionRepository(session)
self.bank_account_repo = BankAccountRepository(session)
self.voucher_repo = VoucherRepository(session)
self.period_repo = FiscalPeriodRepository(session)
self.posting_engine = PostingEngine(session)
async def record_cash_receipt(
self,
tenant_id: UUID,
cash_box_id: UUID,
amount: Decimal,
*,
debit_account_id: UUID,
credit_account_id: UUID,
actor_user_id: str,
description: str | None = None,
transaction_date: date | None = None,
) -> CashTransaction:
cash_box = await self.cash_box_repo.get(tenant_id, cash_box_id)
if cash_box is None:
raise NotFoundError("صندوق یافت نشد", error_code="cash_box_not_found")
period = await self.period_repo.get_current(tenant_id)
if period is None:
raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found")
tx_date = transaction_date or date.today()
ref = f"CASH-R-{tx_date.isoformat()}-{cash_box.code}"
voucher = await self._create_treasury_voucher(
tenant_id, period.id, ref, tx_date,
debit_account_id, credit_account_id, amount,
description or "دریافت نقدی", actor_user_id, "treasury",
)
tx = CashTransaction(
tenant_id=tenant_id,
cash_box_id=cash_box_id,
transaction_type=TreasuryTransactionType.CASH_RECEIPT,
transaction_date=tx_date,
amount=amount,
description=description,
voucher_id=voucher.id,
reference_number=ref,
)
await self.cash_tx_repo.add(tx)
cash_box.current_balance += amount
return tx
async def record_cash_payment(
self,
tenant_id: UUID,
cash_box_id: UUID,
amount: Decimal,
*,
debit_account_id: UUID,
credit_account_id: UUID,
actor_user_id: str,
description: str | None = None,
transaction_date: date | None = None,
) -> CashTransaction:
cash_box = await self.cash_box_repo.get(tenant_id, cash_box_id)
if cash_box is None:
raise NotFoundError("صندوق یافت نشد", error_code="cash_box_not_found")
if cash_box.current_balance < amount:
raise TreasuryError("موجودی صندوق کافی نیست")
period = await self.period_repo.get_current(tenant_id)
if period is None:
raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found")
tx_date = transaction_date or date.today()
ref = f"CASH-P-{tx_date.isoformat()}-{cash_box.code}"
voucher = await self._create_treasury_voucher(
tenant_id, period.id, ref, tx_date,
debit_account_id, credit_account_id, amount,
description or "پرداخت نقدی", actor_user_id, "treasury",
)
tx = CashTransaction(
tenant_id=tenant_id,
cash_box_id=cash_box_id,
transaction_type=TreasuryTransactionType.CASH_PAYMENT,
transaction_date=tx_date,
amount=amount,
description=description,
voucher_id=voucher.id,
reference_number=ref,
)
await self.cash_tx_repo.add(tx)
cash_box.current_balance -= amount
return tx
async def _create_treasury_voucher(
self,
tenant_id: UUID,
period_id: UUID,
voucher_number: str,
voucher_date: date,
debit_account_id: UUID,
credit_account_id: UUID,
amount: Decimal,
description: str,
actor_user_id: str,
source_module: str,
) -> Voucher:
voucher = Voucher(
tenant_id=tenant_id,
fiscal_period_id=period_id,
voucher_number=voucher_number,
voucher_date=voucher_date,
status=VoucherStatus.DRAFT,
description=description,
source_module=source_module,
created_by=actor_user_id,
)
await self.voucher_repo.add(voucher)
self.session.add(VoucherLine(
tenant_id=tenant_id, voucher_id=voucher.id, line_number=1,
account_id=debit_account_id, debit=amount, credit=Decimal("0"),
description=description,
))
self.session.add(VoucherLine(
tenant_id=tenant_id, voucher_id=voucher.id, line_number=2,
account_id=credit_account_id, debit=Decimal("0"), credit=amount,
description=description,
))
await self.session.flush()
return await self.posting_engine.post_voucher(
tenant_id, voucher.id, actor_user_id=actor_user_id, source_module=source_module
)

View File

@ -0,0 +1,39 @@
import os
import uuid
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
os.environ["ENVIRONMENT"] = "test"
os.environ["AUTH_REQUIRED"] = "false"
os.environ["ACCOUNTING_DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
os.environ["ACCOUNTING_DATABASE_URL_SYNC"] = "sqlite:///:memory:"
os.environ["JWT_VERIFY_SIGNATURE"] = "false"
from app.core.database import Base, engine # noqa: E402
from app.main import app # noqa: E402
TENANT_A = uuid.uuid4()
TENANT_B = uuid.uuid4()
@pytest_asyncio.fixture(scope="session")
async def db_setup():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
yield
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest_asyncio.fixture
async def client(db_setup):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
yield ac
def tenant_headers(tenant_id: uuid.UUID) -> dict[str, str]:
return {"X-Tenant-ID": str(tenant_id)}

View File

@ -0,0 +1,228 @@
import uuid
from datetime import date
import pytest
from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers
@pytest.mark.asyncio
async def test_health(client):
resp = await client.get("/health")
assert resp.status_code == 200
assert resp.json()["service"] == "accounting-service"
@pytest.mark.asyncio
async def test_create_chart_of_accounts(client):
resp = await client.post(
"/api/v1/accounts/charts",
json={"name": "Default COA", "code": "COA-001", "is_default": True},
headers=tenant_headers(TENANT_A),
)
assert resp.status_code == 201
data = resp.json()
assert data["code"] == "COA-001"
assert data["tenant_id"] == str(TENANT_A)
@pytest.mark.asyncio
async def test_tenant_isolation_accounts(client):
chart_resp = await client.post(
"/api/v1/accounts/charts",
json={"name": "Tenant A COA", "code": "COA-A"},
headers=tenant_headers(TENANT_A),
)
assert chart_resp.status_code == 201
chart_id = chart_resp.json()["id"]
acc_resp = await client.post(
"/api/v1/accounts",
json={
"chart_id": chart_id,
"code": "1000",
"name": "Test Account",
"account_type": "asset",
"account_category": "current_asset",
},
headers=tenant_headers(TENANT_A),
)
assert acc_resp.status_code == 201
account_id = acc_resp.json()["id"]
resp_b_list = await client.get(
"/api/v1/accounts",
headers=tenant_headers(TENANT_B),
)
assert resp_b_list.status_code == 200
assert len(resp_b_list.json()) == 0
resp_b_get = await client.get(
f"/api/v1/accounts/{account_id}",
headers=tenant_headers(TENANT_B),
)
assert resp_b_get.status_code == 404
@pytest.mark.asyncio
async def test_posting_engine_balanced_voucher(client):
year_resp = await client.post(
"/api/v1/fiscal/years",
json={"name": "1403", "start_date": "2024-03-20", "end_date": "2025-03-19", "is_current": True},
headers=tenant_headers(TENANT_A),
)
assert year_resp.status_code == 201
year_id = year_resp.json()["id"]
period_resp = await client.post(
"/api/v1/fiscal/periods",
json={
"fiscal_year_id": year_id,
"name": "Month 1",
"period_number": 1,
"start_date": "2024-03-20",
"end_date": "2024-04-19",
"is_current": True,
},
headers=tenant_headers(TENANT_A),
)
assert period_resp.status_code == 201
period_id = period_resp.json()["id"]
chart_resp = await client.post(
"/api/v1/accounts/charts",
json={"name": "COA", "code": "COA-POST"},
headers=tenant_headers(TENANT_A),
)
chart_id = chart_resp.json()["id"]
acc1_resp = await client.post(
"/api/v1/accounts",
json={
"chart_id": chart_id,
"code": "1101",
"name": "Cash",
"account_type": "asset",
"account_category": "current_asset",
},
headers=tenant_headers(TENANT_A),
)
acc2_resp = await client.post(
"/api/v1/accounts",
json={
"chart_id": chart_id,
"code": "4101",
"name": "Revenue",
"account_type": "revenue",
"account_category": "operating_revenue",
},
headers=tenant_headers(TENANT_A),
)
acc1_id = acc1_resp.json()["id"]
acc2_id = acc2_resp.json()["id"]
voucher_resp = await client.post(
"/api/v1/posting/vouchers",
json={
"fiscal_period_id": period_id,
"voucher_number": "V-001",
"voucher_date": str(date.today()),
"description": "Test voucher",
"lines": [
{"account_id": acc1_id, "debit": "1000.0000", "credit": "0"},
{"account_id": acc2_id, "debit": "0", "credit": "1000.0000"},
],
},
headers=tenant_headers(TENANT_A),
)
assert voucher_resp.status_code == 201
voucher_id = voucher_resp.json()["id"]
post_resp = await client.post(
f"/api/v1/posting/vouchers/{voucher_id}/post",
json={},
headers=tenant_headers(TENANT_A),
)
assert post_resp.status_code == 200
assert post_resp.json()["status"] == "posted"
@pytest.mark.asyncio
async def test_unbalanced_voucher_rejected(client):
year_resp = await client.post(
"/api/v1/fiscal/years",
json={"name": "1404", "start_date": "2025-03-20", "end_date": "2026-03-19"},
headers=tenant_headers(TENANT_A),
)
year_id = year_resp.json()["id"]
period_resp = await client.post(
"/api/v1/fiscal/periods",
json={
"fiscal_year_id": year_id,
"name": "P1",
"period_number": 1,
"start_date": "2025-03-20",
"end_date": "2025-04-19",
"is_current": True,
},
headers=tenant_headers(TENANT_A),
)
period_id = period_resp.json()["id"]
chart_resp = await client.post(
"/api/v1/accounts/charts",
json={"name": "COA2", "code": "COA-UNBAL"},
headers=tenant_headers(TENANT_A),
)
chart_id = chart_resp.json()["id"]
acc_resp = await client.post(
"/api/v1/accounts",
json={
"chart_id": chart_id,
"code": "1102",
"name": "Bank",
"account_type": "asset",
"account_category": "current_asset",
},
headers=tenant_headers(TENANT_A),
)
acc_id = acc_resp.json()["id"]
voucher_resp = await client.post(
"/api/v1/posting/vouchers",
json={
"fiscal_period_id": period_id,
"voucher_number": "V-UNBAL",
"voucher_date": str(date.today()),
"lines": [
{"account_id": acc_id, "debit": "500", "credit": "0"},
{"account_id": acc_id, "debit": "0", "credit": "300"},
],
},
headers=tenant_headers(TENANT_A),
)
voucher_id = voucher_resp.json()["id"]
validate_resp = await client.post(
f"/api/v1/posting/vouchers/{voucher_id}/validate",
headers=tenant_headers(TENANT_A),
)
assert validate_resp.status_code == 422
@pytest.mark.asyncio
async def test_trial_balance(client):
tb_resp = await client.post(
"/api/v1/ledger/trial-balance",
params={"fiscal_period_id": str(uuid.uuid4())},
headers=tenant_headers(TENANT_A),
)
assert tb_resp.status_code == 200
assert "is_balanced" in tb_resp.json()
@pytest.mark.asyncio
async def test_requires_tenant_header(client):
resp = await client.get("/api/v1/accounts")
assert resp.status_code == 400

View File

@ -0,0 +1,45 @@
"""Architecture tests — module boundary enforcement."""
import importlib
import pkgutil
import pytest
def test_no_direct_journal_creation_outside_posting_engine():
"""ADR-010: JournalEntry creation must go through PostingEngine."""
from app.services.posting_engine import PostingEngine
assert hasattr(PostingEngine, "post_voucher")
assert hasattr(PostingEngine, "reverse_voucher")
def test_all_models_have_tenant_id():
from app.core.database import Base
import app.models # noqa: F401
skip = {"alembic_version"}
for table in Base.metadata.tables.values():
if table.name in skip:
continue
assert "tenant_id" in table.columns, f"{table.name} missing tenant_id"
def test_permissions_defined():
from app.permissions.definitions import ALL_PERMISSIONS
assert "accounting.view" in ALL_PERMISSIONS
assert "purchase_accounting.post" in ALL_PERMISSIONS
assert "assets.depreciate" in ALL_PERMISSIONS
assert "payroll.calculate" in ALL_PERMISSIONS
assert "reports.view" in ALL_PERMISSIONS
assert "audit.view" in ALL_PERMISSIONS
def test_events_defined():
from app.events.types import AccountingEventType
assert AccountingEventType.GOODS_RECEIVED.value == "goods.received"
assert AccountingEventType.ASSET_DEPRECIATED.value == "asset.depreciated"
assert AccountingEventType.PAYROLL_POSTED.value == "payroll.posted"
assert AccountingEventType.BALANCE_SHEET_GENERATED.value == "balance_sheet.generated"
assert AccountingEventType.AUDIT_RECORD_CREATED.value == "audit_record.created"

View File

@ -0,0 +1,142 @@
"""Tests for phases 5.75.11."""
import uuid
from decimal import Decimal
import pytest
from app.tests.conftest import TENANT_A, tenant_headers
@pytest.mark.asyncio
async def test_create_purchase_profile(client):
resp = await client.post(
"/api/v1/purchase-inventory/posting-profiles",
json={"name": "Default Purchase", "is_default": True},
headers=tenant_headers(TENANT_A),
)
assert resp.status_code == 201
@pytest.mark.asyncio
async def test_create_asset_category(client):
resp = await client.post(
"/api/v1/assets/categories",
json={"code": "VEH", "name": "Vehicles"},
headers=tenant_headers(TENANT_A),
)
assert resp.status_code == 201
@pytest.mark.asyncio
async def test_create_asset(client):
resp = await client.post(
"/api/v1/assets/assets",
json={"code": "AST-001", "name": "Office Desk", "acquisition_cost": "5000000"},
headers=tenant_headers(TENANT_A),
)
assert resp.status_code == 201
assert resp.json()["code"] == "AST-001"
@pytest.mark.asyncio
async def test_depreciation_engine():
from app.models.fixed_assets import Asset
from app.models.types import DepreciationMethod
from app.services.asset_service import DepreciationEngine
asset = Asset(
tenant_id=uuid.uuid4(),
code="T1",
name="Test",
acquisition_cost=Decimal("1200000"),
current_book_value=Decimal("1200000"),
residual_value=Decimal("0"),
useful_life_months=12,
depreciation_method=DepreciationMethod.STRAIGHT_LINE,
)
engine = DepreciationEngine()
amount = engine.calculate(asset)
assert amount == Decimal("100000.0000")
schedule = engine.generate_schedule(asset)
assert len(schedule) == 12
@pytest.mark.asyncio
async def test_create_employee(client):
resp = await client.post(
"/api/v1/payroll/employees",
json={"employee_code": "EMP-001", "first_name": "Ali", "last_name": "Rezaei", "base_salary": "50000000"},
headers=tenant_headers(TENANT_A),
)
assert resp.status_code == 201
@pytest.mark.asyncio
async def test_payroll_calculation():
from app.models.payroll import Employee
from app.models.types import EmploymentStatus
from app.services.payroll_service import PayrollEngine
employee = Employee(
tenant_id=uuid.uuid4(),
employee_code="E1",
first_name="Test",
last_name="User",
base_salary=Decimal("10000000"),
employment_status=EmploymentStatus.ACTIVE,
)
engine = PayrollEngine()
result = engine.calculate(employee, benefits=Decimal("1000000"), deductions=Decimal("500000"))
assert result["gross_salary"] == Decimal("11000000")
assert result["net_salary"] == Decimal("10500000")
@pytest.mark.asyncio
async def test_generate_trial_balance_report(client):
period_id = str(uuid.uuid4())
resp = await client.post(
"/api/v1/reporting/trial-balance",
params={"fiscal_period_id": period_id},
headers=tenant_headers(TENANT_A),
)
assert resp.status_code == 200
assert "report_type" in resp.json()
@pytest.mark.asyncio
async def test_create_audit_record(client):
resp = await client.post(
"/api/v1/compliance/audit-records",
json={
"action": "voucher.posted",
"resource_type": "voucher",
"resource_id": str(uuid.uuid4()),
"source_module": "accounting",
},
headers=tenant_headers(TENANT_A),
)
assert resp.status_code == 201
@pytest.mark.asyncio
async def test_inventory_valuation_engine():
from app.services.purchase_inventory_service import InventoryValuationEngine
engine = InventoryValuationEngine()
avg = engine.calculate_weighted_average(Decimal("10"), Decimal("10000"), Decimal("5"), Decimal("1200"))
assert avg == Decimal("10000") / Decimal("15") + Decimal("6000") / Decimal("15")
@pytest.mark.asyncio
async def test_tenant_isolation_assets(client):
resp = await client.post(
"/api/v1/assets/assets",
json={"code": "ISO-001", "name": "Isolated Asset"},
headers=tenant_headers(TENANT_A),
)
asset_id = resp.json()["id"]
other_tenant = uuid.uuid4()
list_resp = await client.get("/api/v1/assets/assets", headers=tenant_headers(other_tenant))
assert list_resp.status_code == 200
assert len(list_resp.json()) == 0

View File

@ -0,0 +1,6 @@
[pytest]
asyncio_mode = auto
testpaths = app/tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*

View File

@ -0,0 +1,14 @@
fastapi==0.111.0
uvicorn[standard]==0.30.1
pydantic[email]==2.7.4
pydantic-settings==2.3.4
sqlalchemy==2.0.31
alembic==1.13.2
asyncpg==0.29.0
psycopg[binary]==3.2.1
httpx==0.27.0
pyjwt[crypto]==2.8.0
-e ../../shared-lib
pytest==8.2.2
pytest-asyncio==0.23.7
aiosqlite==0.20.0

View File

@ -0,0 +1,41 @@
"""Ensure accounting_db exists before migration."""
from __future__ import annotations
import os
import sys
from urllib.parse import urlparse
def main() -> None:
sync_url = os.environ.get("ACCOUNTING_DATABASE_URL_SYNC", "")
if not sync_url:
print("ACCOUNTING_DATABASE_URL_SYNC not set", file=sys.stderr)
return
parsed = urlparse(sync_url.replace("+psycopg", ""))
db_name = (parsed.path or "").lstrip("/") or "accounting_db"
import psycopg
conn = psycopg.connect(
host=parsed.hostname or "localhost",
port=parsed.port or 5432,
user=parsed.username,
password=parsed.password,
dbname="postgres",
autocommit=True,
)
try:
with conn.cursor() as cur:
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (db_name,))
if cur.fetchone() is None:
cur.execute(f'CREATE DATABASE "{db_name}"')
print(f"Created database: {db_name}")
else:
print(f"Database exists: {db_name}")
finally:
conn.close()
if __name__ == "__main__":
main()

View File

@ -13,3 +13,10 @@
- دیتابیس مستقل (`ai_assistant_db`) با `tenant_id` در جداول بیزینسی.
- ارتباط با سایر سرویس‌ها فقط از طریق API/Event.
- بررسی دسترسی قابلیت‌ها از Core (`ai_assistant.*`).
## Related Documents
- [Module Registry](../../../docs/module-registry.md#ai_assistant)
- [Services Contracts](../../../docs/reference/services-contracts.md)
- [Architecture](../../../docs/architecture/architecture.md)

View File

@ -16,3 +16,10 @@
- دیتابیس مستقل (`crm_db`) با `tenant_id` در جداول بیزینسی.
- ارتباط با سایر سرویس‌ها فقط از طریق API/Event.
- بررسی دسترسی قابلیت‌ها از Core (`crm.*`).
## Related Documents
- [Module Registry](../../../docs/module-registry.md#crm)
- [Services Contracts](../../../docs/reference/services-contracts.md)
- [Architecture](../../../docs/architecture/architecture.md)

View File

@ -17,3 +17,10 @@
- دیتابیس مستقل (`ecommerce_db`) با `tenant_id` در جداول بیزینسی.
- ارتباط با سایر سرویس‌ها فقط از طریق API/Event.
- بررسی دسترسی قابلیت‌ها از Core (`ecommerce.*`).
## Related Documents
- [Module Registry](../../../docs/module-registry.md#ecommerce)
- [Services Contracts](../../../docs/reference/services-contracts.md)
- [Architecture](../../../docs/architecture/architecture.md)

View File

@ -12,3 +12,10 @@
- دیتابیس مستقل (`file_storage_db`) با `tenant_id` در جداول بیزینسی.
- ارتباط با سایر سرویس‌ها فقط از طریق API/Event.
- بررسی دسترسی قابلیت‌ها از Core (`file_storage.*`).
## Related Documents
- [Module Registry](../../../docs/module-registry.md#file_storage)
- [Services Contracts](../../../docs/reference/services-contracts.md)
- [Architecture](../../../docs/architecture/architecture.md)

View File

@ -1,27 +1,47 @@
# Identity & Access Service
سرویس احراز هویت و دسترسی (فاز ۲) — SSO مرکزی با Keycloak.
سرویس احراز هویت و دسترسی — SSO مرکزی با Keycloak.
## مسئولیت‌ها
- مدیریت پروفایل کاربر و عضویت tenant
- ارائه پیکربندی OIDC به frontend
- تبدیل authorization code به token (BFF)
- همگام‌سازی کاربران با Keycloak Admin API
Canonical contracts: [`docs/reference/services-contracts.md`](../../../docs/reference/services-contracts.md) §8
Architecture: [`docs/architecture/identity-architecture.md`](../../../docs/architecture/identity-architecture.md)
Registry: [`docs/module-registry.md`](../../../docs/module-registry.md#identity-access)
## دیتابیس
`identity_access_db` (مستقل از Core — database-per-service)
## Responsibilities
## APIهای اصلی
| متد | مسیر | توضیح |
- User profile + identity-layer tenant membership
- OIDC config for frontend
- Authorization code → token (BFF)
- Mobile OTP start/complete + session handoff redeem
- Keycloak Admin sync
## Database
`identity_access_db` (database-per-service; not Core)
> Operational workspace roles live in Core `tenant_memberships` — see [ADR-007](../../../docs/architecture/adr/ADR-007.md).
## Main APIs
| Method | Path | Notes |
| --- | --- | --- |
| GET | `/health` | سلامت سرویس |
| GET | `/api/v1/auth/config` | پیکربندی OIDC (عمومی) |
| POST | `/api/v1/auth/token` | تبدیل code به token |
| GET | `/api/v1/auth/me` | کاربر جاری |
| POST | `/api/v1/users` | ثبت کاربر (admin) |
| POST | `/api/v1/tenants/{id}/members` | افزودن عضو tenant |
| GET | `/health` | Public |
| GET | `/api/v1/auth/config` | OIDC config |
| GET | `/api/v1/auth/login-url` | Login redirect helper |
| POST | `/api/v1/auth/token` | Code exchange |
| GET | `/api/v1/auth/me` | Current user |
| POST | `/api/v1/auth/mobile/start` | Unified mobile start |
| POST | `/api/v1/auth/mobile/complete` | Unified mobile complete |
| POST | `/api/v1/auth/session/redeem` | Handoff redeem |
| POST | `/api/v1/auth/mobile/request` | SSO user mobile verify request |
| POST | `/api/v1/auth/mobile/verify` | SSO user mobile verify |
| POST | `/api/v1/auth/register*` | Legacy register flows |
| POST | `/api/v1/users` | platform_admin |
| POST/GET | `/api/v1/tenants/{id}/members` | platform_admin |
Full table: services-contracts §8.
## Run
## اجرا
```bash
cd backend/services/identity-access
pip install -r requirements.txt
@ -29,14 +49,8 @@ alembic upgrade head
uvicorn app.main:app --reload --port 8001
```
## تست
## Test
```bash
pytest -q
```
## SSO Flow
1. Frontend از `/api/v1/auth/config` آدرس Keycloak را می‌گیرد.
2. کاربر به Keycloak redirect می‌شود.
3. پس از login، callback با `code` به frontend برمی‌گردد.
4. Frontend کد را به `/api/v1/auth/token` می‌فرستد و token دریافت می‌کند.
5. همه سرویس‌ها (Core، CRM، ...) همان JWT را validate می‌کنند.

View File

@ -17,6 +17,7 @@ from app.schemas.auth import (
PasswordChangeRequest,
PasswordChangeResponse,
ProfileUpdateRequest,
RefreshTokenRequest,
RegisterCompleteResponse,
RegisterRequest,
TokenExchangeRequest,
@ -53,6 +54,14 @@ async def exchange_token(
return await AuthService(db).exchange_token(payload)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(
payload: RefreshTokenRequest, db: AsyncSession = Depends(get_db)
) -> TokenResponse:
"""تمدید access token با refresh_token تا نشست کاربر قطع نشود."""
return await AuthService(db).refresh_token(payload.refresh_token)
@router.get("/me", response_model=MeResponse)
async def get_me(
db: AsyncSession = Depends(get_db),

View File

@ -32,6 +32,10 @@ class TokenExchangeRequest(BaseModel):
code_verifier: str | None = Field(default=None, min_length=43, max_length=128)
class RefreshTokenRequest(BaseModel):
refresh_token: str = Field(..., min_length=1)
class TokenResponse(BaseModel):
access_token: str
refresh_token: str | None = None

View File

@ -83,6 +83,15 @@ class AuthService:
token_type=raw.get("token_type", "Bearer"),
)
async def refresh_token(self, refresh_token: str) -> TokenResponse:
raw = await self.keycloak.refresh_tokens(refresh_token)
return TokenResponse(
access_token=raw["access_token"],
refresh_token=raw.get("refresh_token") or refresh_token,
expires_in=raw.get("expires_in", 3600),
token_type=raw.get("token_type", "Bearer"),
)
async def issue_session_for_profile(
self,
profile: UserProfile,

View File

@ -319,3 +319,23 @@ class KeycloakAdminClient:
logger.warning("keycloak_token_exchange_failed", extra={"detail": detail})
raise UnauthorizedError(f"تبادل token با Keycloak ناموفق بود: {detail}")
return resp.json()
async def refresh_tokens(self, refresh_token: str) -> dict[str, Any]:
"""تمدید access token با refresh_token (BFF برای frontend)."""
data = {
"grant_type": "refresh_token",
"client_id": settings.keycloak_frontend_client_id,
"refresh_token": refresh_token,
}
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(settings.oidc_token_url, data=data)
if resp.status_code >= 400:
detail = resp.text
try:
payload = resp.json()
detail = payload.get("error_description") or payload.get("error") or detail
except Exception:
pass
logger.warning("keycloak_token_refresh_failed", extra={"detail": detail})
raise UnauthorizedError(f"تمدید نشست ناموفق بود: {detail}")
return resp.json()

View File

@ -112,7 +112,7 @@ class MobileAuthService:
email = f"{data.mobile[1:]}@mobile.torbatyar.local"
password = mobile_keycloak_password(data.mobile)
core_result = await self.otp.verify_otp(data.mobile, data.code, email=email)
# اول Keycloak تا sub داشته باشیم؛ بعد OTP در Core با لینک SSO
sub = await self.keycloak.create_user(
email=email,
username=username,
@ -122,6 +122,18 @@ class MobileAuthService:
)
await self.keycloak.ensure_user_login_ready(sub)
try:
core_result = await self.otp.verify_otp(
data.mobile,
data.code,
email=email,
keycloak_sub=sub,
)
except Exception:
# اگر OTP رد شد، کاربر نیمه‌کاره Keycloak را پاک نکنیم تا
# retry ممکن باشد؛ فقط خطا را بالا می‌فرستیم.
raise
profile = await self.users.get_by_sub(sub)
if profile is None:
profile = UserProfile(

View File

@ -122,12 +122,6 @@ class RegistrationService:
email = f"{data.mobile[1:]}@mobile.torbatyar.local"
temp_password = mobile_keycloak_password(data.mobile)
core_result = await self.otp.verify_otp(
data.mobile,
data.code,
email=email,
)
sub = await self.keycloak.create_user(
email=email,
username=username,
@ -136,6 +130,13 @@ class RegistrationService:
mobile=data.mobile,
)
core_result = await self.otp.verify_otp(
data.mobile,
data.code,
email=email,
keycloak_sub=sub,
)
profile = await self.users.get_by_sub(sub)
if profile is None:
profile = UserProfile(

View File

@ -13,3 +13,10 @@
- دیتابیس مستقل (`link_shortener_db`) با `tenant_id` در جداول بیزینسی.
- ارتباط با سایر سرویس‌ها فقط از طریق API/Event.
- بررسی دسترسی قابلیت‌ها از Core (`link_shortener.*`).
## Related Documents
- [Module Registry](../../../docs/module-registry.md#link_shortener)
- [Services Contracts](../../../docs/reference/services-contracts.md)
- [Architecture](../../../docs/architecture/architecture.md)

View File

@ -13,3 +13,10 @@
- دیتابیس مستقل (`live_chat_db`) با `tenant_id` در جداول بیزینسی.
- ارتباط با سایر سرویس‌ها فقط از طریق API/Event.
- بررسی دسترسی قابلیت‌ها از Core (`live_chat.*`).
## Related Documents
- [Module Registry](../../../docs/module-registry.md#live_chat)
- [Services Contracts](../../../docs/reference/services-contracts.md)
- [Architecture](../../../docs/architecture/architecture.md)

Some files were not shown because too many files have changed in this diff Show More