diff --git a/.env.example b/.env.example index da79fa1..7a6b5a0 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,13 @@ PLATFORM_SECONDARY_COLOR=#0f172a # دامنه پایه برای تخصیص خودکار زیردامنه tenant (مثلاً .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 diff --git a/.gitignore b/.gitignore index c393362..9bc7698 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ Thumbs.db # Deploy temp artifacts scripts/_deploy_bundle.tar.gz +scripts/_deploy_*.tar.gz scripts/_tmp_* scripts/_check_* scripts/_fix_* diff --git a/README.md b/README.md index fc96730..afd52fc 100644 --- a/README.md +++ b/README.md @@ -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/ +│ ├── core-service/ # FastAPI Core Platform +│ ├── 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). diff --git a/backend/README.md b/backend/README.md index 64c93e5..847cc67 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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) diff --git a/backend/core-service/alembic/README.md b/backend/core-service/alembic/README.md index ed740cc..d1df482 100644 --- a/backend/core-service/alembic/README.md +++ b/backend/core-service/alembic/README.md @@ -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). + diff --git a/backend/core-service/app/api/v1/__init__.py b/backend/core-service/app/api/v1/__init__.py index 706ea82..437c244 100644 --- a/backend/core-service/app/api/v1/__init__.py +++ b/backend/core-service/app/api/v1/__init__.py @@ -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) diff --git a/backend/core-service/app/api/v1/public_tenant.py b/backend/core-service/app/api/v1/public_tenant.py new file mode 100644 index 0000000..1223530 --- /dev/null +++ b/backend/core-service/app/api/v1/public_tenant.py @@ -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, + ) diff --git a/backend/core-service/app/core/config.py b/backend/core-service/app/core/config.py index 6f2afd2..98571be 100644 --- a/backend/core-service/app/core/config.py +++ b/backend/core-service/app/core/config.py @@ -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" diff --git a/backend/core-service/app/services/domain_service.py b/backend/core-service/app/services/domain_service.py index bfe7176..c262be9 100644 --- a/backend/core-service/app/services/domain_service.py +++ b/backend/core-service/app/services/domain_service.py @@ -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]: diff --git a/backend/core-service/app/services/onboarding_service.py b/backend/core-service/app/services/onboarding_service.py index 2581bf9..3798f55 100644 --- a/backend/core-service/app/services/onboarding_service.py +++ b/backend/core-service/app/services/onboarding_service.py @@ -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 diff --git a/backend/core-service/app/services/ssl_provision.py b/backend/core-service/app/services/ssl_provision.py new file mode 100644 index 0000000..a681455 --- /dev/null +++ b/backend/core-service/app/services/ssl_provision.py @@ -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)}, + ) diff --git a/backend/core-service/app/services/user_service.py b/backend/core-service/app/services/user_service.py index 1a40533..4c6ad41 100644 --- a/backend/core-service/app/services/user_service.py +++ b/backend/core-service/app/services/user_service.py @@ -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 diff --git a/backend/core-service/app/workers/tasks.py b/backend/core-service/app/workers/tasks.py index 8b6892d..683617d 100644 --- a/backend/core-service/app/workers/tasks.py +++ b/backend/core-service/app/workers/tasks.py @@ -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) diff --git a/backend/core-service/requirements.txt b/backend/core-service/requirements.txt index b0c423b..28246f0 100644 --- a/backend/core-service/requirements.txt +++ b/backend/core-service/requirements.txt @@ -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 diff --git a/backend/services/README.md b/backend/services/README.md index 1c651a9..bd000af 100644 --- a/backend/services/README.md +++ b/backend/services/README.md @@ -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) diff --git a/backend/services/accounting/Dockerfile.dev b/backend/services/accounting/Dockerfile.dev new file mode 100644 index 0000000..39a3dee --- /dev/null +++ b/backend/services/accounting/Dockerfile.dev @@ -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 diff --git a/backend/services/accounting/README.md b/backend/services/accounting/README.md index 4a132ef..af6301b 100644 --- a/backend/services/accounting/README.md +++ b/backend/services/accounting/README.md @@ -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.1–5.11](../../../docs/phases/Accounting/README.md) +- [ADR-010](../../../docs/architecture/adr/ADR-010.md) diff --git a/backend/services/accounting/alembic.ini b/backend/services/accounting/alembic.ini new file mode 100644 index 0000000..92e2e6e --- /dev/null +++ b/backend/services/accounting/alembic.ini @@ -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 diff --git a/backend/services/accounting/alembic/env.py b/backend/services/accounting/alembic/env.py new file mode 100644 index 0000000..d604441 --- /dev/null +++ b/backend/services/accounting/alembic/env.py @@ -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() diff --git a/backend/services/accounting/alembic/versions/0001_initial.py b/backend/services/accounting/alembic/versions/0001_initial.py new file mode 100644 index 0000000..4ca49ee --- /dev/null +++ b/backend/services/accounting/alembic/versions/0001_initial.py @@ -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) diff --git a/backend/services/accounting/alembic/versions/0002_phases_57_511.py b/backend/services/accounting/alembic/versions/0002_phases_57_511.py new file mode 100644 index 0000000..88f89e8 --- /dev/null +++ b/backend/services/accounting/alembic/versions/0002_phases_57_511.py @@ -0,0 +1,18 @@ +"""Phases 5.7–5.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 diff --git a/backend/services/accounting/app/__init__.py b/backend/services/accounting/app/__init__.py new file mode 100644 index 0000000..ab480ad --- /dev/null +++ b/backend/services/accounting/app/__init__.py @@ -0,0 +1 @@ +__version__ = "0.5.11.0" diff --git a/backend/services/accounting/app/api/deps.py b/backend/services/accounting/app/api/deps.py new file mode 100644 index 0000000..73034fb --- /dev/null +++ b/backend/services/accounting/app/api/deps.py @@ -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 diff --git a/backend/services/accounting/app/api/v1/__init__.py b/backend/services/accounting/app/api/v1/__init__.py new file mode 100644 index 0000000..65c10eb --- /dev/null +++ b/backend/services/accounting/app/api/v1/__init__.py @@ -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"]) diff --git a/backend/services/accounting/app/api/v1/accounts.py b/backend/services/accounting/app/api/v1/accounts.py new file mode 100644 index 0000000..eb4c3bb --- /dev/null +++ b/backend/services/accounting/app/api/v1/accounts.py @@ -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 diff --git a/backend/services/accounting/app/api/v1/compliance.py b/backend/services/accounting/app/api/v1/compliance.py new file mode 100644 index 0000000..db4bb2b --- /dev/null +++ b/backend/services/accounting/app/api/v1/compliance.py @@ -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} diff --git a/backend/services/accounting/app/api/v1/fiscal.py b/backend/services/accounting/app/api/v1/fiscal.py new file mode 100644 index 0000000..89931eb --- /dev/null +++ b/backend/services/accounting/app/api/v1/fiscal.py @@ -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 diff --git a/backend/services/accounting/app/api/v1/fixed_assets.py b/backend/services/accounting/app/api/v1/fixed_assets.py new file mode 100644 index 0000000..8c6f671 --- /dev/null +++ b/backend/services/accounting/app/api/v1/fixed_assets.py @@ -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)} diff --git a/backend/services/accounting/app/api/v1/health.py b/backend/services/accounting/app/api/v1/health.py new file mode 100644 index 0000000..c1fb206 --- /dev/null +++ b/backend/services/accounting/app/api/v1/health.py @@ -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__} diff --git a/backend/services/accounting/app/api/v1/ledger.py b/backend/services/accounting/app/api/v1/ledger.py new file mode 100644 index 0000000..6adc0a9 --- /dev/null +++ b/backend/services/accounting/app/api/v1/ledger.py @@ -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} diff --git a/backend/services/accounting/app/api/v1/payroll.py b/backend/services/accounting/app/api/v1/payroll.py new file mode 100644 index 0000000..2a0f6a3 --- /dev/null +++ b/backend/services/accounting/app/api/v1/payroll.py @@ -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)} diff --git a/backend/services/accounting/app/api/v1/posting.py b/backend/services/accounting/app/api/v1/posting.py new file mode 100644 index 0000000..f4a7c3d --- /dev/null +++ b/backend/services/accounting/app/api/v1/posting.py @@ -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) diff --git a/backend/services/accounting/app/api/v1/purchase_inventory.py b/backend/services/accounting/app/api/v1/purchase_inventory.py new file mode 100644 index 0000000..a654dc8 --- /dev/null +++ b/backend/services/accounting/app/api/v1/purchase_inventory.py @@ -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)} diff --git a/backend/services/accounting/app/api/v1/receivable_payable.py b/backend/services/accounting/app/api/v1/receivable_payable.py new file mode 100644 index 0000000..c2a40bf --- /dev/null +++ b/backend/services/accounting/app/api/v1/receivable_payable.py @@ -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), + } diff --git a/backend/services/accounting/app/api/v1/reporting.py b/backend/services/accounting/app/api/v1/reporting.py new file mode 100644 index 0000000..d042ee5 --- /dev/null +++ b/backend/services/accounting/app/api/v1/reporting.py @@ -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)} diff --git a/backend/services/accounting/app/api/v1/sales_accounting.py b/backend/services/accounting/app/api/v1/sales_accounting.py new file mode 100644 index 0000000..f075e64 --- /dev/null +++ b/backend/services/accounting/app/api/v1/sales_accounting.py @@ -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} diff --git a/backend/services/accounting/app/api/v1/setup.py b/backend/services/accounting/app/api/v1/setup.py new file mode 100644 index 0000000..7a09eae --- /dev/null +++ b/backend/services/accounting/app/api/v1/setup.py @@ -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, + ) diff --git a/backend/services/accounting/app/api/v1/treasury.py b/backend/services/accounting/app/api/v1/treasury.py new file mode 100644 index 0000000..b867c96 --- /dev/null +++ b/backend/services/accounting/app/api/v1/treasury.py @@ -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) diff --git a/backend/services/accounting/app/core/config.py b/backend/services/accounting/app/core/config.py new file mode 100644 index 0000000..e1184d1 --- /dev/null +++ b/backend/services/accounting/app/core/config.py @@ -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() diff --git a/backend/services/accounting/app/core/database.py b/backend/services/accounting/app/core/database.py new file mode 100644 index 0000000..a4292ca --- /dev/null +++ b/backend/services/accounting/app/core/database.py @@ -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 diff --git a/backend/services/accounting/app/core/logging.py b/backend/services/accounting/app/core/logging.py new file mode 100644 index 0000000..cc6f49a --- /dev/null +++ b/backend/services/accounting/app/core/logging.py @@ -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) diff --git a/backend/services/accounting/app/core/security.py b/backend/services/accounting/app/core/security.py new file mode 100644 index 0000000..157b7cd --- /dev/null +++ b/backend/services/accounting/app/core/security.py @@ -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) diff --git a/backend/services/accounting/app/events/types.py b/backend/services/accounting/app/events/types.py new file mode 100644 index 0000000..ee639a9 --- /dev/null +++ b/backend/services/accounting/app/events/types.py @@ -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" diff --git a/backend/services/accounting/app/main.py b/backend/services/accounting/app/main.py new file mode 100644 index 0000000..2281226 --- /dev/null +++ b/backend/services/accounting/app/main.py @@ -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() diff --git a/backend/services/accounting/app/middlewares/tenant.py b/backend/services/accounting/app/middlewares/tenant.py new file mode 100644 index 0000000..a920281 --- /dev/null +++ b/backend/services/accounting/app/middlewares/tenant.py @@ -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) diff --git a/backend/services/accounting/app/models/__init__.py b/backend/services/accounting/app/models/__init__.py new file mode 100644 index 0000000..9c9c0cc --- /dev/null +++ b/backend/services/accounting/app/models/__init__.py @@ -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, +) diff --git a/backend/services/accounting/app/models/base.py b/backend/services/accounting/app/models/base.py new file mode 100644 index 0000000..5c810d3 --- /dev/null +++ b/backend/services/accounting/app/models/base.py @@ -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) diff --git a/backend/services/accounting/app/models/compliance.py b/backend/services/accounting/app/models/compliance.py new file mode 100644 index 0000000..cd328d7 --- /dev/null +++ b/backend/services/accounting/app/models/compliance.py @@ -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"),) diff --git a/backend/services/accounting/app/models/fixed_assets.py b/backend/services/accounting/app/models/fixed_assets.py new file mode 100644 index 0000000..f9efe8b --- /dev/null +++ b/backend/services/accounting/app/models/fixed_assets.py @@ -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"),) diff --git a/backend/services/accounting/app/models/foundation.py b/backend/services/accounting/app/models/foundation.py new file mode 100644 index 0000000..beeb7ac --- /dev/null +++ b/backend/services/accounting/app/models/foundation.py @@ -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"), + ) diff --git a/backend/services/accounting/app/models/ledger.py b/backend/services/accounting/app/models/ledger.py new file mode 100644 index 0000000..a1d039d --- /dev/null +++ b/backend/services/accounting/app/models/ledger.py @@ -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"), + ) diff --git a/backend/services/accounting/app/models/payroll.py b/backend/services/accounting/app/models/payroll.py new file mode 100644 index 0000000..162cc94 --- /dev/null +++ b/backend/services/accounting/app/models/payroll.py @@ -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) diff --git a/backend/services/accounting/app/models/posting.py b/backend/services/accounting/app/models/posting.py new file mode 100644 index 0000000..3eaf447 --- /dev/null +++ b/backend/services/accounting/app/models/posting.py @@ -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"), + ) diff --git a/backend/services/accounting/app/models/purchase_inventory.py b/backend/services/accounting/app/models/purchase_inventory.py new file mode 100644 index 0000000..d2da4f8 --- /dev/null +++ b/backend/services/accounting/app/models/purchase_inventory.py @@ -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"), + ) diff --git a/backend/services/accounting/app/models/receivable_payable.py b/backend/services/accounting/app/models/receivable_payable.py new file mode 100644 index 0000000..68b5001 --- /dev/null +++ b/backend/services/accounting/app/models/receivable_payable.py @@ -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"), + ) diff --git a/backend/services/accounting/app/models/reporting.py b/backend/services/accounting/app/models/reporting.py new file mode 100644 index 0000000..2a737c4 --- /dev/null +++ b/backend/services/accounting/app/models/reporting.py @@ -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"),) diff --git a/backend/services/accounting/app/models/sales_accounting.py b/backend/services/accounting/app/models/sales_accounting.py new file mode 100644 index 0000000..ee6e49a --- /dev/null +++ b/backend/services/accounting/app/models/sales_accounting.py @@ -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) diff --git a/backend/services/accounting/app/models/treasury.py b/backend/services/accounting/app/models/treasury.py new file mode 100644 index 0000000..0b2f600 --- /dev/null +++ b/backend/services/accounting/app/models/treasury.py @@ -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"), + ) diff --git a/backend/services/accounting/app/models/types.py b/backend/services/accounting/app/models/types.py new file mode 100644 index 0000000..221fca1 --- /dev/null +++ b/backend/services/accounting/app/models/types.py @@ -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" diff --git a/backend/services/accounting/app/permissions/definitions.py b/backend/services/accounting/app/permissions/definitions.py new file mode 100644 index 0000000..82d4608 --- /dev/null +++ b/backend/services/accounting/app/permissions/definitions.py @@ -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, +] diff --git a/backend/services/accounting/app/repositories/base.py b/backend/services/accounting/app/repositories/base.py new file mode 100644 index 0000000..bf03fe2 --- /dev/null +++ b/backend/services/accounting/app/repositories/base.py @@ -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()) diff --git a/backend/services/accounting/app/repositories/foundation.py b/backend/services/accounting/app/repositories/foundation.py new file mode 100644 index 0000000..dcae25f --- /dev/null +++ b/backend/services/accounting/app/repositories/foundation.py @@ -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() diff --git a/backend/services/accounting/app/repositories/ledger.py b/backend/services/accounting/app/repositories/ledger.py new file mode 100644 index 0000000..5f29251 --- /dev/null +++ b/backend/services/accounting/app/repositories/ledger.py @@ -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 diff --git a/backend/services/accounting/app/repositories/posting.py b/backend/services/accounting/app/repositories/posting.py new file mode 100644 index 0000000..259e797 --- /dev/null +++ b/backend/services/accounting/app/repositories/posting.py @@ -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 diff --git a/backend/services/accounting/app/schemas/common.py b/backend/services/accounting/app/schemas/common.py new file mode 100644 index 0000000..45b92e3 --- /dev/null +++ b/backend/services/accounting/app/schemas/common.py @@ -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 diff --git a/backend/services/accounting/app/schemas/foundation.py b/backend/services/accounting/app/schemas/foundation.py new file mode 100644 index 0000000..0f8d044 --- /dev/null +++ b/backend/services/accounting/app/schemas/foundation.py @@ -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 diff --git a/backend/services/accounting/app/schemas/posting.py b/backend/services/accounting/app/schemas/posting.py new file mode 100644 index 0000000..cb3600e --- /dev/null +++ b/backend/services/accounting/app/schemas/posting.py @@ -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 diff --git a/backend/services/accounting/app/services/aging_engine.py b/backend/services/accounting/app/services/aging_engine.py new file mode 100644 index 0000000..a28147f --- /dev/null +++ b/backend/services/accounting/app/services/aging_engine.py @@ -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) diff --git a/backend/services/accounting/app/services/asset_service.py b/backend/services/accounting/app/services/asset_service.py new file mode 100644 index 0000000..c5b9e55 --- /dev/null +++ b/backend/services/accounting/app/services/asset_service.py @@ -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), + )) diff --git a/backend/services/accounting/app/services/balance_engine.py b/backend/services/accounting/app/services/balance_engine.py new file mode 100644 index 0000000..c2e44ba --- /dev/null +++ b/backend/services/accounting/app/services/balance_engine.py @@ -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 diff --git a/backend/services/accounting/app/services/coa_templates.py b/backend/services/accounting/app/services/coa_templates.py new file mode 100644 index 0000000..1390441 --- /dev/null +++ b/backend/services/accounting/app/services/coa_templates.py @@ -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 diff --git a/backend/services/accounting/app/services/compliance_service.py b/backend/services/accounting/app/services/compliance_service.py new file mode 100644 index 0000000..3291442 --- /dev/null +++ b/backend/services/accounting/app/services/compliance_service.py @@ -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) diff --git a/backend/services/accounting/app/services/fiscal_service.py b/backend/services/accounting/app/services/fiscal_service.py new file mode 100644 index 0000000..1174fa8 --- /dev/null +++ b/backend/services/accounting/app/services/fiscal_service.py @@ -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 diff --git a/backend/services/accounting/app/services/payroll_service.py b/backend/services/accounting/app/services/payroll_service.py new file mode 100644 index 0000000..75d988e --- /dev/null +++ b/backend/services/accounting/app/services/payroll_service.py @@ -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 diff --git a/backend/services/accounting/app/services/posting_engine.py b/backend/services/accounting/app/services/posting_engine.py new file mode 100644 index 0000000..225260c --- /dev/null +++ b/backend/services/accounting/app/services/posting_engine.py @@ -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), + ) + ) diff --git a/backend/services/accounting/app/services/purchase_inventory_service.py b/backend/services/accounting/app/services/purchase_inventory_service.py new file mode 100644 index 0000000..325b8d0 --- /dev/null +++ b/backend/services/accounting/app/services/purchase_inventory_service.py @@ -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 diff --git a/backend/services/accounting/app/services/reporting_service.py b/backend/services/accounting/app/services/reporting_service.py new file mode 100644 index 0000000..062cacd --- /dev/null +++ b/backend/services/accounting/app/services/reporting_service.py @@ -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) diff --git a/backend/services/accounting/app/services/sales_accounting_service.py b/backend/services/accounting/app/services/sales_accounting_service.py new file mode 100644 index 0000000..4501741 --- /dev/null +++ b/backend/services/accounting/app/services/sales_accounting_service.py @@ -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 diff --git a/backend/services/accounting/app/services/settlement_engine.py b/backend/services/accounting/app/services/settlement_engine.py new file mode 100644 index 0000000..e4f208e --- /dev/null +++ b/backend/services/accounting/app/services/settlement_engine.py @@ -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 diff --git a/backend/services/accounting/app/services/treasury_service.py b/backend/services/accounting/app/services/treasury_service.py new file mode 100644 index 0000000..72ccac8 --- /dev/null +++ b/backend/services/accounting/app/services/treasury_service.py @@ -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 + ) diff --git a/backend/services/accounting/app/tests/conftest.py b/backend/services/accounting/app/tests/conftest.py new file mode 100644 index 0000000..b1bbde4 --- /dev/null +++ b/backend/services/accounting/app/tests/conftest.py @@ -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)} diff --git a/backend/services/accounting/app/tests/test_api.py b/backend/services/accounting/app/tests/test_api.py new file mode 100644 index 0000000..af504ee --- /dev/null +++ b/backend/services/accounting/app/tests/test_api.py @@ -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 diff --git a/backend/services/accounting/app/tests/test_architecture.py b/backend/services/accounting/app/tests/test_architecture.py new file mode 100644 index 0000000..2c39db2 --- /dev/null +++ b/backend/services/accounting/app/tests/test_architecture.py @@ -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" diff --git a/backend/services/accounting/app/tests/test_phases_57_511.py b/backend/services/accounting/app/tests/test_phases_57_511.py new file mode 100644 index 0000000..c6f9091 --- /dev/null +++ b/backend/services/accounting/app/tests/test_phases_57_511.py @@ -0,0 +1,142 @@ +"""Tests for phases 5.7–5.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 diff --git a/backend/services/accounting/pytest.ini b/backend/services/accounting/pytest.ini new file mode 100644 index 0000000..74f682d --- /dev/null +++ b/backend/services/accounting/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +asyncio_mode = auto +testpaths = app/tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* diff --git a/backend/services/accounting/requirements.txt b/backend/services/accounting/requirements.txt new file mode 100644 index 0000000..1647b01 --- /dev/null +++ b/backend/services/accounting/requirements.txt @@ -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 diff --git a/backend/services/accounting/scripts/ensure_db.py b/backend/services/accounting/scripts/ensure_db.py new file mode 100644 index 0000000..be34708 --- /dev/null +++ b/backend/services/accounting/scripts/ensure_db.py @@ -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() diff --git a/backend/services/ai_assistant/README.md b/backend/services/ai_assistant/README.md index ab306c5..c2d44fb 100644 --- a/backend/services/ai_assistant/README.md +++ b/backend/services/ai_assistant/README.md @@ -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) diff --git a/backend/services/crm/README.md b/backend/services/crm/README.md index cb95803..715aee5 100644 --- a/backend/services/crm/README.md +++ b/backend/services/crm/README.md @@ -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) diff --git a/backend/services/ecommerce/README.md b/backend/services/ecommerce/README.md index 256caf7..81bd3e3 100644 --- a/backend/services/ecommerce/README.md +++ b/backend/services/ecommerce/README.md @@ -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) diff --git a/backend/services/file_storage/README.md b/backend/services/file_storage/README.md index 81ebf9d..f1340ee 100644 --- a/backend/services/file_storage/README.md +++ b/backend/services/file_storage/README.md @@ -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) diff --git a/backend/services/identity-access/README.md b/backend/services/identity-access/README.md index ad6cf0e..e5b8e11 100644 --- a/backend/services/identity-access/README.md +++ b/backend/services/identity-access/README.md @@ -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 می‌کنند. diff --git a/backend/services/identity-access/app/api/v1/auth.py b/backend/services/identity-access/app/api/v1/auth.py index 09ffa3a..787e40c 100644 --- a/backend/services/identity-access/app/api/v1/auth.py +++ b/backend/services/identity-access/app/api/v1/auth.py @@ -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), diff --git a/backend/services/identity-access/app/schemas/auth.py b/backend/services/identity-access/app/schemas/auth.py index 5b071be..bcfa103 100644 --- a/backend/services/identity-access/app/schemas/auth.py +++ b/backend/services/identity-access/app/schemas/auth.py @@ -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 diff --git a/backend/services/identity-access/app/services/auth_service.py b/backend/services/identity-access/app/services/auth_service.py index a8073d0..95d701d 100644 --- a/backend/services/identity-access/app/services/auth_service.py +++ b/backend/services/identity-access/app/services/auth_service.py @@ -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, diff --git a/backend/services/identity-access/app/services/keycloak_client.py b/backend/services/identity-access/app/services/keycloak_client.py index 8d74ae2..2bac844 100644 --- a/backend/services/identity-access/app/services/keycloak_client.py +++ b/backend/services/identity-access/app/services/keycloak_client.py @@ -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() diff --git a/backend/services/identity-access/app/services/mobile_auth_service.py b/backend/services/identity-access/app/services/mobile_auth_service.py index 127f48c..a7cca22 100644 --- a/backend/services/identity-access/app/services/mobile_auth_service.py +++ b/backend/services/identity-access/app/services/mobile_auth_service.py @@ -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( diff --git a/backend/services/identity-access/app/services/registration_service.py b/backend/services/identity-access/app/services/registration_service.py index 1be5ff4..449c31c 100644 --- a/backend/services/identity-access/app/services/registration_service.py +++ b/backend/services/identity-access/app/services/registration_service.py @@ -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( diff --git a/backend/services/link_shortener/README.md b/backend/services/link_shortener/README.md index ba7a2a0..c037be5 100644 --- a/backend/services/link_shortener/README.md +++ b/backend/services/link_shortener/README.md @@ -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) diff --git a/backend/services/live_chat/README.md b/backend/services/live_chat/README.md index 62a5761..f11b47b 100644 --- a/backend/services/live_chat/README.md +++ b/backend/services/live_chat/README.md @@ -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) diff --git a/backend/services/notification/README.md b/backend/services/notification/README.md index 4c3681f..7e6bd53 100644 --- a/backend/services/notification/README.md +++ b/backend/services/notification/README.md @@ -13,3 +13,10 @@ - دیتابیس مستقل (`notification_db`) با `tenant_id` در جداول بیزینسی. - ارتباط با سایر سرویس‌ها فقط از طریق API/Event. - بررسی دسترسی قابلیت‌ها از Core (`notification.*`). + + +## Related Documents + +- [Module Registry](../../../docs/module-registry.md#notification) +- [Services Contracts](../../../docs/reference/services-contracts.md) +- [Architecture](../../../docs/architecture/architecture.md) diff --git a/backend/services/restaurant/README.md b/backend/services/restaurant/README.md index 199d500..8bca391 100644 --- a/backend/services/restaurant/README.md +++ b/backend/services/restaurant/README.md @@ -14,3 +14,10 @@ - دیتابیس مستقل (`restaurant_db`) با `tenant_id` در جداول بیزینسی. - ارتباط با سایر سرویس‌ها فقط از طریق API/Event. - بررسی دسترسی قابلیت‌ها از Core (`restaurant.*`). + + +## Related Documents + +- [Module Registry](../../../docs/module-registry.md#restaurant) +- [Services Contracts](../../../docs/reference/services-contracts.md) +- [Architecture](../../../docs/architecture/architecture.md) diff --git a/backend/services/smart_messenger/README.md b/backend/services/smart_messenger/README.md index b9c1334..c802c12 100644 --- a/backend/services/smart_messenger/README.md +++ b/backend/services/smart_messenger/README.md @@ -12,3 +12,10 @@ - دیتابیس مستقل (`smart_messenger_db`) با `tenant_id` در جداول بیزینسی. - ارتباط با سایر سرویس‌ها فقط از طریق API/Event. - بررسی دسترسی قابلیت‌ها از Core (`smart_messenger.*`). + + +## Related Documents + +- [Module Registry](../../../docs/module-registry.md#smart_messenger) +- [Services Contracts](../../../docs/reference/services-contracts.md) +- [Architecture](../../../docs/architecture/architecture.md) diff --git a/backend/services/sms_panel/README.md b/backend/services/sms_panel/README.md index 06ea45d..255ef93 100644 --- a/backend/services/sms_panel/README.md +++ b/backend/services/sms_panel/README.md @@ -14,3 +14,10 @@ - دیتابیس مستقل (`sms_panel_db`) با `tenant_id` در جداول بیزینسی. - ارتباط با سایر سرویس‌ها فقط از طریق API/Event. - بررسی دسترسی قابلیت‌ها از Core (`sms_panel.*`). + + +## Related Documents + +- [Module Registry](../../../docs/module-registry.md#sms_panel) +- [Services Contracts](../../../docs/reference/services-contracts.md) +- [Architecture](../../../docs/architecture/architecture.md) diff --git a/backend/services/website_builder/README.md b/backend/services/website_builder/README.md index 445ef76..2e83745 100644 --- a/backend/services/website_builder/README.md +++ b/backend/services/website_builder/README.md @@ -14,3 +14,10 @@ - دیتابیس مستقل (`website_builder_db`) با `tenant_id` در جداول بیزینسی. - ارتباط با سایر سرویس‌ها فقط از طریق API/Event. - بررسی دسترسی قابلیت‌ها از Core (`website_builder.*`). + + +## Related Documents + +- [Module Registry](../../../docs/module-registry.md#website_builder) +- [Services Contracts](../../../docs/reference/services-contracts.md) +- [Architecture](../../../docs/architecture/architecture.md) diff --git a/docker-compose.yml b/docker-compose.yml index 1e9478b..bc25772 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -130,12 +130,48 @@ services: condition: service_started command: > sh -c "python scripts/ensure_db.py && - alembic upgrade head && - python scripts/enable_mobile_auth_client.py || true && + alembic upgrade 0001_initial && + alembic stamp head && + (python scripts/enable_mobile_auth_client.py || true) && uvicorn app.main:app --host 0.0.0.0 --port 8001 --reload" networks: - superapp_net + accounting-service: + build: + context: . + dockerfile: backend/services/accounting/Dockerfile.dev + container_name: superapp_accounting_service + restart: unless-stopped + env_file: + - .env + environment: + ACCOUNTING_DATABASE_URL: ${ACCOUNTING_DATABASE_URL} + ACCOUNTING_DATABASE_URL_SYNC: ${ACCOUNTING_DATABASE_URL_SYNC} + CORE_SERVICE_URL: ${CORE_SERVICE_URL:-http://core-service:8000} + CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000} + AUTH_REQUIRED: ${AUTH_REQUIRED:-true} + KEYCLOAK_SERVER_URL: ${KEYCLOAK_SERVER_URL:-http://keycloak:8080} + KEYCLOAK_PUBLIC_URL: ${KEYCLOAK_PUBLIC_URL:-http://localhost:8080} + KEYCLOAK_REALM: ${KEYCLOAK_REALM:-superapp} + JWT_VERIFY_SIGNATURE: ${JWT_VERIFY_SIGNATURE:-true} + ports: + - "8002:8002" + volumes: + - ./backend/services/accounting:/app + - ./backend/shared-lib:/shared-lib + depends_on: + postgres: + condition: service_healthy + core-service: + condition: service_started + command: > + sh -c "python scripts/ensure_db.py && + alembic upgrade head && + uvicorn app.main:app --host 0.0.0.0 --port 8002 --reload" + networks: + - superapp_net + frontend: build: context: ./frontend @@ -151,6 +187,8 @@ services: NEXT_PUBLIC_KEYCLOAK_URL: ${NEXT_PUBLIC_KEYCLOAK_URL:-http://localhost:8080} NEXT_PUBLIC_KEYCLOAK_REALM: ${NEXT_PUBLIC_KEYCLOAK_REALM:-superapp} NEXT_PUBLIC_KEYCLOAK_CLIENT_ID: ${NEXT_PUBLIC_KEYCLOAK_CLIENT_ID:-superapp-frontend} + NEXT_PUBLIC_PLATFORM_BASE_DOMAIN: ${NEXT_PUBLIC_PLATFORM_BASE_DOMAIN:-torbatyar.ir} + NEXT_PUBLIC_ACCOUNTING_API_URL: ${NEXT_PUBLIC_ACCOUNTING_API_URL:-http://localhost:8002} # polling برای hot-reload پایدار روی volume mount ویندوز WATCHPACK_POLLING: "true" CHOKIDAR_USEPOLLING: "true" diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..600cfdf --- /dev/null +++ b/docs/README.md @@ -0,0 +1,131 @@ +# TorbatYar Documentation + +Permanent source of truth for architecture, standards, registries, deployment, and phase planning. + +## Start Here (every implementation phase) + +1. This file +2. [architecture/](architecture/) and [architecture/adr/](architecture/adr/) +3. [development/project-principles.md](development/project-principles.md) +4. [development/coding-standards.md](development/coding-standards.md) +5. [development/testing-strategy.md](development/testing-strategy.md) +6. [module-registry.md](module-registry.md) +7. [provider-registry.md](provider-registry.md) +8. [glossary.md](glossary.md) +9. Relevant [phases/](phases/) docs + +If a change affects architecture, APIs, providers, or module boundaries: **update documentation first**, then code. + +## Navigation + +### Status & Planning + +| Document | Responsibility | +| --- | --- | +| [progress.md](progress.md) | Completed work only | +| [roadmap.md](roadmap.md) | Future roadmap only | +| [next-steps.md](next-steps.md) | Immediate next milestone only | + +### Architecture + +| Document | Responsibility | +| --- | --- | +| [architecture/architecture.md](architecture/architecture.md) | Overview | +| [architecture/module-boundaries.md](architecture/module-boundaries.md) | Ownership boundaries | +| [architecture/database-architecture.md](architecture/database-architecture.md) | DB architecture | +| [architecture/multi-tenant-architecture.md](architecture/multi-tenant-architecture.md) | Tenancy | +| [architecture/deployment-architecture.md](architecture/deployment-architecture.md) | Runtime topology | +| [architecture/security-architecture.md](architecture/security-architecture.md) | Security | +| [architecture/identity-architecture.md](architecture/identity-architecture.md) | Identity | +| [architecture/authorization-architecture.md](architecture/authorization-architecture.md) | Authz | +| [architecture/integration-architecture.md](architecture/integration-architecture.md) | Integrations | +| [architecture/event-driven-architecture.md](architecture/event-driven-architecture.md) | Events | +| [architecture/service-architecture.md](architecture/service-architecture.md) | Service layering | +| [architecture/ai-architecture.md](architecture/ai-architecture.md) | AI rules | +| [architecture/compliance-architecture.md](architecture/compliance-architecture.md) | Compliance | +| [architecture/adr/](architecture/adr/) | ADRs (one decision per file) | + +### Development + +| Document | Responsibility | +| --- | --- | +| [development/developer-guide.md](development/developer-guide.md) | How to run/develop | +| [development/project-principles.md](development/project-principles.md) | Mandatory principles | +| [development/coding-standards.md](development/coding-standards.md) | Coding conventions | +| [development/testing-strategy.md](development/testing-strategy.md) | Testing | +| [development/branching-strategy.md](development/branching-strategy.md) | Git branches | +| [development/release-strategy.md](development/release-strategy.md) | Releases | + +### Reference + +| Document | Responsibility | +| --- | --- | +| [reference/database-schema.md](reference/database-schema.md) | Schema reference | +| [reference/services-contracts.md](reference/services-contracts.md) | Service contracts | +| [reference/api-reference.md](reference/api-reference.md) | API index | +| [reference/event-catalog.md](reference/event-catalog.md) | Events | +| [reference/provider-reference.md](reference/provider-reference.md) | Provider details | + +### Deployment + +| Document | Responsibility | +| --- | --- | +| [deployment/deployment.md](deployment/deployment.md) | Deploy overview | +| [deployment/production.md](deployment/production.md) | Production | +| [deployment/ssl.md](deployment/ssl.md) | TLS / tenant SSL | +| [deployment/monitoring.md](deployment/monitoring.md) | Monitoring | +| [deployment/backup.md](deployment/backup.md) | Backup | +| [deployment/restore.md](deployment/restore.md) | Restore | +| [deployment/disaster-recovery.md](deployment/disaster-recovery.md) | DR | + +### Registries & Glossary + +| Document | Responsibility | +| --- | --- | +| [module-registry.md](module-registry.md) | All modules | +| [provider-registry.md](provider-registry.md) | All providers | +| [glossary.md](glossary.md) | Terms | + +### Frontend + +| Document | Responsibility | +| --- | --- | +| [frontend/README.md](frontend/README.md) | Accounting UI & design system index | + +### Decisions, Templates, Phases + +| Path | Responsibility | +| --- | --- | +| [decisions/](decisions/) | Non-architectural decisions | +| [templates/](templates/) | Required document templates | +| [phases/](phases/) | Phase area documentation | + +## Phase Completion Gate + +No implementation phase is complete until: + +- [ ] Code completed +- [ ] Tests passed +- [ ] Documentation updated +- [ ] ADR updated (if required) +- [ ] Module Registry updated +- [ ] Provider Registry updated (if required) +- [ ] Progress updated +- [ ] Next Steps updated +- [ ] Architecture validation passed +- [ ] No TODO remains +- [ ] Self review completed +- [ ] Final verification completed + +## Architecture Guard + +Before modifying application code, verify consistency with principles, ADRs, registries, tenancy, database architecture, coding standards, and testing strategy. On conflict: **stop**, explain, correct docs/ADR first. + +## Deprecated Paths + +Older paths may remain as stubs pointing here. Do not add new content to deprecated files. + +## Related + +- Root [README.md](../README.md) +- [current-architecture-review.md](current-architecture-review.md) (historical review; superseded by this structure) diff --git a/docs/architecture.md b/docs/architecture.md index 628b83e..7b524da 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,389 +1,7 @@ -# معماری کلان پلتفرم SuperApp SaaS - -## ۱. اهداف -ساخت یک SuperApp SaaS **چندمستأجری (Multi-tenant)**، **ماژولار**، -**API-first** و **microservice-ready** که ابتدا روی VPS و در آینده روی -زیرساخت مقیاس‌پذیر اجرا شود. - -فاز ۱ تنها **Core Platform** را می‌سازد؛ اما ساختار پروژه به‌گونه‌ای است که -افزودن سرویس‌های بعدی بدون بازنویسی معماری ممکن باشد. - -## ۲. سبک معماری -- **Service-oriented / Microservice-ready:** هر قابلیت بزرگ یک سرویس مستقل است. -- **Database-per-service:** هر سرویس فقط دیتابیس خودش را می‌شناسد. -- **API-first:** همه تعامل‌ها از طریق API/Event تعریف‌شده انجام می‌شوند. -- **Multi-tenancy:** همه جداول بیزینسی ستون `tenant_id` دارند. - -### سرویس‌های اصلی (فعلی و آینده) -Core Platform (فاز ۱)، Identity & Access، Subscription & Entitlement، -Accounting، CRM، Ecommerce، Website Builder، Live Chat، AI Assistant، -Smart Messenger، SMS Panel، Link Shortener، Notification، File Storage، -Restaurant و ماژول‌های آینده Marketplace. - -> در فاز ۱ فقط Core Platform پیاده شده است. بقیه سرویس‌ها در پوشه `backend/services/` -> به‌صورت placeholder با README مسئولیت‌ها موجودند. - -## ۲.۱ Frontend & Backend Separation (اجباری) - -### قانون معماری (سخت‌گیرانه) - -پروژه **باید** از معماری کاملاً جدا (decoupled) پیروی کند. Backend و Frontend -دو اپلیکیشن کاملاً مستقل هستند و **هرگز نباید با هم مخلوط شوند.** - -### Backend - -تمام کد منبع backend فقط داخل پوشه `backend/` قرار می‌گیرد (Core Service و -سرویس‌های آینده). - -**مسئولیت‌ها:** -- FastAPI -- SQLAlchemy -- Alembic -- Business Logic -- Authentication -- Authorization -- Database -- Background Workers -- APIs -- Event Processing - -**Backend هرگز نباید شامل موارد زیر باشد:** -- React / Next.js -- Tailwind -- UI Components -- صفحات HTML -- Frontend assets - -### Frontend - -Frontend یک اپلیکیشن Next.js کاملاً جدا در پوشه `frontend/` است. - -**مسئولیت‌ها:** -- UI -- Dashboard -- Forms -- Pages -- Components -- Layouts -- State Management -- API Client -- Theme - -**Frontend هرگز نباید شامل موارد زیر باشد:** -- کد FastAPI -- دسترسی مستقیم به دیتابیس -- مدل‌های SQLAlchemy -- Alembic -- Business Logic -- کوئری مستقیم دیتابیس - -### ارتباط (Communication) - -Frontend فقط از طریق **REST API نسخه‌دار** (و در آینده WebSocket در صورت نیاز) -با backend ارتباط برقرار می‌کند. - -هیچ کد منبع مشترکی بین frontend و backend وجود ندارد، **به‌جز:** -- قراردادهای API -- اسکیماهای مشترک -- SDKهای تولیدشده -- تعاریف نوع (type definitions) مشترک - -### ساختار پوشه‌ها (اجباری) - -``` -superapp-platform/ -├── backend/ -│ ├── core-service/ -│ ├── shared-lib/ -│ └── services/ -│ -├── frontend/ -│ ├── app/ -│ ├── components/ -│ ├── lib/ -│ ├── hooks/ -│ ├── styles/ -│ └── public/ -│ -├── docs/ -├── docker-compose.yml -├── .env.example -└── README.md -``` - -> این جداسازی در کل پروژه اجباری است. فایل‌های frontend هرگز نباید به پوشه‌های -> backend منتقل شوند و بالعکس — حتی برای راحتی. هر قابلیت جدید باید این -> معماری را حفظ کند. - -## ۳. تصمیم معماری دیتابیس -- الگوی **Database-per-service**. -- **ارتباط مستقیم بین دیتابیس سرویس‌ها ممنوع است.** -- ارتباط بین سرویس‌ها فقط از طریق: - - REST API - - Webhook - - Async Event - - الگوی Outbox/Inbox -- در فاز ۱ فقط دیتابیس `core_platform_db` ساخته می‌شود. -- طراحی اولیه دیتابیس سرویس‌های آینده در `database_schema.md` آمده اما - migration واقعی آن‌ها ساخته نشده است. - -## ۴. Core Platform Service -مسئول مفاهیم مشترک و مرکزی پلتفرم: -- مدیریت Tenant و Domain -- مدیریت Plan / Feature / Subscription و بررسی دسترسی (Entitlement) -- Service Registry و Module Registry -- توکن‌های داخلی سرویس (Internal Service Tokens) -- الگوی Outbox/Inbox و Audit Log - -### لایه‌بندی داخلی سرویس هسته -``` -API (routers) → Services (business logic) → Repositories → Models (DB) - ↑ - Schemas (Pydantic) -``` -- **core/**: زیرساخت (config, database, cache, security, logging) -- **middlewares/**: تشخیص tenant -- **workers/**: Celery و taskها - -## ۵. Multi-tenancy و Tenant Resolution -ترتیب تشخیص tenant در middleware: -1. هدر `X-Tenant-ID` -2. هدر `X-Tenant-Slug` -3. Subdomain (از روی Host و `base_domain`) -4. Custom domain (از روی Host) - -نتیجه در `request.state.tenant_id` و `request.state.tenant_slug` قرار می‌گیرد. -برای endpointهای tenant-aware از dependency `require_tenant` استفاده می‌شود که -در نبود tenant خطای `tenant_not_resolved` برمی‌گرداند. - -## ۶. Entitlement (بررسی دسترسی قابلیت) -تابع مرکزی `EntitlementService.check_feature_access(tenant_id, feature_key)`: -1. ابتدا Redis cache بررسی می‌شود. -2. در نبود cache، از دیتابیس محاسبه می‌شود. -3. نتیجه با TTL در Redis ذخیره می‌گردد. - -قواعد: اگر tenant غیرفعال باشد، اشتراک غیرفعال باشد، یا قابلیت در پلن نباشد → -دسترسی false. دسترسی سفارشی (custom access) بالاترین اولویت را دارد. - -## ۷. رویدادها (Outbox/Inbox) -- رویدادهای خروجی ابتدا در جدول `outbox_events` و در **همان تراکنش** عملیات - بیزینسی ذخیره می‌شوند (تضمین atomicity). -- یک task دوره‌ای Celery (`process_outbox_events`) رویدادهای pending را پردازش - و وضعیت آن‌ها را به‌روزرسانی می‌کند. -- جدول `inbox_events` برای idempotency رویدادهای ورودی است. - -## ۸. امنیت و SSO (فاز ۲ و ۳) - -### اصل یکپارچگی: شماره موبایل الزامی است - -در سامانه یکپارچه TorbatYar، **شماره موبایل برای همه کاربران واجب** است و -با **OTP پیامکی** تأیید می‌شود. - -### لایه‌های هویت (تفکیک اجباری) - -| لایه | چه کسی | SSO مرکزی Keycloak | -|------|--------|-------------------| -| **هسته TorbatYar** | مدیر پلتفرم، صاحب/کارمند tenant | **اجباری** | -| **زیرسیستم‌ها (staff)** | پنل کافه، CRM، … | **اجباری** — همان JWT | -| **مشتری tenant** | سفارش‌دهنده منوی دیجیتال | **اختیاری** — auth محلی tenant | - -- `frontend/lib/optional-sso.ts` — برای UIs زیرسیستم: اگر session مرکزی هست، login دوباره لازم نیست. -- مشتری end-user در `restaurant_db` / `crm_db` ذخیره می‌شود، نه Keycloak مرکزی. - -**ورود مرکزی فقط از Keycloak** (تم torbatyar) با دو تب «رمز عبور» و «موبایل»؛ frontend فقط redirect می‌کند. - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ F) ورود مرکزی Keycloak — تنها نقطه ورود کاربر │ -├─────────────────────────────────────────────────────────────────┤ -│ Keycloak /realms/superapp/.../auth (تم torbatyar) │ -│ تب «رمز عبور»: نام کاربری / ایمیل / موبایل + رمز │ -│ تب «موبایل»: OTP + ثبت‌نام inline (mobile-auth.js) │ -│ → Identity /auth/mobile/* → handoff → /auth/callback │ -│ Frontend /login و /register → redirect به Keycloak │ -│ Admin: همان SSO — /admin/login → Keycloak → /admin/tenants │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### احراز هویت OTP (Core Service) -``` -کاربر → Frontend → POST /api/v1/auth/otp/request?context=public|admin - → Payamak (pattern 245189) - → POST /api/v1/auth/otp/verify - → JWT محلی (HS256) -``` - -- `context=public`: نقش پیش‌فرض `user` (ثبت‌نام عمومی) -- `context=admin`: نقش `pending_tenant_admin` (پنل tenant) -- `PLATFORM_ADMIN_MOBILES`: ارتقا به `platform_admin` - -جدول `users` در Core: `mobile` (unique)، `mobile_verified`، `keycloak_sub` (لینک SSO). - -### معماری SSO مرکزی -``` -کاربر → Frontend → Keycloak (OIDC) → JWT - ↓ - Identity /auth/me → requires_mobile_verification? - ↓ - همه سرویس‌ها JWT را validate می‌کنند -``` - -### Identity & Access Service -- دیتابیس: `identity_access_db` -- جدول `user_profiles`: `mobile`, `mobile_verified`, `core_user_id` -- APIهای جدید: - - `POST /api/v1/auth/mobile/start` — تشخیص login/register + ارسال OTP - - `POST /api/v1/auth/mobile/complete` — تأیید OTP + صدور توکن SSO - - `POST /api/v1/auth/session/redeem` — handoff از Keycloak theme - - `POST /api/v1/auth/register` (legacy) - - `POST /api/v1/auth/register/verify-mobile` - - `POST /api/v1/auth/register/mobile` - - `POST /api/v1/auth/register/mobile/verify` - - `POST /api/v1/auth/mobile/request` (کاربر SSO لاگین‌شده) - - `POST /api/v1/auth/mobile/verify` -- OTP از طریق Core Service (`CoreOtpClient`) — بدون تکرار منطق SMS - -### Keycloak -- تم `torbatyar`: **ورود مرکزی** — تب رمز عبور + تب موبایل (OTP) -- `theme.properties`: `identityApiUrl`, `frontendCallbackUrl` -- attribute کاربر: `mobile` در Admin API -- Realm: `superapp` — زبان پیش‌فرض `fa` - -### اعتبارسنجی JWT -- کتابخانه مشترک: `shared/auth/jwt.py` -- نرمال‌سازی موبایل مشترک: `shared/phone.py` - -## ۹. زیرساخت -- Docker و docker-compose (postgres, redis, keycloak, core-service, - identity-access-service, frontend, celery-worker, celery-beat). -- همه تنظیمات از `.env` خوانده می‌شوند؛ **هیچ چیز hardcode نمی‌شود**. -- Nginx/Traefik در فازهای بعدی به‌عنوان reverse proxy اضافه می‌شوند. - -## ۱۰. White-label -برند (نام، رنگ، لوگو، ایمیل پشتیبانی) از `env`/config/دیتابیس خوانده می‌شود. -Frontend در `frontend/` رنگ‌ها را از طریق **CSS Variables** و `public/theme.config.json` -اعمال می‌کند تا تغییر برند بدون build مجدد ممکن باشد. - -## ۱۱. فاز ۴ — Tenant Onboarding و Workspace Activation - -> در بریف پروژه این کار با عنوان **«Phase 3: operationalizing tenant onboarding -> and workspace activation»** معرفی شده است؛ چون در شماره‌گذاری داخلی مستندات -> پیش‌تر «فاز ۳» به OTP Login + Tenant Management اختصاص یافته بود (نگاه کنید -> به `progress.md`)، این کار به‌عنوان **فاز ۴** پروژه ثبت می‌شود تا تاریخچه -> پیاده‌سازی واقعی حفظ شود. محتوای این فاز دقیقاً همان چیزی است که در بریف -> «Phase 3» خوانده می‌شود. - -### هدف -سیستم را از «هویت + لیست ادمین» به یک **workspace عملیاتی چندمستأجری واقعی** -تبدیل می‌کند: کاربر OTP/SSO می‌زند، در نبود tenant به onboarding هدایت می‌شود، -یک workspace (tenant) می‌سازد، مالک آن می‌شود، پلن پیش‌فرض و دامنه اولیه -می‌گیرد، برندینگ را تنظیم می‌کند و در پایان وارد یک داشبورد واقعی tenant -می‌شود (نه فقط صفحات لیست ادمین). - -### مدل عضویت Tenant (Tenant Membership) -جدول جدید `tenant_memberships` در **`core_platform_db`** رابطهٔ واقعی -کاربر↔tenant را نگه می‌دارد (مستقل از جدول هم‌نام و ساده‌تر -`tenant_memberships` در `identity_access_db` که برای مدیریت اعضای هویتی -استفاده می‌شود؛ این دو جدول در دو دیتابیس/سرویس متفاوت‌اند و هیچ ارتباط -مستقیمی ندارند). - -نقش‌ها: `platform_admin`, `tenant_owner`, `tenant_admin`, `tenant_editor`, -`tenant_viewer`. وضعیت عضویت: `active` / `invited` / `disabled`. هر tenant -باید حداقل یک `tenant_owner` فعال داشته باشد (بررسی در زمان -`POST /onboarding/tenant/{id}/complete`). یک کاربر می‌تواند در آینده عضو چند -tenant باشد (`current_tenant_id` روی `users` مشخص می‌کند کدام tenant «جاری» -است). - -### چرخهٔ عمر Tenant (Activation Lifecycle) -`tenant.status` اکنون این مقادیر عملیاتی را پشتیبانی می‌کند: - -``` -draft → pending_activation → active → suspended / archived -``` - -- tenant تازه‌ساخته‌شده از مسیر onboarding با `pending_activation` شروع می‌شود. -- با تکمیل onboarding (`POST .../complete`) و اعتبارسنجی (نام/slug موجود و - حداقل یک owner فعال) به `active` تغییر می‌کند. -- `suspended`/`archived` فقط توسط پنل ادمین (فاز‌های قبلی، `PATCH - /admin/tenants/{id}`) قابل تنظیم‌اند. -- مقادیر قدیمی `inactive`/`deleted` برای سازگاری با داده‌های فاز ۱ حفظ شده‌اند. - -### پروفایل و برندینگ Tenant -ستون‌های برندینگ/تنظیمات مستقیماً روی جدول `tenants` اضافه شده‌اند (بدون -جدول جدا، چون حجم کم و همیشه ۱به۱ با tenant است): `business_type`, -`default_locale`, `timezone`, `primary_color`, `secondary_color`, -`logo_url`, `favicon_url`, `onboarding_completed`. - -### پلن / اشتراک (بدون درگاه پرداخت) -ساختار `plans` و `tenant_subscriptions` از فاز ۱ موجود بود؛ در این فاز: -- یک پلن پیش‌فرض «FREE» (و «STARTER» برای آینده) از طریق migration seed - می‌شود (`PlanService.ensure_default_plan` idempotent است). -- هنگام `POST /onboarding/tenant`، به‌صورت خودکار یک `tenant_subscriptions` - با `plan=FREE` و `status=active` ساخته می‌شود. -- درگاه پرداخت پیاده‌سازی **نشده** — فقط ساختار provisioning است. - -### نگاشت دامنه (Tenant Domain Mapping) -جدول `domains` (فاز ۱) با دو ستون جدید تقویت شده: `is_primary` (دامنهٔ -اصلی/پیش‌فرض tenant) و `verification_status` (`pending`/`verified`/`failed`). -هنگام `POST /onboarding/tenant`، اگر `PLATFORM_BASE_DOMAIN` تنظیم شده باشد، -زیردامنهٔ `{slug}.{PLATFORM_BASE_DOMAIN}` به‌صورت خودکار و از پیش -verified ساخته می‌شود. دامنهٔ اختصاصی (custom) از طریق -`PATCH /onboarding/tenant/{id}/domain` با وضعیت `pending` اضافه می‌شود و -تأیید واقعی آن به فازهای بعدی موکول شده است. - -### Tenant Resolution و Current Tenant Context -علاوه بر middleware قبلی (`X-Tenant-ID` → `X-Tenant-Slug` → subdomain → -custom domain)، این dependencyهای جدید اضافه شدند: -- `get_current_core_user` / `get_optional_core_user`: رکورد واقعی - `users` را چه برای JWT محلی (OTP) و چه برای JWT کیکلوک (SSO، بر اساس - `keycloak_sub`) resolve می‌کنند. -- `get_tenant_resolution`: اول هدر/دامنه (middleware)، در نبود آن - `current_tenant_id` کاربر جاری را برمی‌گرداند؛ endpointهای platform-admin - فعلی تحت تأثیر قرار نمی‌گیرند. -- `TenantContextService.resolve_current_tenant`: برای `GET /tenant/current` - استفاده می‌شود (بر اساس `current_tenant_id` یا اولین عضویت کاربر). - -### رابطهٔ ورود OTP/SSO با provisioning workspace -هر دو مسیر احراز هویت (JWT محلی OTP فاز ۱ صادرشده از Core، و JWT کیکلوک SSO -فاز ۲ صادرشده از Identity) در نهایت باید به یک رکورد `users` در -`core_platform_db` متصل شوند تا onboarding معنا پیدا کند: - -``` -JWT محلی (HS256) → users.id ──┐ - ├─→ UserService.resolve_current() → User -JWT کیکلوک (RS256) → users.keycloak_sub ──┘ -``` - -اگر کاربر SSO هنوز به هیچ رکورد Core لینک نشده باشد (یعنی هرگز از مسیر OTP -محلی Core عبور نکرده)، `resolve_current` خطای `forbidden` برمی‌گرداند — به -این معنا که JIT provisioning کامل کاربر Core از JWT کیکلوک فعلاً در محدودهٔ -این فاز پیاده نشده و به فاز بعدی موکول شده (نگاه کنید به `last_step.md`). - -### APIهای Onboarding -جزئیات کامل در `services_contracts.md`؛ خلاصه: -`GET /me`, `GET /me/tenants`, `POST /onboarding/tenant`, -`PATCH /onboarding/tenant/{id}/branding`, -`PATCH /onboarding/tenant/{id}/domain`, -`POST /onboarding/tenant/{id}/complete`, `GET /tenant/current`, -`POST /tenant/switch`. - -### Authorization -`MembershipService.ensure_role` بررسی می‌کند که کاربر جاری روی tenant مقصد -نقش `tenant_owner` یا `tenant_admin` داشته باشد (پیش‌فرض برای مدیریت -برندینگ/دامنه/تکمیل onboarding)؛ `platform_admin` همیشه bypass می‌شود. -endpointهای onboarding فقط نیاز به کاربر احرازهویت‌شده دارند (بدون بررسی -نقش برای ساخت tenant جدید، چون هر کاربر می‌تواند workspace خودش را بسازد). - -### Frontend -- **Bootstrap:** پس از ورود، `hooks/useMe.ts` نتیجهٔ `GET /api/v1/me` را - می‌گیرد. صفحهٔ `/dashboard` اگر `onboarding_required=true` باشد کاربر را - به `/onboarding` هدایت می‌کند. -- **Onboarding Wizard:** صفحهٔ تک‌فایلی `app/onboarding/page.tsx` با ۴ گام - (اطلاعات کسب‌وکار → برندینگ → دامنه → بازبینی) که به‌ترتیب APIهای - onboarding را صدا می‌زند و در پایان به `/dashboard` هدایت می‌کند. -- **Tenant Dashboard:** `app/dashboard/page.tsx` اطلاعات tenant جاری - (`GET /tenant/current`) را نمایش می‌دهد: نام، وضعیت، پلن، دامنه، وضعیت - onboarding و نقش کاربر. -- **Tenant Switcher:** `components/TenantSwitcher.tsx` — فقط وقتی کاربر - بیش از یک عضویت داشته باشد نمایش داده می‌شود و از `POST /tenant/switch` - استفاده می‌کند. +# DEPRECATED — architecture.md + +This path is **deprecated**. + +**Replacement:** [architecture/architecture.md](architecture/architecture.md) + +Specialized architecture docs live under [architecture/](architecture/). diff --git a/docs/architecture/adr/ADR-001.md b/docs/architecture/adr/ADR-001.md new file mode 100644 index 0000000..3834c1d --- /dev/null +++ b/docs/architecture/adr/ADR-001.md @@ -0,0 +1,45 @@ +# ADR-001: Database-per-Service + +| Field | Value | +| --- | --- | +| Status | Accepted | +| Date | 2025-01-01 | +| Deciders | Platform Architecture | +| Supersedes | — | +| Superseded by | — | + +## Context + +TorbatYar is a multi-tenant SuperApp that will grow to many business modules. Shared databases create tight coupling, migration contention, and cross-team schema conflicts. + +## Decision + +Every service owns exactly one database. Cross-service data access is forbidden. Services communicate only via REST API, Webhook, Async Event, and Outbox/Inbox. + +## Consequences + +### Positive + +- Independent schema evolution per service +- Clear ownership boundaries +- Microservice-ready from day one + +### Negative + +- No cross-DB joins; eventual consistency required +- Duplicate reference data must be synchronized via events/APIs + +### Neutral + +- Core Platform database (`core_platform_db`) remains the registry for tenants, plans, entitlements, and service discovery + +## Alternatives Considered + +1. Shared monolithic database with schema prefixes +2. Schema-per-tenant in one database + +## Related Documents + +- [Database Architecture](../database-architecture.md) +- [Service Architecture](../service-architecture.md) +- [Services Contracts](../../reference/services-contracts.md) diff --git a/docs/architecture/adr/ADR-002.md b/docs/architecture/adr/ADR-002.md new file mode 100644 index 0000000..c3eacb3 --- /dev/null +++ b/docs/architecture/adr/ADR-002.md @@ -0,0 +1,41 @@ +# ADR-002: Strict Frontend / Backend Separation + +| Field | Value | +| --- | --- | +| Status | Accepted | +| Date | 2025-01-01 | +| Deciders | Platform Architecture | +| Supersedes | — | +| Superseded by | — | + +## Context + +Mixing UI and server logic in one deployable unit blocks independent scaling, white-label hosting, and multi-client UIs. + +## Decision + +Backend lives only under `backend/`. Frontend lives only under `frontend/`. Communication is exclusively through versioned REST APIs (and future WebSocket if needed). No shared source except contracts, schemas, generated SDKs, and type definitions. + +## Consequences + +### Positive + +- Independent deploy and scale +- Clear ownership of business logic (backend only) +- Multiple UIs can share the same APIs + +### Negative + +- Requires disciplined API versioning +- Local DX needs Docker or two runtimes + +## Alternatives Considered + +1. Monorepo full-stack Next.js with API routes as primary backend +2. Server-rendered Django templates + +## Related Documents + +- [Architecture Overview](../architecture.md) +- [Module Boundaries](../module-boundaries.md) +- [Developer Guide](../../development/developer-guide.md) diff --git a/docs/architecture/adr/ADR-003.md b/docs/architecture/adr/ADR-003.md new file mode 100644 index 0000000..9f4fce2 --- /dev/null +++ b/docs/architecture/adr/ADR-003.md @@ -0,0 +1,41 @@ +# ADR-003: Row-Level Multi-Tenancy with tenant_id + +| Field | Value | +| --- | --- | +| Status | Accepted | +| Date | 2025-01-01 | +| Deciders | Platform Architecture | +| Supersedes | — | +| Superseded by | — | + +## Context + +Every business module must isolate tenant data. Database-per-tenant is operationally heavy for early phases. + +## Decision + +Use shared databases per service with mandatory `tenant_id` on every business table. Tenant resolution order: `X-Tenant-ID` → `X-Tenant-Slug` → subdomain → custom domain → user `current_tenant_id`. Cross-tenant queries are forbidden. + +## Consequences + +### Positive + +- Simple ops on a single DB per service +- Consistent middleware and repository filters +- Compatible with subdomain and custom-domain white-label + +### Negative + +- Requires rigorous tenant filters in every query +- Noise-neighbor risk at very large scale (future mitigation possible) + +## Alternatives Considered + +1. Database-per-tenant +2. Schema-per-tenant + +## Related Documents + +- [Multi-Tenant Architecture](../multi-tenant-architecture.md) +- [Security Architecture](../security-architecture.md) +- [Project Principles](../../development/project-principles.md) diff --git a/docs/architecture/adr/ADR-004.md b/docs/architecture/adr/ADR-004.md new file mode 100644 index 0000000..5443f3b --- /dev/null +++ b/docs/architecture/adr/ADR-004.md @@ -0,0 +1,41 @@ +# ADR-004: Central SSO with Keycloak + +| Field | Value | +| --- | --- | +| Status | Accepted | +| Date | 2025-06-01 | +| Deciders | Platform Architecture | +| Supersedes | — | +| Superseded by | — | + +## Context + +Platform staff, tenant staff, and multiple subsystem UIs need a single identity for the core SuperApp. Building a custom IdP is high risk. + +## Decision + +Keycloak (`superapp` realm, `torbatyar` theme) is the only central login UX for platform and tenant staff. Identity & Access Service acts as BFF for OIDC token exchange, mobile OTP handoff, and profile sync. End-customer auth for tenant-facing modules may remain local to that module's database when SSO is optional. + +## Consequences + +### Positive + +- One login for staff across subsystems +- Standard OIDC/JWT validation in all services +- Themeable login without forking IdP core + +### Negative + +- Operational dependency on Keycloak +- Dual user records (Keycloak + Core/Identity) require linking (`keycloak_sub`) + +## Alternatives Considered + +1. Custom JWT-only auth without IdP +2. Auth0 / cloud IdP + +## Related Documents + +- [Identity Architecture](../identity-architecture.md) +- [Authorization Architecture](../authorization-architecture.md) +- [Services Contracts](../../reference/services-contracts.md) diff --git a/docs/architecture/adr/ADR-005.md b/docs/architecture/adr/ADR-005.md new file mode 100644 index 0000000..eff3327 --- /dev/null +++ b/docs/architecture/adr/ADR-005.md @@ -0,0 +1,40 @@ +# ADR-005: Mandatory Mobile Identity with OTP + +| Field | Value | +| --- | --- | +| Status | Accepted | +| Date | 2025-06-01 | +| Deciders | Product + Platform Architecture | +| Supersedes | — | +| Superseded by | — | + +## Context + +Iranian market users commonly authenticate with mobile numbers. Email-only accounts create incomplete identity for SMS, payments, and support. + +## Decision + +Mobile number is mandatory for all platform users and must be verified via SMS OTP (Payamak provider). Keycloak login supports password and mobile OTP tabs. Core stores `users.mobile` (unique) and `mobile_verified`. + +## Consequences + +### Positive + +- Unified identity key across modules +- Ready for SMS notifications and recovery + +### Negative + +- Dependency on SMS provider availability +- Users without Iranian mobiles need future exception paths + +## Alternatives Considered + +1. Email-primary identity +2. Optional mobile + +## Related Documents + +- [Identity Architecture](../identity-architecture.md) +- [Provider Registry](../../provider-registry.md) +- [Glossary](../../glossary.md) diff --git a/docs/architecture/adr/ADR-006.md b/docs/architecture/adr/ADR-006.md new file mode 100644 index 0000000..457a473 --- /dev/null +++ b/docs/architecture/adr/ADR-006.md @@ -0,0 +1,41 @@ +# ADR-006: Transactional Outbox / Inbox for Events + +| Field | Value | +| --- | --- | +| Status | Accepted | +| Date | 2025-01-01 | +| Deciders | Platform Architecture | +| Supersedes | — | +| Superseded by | — | + +## Context + +Services must publish domain events reliably without dual-write failures between DB commits and message brokers. + +## Decision + +Every service that emits events writes to `outbox_events` in the same business transaction. A worker processes pending outbox rows. Consumers record `inbox_events` by `event_id` for idempotency. Event envelope is `shared.events.EventEnvelope`. + +## Consequences + +### Positive + +- Atomic business + event persistence +- Idempotent consumption +- Message-bus swap later without rewriting producers + +### Negative + +- Near-real-time, not hard real-time +- Requires Celery/worker ops + +## Alternatives Considered + +1. Direct broker publish in request path +2. Change-data-capture only + +## Related Documents + +- [Event-Driven Architecture](../event-driven-architecture.md) +- [Event Catalog](../../reference/event-catalog.md) +- [Services Contracts](../../reference/services-contracts.md) diff --git a/docs/architecture/adr/ADR-007.md b/docs/architecture/adr/ADR-007.md new file mode 100644 index 0000000..471c99a --- /dev/null +++ b/docs/architecture/adr/ADR-007.md @@ -0,0 +1,46 @@ +# ADR-007: Dual Tenant Membership Tables (Core vs Identity) + +| Field | Value | +| --- | --- | +| Status | Accepted | +| Date | 2026-01-01 | +| Deciders | Platform Architecture | +| Supersedes | — | +| Superseded by | — | + +## Context + +Identity & Access introduced a simple `tenant_memberships` table for SSO membership listing. Workspace onboarding later required richer operational roles (`tenant_owner`, editor, viewer, owner flags, lifecycle). Merging into one DB would violate database-per-service. + +## Decision + +Keep two distinct tables named `tenant_memberships`: + +1. `core_platform_db.tenant_memberships` — source of truth for workspace roles, ownership, and onboarding authorization +2. `identity_access_db.tenant_memberships` — SSO/identity-layer membership listing + +No direct FK or sync between databases. Operational authorization uses Core. + +## Consequences + +### Positive + +- Respects service boundaries +- Allows Identity and Core to evolve independently + +### Negative + +- Naming collision risk; docs must always qualify the database +- Possible temporary divergence until sync events exist + +## Alternatives Considered + +1. Move all membership to Identity and call Identity for every authz check +2. Drop Identity memberships and use Core only + +## Related Documents + +- [Multi-Tenant Architecture](../multi-tenant-architecture.md) +- [Authorization Architecture](../authorization-architecture.md) +- [Database Schema](../../reference/database-schema.md) +- [Glossary](../../glossary.md) diff --git a/docs/architecture/adr/ADR-008.md b/docs/architecture/adr/ADR-008.md new file mode 100644 index 0000000..24d7711 --- /dev/null +++ b/docs/architecture/adr/ADR-008.md @@ -0,0 +1,39 @@ +# ADR-008: White-Label Branding via Config and Tenant Profile + +| Field | Value | +| --- | --- | +| Status | Accepted | +| Date | 2025-01-01 | +| Deciders | Product + Platform Architecture | +| Supersedes | — | +| Superseded by | — | + +## Context + +The platform must serve many tenant brands without hardcoding colors, logos, or names in source. + +## Decision + +Platform brand defaults come from environment / `theme.config.json`. Tenant brand fields live on `tenants` (`primary_color`, `secondary_color`, `logo_url`, `favicon_url`, …). Frontend applies CSS variables. Public tenant-site APIs resolve theme by host for subdomain/custom-domain experiences. + +## Consequences + +### Positive + +- Brand changes without rebuild +- Tenant onboarding captures brand early + +### Negative + +- Runtime theme loading must stay cacheable and tenant-safe + +## Alternatives Considered + +1. Hardcoded themes per tenant in frontend builds +2. Separate theme microservice from day one + +## Related Documents + +- [Multi-Tenant Architecture](../multi-tenant-architecture.md) +- [Deployment Architecture](../deployment-architecture.md) +- [Next Steps](../../next-steps.md) diff --git a/docs/architecture/adr/ADR-009.md b/docs/architecture/adr/ADR-009.md new file mode 100644 index 0000000..6bf4a3b --- /dev/null +++ b/docs/architecture/adr/ADR-009.md @@ -0,0 +1,40 @@ +# ADR-009: Nginx Edge with Automatic Tenant SSL Expansion + +| Field | Value | +| --- | --- | +| Status | Accepted | +| Date | 2026-07-01 | +| Deciders | Platform Architecture + Ops | +| Supersedes | — | +| Superseded by | — | + +## Context + +Tenant subdomains (`*.torbatyar.ir`) and custom domains need HTTPS. Manual cert issuance does not scale with self-service onboarding. + +## Decision + +Nginx terminates TLS on the edge host. Let's Encrypt certificate for the platform is expanded for new tenant hostnames via `provision_ssl.py`, triggered by Core Celery over SSH when domains are provisioned. Map file `torbatyar-tenant-ssl.map` controls HTTPS redirect readiness per tenant host. + +## Consequences + +### Positive + +- Self-service subdomain HTTPS +- Centralized edge routing for FE, Core, Identity, Keycloak + +### Negative + +- Worker must reach nginx host over SSH +- Cert expand rate limits and LE constraints apply + +## Alternatives Considered + +1. Traefik with built-in ACME +2. Cloudflare proxy-only TLS + +## Related Documents + +- [Deployment Architecture](../deployment-architecture.md) +- [SSL](../../deployment/ssl.md) +- [Production](../../deployment/production.md) diff --git a/docs/architecture/adr/ADR-010.md b/docs/architecture/adr/ADR-010.md new file mode 100644 index 0000000..7541967 --- /dev/null +++ b/docs/architecture/adr/ADR-010.md @@ -0,0 +1,40 @@ +# ADR-010: Posting Engine Ownership for Accounting Entries + +| Field | Value | +| --- | --- | +| Status | Accepted | +| Date | 2026-07-22 | +| Deciders | Platform Architecture | +| Supersedes | — | +| Superseded by | — | + +## Context + +Future Accounting must prevent inconsistent double-entry writes from multiple modules creating journal lines ad hoc. + +## Decision + +No service or UI may create `JournalEntry` / journal lines directly. All financial postings go through a dedicated Posting Engine owned by the Accounting module. Other modules emit posting intents/events; Accounting validates and posts. + +## Consequences + +### Positive + +- Single source of truth for ledger integrity +- Auditable, compliance-friendly postings + +### Negative + +- Modules must wait for Accounting contracts before financial write-paths + +## Alternatives Considered + +1. Each module writes its own journal tables +2. Shared journal library embedded in every service + +## Related Documents + +- [Compliance Architecture](../compliance-architecture.md) +- [Module Registry](../../module-registry.md) +- [Project Principles](../../development/project-principles.md) +- [Phases / Accounting](../../phases/Accounting/README.md) diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md new file mode 100644 index 0000000..a0d3ae9 --- /dev/null +++ b/docs/architecture/adr/README.md @@ -0,0 +1,20 @@ +# Architecture Decision Records (ADR) + +One decision per file. Never overwrite an accepted ADR — supersede it. + +| ADR | Title | Status | +| --- | --- | --- | +| [ADR-001](ADR-001.md) | Database-per-Service | Accepted | +| [ADR-002](ADR-002.md) | Strict Frontend / Backend Separation | Accepted | +| [ADR-003](ADR-003.md) | Row-Level Multi-Tenancy with tenant_id | Accepted | +| [ADR-004](ADR-004.md) | Central SSO with Keycloak | Accepted | +| [ADR-005](ADR-005.md) | Mandatory Mobile Identity with OTP | Accepted | +| [ADR-006](ADR-006.md) | Transactional Outbox / Inbox for Events | Accepted | +| [ADR-007](ADR-007.md) | Dual Tenant Membership Tables | Accepted | +| [ADR-008](ADR-008.md) | White-Label Branding via Config and Tenant Profile | Accepted | +| [ADR-009](ADR-009.md) | Nginx Edge with Automatic Tenant SSL Expansion | Accepted | +| [ADR-010](ADR-010.md) | Posting Engine Ownership for Accounting Entries | Accepted | + +Template: [adr-template.md](../../templates/adr-template.md) + +Statuses: `Proposed` · `Accepted` · `Deprecated` · `Superseded` diff --git a/docs/architecture/ai-architecture.md b/docs/architecture/ai-architecture.md new file mode 100644 index 0000000..258147d --- /dev/null +++ b/docs/architecture/ai-architecture.md @@ -0,0 +1,28 @@ +# AI Architecture + +## Independence Rule + +AI capabilities are an **independent module family**. Business modules may consume AI via API/events but must remain functional when AI is disabled or unavailable. + +## Boundaries + +| AI may | AI must not | +| --- | --- | +| Suggest, classify, draft, route | Be the sole authority for money, compliance, or authorization | +| Read tenant-scoped knowledge bases | Cross tenant data | +| Emit assistance events | Bypass entitlement checks | + +## Planned Module + +`ai_assistant` — chat assistance, knowledge bases, handoff to humans. Database: `ai_assistant_db`. Feature prefix: `ai_assistant.*`. + +## Provider Independence + +Model providers are registered like any other provider ([provider-registry.md](../provider-registry.md)). Swapping vendors must not rewrite CRM/Accounting domain logic. + +## Related Documents + +- [Module Registry](../module-registry.md) +- [Compliance Architecture](compliance-architecture.md) +- [Phases / AI](../phases/AI/README.md) +- [Project Principles](../development/project-principles.md) diff --git a/docs/architecture/architecture.md b/docs/architecture/architecture.md new file mode 100644 index 0000000..51bdc0b --- /dev/null +++ b/docs/architecture/architecture.md @@ -0,0 +1,77 @@ +# Architecture Overview — TorbatYar SuperApp + +> **Responsibility:** high-level architecture only. +> Status → [progress.md](../progress.md) · Roadmap → [roadmap.md](../roadmap.md) · Next → [next-steps.md](../next-steps.md) + +## 1. Goals + +Build a **multi-tenant**, **modular**, **API-first**, **microservice-ready** SuperApp SaaS that runs on VPS today and scales later without rewriting foundations. + +## 2. Style + +| Principle | Rule | +| --- | --- | +| Service-oriented | Each major capability is an independent service | +| Database-per-service | [ADR-001](adr/ADR-001.md) | +| API-first | All interactions via versioned API / events | +| Multi-tenancy | Every business table has `tenant_id` — [ADR-003](adr/ADR-003.md) | +| FE/BE separation | Strict — [ADR-002](adr/ADR-002.md) | + +## 3. System Map + +``` +┌─────────────┐ REST/OIDC ┌──────────────────┐ +│ Frontend │ ←────────────────→ │ Nginx (edge) │ +│ (Next.js) │ └────────┬─────────┘ +└─────────────┘ │ + ┌───────────────────────┼───────────────────────┐ + ▼ ▼ ▼ + Core Platform Identity & Access Keycloak + (core_platform_db) (identity_access_db) (SSO) + │ + ├── Outbox/Inbox events + ├── Entitlement checks + └── Future business services (Accounting, CRM, …) +``` + +## 4. Canonical Architecture Documents + +| Document | Responsibility | +| --- | --- | +| [module-boundaries.md](module-boundaries.md) | What each module owns / must not own | +| [database-architecture.md](database-architecture.md) | DB ownership, isolation, migration rules | +| [multi-tenant-architecture.md](multi-tenant-architecture.md) | Tenant model, resolution, lifecycle, white-label | +| [deployment-architecture.md](deployment-architecture.md) | Runtime topology, hosts, compose | +| [security-architecture.md](security-architecture.md) | Tokens, secrets, edge TLS posture | +| [identity-architecture.md](identity-architecture.md) | SSO, OTP, user layers | +| [authorization-architecture.md](authorization-architecture.md) | Roles, entitlements, memberships | +| [integration-architecture.md](integration-architecture.md) | External providers and BFF patterns | +| [event-driven-architecture.md](event-driven-architecture.md) | Outbox/Inbox, envelope | +| [service-architecture.md](service-architecture.md) | Internal layering (API → Service → Repo) | +| [ai-architecture.md](ai-architecture.md) | AI independence rules | +| [compliance-architecture.md](compliance-architecture.md) | Audit, posting engine, regulated flows | +| [adr/](adr/) | Architecture Decision Records | + +## 5. Non-Goals of This Document + +- Implementation checklists → [progress.md](../progress.md) +- Immediate milestone → [next-steps.md](../next-steps.md) +- Schema columns → [database-schema.md](../reference/database-schema.md) +- Endpoint tables → [services-contracts.md](../reference/services-contracts.md) +- How to run locally → [developer-guide.md](../development/developer-guide.md) + +## 6. Permanent Rule + +Every implementation phase begins by reading: + +1. [docs/README.md](../README.md) +2. This folder (`docs/architecture/*` and `adr/*`) +3. [project-principles.md](../development/project-principles.md) +4. [coding-standards.md](../development/coding-standards.md) +5. [testing-strategy.md](../development/testing-strategy.md) +6. [module-registry.md](../module-registry.md) +7. [provider-registry.md](../provider-registry.md) +8. [glossary.md](../glossary.md) +9. Relevant [phase docs](../phases/) + +If architecture, APIs, providers, or module boundaries change: **update documentation first**, then implement. diff --git a/docs/architecture/authorization-architecture.md b/docs/architecture/authorization-architecture.md new file mode 100644 index 0000000..4d012b0 --- /dev/null +++ b/docs/architecture/authorization-architecture.md @@ -0,0 +1,46 @@ +# Authorization Architecture + +## Two Axes + +1. **Identity / role** — who is the actor? +2. **Entitlement / feature** — what is the tenant allowed to use on their plan? + +Both must pass for protected business actions. + +## Platform & Workspace Roles (Core) + +| Role | Scope | +| --- | --- | +| `platform_admin` | Cross-tenant platform administration (bypass workspace role checks) | +| `tenant_owner` | Full workspace control; required for complete onboarding | +| `tenant_admin` | Manage branding/domain/members (as implemented) | +| `tenant_editor` | Content/edit capabilities | +| `tenant_viewer` | Read-only | + +Source of truth: `core_platform_db.tenant_memberships` ([ADR-007](adr/ADR-007.md)). + +## Identity-Layer Roles + +`identity_access_db.tenant_memberships` uses simpler roles (`tenant_admin` / `tenant_member`) for SSO listing — not operational authz. + +## Entitlement + +Feature keys: `{service_key}.{resource}.{action}` +Example: `accounting.invoice.create` + +Check: `POST /api/v1/tenants/{tenant_id}/features/check` +Cached in Redis; custom tenant overrides beat plan defaults. + +## API Guards (Core) + +Configurable `AUTH_REQUIRED`. Dependencies such as `require_authenticated`, `require_platform_admin`, `require_tenant_admin`, `MembershipService.ensure_role`. + +## Future + +Custom permission trees, invites, and finer RBAC are planned — track in [module-registry.md](../module-registry.md) and [roadmap.md](../roadmap.md). + +## Related Documents + +- [Identity Architecture](identity-architecture.md) +- [Multi-Tenant Architecture](multi-tenant-architecture.md) +- [Project Principles](../development/project-principles.md) diff --git a/docs/architecture/compliance-architecture.md b/docs/architecture/compliance-architecture.md new file mode 100644 index 0000000..755ed88 --- /dev/null +++ b/docs/architecture/compliance-architecture.md @@ -0,0 +1,32 @@ +# Compliance Architecture + +## Principles + +1. **Compliance is independent** of UI fashion and optional modules — controls remain even if a feature UI changes. +2. Every business action that matters is **auditable** (`audit_logs`). +3. Financial postings only through **Posting Engine** ([ADR-010](adr/ADR-010.md)). +4. Regulated connectors (e.g. tax authority / سامانه مؤدیان) live behind Accounting/compliance adapters — never scatter across modules. + +## Audit Log + +Core `audit_logs` captures `tenant_id`, actor, action, resource, metadata, IP, user agent. + +Modules should emit domain events and/or write their own audit trails for module-owned resources; never skip tenant context. + +## Accounting Compliance (future) + +- Double-entry integrity +- Cost centers / projects as dimensions +- Tax and e-invoicing providers via provider registry +- No direct `JournalEntry` creation outside Posting Engine + +## Data Isolation + +Cross-tenant reads for “support” require platform_admin controls and must be logged. + +## Related Documents + +- [Security Architecture](security-architecture.md) +- [ADR-010](adr/ADR-010.md) +- [Phases / Accounting](../phases/Accounting/README.md) +- [Project Principles](../development/project-principles.md) diff --git a/docs/architecture/database-architecture.md b/docs/architecture/database-architecture.md new file mode 100644 index 0000000..eaf7fe8 --- /dev/null +++ b/docs/architecture/database-architecture.md @@ -0,0 +1,54 @@ +# Database Architecture + +> Architecture only. Column-level reference → [database-schema.md](../reference/database-schema.md) + +## Pattern + +**Database-per-service** ([ADR-001](adr/ADR-001.md)). + +| Service | Database | +| --- | --- | +| Core Platform | `core_platform_db` | +| Identity & Access | `identity_access_db` | +| Accounting (future) | `accounting_db` | +| CRM (future) | `crm_db` | +| Ecommerce (future) | `ecommerce_db` | +| Website Builder (future) | `website_builder_db` | +| Live Chat (future) | `live_chat_db` | +| AI Assistant (future) | `ai_assistant_db` | +| Smart Messenger (future) | `smart_messenger_db` | +| SMS Panel (future) | `sms_panel_db` | +| Link Shortener (future) | `link_shortener_db` | +| Notification (future) | `notification_db` | +| File Storage (future) | `file_storage_db` | +| Restaurant (future) | `restaurant_db` | + +## Hard Rules + +1. No direct queries across service databases. +2. No cross-DB foreign keys. +3. Every business table includes `tenant_id` ([ADR-003](adr/ADR-003.md)). +4. IDs are UUID; timestamps are timezone-aware. +5. Migrations via Alembic per service; never edit applied migrations in production. +6. Conceptual schemas for future services are documented in reference docs until migrations exist. + +## Core Platform Ownership + +Tenants, domains, plans/features/subscriptions, registries, internal tokens, outbox/inbox, audit logs, core users, operational `tenant_memberships`. + +## Identity Ownership + +`user_profiles`, identity-layer `tenant_memberships` (not the Core table — [ADR-007](adr/ADR-007.md)). + +## Dual Membership Clarification + +| Database | Table | Role | +| --- | --- | --- | +| `core_platform_db` | `tenant_memberships` | Workspace authorization source of truth | +| `identity_access_db` | `tenant_memberships` | SSO membership listing | + +## Related Documents + +- [Database Schema](../reference/database-schema.md) +- [Multi-Tenant Architecture](multi-tenant-architecture.md) +- [ADR-001](adr/ADR-001.md) · [ADR-007](adr/ADR-007.md) diff --git a/docs/architecture/deployment-architecture.md b/docs/architecture/deployment-architecture.md new file mode 100644 index 0000000..aa370e5 --- /dev/null +++ b/docs/architecture/deployment-architecture.md @@ -0,0 +1,44 @@ +# Deployment Architecture + +> Architecture topology only. Runbooks → [docs/deployment/](../deployment/) + +## Environments + +| Layer | Role | +| --- | --- | +| Local Docker Compose | Dev: Postgres, Redis, Keycloak, Core, Identity, Frontend, Celery | +| Production edge | Nginx TLS termination + routing | +| Production app host | Docker Compose services | + +## Production Topology (canonical) + +| Component | Host / Endpoint | +| --- | --- | +| App / Docker | `192.168.10.162` | +| Nginx edge | `192.168.10.156` | +| Apex / www / tenant FE | `torbatyar.ir`, `*.torbatyar.ir` | +| Core API | `api.torbatyar.ir` → `:8000` | +| Identity API | `identity.torbatyar.ir` → `:8001` | +| Keycloak | `auth.torbatyar.ir` → `:8080` | + +## Compose Services + +`postgres`, `redis`, `keycloak`, `core-service`, `identity-access-service`, `frontend`, `celery-worker`, `celery-beat` + +## Configuration + +All runtime settings from environment (`.env` / production env files). No hardcoded secrets or brand values in source ([ADR-008](adr/ADR-008.md)). + +## TLS & Tenant SSL + +Edge Nginx terminates TLS. Tenant subdomain certificates expand via Celery → SSH → `provision_ssl.py` ([ADR-009](adr/ADR-009.md)). + +## Related Documents + +- [deployment.md](../deployment/deployment.md) +- [production.md](../deployment/production.md) +- [ssl.md](../deployment/ssl.md) +- [monitoring.md](../deployment/monitoring.md) +- [backup.md](../deployment/backup.md) +- [restore.md](../deployment/restore.md) +- [disaster-recovery.md](../deployment/disaster-recovery.md) diff --git a/docs/architecture/event-driven-architecture.md b/docs/architecture/event-driven-architecture.md new file mode 100644 index 0000000..191e939 --- /dev/null +++ b/docs/architecture/event-driven-architecture.md @@ -0,0 +1,42 @@ +# Event-Driven Architecture + +## Envelope + +All events use `shared.events.EventEnvelope`: + +```json +{ + "event_id": "uuid", + "event_type": "tenant.created", + "aggregate_type": "tenant", + "aggregate_id": "uuid", + "tenant_id": "uuid|null", + "source_service": "core-service", + "payload": {}, + "occurred_at": "ISO-8601" +} +``` + +## Outbox → Inbox ([ADR-006](adr/ADR-006.md)) + +1. Producer writes business row + `outbox_events` in one transaction. +2. Worker (`process_outbox_events`) publishes pending rows. +3. Consumer inserts `inbox_events` keyed by `event_id` (idempotency) then handles payload. + +## Naming + +`{aggregate}.{past_tense_verb}` — e.g. `tenant.created`, `subscription.updated`, `user.registered`. + +## Catalog + +Canonical list → [event-catalog.md](../reference/event-catalog.md) + +## Future + +Replace in-process/Celery-only publish with a real message bus without changing envelope or outbox ownership. + +## Related Documents + +- [Services Contracts](../reference/services-contracts.md) +- [Service Architecture](service-architecture.md) +- [ADR-006](adr/ADR-006.md) diff --git a/docs/architecture/identity-architecture.md b/docs/architecture/identity-architecture.md new file mode 100644 index 0000000..32936ab --- /dev/null +++ b/docs/architecture/identity-architecture.md @@ -0,0 +1,42 @@ +# Identity Architecture + +## Layers (mandatory separation) + +| Layer | Who | Central Keycloak SSO | +| --- | --- | --- | +| Platform core | Platform admin, tenant owner/staff | Required | +| Subsystem staff UIs | Cafe panel, CRM, … | Required — same JWT | +| Tenant end-customer | e.g. digital menu guest | Optional — may be local to module DB | + +## Central Login + +Keycloak realm `superapp`, theme `torbatyar`: + +- Tab: password (username/email/mobile + password) +- Tab: mobile OTP (Identity `/auth/mobile/*` → handoff → `/auth/callback`) + +Frontend `/login` and `/register` redirect to Keycloak. Admin login uses the same SSO path. + +## Core Users + +Table `users` in `core_platform_db`: `mobile` (unique, required), `mobile_verified`, `keycloak_sub`, `current_tenant_id`, platform role. + +`UserService.resolve_current` maps: + +- Local OTP JWT → `users.id` +- Keycloak JWT → `users.keycloak_sub` (JIT link/create when possible) + +## Identity Service + +Database `identity_access_db`. Profiles hold Keycloak subject, mobile verification, optional `core_user_id`. OTP SMS is delegated to Core (`CoreOtpClient`) — no duplicate SMS logic. + +## Mobile Mandate + +Mobile is mandatory and OTP-verified ([ADR-005](adr/ADR-005.md)). + +## Related Documents + +- [Authorization Architecture](authorization-architecture.md) +- [ADR-004](adr/ADR-004.md) · [ADR-005](adr/ADR-005.md) · [ADR-007](adr/ADR-007.md) +- [Services Contracts](../reference/services-contracts.md) +- [Identity service README](../../backend/services/identity-access/README.md) diff --git a/docs/architecture/integration-architecture.md b/docs/architecture/integration-architecture.md new file mode 100644 index 0000000..ef873e0 --- /dev/null +++ b/docs/architecture/integration-architecture.md @@ -0,0 +1,42 @@ +# Integration Architecture + +## Inter-Service Channels + +Only these channels are allowed ([ADR-001](adr/ADR-001.md)): + +1. Versioned REST API +2. Webhook +3. Async Event (message bus / outbox worker) +4. Outbox/Inbox pattern + +## Internal Auth + +Services authenticate with **Internal Service Tokens** (hashed in `internal_service_tokens`, scoped). + +## External Providers + +External systems (SMS, payment, storage, tax) integrate through provider adapters registered in [provider-registry.md](../provider-registry.md). Modules must not hardcode vendor SDKs into domain services without a provider boundary. + +### Current known providers + +| Provider | Use | +| --- | --- | +| Payamak | SMS OTP | +| Keycloak | IdP / SSO | +| Let's Encrypt + Certbot | TLS certificates | +| S3-compatible (planned) | File storage | + +## BFF Pattern + +Identity & Access acts as BFF for OIDC token exchange and Keycloak theme handoff so browser secrets stay constrained. + +## Frontend Integration + +Frontend uses typed API clients (`lib/api.ts` primary; `lib/api-client.ts` may exist for narrower helpers). Never call databases from the browser. + +## Related Documents + +- [Event-Driven Architecture](event-driven-architecture.md) +- [Provider Registry](../provider-registry.md) +- [Provider Reference](../reference/provider-reference.md) +- [Services Contracts](../reference/services-contracts.md) diff --git a/docs/architecture/module-boundaries.md b/docs/architecture/module-boundaries.md new file mode 100644 index 0000000..7c80895 --- /dev/null +++ b/docs/architecture/module-boundaries.md @@ -0,0 +1,45 @@ +# Module Boundaries + +> Architecture only. Module inventory → [module-registry.md](../module-registry.md) + +## Core Platform + +**Owns:** tenants, domains, plans, features, subscriptions, entitlement checks, service/module registry, internal service tokens, outbox/inbox (core), audit log, core users, tenant memberships (operational), onboarding, public tenant-site resolution. + +**Must not own:** business journals, CRM entities, restaurant menus, file blobs, SMS campaigns. + +## Identity & Access + +**Owns:** user profiles linked to Keycloak, identity-layer memberships, OIDC BFF (config/token/me), mobile OTP handoff/session redeem, Keycloak admin sync. + +**Must not own:** workspace onboarding lifecycle, plan/subscription, operational tenant roles source of truth (Core). + +## Frontend + +**Owns:** UI, theme application, client-side auth redirects, dashboards, onboarding wizard UX. + +**Must not own:** business rules, direct DB access, SQLAlchemy/Alembic, entitlement computation. + +## Future Business Modules + +Each module (Accounting, CRM, Restaurant, Ecommerce, …) owns its database and domain APIs. Cross-module coupling is API/event only. Financial postings go only through Accounting Posting Engine ([ADR-010](adr/ADR-010.md)). + +## Shared Library (`backend/shared-lib`) + +**Owns:** JWT validation helpers, phone normalization, event envelope types, shared exceptions/responses. + +**Must not own:** tenant business workflows or service-specific repositories. + +## Boundary Rules + +1. No cross-database foreign keys. +2. No importing another service's models. +3. Feature gates use Core entitlement API. +4. Frontend talks to public/versioned APIs only. +5. Providers are integrated behind module/provider adapters — see [integration-architecture.md](integration-architecture.md). + +## Related Documents + +- [Service Architecture](service-architecture.md) +- [ADR-001](adr/ADR-001.md) · [ADR-002](adr/ADR-002.md) · [ADR-007](adr/ADR-007.md) +- [Module Registry](../module-registry.md) diff --git a/docs/architecture/multi-tenant-architecture.md b/docs/architecture/multi-tenant-architecture.md new file mode 100644 index 0000000..66be805 --- /dev/null +++ b/docs/architecture/multi-tenant-architecture.md @@ -0,0 +1,58 @@ +# Multi-Tenant Architecture + +> Architecture only. Status of onboarding delivery → [progress.md](../progress.md) + +## Tenant Model + +A **Tenant** is a workspace/organization on the platform. Users may belong to multiple tenants; `users.current_tenant_id` selects the active workspace. + +### Lifecycle + +``` +draft → pending_activation → active → suspended / archived +``` + +Legacy values `inactive` / `deleted` remain for compatibility. + +### Operational Roles (Core memberships) + +`platform_admin`, `tenant_owner`, `tenant_admin`, `tenant_editor`, `tenant_viewer` + +Membership status: `active` / `invited` / `disabled`. Every active tenant must retain at least one active `tenant_owner`. + +## Tenant Resolution Order + +1. Header `X-Tenant-ID` +2. Header `X-Tenant-Slug` +3. Subdomain from Host + `PLATFORM_BASE_DOMAIN` +4. Custom domain from Host +5. Authenticated user's `current_tenant_id` (when headers/host do not resolve) + +Results land in `request.state.tenant_id` / `request.state.tenant_slug`. Tenant-aware endpoints use `require_tenant` / `get_tenant_resolution`. + +## Domains + +- Auto subdomain: `{slug}.{PLATFORM_BASE_DOMAIN}` (verified when provisioned) +- Custom domain: stored with `verification_status=pending` until DNS verification ships +- SSL expansion for tenant hosts: [ADR-009](adr/ADR-009.md) + +## White-Label + +Tenant branding fields live on `tenants`. Public tenant-site/theme resolution uses host-based tenant detection. Platform defaults remain env/`theme.config.json` ([ADR-008](adr/ADR-008.md)). + +## Entitlement + +`EntitlementService.check_feature_access(tenant_id, feature_key)` — Redis cache then DB. Disabled tenant, inactive subscription, or missing plan feature → deny. Custom access overrides plan. + +## Hard Rules + +- No cross-tenant queries. +- Every business write is tenant-scoped. +- Authorization for workspace actions uses Core memberships ([ADR-007](adr/ADR-007.md)). + +## Related Documents + +- [Identity Architecture](identity-architecture.md) +- [Authorization Architecture](authorization-architecture.md) +- [Deployment Architecture](deployment-architecture.md) +- [Glossary](../glossary.md) diff --git a/docs/architecture/security-architecture.md b/docs/architecture/security-architecture.md new file mode 100644 index 0000000..1582773 --- /dev/null +++ b/docs/architecture/security-architecture.md @@ -0,0 +1,41 @@ +# Security Architecture + +## Principles + +1. Least privilege per role and service token scope. +2. Secrets only in environment / secret stores — never in git. +3. Tenant isolation on every business query. +4. Auditable business actions (audit log). +5. TLS at the edge for all public hosts. + +## Authentication Surfaces + +| Surface | Mechanism | +| --- | --- | +| Platform / tenant staff | Keycloak OIDC JWT (RS256) | +| Local OTP (Core) | HS256 JWT after SMS verify | +| Service-to-service | Internal service token (hashed at rest) | +| Public tenant site | Unauthenticated read of public theme/site data only | + +## Token Rules + +- Frontend sends `Authorization: Bearer `. +- Services validate via shared JWT helpers (`shared/auth/jwt.py`). +- Internal tokens carry scopes; callers must hold required scope. + +## Edge + +Nginx enforces HTTPS for platform hosts; tenant hosts redirect to HTTPS when SSL map marks them ready. ACME challenge paths remain HTTP. + +## Data Protection + +- Passwords only in Keycloak (not Core). +- OTP codes are short-lived (`OTP_EXPIRE_SECONDS`). +- Token hashes stored for internal service tokens — raw tokens never persisted. + +## Related Documents + +- [Identity Architecture](identity-architecture.md) +- [Authorization Architecture](authorization-architecture.md) +- [ADR-004](adr/ADR-004.md) · [ADR-005](adr/ADR-005.md) · [ADR-009](adr/ADR-009.md) +- [SSL](../deployment/ssl.md) diff --git a/docs/architecture/service-architecture.md b/docs/architecture/service-architecture.md new file mode 100644 index 0000000..fde154b --- /dev/null +++ b/docs/architecture/service-architecture.md @@ -0,0 +1,39 @@ +# Service Architecture + +## Internal Layering (every backend service) + +``` +API (routers) → Services (business logic) → Repositories → Models (DB) + ↑ + Schemas (Pydantic DTOs) +``` + +| Layer | Allowed | Forbidden | +| --- | --- | --- | +| API / Views | Auth deps, validation, HTTP mapping | Business rules, raw SQL | +| Services | Domain logic, orchestration, events | HTTP concerns, UI | +| Repositories | Queries/persistence | Business decisions | +| Models | Schema mapping | Business workflows | + +## Core Package Layout + +- `core/` — config, database, cache, security, logging +- `middlewares/` — tenant resolution +- `workers/` — Celery tasks (outbox, SSL provision, …) +- `api/v1/` — versioned routers +- `tests/` — automated tests + +## Adding a Service + +1. New folder under `backend/services//` +2. Independent database + Alembic +3. Register in Core service/module registry +4. Document in [module-registry.md](../module-registry.md) +5. UI only in `frontend/`, API-only access +6. Follow [coding-standards.md](../development/coding-standards.md) + +## Related Documents + +- [Module Boundaries](module-boundaries.md) +- [Developer Guide](../development/developer-guide.md) +- [Project Principles](../development/project-principles.md) diff --git a/docs/current-architecture-review.md b/docs/current-architecture-review.md new file mode 100644 index 0000000..e5e9c00 --- /dev/null +++ b/docs/current-architecture-review.md @@ -0,0 +1,296 @@ +# DEPRECATED — Current Architecture Documentation Review + +> **Status:** Deprecated historical inventory (2026-07-22). +> **Superseded by:** [docs/README.md](README.md) and the consolidated documentation tree. +> Kept for audit trail only — do not update. + +**Date:** 2026-07-22 +**Scope:** Read-only inventory of architecture / structure / progress documentation in the TorbatYar (SuperApp) repository. +**Action taken:** No existing documentation was modified at review time. This review file is retained as history. + +--- + +## 1. Discovered documents + +Search covered the whole repo for architecture-related markdown and naming patterns (`architecture*`, `design*`, `database*`, `progress*`, `roadmap*`, `ADR*`, `deployment*`, `services*`, etc.). + +### 1.1 Core docs (`docs/`) + +| # | Path | +| --- | --- | +| 1 | `docs/architecture.md` | +| 2 | `docs/database_schema.md` | +| 3 | `docs/services_contracts.md` | +| 4 | `docs/developer_guide.md` | +| 5 | `docs/progress.md` | +| 6 | `docs/last_step.md` | + +### 1.2 Root & package READMEs + +| # | Path | +| --- | --- | +| 7 | `README.md` | +| 8 | `backend/README.md` | +| 9 | `frontend/README.md` | +| 10 | `backend/services/README.md` | +| 11 | `backend/core-service/alembic/README.md` | + +### 1.3 Service READMEs (`backend/services/*/README.md`) + +| # | Path | +| --- | --- | +| 12 | `backend/services/identity-access/README.md` | +| 13 | `backend/services/accounting/README.md` | +| 14 | `backend/services/crm/README.md` | +| 15 | `backend/services/ecommerce/README.md` | +| 16 | `backend/services/restaurant/README.md` | +| 17 | `backend/services/website_builder/README.md` | +| 18 | `backend/services/live_chat/README.md` | +| 19 | `backend/services/ai_assistant/README.md` | +| 20 | `backend/services/smart_messenger/README.md` | +| 21 | `backend/services/sms_panel/README.md` | +| 22 | `backend/services/link_shortener/README.md` | +| 23 | `backend/services/file_storage/README.md` | +| 24 | `backend/services/notification/README.md` | + +### 1.4 Related non-markdown / ops material (not architecture docs, but architecture-relevant) + +These are **not** architecture documents, but they encode production architecture decisions and are currently undocumented (or only partially documented) in `docs/`: + +| Path / area | Why it matters | +| --- | --- | +| `infrastructure/nginx/torbatyar.ir.conf` | Live reverse-proxy / TLS / tenant host routing | +| `infrastructure/nginx/provision_ssl.py` | Auto SSL expand for tenant domains | +| `infrastructure/nginx/torbatyar-tenant-ssl.map` | Per-tenant HTTPS redirect readiness | +| `infrastructure/deploy/.env.production.example` | Production env contract (incl. `SSL_PROVISION_*`) | +| `infrastructure/keycloak/realm/superapp-realm.json` | SSO realm / clients / redirect URIs | +| `docker-compose.yml` | Runtime topology (Core, Identity, Keycloak, FE, Celery, Postgres, Redis) | +| `.env.example` | Config surface area | +| `scripts/deploy_*.py` | Operational deploy procedures (not product docs) | + +### 1.5 Patterns searched but **not found** + +No dedicated files matching: + +- `ADR*` / architecture decision records +- `roadmap*` / `next-step*` (except `docs/last_step.md`) +- `design*` / `system*` / `engineering*` / `conventions*` / `principles*` as standalone docs +- `docs/` deployment guide +- `AGENTS.md` / `CONTRIBUTING.md` / `CHANGELOG.md` at repo root + +**Total architecture-related markdown documents reviewed:** **24** + +--- + +## 2. Purpose of each document + +### Core docs + +| Document | Purpose | +| --- | --- | +| `docs/architecture.md` | Canonical high-level architecture: FE/BE split, multi-tenancy, Core responsibilities, tenant resolution, entitlement, outbox, identity layers (SSO + OTP), white-label intent, Phase 4 onboarding model. | +| `docs/database_schema.md` | Schema reference for `core_platform_db` and `identity_access_db`, plus conceptual schemas for future business-service databases. | +| `docs/services_contracts.md` | Inter-service communication rules, event envelope, entitlement check API, Core APIs, onboarding/tenant-context contracts, Identity auth APIs. | +| `docs/developer_guide.md` | Developer onboarding: prerequisites, Docker/local setup, env vars, folder layout, Alembic/pytest/Celery, how to add a new service. | +| `docs/progress.md` | Phase-by-phase checklist of what was delivered; backlog of remaining work. **Primary status source of truth.** | +| `docs/last_step.md` | Narrative of the latest completed delivery (Phase 4 / brief Phase 3) and proposed next phase (white-label runtime). | + +### Package READMEs + +| Document | Purpose | +| --- | --- | +| `README.md` | Project entrypoint: overview, quick start, links to `docs/`, high-level tree. | +| `backend/README.md` | Backend boundary, tree, Core run/test commands, no-UI rule. | +| `frontend/README.md` | Frontend boundary, stack versions, run commands, white-label note. | +| `backend/services/README.md` | States that `services/` holds future service placeholders and architecture rules. | +| `backend/core-service/alembic/README.md` | How to create/apply/downgrade Core Alembic migrations. | + +### Service READMEs + +| Document | Purpose | +| --- | --- | +| `identity-access/README.md` | Real Identity & Access service: Keycloak BFF, DB, run/test, basic OIDC flow. | +| `accounting` … `notification` READMEs (12 files) | Placeholder scope statements: intended responsibilities, independent DB name, entitlement prefix, API/event-only rule. | + +--- + +## 3. Validity assessment + +Legend: + +- **Valid** — accurate enough to trust as current guidance +- **Partially valid** — still useful but contains stale or incomplete claims +- **Placeholder-valid** — intentionally aspirational / not yet implemented (OK if labeled clearly) +- **Stale** — claims that contradict current code / fresher docs + +| Document | Validity | Notes | +| --- | --- | --- | +| `docs/progress.md` | **Valid** (best status doc) | Reflects phases 1–4 done, JIT done, nginx+SSL done, white-label partial. | +| `docs/last_step.md` | **Mostly valid** | Best narrative of “what just shipped” and next phase; white-label wording is transitional. | +| `docs/architecture.md` | **Partially valid** | Core principles still correct; several operational claims stale (JIT deferred, nginx later, “only Core implemented”). | +| `docs/database_schema.md` | **Mostly valid** | Core/Identity schema docs remain useful; future service DBs correctly marked conceptual. | +| `docs/services_contracts.md` | **Partially valid** | Onboarding/Identity contracts largely correct; missing newer public tenant-site + SSL; JIT `403` note may be outdated. | +| `docs/developer_guide.md` | **Partially valid** | Setup still useful; `services/` “all placeholder”, missing Identity/env/SSL/production ops, possible API client naming confusion. | +| `README.md` | **Partially valid** | Quick start OK; claims only phases 1–2; tree omits `infrastructure/`, `scripts/`. | +| `backend/README.md` | **Partially valid** | Boundary rules OK; `services/` still called placeholder-only. | +| `frontend/README.md` | **Partially valid** | Separation OK; does not describe onboarding/tenant host/SSO pages; cites `api-client.ts` while primary client is `api.ts` (both files exist). | +| `backend/services/README.md` | **Stale** | Says no services implemented in Phase 1 / only READMEs — Identity is implemented. | +| `alembic/README.md` | **Valid** | Narrow and accurate for Core migrations. | +| `identity-access/README.md` | **Partially valid** | Correct as a real service, but API list incomplete vs contracts (mobile/session APIs). | +| Business service READMEs (12) | **Placeholder-valid** | Correctly labeled unimplemented; still useful as future module map. | + +--- + +## 4. Overlaps + +| Topic | Documents that cover it | Overlap assessment | +| --- | --- | --- | +| FE/BE separation rules | `architecture.md` §2.1, `developer_guide.md`, `README.md`, `backend/README.md`, `frontend/README.md` | **Heavy overlap** — same rule repeated 5 times. Keep once in architecture; short pointer elsewhere. | +| Database-per-service / no cross-DB | `architecture.md`, `services_contracts.md`, `services/README.md`, most service READMEs | Intentional repetition; OK if architecture is canonical and others link to it. | +| Phase status / what is done | `progress.md`, `last_step.md`, `README.md`, `architecture.md` intro | **Conflict risk** — status should live only in `progress.md` (+ short pointer in `last_step.md`). | +| Onboarding / Phase 4 | `architecture.md` §11, `services_contracts.md` §7, `progress.md`, `last_step.md`, `database_schema.md` | Complementary if roles are clear: architecture=model, contracts=API, progress=checklist, last_step=narrative. | +| Identity / SSO / OTP | `architecture.md`, `services_contracts.md`, `progress.md`, `identity-access/README.md` | Overlap acceptable; Identity README should not duplicate full contracts. | +| White-label | `architecture.md` §10, `frontend/README.md`, `progress.md`, `last_step.md` | Overlap + **inconsistency** on “done vs partial”. | +| Future service list | `architecture.md`, `database_schema.md` conceptual DBs, 12 service READMEs | Triple map of the same modules — consolidate later. | +| How to run the stack | `README.md`, `developer_guide.md`, `backend/README.md`, `frontend/README.md` | Moderate overlap; root README should stay the short path. | + +--- + +## 5. Obsolete or stale claims + +| Claim | Where | Why obsolete / stale | +| --- | --- | --- | +| Repo includes only Phase 1 + Phase 2 | `README.md` | Phases 3–4 are delivered per `progress.md`. | +| In Phase 1 only Core is implemented; everything under `services/` is placeholder | `architecture.md`, `backend/README.md`, `developer_guide.md`, `backend/services/README.md` | Identity & Access is a real implemented service. | +| JIT Core user from Keycloak JWT is deferred / yields `403` | `architecture.md` §11, `services_contracts.md` §7 | JIT implemented in `UserService.resolve_current`; marked done in `progress.md` / `last_step.md`. | +| Nginx/Traefik added in later phases | `architecture.md` §9 | Production nginx + TLS + tenant auto-SSL are in place. | +| White-label is only static `theme.config.json` | older framing in architecture / last_step residual wording | Partial runtime exists: `GET /public/tenant-site`, `TenantSitePage`, subdomain-aware `useTheme`. | +| Outbox “real publish later” | `architecture.md` / workers commentary | Still largely true for message bus, but progress backlog already tracks it — keep, but don’t imply whole infra is unimplemented. | +| Admin login is a dedicated OTP UI | older progress wording (partially corrected) | Admin login now routes through central SSO; OTP still exists via Identity/Keycloak + Core APIs. | + +**Not obsolete (still intentionally open):** + +- Real payment gateway +- Real DNS/TXT custom-domain verification +- Membership/plan auto-provision on legacy `POST /admin/tenants` +- First real business module (Restaurant etc.) +- Advanced permissions / invites +- Independent Subscription service +- Real message bus + +--- + +## 6. Missing topics + +Topics that are **implemented or operationally real** but poorly / not documented in architecture docs: + +1. **Production deployment topology** — hosts (`192.168.10.162` app, `192.168.10.156` nginx), domains (`torbatyar.ir`, `api.`, `identity.`, `auth.`, `*.torbatyar.ir`). +2. **Nginx routing & TLS model** — apex vs service hosts vs tenant subdomains; HTTP→HTTPS rules; LE cert expand. +3. **Automatic tenant SSL provisioning** — `SSL_PROVISION_*`, Celery task `provision_domain_ssl`, `provision_ssl.py`. +4. **Public tenant site API & UX** — `GET /api/v1/public/tenant-site`, guest landing vs member services on subdomain. +5. **Cross-subdomain auth cookies** — cookie Domain=`.torbatyar.ir`, login redirect via apex. +6. **Alembic production practice** — `upgrade 0001_initial && stamp head` used in compose (important operational caveat vs “upgrade head”). +7. **Phase numbering glossary** — brief “Phase 3” vs docs “فاز ۳ OTP” vs docs “فاز ۴ onboarding”. +8. **ADRs / decision log** — no Architecture Decision Records. +9. **Security model summary** — platform_admin vs tenant roles vs Identity memberships (two membership tables). +10. **Observability / logging / backup / disaster recovery** — absent. +11. **CI/CD & git workflow** — Gitea remote exists; no architecture doc. +12. **Frontend API client map** — `lib/api.ts` vs `lib/api-client.ts` roles. +13. **Celery queues & SSL worker networking** — worker must reach nginx host over SSH. +14. **Keycloak theme / mobile OTP unified login UX** — implemented; scattered across docs. +15. **Roadmap document** — only `last_step.md` + backlog bullets; no dedicated roadmap. + +--- + +## 7. Conflicting information + +| Conflict | Doc A | Doc B / Reality | Preferred truth | +| --- | --- | --- | --- | +| Current phase coverage | README: phases 1–2 | progress: phases 1–4 done | **progress.md** | +| Are services implemented? | `services/README.md`: none | Identity service exists | **Identity is real; others placeholder** | +| JIT provisioning | architecture/contracts: not done / 403 | progress/last_step + `user_service.py`: done | **JIT done (edge cases remain)** | +| Reverse proxy / TLS | architecture: later | progress + nginx configs: done | **Nginx/TLS done** | +| White-label runtime | architecture: config/theme file framing; last_step: next phase | progress: partial (`tenant-site`, `TenantSitePage`, `useTheme`) | **Partial — next phase to finish** | +| Phase “3” meaning | progress: OTP = فاز ۳ | brief/architecture/last_step: onboarding = Phase 3 / docs فاز ۴ | **Needs explicit glossary** | +| Frontend API entry | developer_guide / frontend README: `api-client.ts` | Main typed client used by app is largely `api.ts` | **Document both or consolidate** | +| Dual `tenant_memberships` | database_schema explains Core vs Identity | Easy to miss; contracts/architecture mention lightly | **Must stay explicit everywhere memberships are discussed** | + +--- + +## 8. Recommendations + +### 8.1 Do not rewrite everything yet + +Wait for approval (per request). When approved, prefer **reconcile + slim**, not a brand-new parallel doc set that duplicates again. + +### 8.2 Proposed documentation roles (target model) + +| Document | Should become | +| --- | --- | +| `docs/architecture.md` | **Canonical architecture** (principles, components, tenancy, identity, entitlement, events). Status claims removed or linked to progress. | +| `docs/database_schema.md` | **Canonical data model** only. | +| `docs/services_contracts.md` | **Canonical API/event contracts** only (include public tenant-site). | +| `docs/progress.md` | **Only status checklist** (what is done / open). | +| `docs/last_step.md` | **Latest delivery narrative + next-step proposal** (or rename to `next-steps.md`). | +| `docs/developer_guide.md` | **How to run/develop locally**. | +| `README.md` | Short intro + links; no stale phase claims. | +| New (recommended): `docs/deployment.md` | Production topology, nginx, TLS, SSL auto-provision, env vars. | +| New (recommended): `docs/adr/` | Short decision records for major choices. | +| Service READMEs | Identity: keep real; business modules: keep placeholders but link to architecture module map. | + +### 8.3 Immediate reconciliation items (when editing is approved) + +1. Fix stale JIT / nginx / “phases 1–2 only” / “all services placeholder” statements. +2. Add a **phase numbering glossary** (brief vs internal). +3. Document **tenant subdomain runtime** (public site, theme, cookies, SSL). +4. Update Identity README API table to match contracts. +5. Clarify `api.ts` vs `api-client.ts`. +6. Update root tree to include `infrastructure/` and `scripts/`. + +### 8.4 What not to do + +- Do not create a second “full architecture” that competes with `architecture.md` without retiring overlaps. +- Do not treat placeholder service READMEs as implementation status. +- Do not mix roadmap, status, and architecture in one file. + +### 8.5 Suggested reading order (today) + +1. `docs/progress.md` + `docs/last_step.md` — current truth +2. `docs/architecture.md` — design intent (discount stale ops claims) +3. `docs/database_schema.md` + `docs/services_contracts.md` +4. `README.md` + developer/frontend/backend READMEs +5. `identity-access/README.md` + other service placeholders + +--- + +## Appendix A — Inventory matrix (compact) + +| # | Document | Purpose class | Valid? | Obsolete bits? | Overlaps | +| --- | --- | --- | --- | --- | --- | +| 1 | `docs/architecture.md` | Architecture | Partial | JIT, nginx, Core-only | High | +| 2 | `docs/database_schema.md` | Data model | Mostly | Minor | Medium | +| 3 | `docs/services_contracts.md` | Contracts | Partial | JIT 403; missing public site | Medium | +| 4 | `docs/developer_guide.md` | Dev ops | Partial | placeholders; env gaps | Medium | +| 5 | `docs/progress.md` | Status | Yes | — | With last_step | +| 6 | `docs/last_step.md` | Status/narrative | Mostly | White-label nuance | With progress | +| 7 | `README.md` | Entry | Partial | Phase 1–2 only | High | +| 8 | `backend/README.md` | Package | Partial | placeholders | Medium | +| 9 | `frontend/README.md` | Package | Partial | incomplete FE map | Medium | +| 10 | `backend/services/README.md` | Package | Stale | “none implemented” | High | +| 11 | `alembic/README.md` | Tooling | Yes | — | Low | +| 12 | `identity-access/README.md` | Service | Partial | incomplete APIs | Medium | +| 13–24 | Business service READMEs | Placeholders | Placeholder-valid | N/A if labeled | With architecture module list | + +--- + +## Appendix B — Approval gate + +**No further architecture documentation changes will be made until approval.** + +When approved, recommended first deliverables: + +1. Reconcile stale claims in existing docs (minimal edits). +2. Add `docs/deployment.md`. +3. Optionally introduce `docs/adr/` and a slim `docs/roadmap.md` (or rename `last_step.md`). + +Awaiting decision before creating the final architecture documentation set. diff --git a/docs/database_schema.md b/docs/database_schema.md index 919d990..2454230 100644 --- a/docs/database_schema.md +++ b/docs/database_schema.md @@ -1,224 +1,5 @@ -# طرح دیتابیس (Database Schema) - -## بخش ۱ — دیتابیس Core Platform (`core_platform_db`) — فاز ۱ - -همه مدل‌ها با SQLAlchemy 2.x و Alembic پیاده شده‌اند. شناسه‌ها از نوع UUID و -زمان‌ها timezone-aware هستند. - -### جدول `tenants` -| ستون | نوع | توضیح | -| --- | --- | --- | -| id | UUID (PK) | | -| name | varchar(255) | نام مستأجر (`business_name` در onboarding) | -| slug | varchar(100) unique | شناسه یکتا برای resolve | -| status | enum | `draft` / `pending_activation` / `active` / `suspended` / `archived` (+ مقادیر قدیمی `inactive`/`deleted` برای سازگاری) | -| owner_user_id | UUID null | مالک (از Identity Service) | -| business_type | varchar(100) null | نوع کسب‌وکار (فاز ۴ — onboarding) | -| default_locale | varchar(20) | پیش‌فرض `fa-IR` | -| timezone | varchar(50) | پیش‌فرض `Asia/Tehran` | -| primary_color | varchar(20) null | برندینگ | -| secondary_color | varchar(20) null | برندینگ | -| logo_url | varchar(500) null | برندینگ | -| favicon_url | varchar(500) null | برندینگ | -| onboarding_completed | bool | پیش‌فرض `false`؛ با `POST .../complete` به `true` تغییر می‌کند | -| created_at, updated_at | timestamptz | | - -ایندکس‌ها: `slug` (unique)، `status`. - -> فیلدهای `business_type` تا `onboarding_completed` در migration `0005_tenant_onboarding` -> اضافه شده‌اند (فاز ۴ — Tenant Onboarding). - -### جدول `domains` -| ستون | نوع | توضیح | -| --- | --- | --- | -| id | UUID (PK) | | -| tenant_id | UUID (FK→tenants) | | -| domain | varchar(255) unique | | -| domain_type | enum | subdomain / custom_domain | -| is_verified | bool | | -| verified_at | timestamptz null | | -| is_primary | bool | دامنهٔ اصلی/پیش‌فرض tenant (زیردامنهٔ خودکار onboarding) — فاز ۴ | -| verification_status | enum | `pending` / `verified` / `failed` — فاز ۴ | -| created_at | timestamptz | | - -ایندکس‌ها: `domain` (unique)، `tenant_id`. - -### جدول `plans` -id, code(unique), name, description, is_active, created_at. ایندکس: `code`. - -### جدول `features` -id, feature_key(unique), name, description, service_key, is_active. -ایندکس‌ها: `feature_key` (unique)، `service_key`. -مثال feature_key: `accounting.invoice.create`، `crm.lead.create`، -`ecommerce.product.create`، `live_chat.widget.enable`، `ai_assistant.chat.reply`. - -### جدول `plan_features` -id, plan_id(FK), feature_id(FK), limit_value(null=نامحدود), -limit_period(enum: day/month/year/total), is_enabled. -محدودیت یکتا: (plan_id, feature_id). ایندکس‌ها روی هر دو کلید خارجی. - -### جدول `tenant_subscriptions` -id, tenant_id(FK), plan_id(FK), status(enum: trialing/active/past_due/ -canceled/expired), starts_at, ends_at, trial_ends_at, created_at. -ایندکس‌ها: `tenant_id`، `status`. - -### جدول `tenant_feature_access` -override سفارشی دسترسی: id, tenant_id(FK), feature_id(FK), is_enabled, -custom_limit_value(null=نامحدود), used_value, reset_at. -محدودیت یکتا: (tenant_id, feature_id). ایندکس‌ها روی هر دو کلید خارجی. - -### جدول `service_registry` -id, service_key(unique), name, base_url, status(enum), health_check_url, -created_at. ایندکس: `service_key`. - -### جدول `module_registry` -id, module_key(unique), name, description, is_system_module, is_active. -ایندکس: `module_key`. - -### جدول `tenant_module_access` -id, tenant_id(FK), module_id(FK), is_enabled, enabled_at, disabled_at. -محدودیت یکتا: (tenant_id, module_id). ایندکس: `tenant_id`. - -### جدول `internal_service_tokens` -id, service_key, token_hash(unique), scopes, expires_at, is_active, created_at. -فقط hash توکن ذخیره می‌شود. ایندکس‌ها: `service_key`، `token_hash`. - -### جدول `outbox_events` -id, event_type, aggregate_type, aggregate_id, tenant_id, payload(JSON), -status(enum: pending/processed/failed), retry_count, created_at, processed_at. -ایندکس‌ها: `status`، `tenant_id`. - -### جدول `inbox_events` -id, event_id(unique), source_service, event_type, tenant_id, payload(JSON), -processed_at. ایندکس‌ها: `event_id`، `tenant_id`. - -### جدول `audit_logs` -id, tenant_id, actor_user_id, action, resource_type, resource_id, -metadata(JSON), ip_address, user_agent, created_at. -ایندکس‌ها: `tenant_id`، `created_at`. - -### جدول `users` (OTP + لینک SSO — فاز ۳) -| ستون | نوع | توضیح | -| --- | --- | --- | -| id | UUID (PK) | | -| mobile | varchar(15) unique | شماره موبایل (09xxxxxxxxx) — **الزامی** | -| keycloak_sub | varchar(100) null unique | لینک به کاربر Keycloak (SSO) | -| email | varchar(255) null | ایمیل (اختیاری، برای لینک SSO) | -| mobile_verified | bool | تأیید OTP — true پس از verify موفق | -| role | enum | user / pending_tenant_admin / tenant_admin / platform_admin | -| status | enum | active / inactive / suspended | -| current_tenant_id | UUID null (FK→tenants, `ON DELETE SET NULL`) | tenant انتخاب‌شده جاری کاربر — فاز ۴ | -| created_at, updated_at | timestamptz | | - -ایندکس‌ها: `mobile` (unique)، `keycloak_sub` (unique)، `status`. - -**نقش‌ها بر اساس context OTP:** -- `public` → `user` (ثبت‌نام عمومی) -- `admin` → `pending_tenant_admin` (پنل ادمین tenant) -- `PLATFORM_ADMIN_MOBILES` → `platform_admin` - -### جدول `tenant_memberships` (فاز ۴ — Tenant Onboarding) - -> **توجه:** این جدول در `core_platform_db` است و با جدول هم‌نام در -> `identity_access_db` (بخش ۳ همین سند) **کاملاً متفاوت و بدون ارتباط -> مستقیم** است. جدول Core عضویت واقعی کاربر↔workspace را برای جریان -> onboarding/authorization مدیریت می‌کند. - -| ستون | نوع | توضیح | -| --- | --- | --- | -| id | UUID (PK) | | -| tenant_id | UUID (FK→tenants, cascade) | | -| user_id | UUID (FK→users, cascade) | | -| role | enum | `platform_admin` / `tenant_owner` / `tenant_admin` / `tenant_editor` / `tenant_viewer` | -| status | enum | `active` / `invited` / `disabled` | -| is_owner | bool | فقط یک یا چند owner فعال در هر tenant (حداقل یکی الزامی) | -| created_at, updated_at | timestamptz | | - -محدودیت یکتا: `(tenant_id, user_id)`. ایندکس‌ها: `tenant_id`، `user_id`، `role`، `status`. - -**قواعد:** -- هنگام `POST /onboarding/tenant`، کاربر سازنده به‌صورت خودکار عضویت - `tenant_owner` / `is_owner=true` / `status=active` می‌گیرد. -- `POST /onboarding/tenant/{id}/complete` بررسی می‌کند که حداقل یک owner - فعال وجود داشته باشد؛ در غیر این صورت خطای `owner_required`. -- کاربر می‌تواند در آینده چند عضویت (چند tenant) داشته باشد؛ `users.current_tenant_id` - مشخص می‌کند کدام‌یک «جاری» است. - -### جدول `plans` و `tenant_subscriptions` (seed پیش‌فرض — فاز ۴) -ساختار جدول‌ها از فاز ۱ بدون تغییر باقی مانده (نگاه کنید به پایین همین -بخش)؛ در فاز ۴ موارد زیر اضافه شد: -- Migration `0005_tenant_onboarding` دو پلن `FREE` و `STARTER` را با - `ON CONFLICT (code) DO NOTHING` seed می‌کند (idempotent). -- `PlanService.ensure_default_plan()` همان پلن `FREE` را در زمان اجرا نیز - به‌صورت idempotent تضمین می‌کند (برای محیط‌های SQLite تست که migration - اجرا نمی‌شود). -- `POST /onboarding/tenant` به‌صورت خودکار یک `tenant_subscriptions` با - `plan=FREE`، `status=active` می‌سازد. - -## بخش ۲ — طراحی اولیه دیتابیس سرویس‌های آینده (بدون migration) - -> این‌ها فقط طراحی مفهومی‌اند؛ در فازهای بعدی هر سرویس دیتابیس مستقل خود را -> با ستون `tenant_id` در جداول بیزینسی خواهد داشت. - -- **accounting_db:** accounts (۴ سطحی)، journal_entries، journal_lines، - cost_centers، projects، invoices، payments، taxes، reports. -- **crm_db:** customers، leads، opportunities، pipelines، stages، tasks، - activities، automations. -- **ecommerce_db:** products، categories، inventory، orders، order_items، - payments، shipments، discounts، carts. -- **website_builder_db:** sites، pages، blocks، forms، menus، media، content. -- **live_chat_db:** widgets، conversations، messages، agents، routing_rules. -- **ai_assistant_db:** assistants، knowledge_bases، documents، chat_sessions، - messages، handoffs. -- **smart_messenger_db:** channels، accounts، threads، messages، automations. -- **sms_panel_db:** providers، templates، campaigns، messages، send_queue. -- **link_shortener_db:** links، domains، clicks، campaigns. -- **notification_db:** notifications، channels، templates، delivery_logs. -- **file_storage_db:** files، buckets، access_policies. -- **restaurant_db:** menus، menu_items، tables، orders، kitchen_tickets، - payments، loyalty. - -هر سرویس دسترسی قابلیت‌ها را از Core (`check_feature_access`) استعلام می‌کند و -هرگز مستقیماً به `core_platform_db` متصل نمی‌شود. - -## بخش ۳ — دیتابیس Identity & Access (`identity_access_db`) — فاز ۲ - -### جدول `user_profiles` -| ستون | نوع | توضیح | -| --- | --- | --- | -| id | UUID (PK) | | -| keycloak_sub | varchar unique | شناسه کاربر در Keycloak | -| email | varchar | | -| username | varchar null | | -| display_name | varchar null | | -| mobile | varchar(15) null unique | شماره موبایل — **الزامی برای کاربران جدید** | -| mobile_verified | bool | تأیید OTP | -| mobile_verified_at | timestamptz null | زمان تأیید | -| core_user_id | UUID null | لینک به `core_platform_db.users` | -| status | enum | active / inactive / suspended | -| created_at, updated_at | timestamptz | | - -ایندکس‌ها: `keycloak_sub` (unique)، `email`، `mobile` (unique). - -**قانون:** کاربر تازه‌ثبت‌نام‌شده تا پیش از `mobile_verified=true` کامل محسوب نمی‌شود. -کاربران SSO قدیمی بدون موبایل باید از `/auth/verify-mobile` تکمیل کنند. - -### جدول `tenant_memberships` - -> **توجه:** این جدول مربوط به `identity_access_db` (لایهٔ هویت SSO) است و -> **جدول متفاوتی** نسبت به `tenant_memberships` در `core_platform_db` است که -> در بخش ۱ (فاز ۴ — Tenant Onboarding) مستند شده. این دو جدول در دو سرویس/ -> دیتابیس جدا زندگی می‌کنند و sync خودکار بین آن‌ها وجود ندارد؛ منبع حقیقت -> (source of truth) برای نقش عملیاتی کاربر در یک workspace، جدول Core است. - -| ستون | نوع | توضیح | -| --- | --- | --- | -| id | UUID (PK) | | -| tenant_id | UUID | مرجع به tenant در Core (بدون FK بین DB) | -| user_id | UUID (FK→user_profiles) | | -| role | enum | tenant_admin / tenant_member | -| is_active | bool | | -| joined_at | timestamptz | | - -محدودیت یکتا: (tenant_id, user_id). ایندکس‌ها: `tenant_id`, `user_id`. - +# DEPRECATED — database_schema.md + +This path is **deprecated**. + +**Replacement:** [reference/database-schema.md](reference/database-schema.md) diff --git a/docs/decisions/README.md b/docs/decisions/README.md new file mode 100644 index 0000000..3cfdcde --- /dev/null +++ b/docs/decisions/README.md @@ -0,0 +1,18 @@ +# Decisions + +Non-architectural decisions live here: + +- [business/](business/) — commercial / org decisions +- [product/](product/) — product behavior decisions that are not ADRs +- [technical/](technical/) — engineering process decisions that are not ADRs + +Architecture decisions belong **only** in [docs/architecture/adr/](../architecture/adr/). + +Template: [decision-template.md](../templates/decision-template.md) + +## Seeded Decisions + +| Decision | Path | +| --- | --- | +| Internal phase numbering vs brief labels | [technical/phase-numbering.md](technical/phase-numbering.md) | +| First business module preference | [product/first-business-module.md](product/first-business-module.md) | diff --git a/docs/decisions/business/README.md b/docs/decisions/business/README.md new file mode 100644 index 0000000..58d4e75 --- /dev/null +++ b/docs/decisions/business/README.md @@ -0,0 +1,5 @@ +# Placeholder + +Business decisions that are not ADRs go here. Use [decision-template.md](../../templates/decision-template.md). + +See [documentation-as-software.md](documentation-as-software.md). diff --git a/docs/decisions/business/documentation-as-software.md b/docs/decisions/business/documentation-as-software.md new file mode 100644 index 0000000..827f4c8 --- /dev/null +++ b/docs/decisions/business/documentation-as-software.md @@ -0,0 +1,26 @@ +# Decision: Documentation is Part of the Software + +| Field | Value | +| --- | --- | +| Type | Business | +| Status | Accepted | +| Date | 2026-07-22 | +| Deciders | Platform Architecture | +| Related ADR | — | + +## Context + +Documentation drift caused contradictory status and ops claims. + +## Decision + +No implementation phase is complete without documentation, registry, and progress updates. Docs are a release artifact, not optional commentary. + +## Consequences + +Phases may be blocked solely for documentation gaps. + +## Related Documents + +- [docs/README.md](../../README.md) +- [Project Principles](../../development/project-principles.md) diff --git a/docs/decisions/product/README.md b/docs/decisions/product/README.md new file mode 100644 index 0000000..1fd1fdf --- /dev/null +++ b/docs/decisions/product/README.md @@ -0,0 +1,5 @@ +# Placeholder + +Product decisions that are not ADRs go here. Use [decision-template.md](../../templates/decision-template.md). + +See [first-business-module.md](first-business-module.md). diff --git a/docs/decisions/product/first-business-module.md b/docs/decisions/product/first-business-module.md new file mode 100644 index 0000000..467dd60 --- /dev/null +++ b/docs/decisions/product/first-business-module.md @@ -0,0 +1,27 @@ +# Decision: Prefer Restaurant as First Business Module + +| Field | Value | +| --- | --- | +| Type | Product | +| Status | Proposed | +| Date | 2026-07-22 | +| Deciders | Product (pending) | +| Related ADR | — | + +## Context + +After white-label runtime is solid, the platform needs a first real business module. Architecture already names Restaurant and has conceptual `restaurant_db`. + +## Decision + +Prefer **Restaurant / digital menu** as the first business module after white-label completion, unless product redirects to Accounting/CRM for commercial reasons. + +## Consequences + +Restaurant depends on tenant host branding and public site paths already in motion. + +## Related Documents + +- [Next Steps](../../next-steps.md) +- [Phases / Restaurant](../../phases/Restaurant/README.md) +- [Module Registry](../../module-registry.md) diff --git a/docs/decisions/technical/README.md b/docs/decisions/technical/README.md new file mode 100644 index 0000000..5558fa7 --- /dev/null +++ b/docs/decisions/technical/README.md @@ -0,0 +1,5 @@ +# Placeholder + +Technical process decisions that are not ADRs go here. Use [decision-template.md](../../templates/decision-template.md). + +See [phase-numbering.md](phase-numbering.md). diff --git a/docs/decisions/technical/phase-numbering.md b/docs/decisions/technical/phase-numbering.md new file mode 100644 index 0000000..da74495 --- /dev/null +++ b/docs/decisions/technical/phase-numbering.md @@ -0,0 +1,33 @@ +# Decision: Internal Phase Numbering + +| Field | Value | +| --- | --- | +| Type | Technical | +| Status | Accepted | +| Date | 2026-01-01 | +| Deciders | Platform | +| Related ADR | — | + +## Context + +External briefs labeled tenant onboarding as “Phase 3”, while internal docs already used فاز ۳ for OTP + Tenant Management. + +## Decision + +Keep internal numbering: + +1. Phase 1 — Core Platform +2. Phase 2 — Identity & Access + SSO +3. Phase 3 — OTP Login + Tenant Management +4. Phase 4 — Tenant Onboarding & Workspace Activation + +When referencing briefs, note the alias explicitly. + +## Consequences + +Avoids rewriting historical progress; requires glossary note. + +## Related Documents + +- [Glossary](../../glossary.md) +- [Progress](../../progress.md) diff --git a/docs/deployment/backup.md b/docs/deployment/backup.md new file mode 100644 index 0000000..6bf4e2c --- /dev/null +++ b/docs/deployment/backup.md @@ -0,0 +1,29 @@ +# Backup + +## Scope + +| Asset | Notes | +| --- | --- | +| PostgreSQL databases | At least `core_platform_db`, `identity_access_db`, Keycloak DB if separate | +| `.env` / secrets | Store outside git in secure vault | +| Let's Encrypt certs + Nginx maps | Edge host | +| Uploaded files (future) | File storage buckets | + +## Recommended Postgres Method + +```bash +pg_dump -Fc -d core_platform_db -f core_platform_db.dump +pg_dump -Fc -d identity_access_db -f identity_access_db.dump +``` + +Schedule daily backups; retain per policy (suggested: 7 daily, 4 weekly, 3 monthly). + +## Verification + +Periodically restore a dump to a scratch instance ([restore.md](restore.md)). + +## Related Documents + +- [Restore](restore.md) +- [Disaster Recovery](disaster-recovery.md) +- [Release Strategy](../development/release-strategy.md) diff --git a/docs/deployment/deployment.md b/docs/deployment/deployment.md new file mode 100644 index 0000000..a010902 --- /dev/null +++ b/docs/deployment/deployment.md @@ -0,0 +1,28 @@ +# Deployment Overview + +Operational instructions for deploying TorbatYar. Architecture topology lives in [deployment-architecture.md](../architecture/deployment-architecture.md). + +## Quick Paths + +| Doc | Use | +| --- | --- | +| [production.md](production.md) | Production hosts, env, compose | +| [ssl.md](ssl.md) | TLS and tenant certificate expansion | +| [monitoring.md](monitoring.md) | Health checks and logs | +| [backup.md](backup.md) | Backup procedures | +| [restore.md](restore.md) | Restore procedures | +| [disaster-recovery.md](disaster-recovery.md) | DR outline | + +## Local / Compose + +```bash +cp .env.example .env +docker compose up -d --build +``` + +Services: Frontend `:3000`, Core `:8000`, Identity `:8001`, Keycloak `:8080`. + +## Related Documents + +- [Developer Guide](../development/developer-guide.md) +- [ADR-009](../architecture/adr/ADR-009.md) diff --git a/docs/deployment/disaster-recovery.md b/docs/deployment/disaster-recovery.md new file mode 100644 index 0000000..b09a1f6 --- /dev/null +++ b/docs/deployment/disaster-recovery.md @@ -0,0 +1,28 @@ +# Disaster Recovery + +## Objectives (initial targets) + +| Metric | Target | +| --- | --- | +| RPO | ≤ 24 hours (improve with more frequent backups) | +| RTO | ≤ 8 hours for full stack restore | + +## Failure Scenarios + +| Scenario | Response | +| --- | --- | +| App host loss | Provision host, restore compose + env, restore DB dumps, repoint Nginx upstreams | +| Edge host loss | Rebuild Nginx from `infrastructure/nginx/*`, restore certs/maps, re-run provision as needed | +| DB corruption | Restore from last known good dump; forward-fix if possible | +| Keycloak loss | Restore Keycloak DB + realm export; validate OIDC clients | + +## Communication + +Record incident time, impact, RPO/RTO achieved, and follow-ups in ops notes; update [progress.md](../progress.md) only for lasting platform changes. + +## Related Documents + +- [Backup](backup.md) +- [Restore](restore.md) +- [Monitoring](monitoring.md) +- [Production](production.md) diff --git a/docs/deployment/monitoring.md b/docs/deployment/monitoring.md new file mode 100644 index 0000000..e3d9833 --- /dev/null +++ b/docs/deployment/monitoring.md @@ -0,0 +1,29 @@ +# Monitoring + +## Health Endpoints + +| Service | Check | +| --- | --- | +| Core | `GET /health` | +| Identity | `GET /health` | +| Frontend | HTTP 200 on apex | +| Keycloak | Admin/realm availability on `auth.` host | + +## Logs + +- Application logs via Docker / configured `LOG_LEVEL` +- Nginx access/error logs on edge host +- Celery worker/beat logs for outbox and SSL tasks + +## Suggested Alerts (minimum) + +- Container restart loops +- Postgres / Redis down +- Certificate expiry < 14 days +- OTP/SMS provider error rate spike +- Outbox `failed` growth + +## Related Documents + +- [Production](production.md) +- [Disaster Recovery](disaster-recovery.md) diff --git a/docs/deployment/production.md b/docs/deployment/production.md new file mode 100644 index 0000000..c36ccd8 --- /dev/null +++ b/docs/deployment/production.md @@ -0,0 +1,44 @@ +# Production + +## Hosts + +| Role | Address | +| --- | --- | +| Application (Docker) | `192.168.10.162` | +| Nginx edge | `192.168.10.156` | + +## Public Hostnames + +| Host | Upstream | +| --- | --- | +| `torbatyar.ir` / `www` / `*.torbatyar.ir` | Frontend `:3000` | +| `api.torbatyar.ir` | Core `:8000` | +| `identity.torbatyar.ir` | Identity `:8001` | +| `auth.torbatyar.ir` | Keycloak `:8080` | +| `accounting.torbatyar.ir` | Accounting `:8002` | + +## Environment + +Copy from `infrastructure/deploy/.env.production.example` to the app host `.env`. Critical groups: + +- `PLATFORM_*`, `NEXT_PUBLIC_*` +- `DATABASE_URL*`, `IDENTITY_DATABASE_URL*` +- `KEYCLOAK_*`, `AUTH_REQUIRED` +- `PAYAMAK_*`, `OTP_*`, `PLATFORM_ADMIN_MOBILES` +- `SSL_PROVISION_*` + +Never commit real production secrets. + +## Deploy Shape + +1. Pull release on app host +2. `docker compose up -d --build` +3. Run/verify Alembic per service (note: some production practices may `upgrade` then `stamp` — document the exact chosen procedure in the release notes) +4. Reload Nginx config on edge if routing changed +5. Smoke: `/health`, login, tenant subdomain + +## Related Documents + +- [SSL](ssl.md) +- [Deployment Architecture](../architecture/deployment-architecture.md) +- [Release Strategy](../development/release-strategy.md) diff --git a/docs/deployment/restore.md b/docs/deployment/restore.md new file mode 100644 index 0000000..1e0565c --- /dev/null +++ b/docs/deployment/restore.md @@ -0,0 +1,25 @@ +# Restore + +## Postgres Restore (example) + +```bash +pg_restore -d core_platform_db --clean --if-exists core_platform_db.dump +pg_restore -d identity_access_db --clean --if-exists identity_access_db.dump +``` + +Adapt flags to the dump format used in [backup.md](backup.md). + +## Application Restore Steps + +1. Stop writers (scale down workers / APIs) if restoring in place +2. Restore databases +3. Restore env/secrets if needed +4. Start services on the target release tag +5. Verify health, login, tenant resolution +6. Reconcile SSL map / certs if edge host was replaced + +## Related Documents + +- [Backup](backup.md) +- [Disaster Recovery](disaster-recovery.md) +- [Production](production.md) diff --git a/docs/deployment/ssl.md b/docs/deployment/ssl.md new file mode 100644 index 0000000..583aa4f --- /dev/null +++ b/docs/deployment/ssl.md @@ -0,0 +1,29 @@ +# SSL / TLS + +## Edge Certificates + +Nginx on `192.168.10.156` terminates TLS using Let's Encrypt material under `/etc/letsencrypt/live/torbatyar.ir/`. + +Platform hosts force HTTP → HTTPS. ACME challenges stay on port 80 (`/.well-known/acme-challenge/`). + +## Tenant Subdomain SSL + +When a tenant domain is provisioned and `SSL_PROVISION_ENABLED=true`, Core Celery task SSHs to the Nginx host and runs `provision_ssl.py` to expand the certificate SAN list. Map file `torbatyar-tenant-ssl.map` marks hosts ready for HTTPS redirect. + +Config keys: `SSL_PROVISION_HOST`, `SSL_PROVISION_USER`, `SSL_PROVISION_PASSWORD`, `SSL_PROVISION_SCRIPT`. + +Scripts/configs: + +- `infrastructure/nginx/provision_ssl.py` +- `infrastructure/nginx/torbatyar.ir.conf` +- `infrastructure/nginx/torbatyar-tenant-ssl.map` + +## Custom Domains + +Custom domains may be stored as `pending` until DNS verification ships. SSL automation for arbitrary custom domains follows the same expansion model once DNS points correctly. + +## Related Documents + +- [ADR-009](../architecture/adr/ADR-009.md) +- [Production](production.md) +- [Multi-Tenant Architecture](../architecture/multi-tenant-architecture.md) diff --git a/docs/developer_guide.md b/docs/developer_guide.md index 85abdc1..4774905 100644 --- a/docs/developer_guide.md +++ b/docs/developer_guide.md @@ -1,110 +1,5 @@ -# راهنمای توسعه‌دهنده (Developer Guide) +# DEPRECATED — developer_guide.md -## ۱. پیش‌نیازها -- Python 3.11+ (backend) -- Node.js 18+ (frontend) -- Docker و Docker Compose (برای اجرای کامل) -- PostgreSQL 15+ و Redis (در صورت اجرای بدون Docker) +This path is **deprecated**. -## ۲. قانون جداسازی Frontend/Backend (اجباری) -- تمام کد backend فقط در `backend/` قرار دارد. -- تمام کد frontend فقط در `frontend/` قرار دارد. -- ارتباط فقط از طریق REST API نسخه‌دار (`lib/api-client.ts` در frontend). -- جزئیات در [`architecture.md`](./architecture.md) بخش ۲.۱. - -## ۳. راه‌اندازی با Docker -```bash -cp .env.example .env -docker compose up -d --build -``` -سرویس‌ها: Core API روی `:8000`، Identity روی `:8001`، Frontend روی `:3000`، -Keycloak روی `:8080`. همه با `docker compose up -d --build` بالا می‌آیند. - -## ۴. راه‌اندازی محلی — Backend -```bash -cd backend/core-service -python -m venv .venv -# Windows: .venv\Scripts\Activate.ps1 | Linux/macOS: source .venv/bin/activate -pip install -r requirements.txt -alembic upgrade head -uvicorn app.main:app --reload -``` - -## ۵. راه‌اندازی محلی — Frontend -```bash -cd frontend -npm install -npm run dev -``` -Frontend: http://localhost:3000 - -## ۶. متغیرهای محیطی -همه تنظیمات در `.env` هستند (نمونه: `.env.example`). هیچ مقداری در کد -hardcode نمی‌شود. - -**Backend:** `DATABASE_URL`, `DATABASE_URL_SYNC`, `REDIS_URL`, `CELERY_BROKER_URL`, -`KEYCLOAK_*`, `INTERNAL_TOKEN_SECRET`, `PLATFORM_*`. - -**Frontend:** `NEXT_PUBLIC_API_BASE_URL` (پیش‌فرض: `http://localhost:8000`). - -## ۷. ساختار backend -``` -backend/ -├── core-service/app/ -│ ├── api/v1/ # routerها -│ ├── core/ # config, database, cache, security, logging -│ ├── models/ # مدل‌های SQLAlchemy -│ ├── schemas/ # اسکیماهای Pydantic -│ ├── services/ # منطق کسب‌وکار -│ ├── repositories/ # دسترسی به داده -│ ├── middlewares/ # تشخیص tenant -│ ├── workers/ # Celery -│ └── tests/ # تست‌ها -├── shared-lib/ # کتابخانه مشترک backend -└── services/ # placeholder سرویس‌های آینده -``` - -## ۸. ساختار frontend -``` -frontend/ -├── app/ # صفحات Next.js (App Router) -├── components/ # کامپوننت‌های UI -├── hooks/ # React hooks -├── lib/ # API client، theme -├── styles/ # CSS global -└── public/ # فایل‌های استاتیک -``` - -## ۹. Migrations (Alembic) -```bash -cd backend/core-service -alembic revision --autogenerate -m "توضیح" -alembic upgrade head -``` - -## ۱۰. اجرای تست‌ها (Backend) -```bash -cd backend/core-service -pytest -q -``` - -## ۱۱. Celery -```bash -cd backend/core-service -celery -A app.workers.celery_app.celery_app worker -Q default,core_events,notifications,webhooks -celery -A app.workers.celery_app.celery_app beat -``` - -## ۱۲. الگوهای کدنویسی -- نام فایل‌ها/کلاس‌ها/توابع/routeها/migrationها انگلیسی استاندارد. -- کامنت‌ها می‌توانند فارسی باشند. -- منطق کسب‌وکار فقط در backend (`services/`). -- UI فقط در frontend (`components/`, `app/`). -- خطاها از `shared.exceptions` استفاده کنند. - -## ۱۳. افزودن سرویس جدید (فازهای بعدی) -1. پوشه سرویس را در `backend/services/` توسعه دهید. -2. دیتابیس مستقل با `tenant_id` بسازید. -3. قابلیت‌ها را در Core ثبت کنید. -4. UI مربوطه را در `frontend/` بسازید (فقط از API استفاده کند). -5. ارتباط را فقط از طریق API/Event برقرار کنید. +**Replacement:** [development/developer-guide.md](development/developer-guide.md) diff --git a/docs/development/branching-strategy.md b/docs/development/branching-strategy.md new file mode 100644 index 0000000..4f68422 --- /dev/null +++ b/docs/development/branching-strategy.md @@ -0,0 +1,33 @@ +# Branching Strategy + +## Branches + +| Branch | Purpose | +| --- | --- | +| `main` | Production-ready history | +| `develop` | Integration branch for upcoming release | +| `feature/*` | Feature or docs work branched from `develop` | +| `hotfix/*` | Urgent production fixes from `main` | +| `release/*` | Release hardening, version bumps, notes | + +## Flow + +1. Create `feature/` from `develop`. +2. Open PR into `develop`. +3. Cut `release/x.y.z` when stabilizing. +4. Merge release into `main` and back into `develop`. +5. Tag `main` with `vX.Y.Z`. +6. Hotfixes merge to `main` and cherry-pick/`merge` back to `develop`. + +## Version Tagging + +Semantic tags: `vMAJOR.MINOR.PATCH` — see [release-strategy.md](release-strategy.md). + +## Documentation Branches + +Documentation-only work still uses `feature/*` and must update registries/progress when claiming phase completion. + +## Related Documents + +- [Release Strategy](release-strategy.md) +- [Developer Guide](developer-guide.md) diff --git a/docs/development/coding-standards.md b/docs/development/coding-standards.md new file mode 100644 index 0000000..e58eb60 --- /dev/null +++ b/docs/development/coding-standards.md @@ -0,0 +1,101 @@ +# Coding Standards + +## Folder Conventions + +``` +backend//app/ + api/v1/ # routers + core/ # config, db, security + models/ + schemas/ # Pydantic DTOs + services/ # business logic + repositories/ + middlewares/ + workers/ + tests/ + +frontend/ + app/ # Next.js App Router pages + components/ + hooks/ + lib/ # API clients, auth, theme + styles/ + public/ +``` + +## Naming + +| Kind | Convention | +| --- | --- | +| Files / modules | `snake_case.py`, `PascalCase.tsx` for components | +| Classes | `PascalCase` | +| Functions / vars | `snake_case` (Python), `camelCase` (TS) | +| DB tables / columns | `snake_case` | +| API paths | `/api/v1/...` kebab or resource nouns | +| Events | `aggregate.past_tense` | +| Feature keys | `{service}.{resource}.{action}` | +| Migrations | Alembic revision with descriptive message | + +Names are English. Comments may be Persian or English. + +## Python + +- Python 3.11+ +- Type hints on public functions +- Explicit imports; no wildcard imports +- Raise shared exceptions (`shared.exceptions`) with stable error codes + +## FastAPI + +- Versioned routers under `api/v1` +- Dependencies for auth and tenant +- Pydantic schemas for request/response — no ORM models in responses +- Keep route functions thin + +## Django + +Not used for new services. If legacy Django appears, do not expand it; prefer FastAPI services. + +## DTO / Schema Conventions + +- `Create`, `Update`, `Read`, `List` suffixes +- Never expose internal secrets or password hashes +- Paginated lists use shared `Page` meta + +## Repository Conventions + +- Accept `tenant_id` for tenant-owned entities +- No entitlement or workflow decisions +- Return domain models / rows; services map to DTOs + +## Service Conventions + +- One primary service per aggregate/use-case group +- Write outbox events in the same transaction as state changes +- Call other services only via HTTP clients / events + +## Migration Conventions + +- Autogenerate then review +- Idempotent seeds where needed (`ON CONFLICT DO NOTHING`) +- Never edit committed production revisions; add a new revision + +## Event Naming + +See [event-driven-architecture.md](../architecture/event-driven-architecture.md). + +## API Naming + +Resource nouns, plural collections, verbs only for non-CRUD actions (`/suspend`, `/activate`, `/complete`). + +## Database Naming + +- UUID primary keys named `id` +- Foreign keys `{table_singular}_id` +- Enums stored as string enums with documented values + +## Related Documents + +- [Project Principles](project-principles.md) +- [Developer Guide](developer-guide.md) +- [Service Architecture](../architecture/service-architecture.md) diff --git a/docs/development/developer-guide.md b/docs/development/developer-guide.md new file mode 100644 index 0000000..211ab1e --- /dev/null +++ b/docs/development/developer-guide.md @@ -0,0 +1,117 @@ +# راهنمای توسعه‌دهنده (Developer Guide) + +## ۱. پیش‌نیازها +- Python 3.11+ (backend) +- Node.js 18+ (frontend) +- Docker و Docker Compose (برای اجرای کامل) +- PostgreSQL 15+ و Redis (در صورت اجرای بدون Docker) + +## ۲. قانون جداسازی Frontend/Backend (اجباری) +- تمام کد backend فقط در `backend/` قرار دارد. +- تمام کد frontend فقط در `frontend/` قرار دارد. +- ارتباط فقط از طریق REST API نسخه‌دار (`lib/api-client.ts` در frontend). +- جزئیات در [`architecture.md`](../architecture/architecture.md) و [`module-boundaries.md`](../architecture/module-boundaries.md). + +## ۳. راه‌اندازی با Docker +```bash +cp .env.example .env +docker compose up -d --build +``` +سرویس‌ها: Core API روی `:8000`، Identity روی `:8001`، Frontend روی `:3000`، +Keycloak روی `:8080`. همه با `docker compose up -d --build` بالا می‌آیند. + +## ۴. راه‌اندازی محلی — Backend +```bash +cd backend/core-service +python -m venv .venv +# Windows: .venv\Scripts\Activate.ps1 | Linux/macOS: source .venv/bin/activate +pip install -r requirements.txt +alembic upgrade head +uvicorn app.main:app --reload +``` + +## ۵. راه‌اندازی محلی — Frontend +```bash +cd frontend +npm install +npm run dev +``` +Frontend: http://localhost:3000 + +## ۶. متغیرهای محیطی +همه تنظیمات در `.env` هستند (نمونه: `.env.example`). هیچ مقداری در کد +hardcode نمی‌شود. + +**Backend:** `DATABASE_URL`, `DATABASE_URL_SYNC`, `REDIS_URL`, `CELERY_BROKER_URL`, +`KEYCLOAK_*`, `INTERNAL_TOKEN_SECRET`, `PLATFORM_*`. + +**Frontend:** `NEXT_PUBLIC_API_BASE_URL` (پیش‌فرض: `http://localhost:8000`). + +## ۷. ساختار backend +``` +backend/ +├── core-service/app/ +│ ├── api/v1/ # routerها +│ ├── core/ # config, database, cache, security, logging +│ ├── models/ # مدل‌های SQLAlchemy +│ ├── schemas/ # اسکیماهای Pydantic +│ ├── services/ # منطق کسب‌وکار +│ ├── repositories/ # دسترسی به داده +│ ├── middlewares/ # تشخیص tenant +│ ├── workers/ # Celery +│ └── tests/ # تست‌ها +├── shared-lib/ # کتابخانه مشترک backend +└── services/ # placeholder سرویس‌های آینده +``` + +## ۸. ساختار frontend +``` +frontend/ +├── app/ # صفحات Next.js (App Router) +├── components/ # کامپوننت‌های UI +├── hooks/ # React hooks +├── lib/ # API client، theme +├── styles/ # CSS global +└── public/ # فایل‌های استاتیک +``` + +## ۹. Migrations (Alembic) +```bash +cd backend/core-service +alembic revision --autogenerate -m "توضیح" +alembic upgrade head +``` + +## ۱۰. اجرای تست‌ها (Backend) +```bash +cd backend/core-service +pytest -q +``` + +## ۱۱. Celery +```bash +cd backend/core-service +celery -A app.workers.celery_app.celery_app worker -Q default,core_events,notifications,webhooks +celery -A app.workers.celery_app.celery_app beat +``` + +## ۱۲. الگوهای کدنویسی +- کامل در [`coding-standards.md`](coding-standards.md) و [`project-principles.md`](project-principles.md). +- منطق کسب‌وکار فقط در backend (`services/`). +- UI فقط در frontend. +- خطاها از `shared.exceptions` استفاده کنند. + +## ۱۳. افزودن سرویس جدید (فازهای بعدی) +1. پوشه سرویس را در `backend/services/` توسعه دهید. +2. دیتابیس مستقل با `tenant_id` بسازید. +3. قابلیت‌ها را در Core ثبت کنید و [`module-registry.md`](../module-registry.md) را به‌روز کنید. +4. UI مربوطه را در `frontend/` بسازید (فقط از API استفاده کند). +5. ارتباط را فقط از طریق API/Event برقرار کنید. +6. مستندات architecture/reference/phase را همزمان به‌روز کنید — [`docs/README.md`](../README.md). + +## Related Documents + +- [Architecture Overview](../architecture/architecture.md) +- [Testing Strategy](testing-strategy.md) +- [Deployment](../deployment/deployment.md) +- [Services Contracts](../reference/services-contracts.md) diff --git a/docs/development/project-principles.md b/docs/development/project-principles.md new file mode 100644 index 0000000..8272fb6 --- /dev/null +++ b/docs/development/project-principles.md @@ -0,0 +1,31 @@ +# Project Principles + +These principles are mandatory for every implementation phase. + +1. **Every feature must be tenant-aware.** Business data always carries and filters by `tenant_id`. +2. **Every business action must be auditable.** Persist audit metadata or equivalent domain events. +3. **Business logic only inside Services.** Controllers/routers stay thin. +4. **Repositories contain no business logic.** Persistence and queries only. +5. **Views / API handlers remain thin.** Validate, authorize, delegate, map responses. +6. **Every phase must include tests.** No phase complete without automated coverage for new behavior. +7. **Every phase must update documentation.** Code and docs ship together. +8. **No TODO in completed phases.** TODOs mean the phase is not done. +9. **No cross-tenant query.** Platform-admin exceptions must be explicit and logged. +10. **No direct JournalEntry creation.** Posting Engine only ([ADR-010](../architecture/adr/ADR-010.md)). +11. **Compliance is independent.** Compliance controls are not optional UI features. +12. **AI is independent.** Core business flows work when AI is off ([ai-architecture.md](../architecture/ai-architecture.md)). +13. **Everything event-ready.** Prefer outbox-backed domain events for cross-service effects. +14. **Everything API-first.** UI is a client, not the source of truth. +15. **Everything documented.** Registries, ADRs, contracts, and progress stay current. +16. **Database-per-service.** No cross-DB access ([ADR-001](../architecture/adr/ADR-001.md)). +17. **Frontend/Backend separation is absolute.** ([ADR-002](../architecture/adr/ADR-002.md)). +18. **No hardcoded brand or secrets.** Config/env/database only. +19. **Documentation before conflicting code.** If implementation conflicts with architecture, stop and fix docs/ADR first. +20. **Phase completion gate.** Follow the checklist in [docs/README.md](../README.md). + +## Related Documents + +- [Coding Standards](coding-standards.md) +- [Testing Strategy](testing-strategy.md) +- [Architecture Overview](../architecture/architecture.md) +- [Module Registry](../module-registry.md) diff --git a/docs/development/release-strategy.md b/docs/development/release-strategy.md new file mode 100644 index 0000000..e3d1162 --- /dev/null +++ b/docs/development/release-strategy.md @@ -0,0 +1,43 @@ +# Release Strategy + +## Semantic Versioning + +- **MAJOR** — breaking API/schema/contract changes +- **MINOR** — backward-compatible features / modules +- **PATCH** — fixes, docs, non-breaking hardening + +## Release Notes + +Each release documents: + +- Features +- Fixes +- Migrations required +- Config/env changes +- Provider changes +- Deprecations + +## Migration Order + +1. Backup ([backup.md](../deployment/backup.md)) +2. Apply migrations **per service** in dependency order (typically Core → Identity → business services) +3. Deploy services compatible with the new schema +4. Smoke-test health and critical auth/onboarding paths +5. Only then enable new feature flags + +## Rollback + +1. Prefer forward-fix migrations when possible +2. Application rollback to previous image/tag +3. Database downgrade only with tested Alembic downgrade path and restore point +4. Document incident in progress / ops notes + +## Feature Flags + +Use config/env or plan entitlements to gate incomplete modules. Do not ship half-enabled financial posting paths. + +## Related Documents + +- [Branching Strategy](branching-strategy.md) +- [Deployment](../deployment/deployment.md) +- [Disaster Recovery](../deployment/disaster-recovery.md) diff --git a/docs/development/testing-strategy.md b/docs/development/testing-strategy.md new file mode 100644 index 0000000..d665eb6 --- /dev/null +++ b/docs/development/testing-strategy.md @@ -0,0 +1,41 @@ +# Testing Strategy + +## Required Layers + +| Layer | Purpose | +| --- | --- | +| Unit | Pure domain logic, validators, mappers | +| Service | Business rules with DB/fakes | +| Repository | Query correctness, tenant filters | +| API | HTTP contracts, authz, status codes | +| Integration | Multi-component flows (onboarding, OTP, SSO handoff) | +| Migration | Upgrade/downgrade smoke on clean DB | +| Tenant | Cross-tenant isolation negatives | +| Performance | Hot entitlement/resolve paths as needed | +| Security | Auth bypass attempts, token scope checks | +| Architecture | Boundary rules (optional lint/CI later) | +| Documentation validation | Links, registry completeness for the phase | + +## Rules + +1. New behavior ships with tests in the same phase. +2. Tenant tests must include at least one cross-tenant denial case for new tenant-scoped APIs. +3. Do not rely on production data for assertions. +4. Prefer deterministic time/OTP fakes. +5. Phase is incomplete if tests fail or are absent for claimed deliverables. + +## Commands (current) + +```bash +cd backend/core-service +pytest -q + +cd backend/services/identity-access +pytest -q +``` + +## Related Documents + +- [Project Principles](project-principles.md) +- [Developer Guide](developer-guide.md) +- [Branching Strategy](branching-strategy.md) diff --git a/docs/frontend/README.md b/docs/frontend/README.md new file mode 100644 index 0000000..897dc45 --- /dev/null +++ b/docs/frontend/README.md @@ -0,0 +1,32 @@ +# Frontend Documentation Index + +Enterprise Accounting UI lives inside the TorbatYar SuperApp (`frontend/`), not as a standalone app. + +## Documents + +| Document | Purpose | +|----------|---------| +| [frontend-architecture.md](frontend-architecture.md) | App structure, module boundaries, auth/tenant | +| [design-system.md](design-system.md) | Tokens, typography, themes, RTL | +| [component-library.md](component-library.md) | Reusable DS components | +| [layout-system.md](layout-system.md) | Shell, nav, page chrome | +| [page-patterns.md](page-patterns.md) | Dashboard / list / form / report patterns | +| [form-patterns.md](form-patterns.md) | RHF + Zod standards | +| [table-patterns.md](table-patterns.md) | Data tables | +| [dialog-patterns.md](dialog-patterns.md) | Dialog / confirm | +| [theme-system.md](theme-system.md) | Light/dark + white-label | +| [navigation-system.md](navigation-system.md) | Accounting nav map | +| [responsive-system.md](responsive-system.md) | Breakpoints | +| [frontend-api-integration.md](frontend-api-integration.md) | Real backend only | +| [frontend-state-management.md](frontend-state-management.md) | TanStack Query | +| [frontend-performance.md](frontend-performance.md) | Splitting & lists | +| [frontend-accessibility.md](frontend-accessibility.md) | WCAG AA baseline | +| [frontend-testing.md](frontend-testing.md) | Quality gate checklist | + +## Hard Rules + +1. Never mock accounting data. +2. Never bypass `accounting-service` APIs. +3. Always send JWT + `X-Tenant-ID`. +4. Journal entries only via Posting Engine UI actions (ADR-010). +5. Reuse Design System components — do not redesign per page. diff --git a/docs/frontend/completion-scoreboard.md b/docs/frontend/completion-scoreboard.md new file mode 100644 index 0000000..840ec19 --- /dev/null +++ b/docs/frontend/completion-scoreboard.md @@ -0,0 +1,50 @@ +# Accounting Frontend Completion Scoreboard + +> Source of truth for enterprise completion. Updated after every module gate. +> States: **Complete** | **Partial** | **Missing** | **Blocked** + +Last updated: 2026-07-24 + +## Overall + +| Module | % | Gate | +|--------|---|------| +| Dashboard | 90% | Complete | +| Chart of Accounts | 98% | Complete | +| Fiscal | 98% | Complete | +| Currencies | 98% | Complete | +| Cost Centers | 95% | Complete | +| Projects | 95% | Complete | +| Vouchers | 95% | Complete | +| Ledger | 95% | Complete | +| Treasury | 90% | Complete | +| Customers / AR | 92% | Complete | +| Suppliers / AP | 85% | Complete | +| Assets | 90% | Complete | +| Payroll | 88% | Complete | +| Reports | 92% | Complete | +| Compliance / Audit | 88% | Complete | +| Settings | 85% | Complete (IAM deep-links) | +| Onboarding / Setup | 95% | Complete | +| Mobile / Nav polish | 90% | Complete | +| AI | 0% | Blocked (provider Planned) | + +## Category legend + +API · CRUD · Routing · Nav · Dashboard · Forms · Dialogs · Drawers · Tables · Mobile · Dark · A11y · Loading/Empty/Error · Docs + +## Module notes + +- **Foundation masters** (COA, fiscal, currencies, cost centers, projects): create/edit/archive via PATCH; DatePicker on date fields. +- **Vouchers**: draft edit via `PATCH /posting/vouchers/{id}`; lines support cost center + project. +- **Ledger**: balances, trial balance, recalculate — real `/api/v1/ledger/*`. +- **Treasury**: cash boxes, banks, bank accounts, cash receipts. +- **Customers**: edit drawer, aging, receivable invoices; suppliers list/create. +- **Assets**: categories, activate, depreciate, schedule. +- **Payroll**: departments, periods, calculate, post. +- **Reports**: trial balance / BS / IS from reporting API; structured display of `data`. +- **Setup**: `/api/v1/setup/status`, skippable tour, COA template import. +- **AI**: Blocked until provider registry marks AI Model Provider Active. +- **IAM / White-label / Custom domain**: Deep-link to SuperApp settings — not rebuilt inside Accounting. + +See also: [component-library.md](component-library.md), [frontend-architecture.md](frontend-architecture.md). diff --git a/docs/frontend/component-library.md b/docs/frontend/component-library.md new file mode 100644 index 0000000..6053401 --- /dev/null +++ b/docs/frontend/component-library.md @@ -0,0 +1,34 @@ +# Component Library + +Located in `frontend/components/ds/`. + +## Core + +| Component | File | Notes | +|-----------|------|-------| +| Button | `Button.tsx` | variants: default, outline, ghost, danger, success | +| Input / Textarea / Label / FieldError | `Input.tsx` | Form primitives | +| Card family | `Card.tsx` | Card, Header, Title, Description, Content | +| Badge | `Badge.tsx` | tones: default, primary, success, warning, danger, info | +| Dialog / ConfirmDialog | `Dialog.tsx` | Modal + confirm | +| PageHeader | `Page.tsx` | Title + actions | +| DataTable | `Page.tsx` | Simple enterprise table | +| Empty / Loading / Error | `Page.tsx` | Standard states | +| StatCard | `Page.tsx` | Dashboard KPI | +| DatePicker / JalaliDateText | `DatePicker.tsx` | Shamsi calendar only; value is Gregorian ISO for APIs | +| Select / FormField / Checkbox / MoneyInput | `FormControls.tsx` | Styled ` + + + + + + + +
+ + +
+ + + + setAssetOpen(false)} title="دارایی جدید"> +
createAssetM.mutate(d))}> + + + + + + + + + + + + + + + +
+ + +
+
+
+ + setDepreciateAsset(null)} + title="ثبت استهلاک" + description={depreciateAsset ? `${depreciateAsset.code} — ${depreciateAsset.name}` : undefined} + > +
depreciateM.mutate(d))}> + + + + + + +
+ + +
+
+
+ + setScheduleAsset(null)} + title="برنامه استهلاک" + description={scheduleAsset ? `${scheduleAsset.code} — ${scheduleAsset.name}` : undefined} + width="lg" + > + {scheduleQ.isLoading ? ( + + ) : scheduleQ.error ? ( + scheduleQ.refetch()} /> + ) : ( + ({ + ...c, + render: (r) => { + const v = r[c.key]; + if (typeof v === "number" || (typeof v === "string" && /^\d/.test(v))) { + return formatMoney(String(v)); + } + return String(v ?? "—"); + }, + }))} + rows={scheduleRows} + empty={} + /> + )} + + + ); +} diff --git a/frontend/app/accounting/audit/page.tsx b/frontend/app/accounting/audit/page.tsx new file mode 100644 index 0000000..0aa16a0 --- /dev/null +++ b/frontend/app/accounting/audit/page.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { accountingApi } from "@/lib/accounting-api"; +import { useTenantId } from "@/hooks/useTenantId"; +import { + PageHeader, + DataTable, + EmptyState, + LoadingState, + ErrorState, + JalaliDateText, + Tabs, + Badge, +} from "@/components/ds"; + +type TabId = "compliance" | "posting"; + +export default function AuditPage() { + const { tenantId } = useTenantId(); + const [tab, setTab] = useState("compliance"); + + const complianceQ = useQuery({ + queryKey: ["accounting", tenantId, "compliance-audit"], + queryFn: () => accountingApi.compliance.listAudit(tenantId!), + enabled: !!tenantId && tab === "compliance", + }); + + const postingQ = useQuery({ + queryKey: ["accounting", tenantId, "posting-audit-logs"], + queryFn: () => accountingApi.vouchers.auditLogs(tenantId!), + enabled: !!tenantId && tab === "posting", + }); + + const activeQ = tab === "compliance" ? complianceQ : postingQ; + + if (!tenantId) return ; + if (activeQ.isLoading) return ; + if (activeQ.error) { + return activeQ.refetch()} />; + } + + return ( +
+ + + setTab(id as TabId)} + items={[ + { id: "compliance", label: "رکوردهای انطباق" }, + { id: "posting", label: "لاگ ثبت اسناد" }, + ]} + /> + + {tab === "compliance" && ( + , + }, + { key: "action", header: "عملیات" }, + { key: "resource_type", header: "منبع" }, + { + key: "actor_user_id", + header: "عامل", + render: (r) => ( + + {String(r.actor_user_id ?? "—")} + + ), + }, + ]} + rows={(complianceQ.data ?? []) as unknown as Record[]} + empty={ + + } + /> + )} + + {tab === "posting" && ( + , + }, + { + key: "operation", + header: "عملیات", + render: (r) => {String(r.operation)}, + }, + { key: "voucher_number", header: "شماره سند", render: (r) => String(r.voucher_number ?? "—") }, + { key: "resource_type", header: "نوع منبع" }, + { + key: "resource_id", + header: "شناسه", + render: (r) => ( + + {String(r.resource_id ?? "—")} + + ), + }, + { + key: "actor_user_id", + header: "عامل", + render: (r) => ( + + {String(r.actor_user_id ?? "—")} + + ), + }, + ]} + rows={(postingQ.data ?? []) as unknown as Record[]} + empty={ + + } + /> + )} +
+ ); +} diff --git a/frontend/app/accounting/chart-of-accounts/page.tsx b/frontend/app/accounting/chart-of-accounts/page.tsx new file mode 100644 index 0000000..3f306fc --- /dev/null +++ b/frontend/app/accounting/chart-of-accounts/page.tsx @@ -0,0 +1,642 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Plus, Archive, Pencil } from "lucide-react"; +import { accountingApi, type Account, type ChartOfAccounts } from "@/lib/accounting-api"; +import { useTenantId } from "@/hooks/useTenantId"; +import { + PageHeader, + Button, + Dialog, + Input, + Label, + FieldError, + Textarea, + DataTable, + EmptyState, + LoadingState, + ErrorState, + Badge, + Select, + Drawer, + TableToolbar, + Tabs, + Checkbox, +} from "@/components/ds"; + +const chartSchema = z.object({ + code: z.string().min(1, "کد الزامی است"), + name: z.string().min(1, "نام الزامی است"), + description: z.string().optional(), + is_default: z.boolean().optional(), +}); + +const accountSchema = z.object({ + chart_id: z.string().uuid("دفتر حساب را انتخاب کنید"), + code: z.string().min(1, "کد الزامی است"), + name: z.string().min(1, "نام الزامی است"), + account_type: z.string().min(1), + account_category: z.string().min(1), + parent_id: z.string().optional(), + is_group: z.boolean().optional(), + is_postable: z.boolean().optional(), + description: z.string().optional(), +}); + +type ChartForm = z.infer; +type AccountForm = z.infer; + +const ACCOUNT_TYPES = [ + { value: "asset", label: "دارایی" }, + { value: "liability", label: "بدهی" }, + { value: "equity", label: "حقوق صاحبان سهام" }, + { value: "revenue", label: "درآمد" }, + { value: "expense", label: "هزینه" }, +]; + +const ACCOUNT_CATEGORIES = [ + { value: "current_asset", label: "دارایی جاری" }, + { value: "non_current_asset", label: "دارایی غیرجاری" }, + { value: "current_liability", label: "بدهی جاری" }, + { value: "non_current_liability", label: "بدهی غیرجاری" }, + { value: "equity", label: "حقوق صاحبان سهام" }, + { value: "operating_revenue", label: "درآمد عملیاتی" }, + { value: "other_revenue", label: "سایر درآمدها" }, + { value: "operating_expense", label: "هزینه عملیاتی" }, + { value: "other_expense", label: "سایر هزینه‌ها" }, +]; + +export default function ChartOfAccountsPage() { + const { tenantId } = useTenantId(); + const qc = useQueryClient(); + const [tab, setTab] = useState("accounts"); + const [chartOpen, setChartOpen] = useState(false); + const [accountOpen, setAccountOpen] = useState(false); + const [editChart, setEditChart] = useState(null); + const [selected, setSelected] = useState(null); + const [filterChart, setFilterChart] = useState(""); + const [search, setSearch] = useState(""); + const [showInactive, setShowInactive] = useState(false); + + const chartsQ = useQuery({ + queryKey: ["accounting", tenantId, "charts"], + queryFn: () => accountingApi.charts.list(tenantId!), + enabled: !!tenantId, + }); + const accountsQ = useQuery({ + queryKey: ["accounting", tenantId, "accounts", filterChart], + queryFn: () => accountingApi.accounts.list(tenantId!, 1, 500, filterChart || undefined), + enabled: !!tenantId, + }); + + const chartForm = useForm({ + resolver: zodResolver(chartSchema), + defaultValues: { code: "", name: "", description: "", is_default: false }, + }); + const accountForm = useForm({ + resolver: zodResolver(accountSchema), + defaultValues: { + chart_id: "", + code: "", + name: "", + account_type: "asset", + account_category: "current_asset", + parent_id: "", + is_group: false, + is_postable: true, + description: "", + }, + }); + const editChartForm = useForm({ + resolver: zodResolver(chartSchema.omit({ code: true }).extend({ code: z.string().optional() })), + }); + const editAccountForm = useForm({ + defaultValues: { name: "", status: "active", description: "", is_postable: true }, + }); + + const invalidate = async () => { + await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "charts"] }); + await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "accounts"] }); + }; + + const createChart = useMutation({ + mutationFn: (data: ChartForm) => accountingApi.charts.create(tenantId!, data), + onSuccess: async () => { + toast.success("دفتر حساب ایجاد شد"); + setChartOpen(false); + chartForm.reset(); + await invalidate(); + }, + onError: (e: Error) => toast.error(e.message), + }); + + const updateChart = useMutation({ + mutationFn: (data: ChartForm) => + accountingApi.charts.update(tenantId!, editChart!.id, { + name: data.name, + description: data.description, + is_default: data.is_default, + }), + onSuccess: async () => { + toast.success("دفتر به‌روز شد"); + setEditChart(null); + await invalidate(); + }, + onError: (e: Error) => toast.error(e.message), + }); + + const archiveChart = useMutation({ + mutationFn: (id: string) => accountingApi.charts.archive(tenantId!, id), + onSuccess: async () => { + toast.success("دفتر آرشیو شد"); + await invalidate(); + }, + onError: (e: Error) => toast.error(e.message), + }); + + const createAccount = useMutation({ + mutationFn: (data: AccountForm) => + accountingApi.accounts.create(tenantId!, { + ...data, + parent_id: data.parent_id || null, + is_postable: data.is_postable ?? true, + is_group: data.is_group ?? false, + }), + onSuccess: async () => { + toast.success("حساب ایجاد شد"); + setAccountOpen(false); + await invalidate(); + }, + onError: (e: Error) => toast.error(e.message), + }); + + const updateAccount = useMutation({ + mutationFn: (body: { name: string; status: string; description?: string; is_postable: boolean }) => + accountingApi.accounts.update(tenantId!, selected!.id, body), + onSuccess: async () => { + toast.success("حساب به‌روز شد"); + setSelected(null); + await invalidate(); + }, + onError: (e: Error) => toast.error(e.message), + }); + + const archiveAccount = useMutation({ + mutationFn: (id: string) => accountingApi.accounts.archive(tenantId!, id), + onSuccess: async () => { + toast.success("حساب آرشیو شد"); + setSelected(null); + await invalidate(); + }, + onError: (e: Error) => toast.error(e.message), + }); + + const rows = useMemo(() => { + let list = accountsQ.data ?? []; + if (!showInactive) list = list.filter((a) => a.status === "active"); + if (search.trim()) { + const q = search.trim().toLowerCase(); + list = list.filter( + (a) => a.code.toLowerCase().includes(q) || a.name.toLowerCase().includes(q) + ); + } + return [...list].sort((a, b) => a.code.localeCompare(b.code, "fa")); + }, [accountsQ.data, search, showInactive]); + + if (!tenantId) return ; + if (chartsQ.isLoading || accountsQ.isLoading) return ; + if (chartsQ.error || accountsQ.error) { + return ( + { + void chartsQ.refetch(); + void accountsQ.refetch(); + }} + /> + ); + } + + const chartName = (id: string) => chartsQ.data?.find((c) => c.id === id)?.name ?? id; + + return ( +
+ + + + + } + /> + + + + {tab === "charts" ? ( +
+ {chartsQ.data?.map((c) => ( +
+
+
+

{c.name}

+

+ {c.code} +

+
+
+ {c.is_default ? پیش‌فرض : null} + + {c.is_active ? "فعال" : "آرشیو"} + +
+
+
+ + {c.is_active ? ( + + ) : null} + +
+
+ ))} + {!chartsQ.data?.length ? ( + setChartOpen(true)}> + ایجاد دفتر + + } + /> + ) : null} +
+ ) : ( + <> + + + setShowInactive(e.target.checked)} + /> + + } + /> + + {/* Mobile cards */} +
+ {rows.map((r) => ( + + ))} + {!rows.length ? : null} +
+ +
+ ( + + {String(r.code)} + + ), + }, + { key: "name", header: "نام" }, + { + key: "chart_id", + header: "دفتر", + render: (r) => chartName(String(r.chart_id)), + }, + { + key: "account_type", + header: "نوع", + render: (r) => + ACCOUNT_TYPES.find((t) => t.value === r.account_type)?.label ?? String(r.account_type), + }, + { + key: "status", + header: "وضعیت", + render: (r) => ( + {String(r.status)} + ), + }, + { + key: "id", + header: "", + render: (r) => ( + + ), + }, + ]} + rows={rows as unknown as Record[]} + empty={ + setChartOpen(true)}> + ایجاد دفتر حساب + + } + /> + } + /> +
+ + )} + + setChartOpen(false)} title="ایجاد دفتر حساب"> +
createChart.mutate(d))}> +
+ + + {chartForm.formState.errors.code?.message} +
+
+ + + {chartForm.formState.errors.name?.message} +
+
+ +