diff --git a/alshuyukh-accounting/.dockerignore b/alshuyukh-accounting/.dockerignore new file mode 100644 index 000000000000..001bb363faa2 --- /dev/null +++ b/alshuyukh-accounting/.dockerignore @@ -0,0 +1,9 @@ +**/node_modules +**/dist +**/coverage +.env +.env.* +!.env.example +deploy/.env +docs +**/*.log diff --git a/alshuyukh-accounting/.env.example b/alshuyukh-accounting/.env.example new file mode 100644 index 000000000000..c12759385b50 --- /dev/null +++ b/alshuyukh-accounting/.env.example @@ -0,0 +1,32 @@ +# Runtime connection — uses the restricted application role (RLS enforced) +DATABASE_URL=postgres://alshuyukh_app:app_dev_password@localhost:5432/alshuyukh +# Migration connection — uses the schema owner role +DATABASE_URL_MIGRATE=postgres://alshuyukh_owner:owner_dev_password@localhost:5432/alshuyukh + +# Tests use a separate database (created by scripts/db-setup.sql) +TEST_DATABASE_URL=postgres://alshuyukh_app:app_dev_password@localhost:5432/alshuyukh_test +TEST_DATABASE_URL_MIGRATE=postgres://alshuyukh_owner:owner_dev_password@localhost:5432/alshuyukh_test + +# At least 32 random characters. Generate with: openssl rand -base64 48 +JWT_SECRET=change-me-to-a-long-random-secret-at-least-32-chars +ACCESS_TOKEN_TTL_SECONDS=900 +REFRESH_TOKEN_TTL_DAYS=30 + +PORT=3000 +HOST=0.0.0.0 +NODE_ENV=development +# Comma-separated list of allowed browser origins +CORS_ORIGINS=http://localhost:5173 + +# ZATCA e-invoicing. Required in production: openssl rand -base64 32 +# ZATCA_ENCRYPTION_KEY= +ZATCA_WORKER=on +ZATCA_WORKER_INTERVAL_SECONDS=60 + +# Behind a reverse proxy (nginx, load balancer) set the number of proxies in front +# of the API (e.g. 1) or their addresses, so client IPs come from X-Forwarded-For. +# Leave false when the API is reachable directly: the header could then be forged. +TRUST_PROXY=false +DB_POOL_MAX=20 +# Queries running longer than this are cancelled (503 QUERY_TIMEOUT) +DB_STATEMENT_TIMEOUT_MS=15000 diff --git a/alshuyukh-accounting/.github/workflows/ci.yml b/alshuyukh-accounting/.github/workflows/ci.yml new file mode 100644 index 000000000000..7a5c09b569a8 --- /dev/null +++ b/alshuyukh-accounting/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +# Continuous integration for ALSHUYUKH ACCOUNTING. +# GitHub runs workflows only from the repository root's .github directory: this +# file takes effect once the project lives in its own repository. +name: ci + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: postgres + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 20 + env: + TEST_DATABASE_URL: postgres://alshuyukh_app:app_dev_password@localhost:5432/alshuyukh_test + TEST_DATABASE_URL_MIGRATE: postgres://alshuyukh_owner:owner_dev_password@localhost:5432/alshuyukh_test + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - name: Create database roles + run: PGPASSWORD=postgres psql -h localhost -U postgres -f scripts/db-setup.sql + - run: npm ci + - run: npm run typecheck + - name: API tests (real PostgreSQL, row-level security enforced) + run: npm test -w apps/api + - run: npm test -w apps/web + - run: npm run build + + images: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + - run: docker build -f deploy/api.Dockerfile -t alshuyukh-api . + - run: docker build -f deploy/web.Dockerfile -t alshuyukh-web . diff --git a/alshuyukh-accounting/.gitignore b/alshuyukh-accounting/.gitignore new file mode 100644 index 000000000000..c69800d95678 --- /dev/null +++ b/alshuyukh-accounting/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.env +*.log +coverage/ +e2e-failure-*.png diff --git a/alshuyukh-accounting/README.md b/alshuyukh-accounting/README.md new file mode 100644 index 000000000000..a230d53cbb3c --- /dev/null +++ b/alshuyukh-accounting/README.md @@ -0,0 +1,1024 @@ +# ALSHUYUKH ACCOUNTING — الشيوخ للمحاسبة + +نظام محاسبي سحابي متعدد المنشآت (Multi-Tenant SaaS) للسوق السعودي. + +**الحالة:** + +- المرحلة 1 مكتملة: تأسيس المشروع، قاعدة البيانات، المصادقة، تعدد المنشآت، الصلاحيات. +- المرحلة 2 مكتملة: دليل الحسابات، المحرك المحاسبي، القيود اليومية، السنوات والفترات المالية. +- المرحلة 3 مكتملة: العملاء، الموردون، المنتجات والخدمات، الوحدات والتصنيفات. +- المرحلة 4 مكتملة: عروض الأسعار، فواتير المبيعات والمشتريات، المرتجعات، أوامر الشراء، المدفوعات، نسب الضريبة. +- المرحلة 5 مكتملة: حركات المخزون، المتوسط المرجح، تكلفة المبيعات، التحويلات، التسويات والجرد، تقييم المخزون. +- المرحلة 6 مكتملة: المصروفات وفئاتها، سجل الحركات الضريبية، إقرار ضريبة القيمة المضافة مع مطابقة الدفتر، إدارة نسب الضريبة. +- المرحلة 7 مكتملة: محرك التقارير (القوائم المالية، الدفاتر، الأعمار، كشوف الحساب، المبيعات والمشتريات والمصروفات) ولوحة المؤشرات الشهرية. +- المرحلة 8 مبنية ومختبرة داخليًا: الفوترة الإلكترونية (ZATCA المرحلة الثانية) — XML بصيغة UBL، UUID، بصمة الفاتورة وسلسلة البصمات، QR، التوقيع الرقمي، إدارة الشهادات، الاعتماد والإبلاغ. **لم يُختبر التكامل بعد مع بوابة الهيئة الفعلية** (انظر أدناه). +- المرحلة 9 مكتملة: الاشتراكات والباقات وحدودها، قياس الاستخدام، القراءة فقط عند انتهاء الاشتراك، ولوحة مدير المنصة `/admin`. +- المرحلة 10 مكتملة: مراجعة أمنية وإصلاحاتها، اختبار أداء على بيانات كبيرة، صور Docker وإعداد إنتاج مع nginx، النسخ الاحتياطي والاستعادة، اختبار متصفح شامل. انظر [دليل النشر](docs/DEPLOYMENT.md). + +بقية الوحدات تظهر في الواجهة بعلامة «قريبًا» دون أي بيانات تجريبية. + +## التقنيات المستخدمة + +| الطبقة | التقنية | سبب الاختيار | +|---|---|---| +| Backend/API | Node.js 22 + TypeScript + Fastify 5 | أداء عالٍ، أنواع صارمة، Plugins معيارية | +| قاعدة البيانات | PostgreSQL 16 | معاملات ACID، `NUMERIC` للمبالغ، Row-Level Security | +| الوصول للبيانات | `pg` مع SQL صريح ومعاملات (parameterized) | تحكم كامل في المعاملات والأقفال لمحرك المحاسبة | +| التحقق من المدخلات | Zod 4 | تحقق على كل endpoint | +| المصادقة | JWT (HS256، 15 دقيقة) + Refresh Token دوّار في Cookie | جلسات قابلة للإلغاء فورًا | +| كلمات المرور | argon2id (معايير OWASP) | | +| Frontend | React 19 + Vite + React Router، RTL أولًا | | +| الاختبارات | Vitest على قاعدة PostgreSQL حقيقية | | + +## هيكل المشروع + +```text +alshuyukh-accounting/ +├── apps/ +│ ├── api/ +│ │ ├── src/ +│ │ │ ├── app.ts # تجميع التطبيق: الأمان، الأخطاء، المسارات +│ │ │ ├── server.ts # نقطة التشغيل +│ │ │ ├── config/env.ts # التحقق من متغيرات البيئة +│ │ │ ├── db/ +│ │ │ │ ├── migrations/*.sql # مخطط قاعدة البيانات +│ │ │ │ ├── migrate.ts # مشغّل الترحيلات + مزامنة الصلاحيات +│ │ │ │ ├── pool.ts # اتصال UTC، NUMERIC كنص +│ │ │ │ └── tx.ts # withTx: معاملة + سياق المنشأة +│ │ │ ├── lib/ # الأخطاء، التحقق، كلمات المرور، الرموز، المبالغ، التواريخ +│ │ │ ├── plugins/auth.ts # المصادقة وحارس الصلاحيات +│ │ │ └── modules/ +│ │ │ ├── auth/ # التسجيل، الدخول، التجديد، الخروج، التبديل +│ │ │ ├── users/ # أعضاء المنشأة +│ │ │ ├── rbac/ # catalog.ts (مصدر الصلاحيات) + الأدوار +│ │ │ ├── accounting/ # المحرك المحاسبي (المرحلة 2) +│ │ │ │ ├── engine.ts # إنشاء القيود، الترحيل، العكس، إقفال السنة +│ │ │ │ ├── chart-template.ts # دليل الحسابات الافتراضي +│ │ │ │ ├── setup.ts # تجهيز الشركة: الدليل + السنة المالية +│ │ │ │ └── *.routes.ts # الحسابات، القيود، السنوات، ميزان المراجعة +│ │ │ ├── documents/ # المستندات التجارية والمدفوعات (المرحلة 4) +│ │ │ │ ├── calc.ts # حساب السطور والضريبة (دوال نقية) +│ │ │ │ ├── kinds.ts # إعدادات المستندات الستة +│ │ │ │ ├── drafts.ts # المسودات والمرتجعات +│ │ │ │ ├── posting.ts # الإصدار والقيود والإلغاء والتحويل +│ │ │ │ └── payments.ts # المقبوضات والمدفوعات والتخصيص +│ │ │ ├── inventory/ # المخزون (المرحلة 5) +│ │ │ │ ├── costing.ts # طريقة التكلفة (المتوسط المرجح؛ واجهة لـ FIFO) +│ │ │ │ ├── engine.ts # الوارد والصادر والإلغاء مع قفل الرصيد +│ │ │ │ └── routes.ts # الأرصدة، حركة الصنف، التقييم، التحويلات، التسويات +│ │ │ ├── parties/ # العملاء والموردون (تنفيذ مشترك) +│ │ │ ├── products/ # المنتجات، الوحدات، التصنيفات +│ │ │ ├── companies/ # الشركات، الفروع، المستودعات +│ │ │ ├── settings/ # إعدادات المنشأة +│ │ │ └── audit/ # سجل التدقيق +│ │ └── test/ # اختبارات تكامل +│ └── web/ # واجهة React عربية +├── scripts/db-setup.sql # إنشاء الأدوار وقواعد البيانات +├── docker-compose.yml # PostgreSQL للتطوير +└── .env.example +``` + +## التشغيل محليًا + +المتطلبات: Node.js 22 أو أحدث، وPostgreSQL 16 (محليًا أو عبر Docker). + +```sh +cd alshuyukh-accounting + +# 1. قاعدة البيانات +docker compose up -d # أو: psql -U postgres -f scripts/db-setup.sql + +# 2. الإعدادات +cp .env.example .env +# غيّر JWT_SECRET إلى قيمة عشوائية: openssl rand -base64 48 + +# 3. الحزم والترحيلات +npm install +npm run db:migrate + +# 4. التشغيل (نافذتان) +npm run dev:api # http://localhost:3000 +npm run dev:web # http://localhost:5173 + +# 5. الاختبارات (تعيد بناء قاعدة alshuyukh_test في كل تشغيل) +npm test +``` + +افتح `http://localhost:5173` واختر «أنشئ منشأة جديدة». + +للتشغيل في الإنتاج (Docker Compose مع nginx، والنسخ الاحتياطي، واختبار ما بعد النشر) راجع [دليل النشر](docs/DEPLOYMENT.md). + +## مخطط قاعدة البيانات (ERD) + +```mermaid +erDiagram + tenants ||--|| tenant_settings : has + tenants ||--o{ user_tenants : has + users ||--o{ user_tenants : "member of" + users ||--o{ user_sessions : has + tenants ||--o{ user_sessions : "active in" + user_tenants ||--o{ user_roles : "(tenant_id, user_id)" + roles ||--o{ user_roles : assigned + roles ||--o{ role_permissions : grants + permissions ||--o{ role_permissions : in + tenants ||--o{ roles : "custom roles" + tenants ||--o{ companies : owns + companies ||--o{ branches : "(company_id, tenant_id)" + companies ||--o{ warehouses : "(company_id, tenant_id)" + branches ||--o{ warehouses : "(branch_id, tenant_id)" + tenants ||--o{ audit_logs : records + users ||--o{ audit_logs : performed + + tenants { uuid id PK; text name; citext slug UK; text status; timestamptz deleted_at } + tenant_settings { uuid tenant_id PK,FK; char default_currency; text timezone; text locale; smallint fiscal_year_start_month } + users { uuid id PK; citext email UK; text password_hash; text status; bool is_platform_admin; int failed_login_attempts; timestamptz locked_until } + user_tenants { uuid id PK; uuid tenant_id FK; uuid user_id FK; text status; bool is_owner } + user_sessions { uuid id PK; uuid user_id FK; uuid tenant_id FK; text token_hash UK; timestamptz expires_at; timestamptz revoked_at; uuid replaced_by FK } + roles { uuid id PK; uuid tenant_id FK "NULL = system"; text code; bool is_system; timestamptz deleted_at } + permissions { uuid id PK; text code UK; text module } + role_permissions { uuid role_id PK,FK; uuid permission_id PK,FK } + user_roles { uuid id PK; uuid tenant_id; uuid user_id; uuid role_id FK } + companies { uuid id PK; uuid tenant_id FK; text name; text vat_number; text commercial_registration; char currency; text timezone; timestamptz deleted_at } + branches { uuid id PK; uuid tenant_id; uuid company_id; text code; bool is_main; timestamptz deleted_at } + warehouses { uuid id PK; uuid tenant_id; uuid company_id; uuid branch_id; text code; timestamptz deleted_at } + audit_logs { uuid id PK; uuid tenant_id FK; uuid user_id FK; text action; text entity_type; uuid entity_id; jsonb old_values; jsonb new_values; inet ip_address } +``` + +### الجداول + +| الجدول | الغرض | `tenant_id` | Soft delete | RLS | +|---|---|---|---|---| +| `tenants` | المنشأة (حدود العزل) | — (هو المعرّف) | `deleted_at` | ✓ | +| `tenant_settings` | العملة، المنطقة الزمنية، بداية السنة المالية | ✓ (PK) | — | ✓ | +| `users` | هوية عامة (بريد + كلمة مرور) | — | `deleted_at` | — (انظر الملاحظات) | +| `user_tenants` | عضوية المستخدم في منشأة | ✓ | `status` | ✓ | +| `user_sessions` | جلسات Refresh Token (hash فقط) | ✓ | `revoked_at` | — (وصول خدمة المصادقة فقط) | +| `permissions` | كتالوج الصلاحيات | — | — | — (للقراءة فقط) | +| `roles` | أدوار النظام (`tenant_id` فارغ) والأدوار المخصصة | ✓ أو NULL | `deleted_at` | ✓ | +| `role_permissions` | ربط الأدوار بالصلاحيات | عبر الدور | — | ✓ | +| `user_roles` | أدوار المستخدم داخل المنشأة | ✓ | — | ✓ | +| `companies` | الشركات | ✓ | `deleted_at` | ✓ | +| `branches` | الفروع | ✓ | `deleted_at` | ✓ | +| `warehouses` | المستودعات (الأرصدة في المرحلة 5) | ✓ | `deleted_at` | ✓ | +| `audit_logs` | سجل التدقيق، إضافة فقط | ✓ (NULL قبل معرفة المنشأة) | ممنوع | ✓ | +| `schema_migrations` | الترحيلات المطبقة وبصمتها | — | — | — | + +### العلاقات والقيود المهمة + +- `branches (company_id, tenant_id)` → `companies (id, tenant_id)`: مفتاح أجنبي مركّب، فلا يمكن ربط فرع بشركة من منشأة أخرى حتى عبر SQL مباشر. +- `warehouses` يرتبط بالشركة وبالفرع بالطريقة نفسها. +- `user_roles (tenant_id, user_id)` → `user_tenants (tenant_id, user_id)`: لا يُسند دور إلا لعضو في المنشأة. ويتحقق Trigger من أن الدور نظامي أو يتبع المنشأة نفسها. +- `roles`: قيد CHECK يفرض أن أدوار النظام بلا `tenant_id` وأن الأدوار المخصصة لها `tenant_id`. +- قيود CHECK على: الرقم الضريبي السعودي (15 رقمًا يبدأ وينتهي بـ 3)، السجل التجاري (10 أرقام)، رموز العملة والدولة، الحالات. +- `audit_logs`: Trigger يمنع UPDATE وDELETE حتى لمالك المخطط. + +### الفهارس + +| الجدول | الفهرس | +|---|---| +| `tenants` | `slug` (فريد)، `status` | +| `users` | `email` (فريد، غير حساس لحالة الأحرف) | +| `user_tenants` | `(tenant_id, user_id)` (فريد)، `user_id` | +| `user_sessions` | `token_hash` (فريد)، `user_id` للجلسات غير الملغاة | +| `permissions` | `code` (فريد) | +| `roles` | `code` فريد لأدوار النظام؛ `(tenant_id, code)` فريد للأدوار المخصصة غير المحذوفة؛ `tenant_id` | +| `role_permissions` | PK `(role_id, permission_id)`، `permission_id` | +| `user_roles` | `(tenant_id, user_id, role_id)` (فريد)، `role_id` | +| `companies` | `tenant_id`؛ `(tenant_id, name)` فريد؛ `(tenant_id, vat_number)` فريد؛ `(id, tenant_id)` فريد للمفاتيح المركّبة | +| `branches` | `(tenant_id, company_id)`؛ `(tenant_id, company_id, code)` فريد؛ فرع رئيسي واحد لكل شركة | +| `warehouses` | `(tenant_id, company_id)`؛ `(tenant_id, company_id, code)` فريد | +| `audit_logs` | `(tenant_id, created_at DESC)`، `(tenant_id, entity_type, entity_id)`، `(user_id, created_at DESC)`، `(tenant_id, action)` | + +الفهارس الفريدة على الجداول ذات الحذف الناعم جزئية (`WHERE deleted_at IS NULL`) حتى يمكن إعادة استخدام الرمز بعد الحذف. + +## واجهات API (المرحلة 1) + +جميع المدخلات يُتحقق منها بـ Zod. الأخطاء بالشكل `{ "error": { "code", "message", "details" } }`. + +| الطريقة | المسار | الصلاحية | +|---|---|---| +| GET | `/api/health` | عام | +| POST | `/api/auth/register` | عام (10/دقيقة) | +| POST | `/api/auth/login` | عام (10/دقيقة) | +| POST | `/api/auth/refresh` | Cookie + ترويسة `X-CSRF-Protection: 1` | +| POST | `/api/auth/logout` | مسجّل دخول + ترويسة CSRF | +| POST | `/api/auth/switch-tenant` | مسجّل دخول وعضو في المنشأة الهدف | +| POST | `/api/auth/change-password` | مسجّل دخول | +| GET | `/api/auth/me` | مسجّل دخول | +| GET / PATCH | `/api/settings/tenant` | عضو / `settings.manage` | +| GET | `/api/users`، `/api/users/:id` | `user.view` | +| POST | `/api/users` | `user.invite` + `user.manage` | +| PATCH | `/api/users/:id` (تفعيل/تعطيل) | `user.manage` | +| PUT | `/api/users/:id/roles` | `user.manage` | +| GET | `/api/permissions`، `/api/roles` | `role.view` | +| POST / PATCH / DELETE | `/api/roles`، `/api/roles/:id` | `role.manage` | +| GET | `/api/companies`، `/api/companies/:id` | `company.view` | +| POST / PATCH / DELETE | `/api/companies`، `/api/companies/:id` | `company.manage` | +| GET / POST | `/api/companies/:id/branches` | `company.view` / `company.manage` | +| PATCH | `/api/branches/:id` | `company.manage` | +| GET / POST | `/api/companies/:id/warehouses` | `company.view` / `company.manage` | +| PATCH | `/api/warehouses/:id` | `company.manage` | +| GET | `/api/audit-logs` (فلاتر: action، entityType، entityId، userId، from، to، cursor) | `audit.view` | + +## المرحلة 2: المحرك المحاسبي + +### القواعد + +كل عملية مالية في النظام (يدوية الآن، ومن الفواتير والمدفوعات لاحقًا) تمر عبر `modules/accounting/engine.ts`. دوال المحرك تعمل داخل معاملة المستدعي، فالمستند وقيده يُحفظان معًا أو يُلغيان معًا. + +| القاعدة | التطبيق | قاعدة البيانات | +|---|---|---| +| مجموع المدين = مجموع الدائن، وأكبر من صفر | عند الترحيل (decimal.js) | Trigger يعيد الحساب عند `POSTED` | +| سطران على الأقل | ✓ | ✓ | +| كل سطر مدين **أو** دائن فقط، موجب، بخانتين عشريتين | ✓ (المبالغ نصوص، لا float) | `CHECK` + `NUMERIC(18,2)` | +| الحساب فرعي (يقبل القيود)، نشط، ومن نفس الشركة | ✓ | Trigger + مفتاح أجنبي مركّب | +| التاريخ داخل فترة مالية مفتوحة في سنة مفتوحة | ✓ مع قفل `FOR SHARE` | Trigger | +| القيد المرحّل لا يُعدَّل ولا يُحذف، وكذلك سطوره | ✓ | Trigger يرفض أي تغيير إلا التحول إلى `REVERSED` | +| لا حذف نهائي للقيود | حذف ناعم للمسودات فقط | دور التطبيق بلا صلاحية `DELETE` | +| ترقيم متسلسل بلا فجوات لكل شركة وسنة | يُسند عند الترحيل `JV-2026-000001` | جدول تسلسل بقفل صف، وفهرس فريد | + +**دورة حياة القيد:** `DRAFT` ← `POSTED` ← `REVERSED`. + +- المسودة تُعدَّل وتُحذف (حذفًا ناعمًا). +- القيد المرحّل يُصحَّح فقط بقيد عكسي (`REVERSAL`) بنفس الحسابات والمبالغ معكوسة، ثم بقيد تصحيح جديد. نقطة `reverse` تنشئ الاثنين في معاملة واحدة إذا أُرسل `correction`. +- القيد الأصلي يبقى في الدفتر بحالة `REVERSED`، والقيدان معًا يصفّران الأثر. +- لا يمكن عكس قيد عكسي، ولا عكس قيد صادر عن مستند (`source = SYSTEM`) يدويًا؛ يُصحَّح من المستند نفسه. + +**إقفال السنة المالية:** + +1. يتطلب أن تكون السنوات السابقة مقفلة، وألا توجد مسودات داخل السنة. +2. يرحّل قيد إقفال (`YEAR_CLOSING`) بتاريخ آخر يوم في السنة، يصفّر حسابات الإيرادات والتكاليف والمصروفات، ويحوّل صافي الربح أو الخسارة إلى الأرباح المحتجزة. +3. يقفل جميع الفترات ثم السنة. لا يمكن بعدها الترحيل في السنة ولا إعادة فتح فتراتها. + +**دليل الحسابات:** يُنشأ تلقائيًا لكل شركة جديدة، مع السنة المالية الحالية (12 فترة شهرية حسب شهر بداية السنة في إعدادات المنشأة). + +- يستخدم المحرك «مفتاح النظام» (`system_key`) مثل `ACCOUNTS_RECEIVABLE` و `VAT_OUTPUT` و `RETAINED_EARNINGS`، لا رمز الحساب، فيمكن للمستخدم إعادة ترقيم الحسابات. +- قواعد الشجرة مفروضة في قاعدة البيانات: نوع الفرع = نوع الأصل، الأصل حساب تجميعي، لا دوائر، والمستوى يُحسب تلقائيًا. +- لا يمكن تغيير نوع حساب له قيود أو فروع، ولا إيقاف حساب له رصيد، ولا حذف حساب عليه قيود أو حساب نظامي. + +### الجداول الجديدة + +| الجدول | الغرض | +|---|---| +| `fiscal_years` | السنوات المالية؛ قيد `EXCLUDE` يمنع التداخل لنفس الشركة | +| `fiscal_periods` | الفترات الشهرية؛ لا تداخل، ولا تُعاد فتح فترة في سنة مقفلة | +| `account_groups` | تصنيف الحسابات للقوائم المالية (أصول متداولة، مصروفات تشغيلية…) | +| `accounts` | دليل الحسابات الشجري | +| `cost_centers` | مراكز التكلفة | +| `journal_entries` | رؤوس القيود | +| `journal_entry_lines` | سطور القيود | +| `journal_sequences` | عداد الترقيم لكل سنة مالية | + +```mermaid +erDiagram + companies ||--o{ fiscal_years : has + fiscal_years ||--o{ fiscal_periods : "split into" + fiscal_years ||--|| journal_sequences : numbers + companies ||--o{ account_groups : has + companies ||--o{ accounts : has + accounts ||--o{ accounts : "parent_id" + account_groups ||--o{ accounts : classifies + companies ||--o{ cost_centers : has + companies ||--o{ journal_entries : has + fiscal_periods ||--o{ journal_entries : "posted in" + journal_entries ||--o{ journal_entry_lines : has + accounts ||--o{ journal_entry_lines : "(account_id, company_id)" + cost_centers ||--o{ journal_entry_lines : tags + branches ||--o{ journal_entry_lines : tags + journal_entries ||--o| journal_entries : "reversal_of_id / correction_of_id" + + accounts { uuid id PK; uuid company_id; text code; text account_type; uuid parent_id; smallint level; bool is_postable; text system_key } + journal_entries { uuid id PK; uuid company_id; text entry_number; date entry_date; text status; text source; numeric total_debit; numeric total_credit; uuid reversal_of_id } + journal_entry_lines { uuid id PK; uuid journal_entry_id; smallint line_no; uuid account_id; numeric debit; numeric credit; uuid cost_center_id; uuid branch_id } + fiscal_years { uuid id PK; uuid company_id; date start_date; date end_date; text status; uuid closing_entry_id } + fiscal_periods { uuid id PK; uuid fiscal_year_id; smallint period_number; date start_date; date end_date; text status } +``` + +كل الجداول الجديدة عليها `tenant_id` و RLS، وكل المراجع بينها مفاتيح أجنبية مركّبة مع `company_id`، فلا يمكن لسطر قيد أن يشير إلى حساب أو مركز تكلفة أو فرع من شركة أخرى. + +**الفهارس الرئيسية:** + +- `journal_entries`: `(company_id, fiscal_year_id, entry_number)` فريد؛ `(tenant_id, company_id, entry_date)`؛ `(company_id, status)`؛ `(company_id, reference_type, reference_id)`؛ قيد عكسي واحد فقط لكل قيد. +- `journal_entry_lines`: `account_id`؛ `(tenant_id, company_id, account_id)`؛ `(journal_entry_id, line_no)` فريد. +- `accounts`: `(company_id, code)` و `(company_id, system_key)` فريدان لغير المحذوفة؛ `parent_id`. +- `fiscal_periods`: `(company_id, start_date, end_date)`. + +### واجهات API (المرحلة 2) + +`companyId` اختياري عندما تملك المنشأة شركة واحدة. المبالغ تُرسل وتُستقبل نصوصًا (`"1150.00"`). + +| الطريقة | المسار | الصلاحية | +|---|---|---| +| POST | `/api/accounting/setup` (للشركات القديمة؛ آمن للتكرار) | `account.manage` + `fiscal.manage` | +| GET | `/api/accounts`، `/api/accounts/:id` (مع الرصيد)، `/api/account-groups` | `account.view` | +| POST / PATCH / DELETE | `/api/accounts`، `/api/accounts/:id` | `account.manage` | +| GET / POST / PATCH | `/api/cost-centers`، `/api/cost-centers/:id` | `account.view` / `account.manage` | +| GET / POST | `/api/fiscal-years` | `account.view` / `fiscal.manage` | +| POST | `/api/fiscal-years/:id/close` | `fiscal.manage` | +| POST | `/api/fiscal-periods/:id/close`، `/api/fiscal-periods/:id/reopen` | `fiscal.manage` | +| GET | `/api/journal-entries` (فلاتر: status، dateFrom، dateTo، accountId، referenceType، search) | `journal.view` | +| GET | `/api/journal-entries/:id` | `journal.view` | +| POST | `/api/journal-entries` (مع `post: true` يتطلب `journal.post`) | `journal.create` | +| PATCH / DELETE | `/api/journal-entries/:id` (المسودات فقط) | `journal.create` | +| POST | `/api/journal-entries/:id/post` | `journal.post` | +| POST | `/api/journal-entries/:id/reverse` (`reason`، `date`، `correction`) | `journal.reverse` | +| GET | `/api/reports/trial-balance?dateFrom&dateTo` (رصيد افتتاحي، حركة، ختامي) | `financial_report.view` | + +## المرحلة 3: العملاء والموردون والمنتجات + +### قرارات التصميم + +- **الرصيد من الدفتر وحده.** لا يوجد حقل «رصيد» مخزّن للعميل أو المورد. سطر القيد يحمل `customer_id` أو `supplier_id`، والرصيد = مجموع (مدين − دائن) للسطور المرحّلة الموسومة بالطرف. يمكن إدخال الأرصدة الافتتاحية للعملاء والموردين بقيد يومية عادي يُختار فيه العميل أو المورد على سطر حساب العملاء أو الموردين، وكشف الحساب في المرحلة 7 يُبنى على الأساس نفسه. +- **العملاء والموردون بتنفيذ واحد** (`parties.routes.ts`) مع جداول وصلاحيات وترقيم وحساب رقابة منفصلة لكل منهما. +- **العنوان الوطني** بحقول ZATCA: رقم المبنى (4 أرقام)، الشارع، الحي، المدينة، الرمز البريدي (5 أرقام)، الرقم الإضافي (4 أرقام). الصيغة مفروضة في قاعدة البيانات. عنوان افتراضي واحد لكل نوع (فوترة / شحن). استبدال العناوين يحذفها حذفًا ناعمًا، لأن الفواتير ستحفظ نسختها الخاصة من العنوان. +- **الترقيم التلقائي** (`document_sequences`): عداد لكل شركة ونوع مستند بقفل صف داخل المعاملة، فلا فجوات ولا تكرار. مثل `CUS-00001` و `SUP-00001` و `PRD-00001`. إذا أدخل المستخدم رمزًا يدويًا يطابق رمزًا قادمًا، يتخطاه المولّد. المرحلة 4 ستستخدم الجدول نفسه لأرقام الفواتير. +- **المنتج لا يحمل نسبة الضريبة**، بل فئة ZATCA فقط: `S` أساسية، `Z` صفرية، `E` معفى، `O` خارج النطاق. النسبة تأتي من إعدادات الضريبة في المرحلة 6. +- **أسعار الوحدة بأربع خانات عشرية** `NUMERIC(18,4)`، وإجماليات الفواتير ستُقرَّب لخانتين. +- **الخدمة لا تتتبع مخزونًا**، والقاعدة مفروضة بقيد `CHECK`. +- **الوحدات** بأكواد UN/ECE (PCE، KGM، LTR، HUR…) لأنها مطلوبة في XML الفوترة الإلكترونية، وتُنشأ تلقائيًا لكل شركة. +- **حسابات بديلة اختيارية:** حساب رقابة للعميل (أصل) أو المورد (التزام)، وحساب مبيعات (إيراد) أو مشتريات (أصل / مصروف / تكلفة) للمنتج. النوع يُتحقق منه. + +### الجداول الجديدة + +| الجدول | الغرض | +|---|---| +| `customers`، `suppliers` | الأطراف: الرمز، الاسم، الرقم الضريبي، السجل التجاري، الهوية، حد الائتمان، مدة السداد، حساب الرقابة | +| `customer_addresses`، `supplier_addresses` | العناوين الوطنية | +| `units` | وحدات القياس | +| `product_categories` | تصنيفات المنتجات (شجرية) | +| `products` | المنتجات والخدمات | +| `document_sequences` | عدادات الترقيم | +| `journal_entry_lines` (تعديل) | عمودا `customer_id` و `supplier_id` مع مفتاح أجنبي مركّب، وطرف واحد كحد أقصى لكل سطر | + +**الفهارس:** رمز فريد لكل شركة؛ رقم ضريبي فريد لكل شركة؛ SKU وباركود فريدان لكل شركة (للسجلات غير المحذوفة)؛ فهارس `pg_trgm` على الأسماء العربية لسرعة البحث؛ فهارس على `customer_id` و `supplier_id` في سطور القيود لحساب الأرصدة. + +### واجهات API (المرحلة 3) + +| الطريقة | المسار | الصلاحية | +|---|---|---| +| GET | `/api/customers` (search، status، limit، offset)، `/api/customers/:id` | `customer.view` | +| POST / PATCH / DELETE | `/api/customers`، `/api/customers/:id` | `customer.manage` | +| PUT | `/api/customers/:id/addresses` | `customer.manage` | +| — | نفس المسارات تحت `/api/suppliers` | `supplier.view` / `supplier.manage` | +| GET / POST / PATCH | `/api/units`، `/api/product-categories` | `product.view` / `product.manage` | +| GET | `/api/products` (search بالاسم أو الرمز أو الباركود، categoryId، productType، status) | `product.view` | +| POST / PATCH / DELETE | `/api/products`، `/api/products/:id` | `product.manage` | + +`POST /api/journal-entries` يقبل `customerId` أو `supplierId` على كل سطر. + +## المرحلة 4: المبيعات والمشتريات والمدفوعات + +### المستندات + +ستة مستندات بتنفيذ واحد (`documents/kinds.ts`)، لكل منها جداوله وصلاحياته وترقيمه: + +| المستند | الجدول | الترقيم | دورة الحياة | قيد محاسبي | +|---|---|---|---|---| +| عرض سعر | `sales_quotes` | `QT-` عند الإنشاء | مسودة ← مرسل ← مقبول/مرفوض ← محوّل لفاتورة | لا | +| فاتورة مبيعات | `sales_invoices` | `INV-` عند الإصدار | مسودة ← صادرة ← مدفوعة جزئيًا ← مدفوعة / مرتجعة / ملغاة | نعم | +| مرتجع مبيعات (إشعار دائن) | `sales_returns` | `CN-` عند الإصدار | مسودة ← صادر ← ملغى | نعم | +| أمر شراء | `purchase_orders` | `PO-` عند الإنشاء | مسودة ← معتمد ← محوّل لفاتورة | لا | +| فاتورة مشتريات | `purchase_invoices` | `PINV-` عند الترحيل | مسودة ← مرحّلة ← مدفوعة جزئيًا ← مدفوعة / مرتجعة / ملغاة | نعم | +| مرتجع مشتريات (إشعار مدين) | `purchase_returns` | `DN-` عند الإصدار | مسودة ← صادر ← ملغى | نعم | + +أرقام الفواتير والمرتجعات تُسند عند الإصدار فقط، فتبقى متسلسلة بلا فجوات (المسودات بلا رقم). + +**أسماء الحقول:** المواصفة تسمي حقلي الفاتورة `invoice_number` و `invoice_date`. استخدمت `doc_number` و `doc_date` في الجداول الستة لأن التنفيذ مشترك، وتظهر في الـ API باسم `number` و `date`. بقية الحقول بنفس أسماء المواصفة: `subtotal`، `discount_total`، `taxable_amount`، `tax_amount`، `total`، `paid_amount`، `remaining_amount`، `status`، `branch_id`، `warehouse_id`، `currency`. + +### القيود التلقائية + +| المستند | مدين | دائن | +|---|---|---| +| فاتورة مبيعات | العملاء (موسوم بالعميل) = الإجمالي | المبيعات (أو حساب المنتج) = الصافي، ضريبة المخرجات = الضريبة | +| مرتجع مبيعات | المبيعات، ضريبة المخرجات | العملاء (موسوم) | +| فاتورة مشتريات | المخزون للسلع المتتبعة، أو حساب الشراء للمنتج، أو حساب السطر؛ ضريبة المدخلات | الموردون (موسوم بالمورد) | +| مرتجع مشتريات | الموردون (موسوم) | الحسابات نفسها، ضريبة المدخلات | +| سند قبض من عميل | حساب طريقة الدفع (صندوق / بنك) | العملاء (موسوم) | +| استرداد لعميل | العملاء (موسوم) | حساب طريقة الدفع | +| سند صرف لمورد | الموردون (موسوم) | حساب طريقة الدفع | +| استرداد من مورد | حساب طريقة الدفع | الموردون (موسوم) | + +- المستند وقيده يُحفظان في المعاملة نفسها؛ فشل أي منهما يلغي الاثنين. +- القيد مصدره `SYSTEM` ومرتبط بالمستند (`reference_type`، `reference_id`)، فلا يُعكس يدويًا. +- يُخزَّن حساب الترحيل على كل سطر عند الإصدار للمراجعة. +- **تكلفة البضاعة المباعة وحركة المخزون غير مطبقتين بعد** — المرحلة 5. + +### الحساب والضريبة (`documents/calc.ts`) + +- كل المبالغ تُحسب في الخادم. الواجهة تطلب `POST /api//calculate` لعرض الإجماليات أثناء الكتابة، ولا تحسب الضريبة بنفسها. +- كمية وسعر الوحدة حتى 4 خانات؛ مبالغ السطر تُقرَّب لخانتين. +- **ضريبة المستند = تقريب (مجموع الصافي لكل فئة ونسبة × النسبة)** مرة واحدة، متوافقًا مع ZATCA. ضريبة السطر للعرض فقط وقد تختلف عن المجموع بهللة. +- الأسعار الشاملة للضريبة: الصافي = تقريب((الكمية × السعر − الخصم) ÷ (1 + النسبة)). كل المبالغ المخزنة غير شاملة. +- **النسبة ليست في الكود:** جدول `tax_rates` بتواريخ سريان. النسبة الأساسية الحالية (15%) تُنشأ افتراضيًا، وإضافة نسبة جديدة بتاريخ تُغلق السابقة تلقائيًا، وكل مستند يأخذ النسبة السارية في تاريخه. الفئات `Z` و `E` و `O` نسبتها صفر. +- الخصم على مستوى السطر (مبلغ أو نسبة). الخصم المُدخل يُحفظ كما هو (`discount_basis`) لإعادة الحساب بدقة. + +### المرتجعات + +- تُنشأ من الفاتورة الأصلية بتحديد السطور والكميات، ولا يمكن إرجاع أكثر من الكمية المتبقية. +- المبالغ تُوزَّع بنسبة الكمية. إرجاع آخر كمية من السطر يأخذ الباقي بالضبط، وآخر مرتجع للفاتورة يأخذ الضريبة المتبقية بالضبط، فيصفّر الإرجاع الكامل الفاتورة دون فروق تقريب. +- يُطبَّق المرتجع على الفاتورة حتى مبلغها المفتوح، والزيادة (إن كانت الفاتورة مدفوعة) تبقى مستحقة للعميل وتُسترد بسند صرف مخصص على المرتجع. + +### المدفوعات + +- طرق الدفع قابلة للتخصيص، وكل طريقة مربوطة بحساب أصل. المنشأة تبدأ بـ: نقدًا، تحويل بنكي، بطاقة، STC Pay، تمارا. يمكن ربط تمارا بحساب تسوية مستقل. +- السند يُخصَّص على فاتورة أو أكثر. **التخصيص لا ينشئ قيدًا**، لأن رصيد العميل في الدفتر صحيح أصلًا؛ هو ربط للمطابقة فقط. المبلغ غير المخصص يبقى دفعة مقدمة على حساب الطرف ويمكن تخصيصه لاحقًا. +- لا يمكن تعديل السند. يُلغى السند فيُعكس قيده وتُفك تخصيصاته وتُعاد حالة الفواتير. +- حالة الفاتورة (صادرة / مدفوعة جزئيًا / مدفوعة / مرتجعة) تُشتق دائمًا من المدفوع والمرتجع. + +### قواعد أخرى + +- **حد الائتمان:** لا تصدر فاتورة ترفع رصيد العميل في الدفتر فوق حده. +- **نوع الفاتورة الضريبية:** فاتورة ضريبية (`STANDARD`) إذا كان للعميل رقم ضريبي، وإلا مبسطة (`SIMPLIFIED`). +- **لقطة الطرف:** اسم العميل أو المورد ورقمه الضريبي وعنوانه الوطني تُنسخ في المستند عند الإصدار. +- **الإلغاء:** يعكس القيد بتاريخ المستند. لا يُلغى مستند عليه دفعات أو مرتجعات، ولا إذا كانت فترته مقفلة. +- **الحماية في قاعدة البيانات:** بعد الإصدار لا يتغير إلا الحالة وحقول التسوية، والسطور للقراءة فقط، ولا حذف للمستندات أو السندات. +- لا يُحذف عميل أو مورد أو منتج مستخدم في مستند أو سند. + +### واجهات API (المرحلة 4) + +لكل مستند `` من: `sales-quotes`، `invoices`، `sales-returns`، `purchase-orders`، `purchase-invoices`، `purchase-returns`: + +| الطريقة | المسار | الوصف | +|---|---|---| +| GET | `/api/` (status، partyId، dateFrom، dateTo، open، search) | قائمة | +| GET | `/api//:id` | المستند مع السطور والدفعات والمرتجعات | +| POST | `/api//calculate` | حساب بدون حفظ | +| POST / PATCH / DELETE | `/api/`، `/api//:id` | المسودات | +| POST | `/api//:id/post` | إصدار / ترحيل (الفواتير والمرتجعات) | +| POST | `/api//:id/status` | حالة عرض السعر أو أمر الشراء | +| POST | `/api//:id/convert` | تحويل عرض أو أمر إلى مسودة فاتورة | +| POST | `/api//:id/cancel` | إلغاء مع السبب | + +| الطريقة | المسار | الصلاحية | +|---|---|---| +| GET / POST | `/api/payments`، `/api/payments/:id` | `payment.view` / `payment.create` | +| POST | `/api/payments/:id/allocations` | `payment.create` | +| POST | `/api/payments/:id/void` | `payment.void` | +| GET / POST / PATCH | `/api/payment-methods` | `payment.view` / `settings.manage` | +| GET / POST | `/api/tax-rates` | `invoice.view` / `tax.manage` | + +صلاحيات المستندات: المبيعات `invoice.view/create/edit/delete/post/cancel`، والمشتريات `purchase.view/create/post/cancel`. + +## المرحلة 5: المخزون + +### المبادئ + +- **لا تُعدَّل الكمية مباشرة.** كل تغيير حركة في `stock_movements` (إضافة فقط، لا تعديل ولا حذف)، ويُحدَّث `inventory_balances` (الكمية والقيمة لكل صنف ومستودع) في المعاملة نفسها بقفل صف. +- **المتوسط المرجح لكل مستودع.** الوارد يضيف قيمته، والصادر يخرج بقيمة تقريب(المتوسط × الكمية، خانتان)، و**آخر وحدة تأخذ القيمة المتبقية بالضبط**، فلا تبقى قيمة بلا كمية (قيد في قاعدة البيانات يمنع ذلك). +- **دفتر المخزون = حساب المخزون في الدفتر العام دائمًا.** القيم بالهللة، وكل حركة لها أثرها في القيد نفسه. تقرير التقييم يعرض الرقمين والفرق، والاختبارات تتحقق من الفرق صفر بعد كل عملية. +- **لا رصيد سالب.** البيع أو التحويل أو التسوية بأكثر من المتاح يُرفض (`INSUFFICIENT_STOCK`) ولا يتغير شيء. +- **طريقة التكلفة قابلة للتوسعة:** `costing.ts` يعرّف واجهة `CostingMethod`، والمتوسط المرجح تنفيذها الحالي. عمود `companies.costing_method` يقبل حاليًا `WEIGHTED_AVERAGE` فقط. FIFO يحتاج جدول طبقات تكلفة ويُضاف كتنفيذ آخر للواجهة نفسها. + +### الحركات والقيود + +| العملية | الحركة | التكلفة | القيد | +|---|---|---|---| +| فاتورة مشتريات | `PURCHASE` وارد | الصافي قبل الضريبة | المخزون مدين (السلع المتتبعة تُرحَّل دائمًا لحساب المخزون) | +| فاتورة مبيعات | `SALE` صادر | المتوسط | ضمن قيد الفاتورة: تكلفة المبيعات مدين / المخزون دائن | +| مرتجع مبيعات | `SALE_RETURN` وارد | التكلفة التي خرجت بها البضاعة، بالتناسب، وآخر مرتجع يأخذ الباقي بالضبط | المخزون مدين / تكلفة المبيعات دائن | +| مرتجع مشتريات | `PURCHASE_RETURN` صادر | المتوسط | المخزون دائن بالتكلفة، والفرق عن سعر الشراء لتكلفة المبيعات | +| تحويل | `TRANSFER_OUT` ثم `TRANSFER_IN` | المتوسط في المستودع المصدر | لا قيد (القيمة باقية في حساب المخزون) | +| تسوية / جرد | `ADJUSTMENT_IN` أو `ADJUSTMENT_OUT` | الزيادة بالتكلفة المُدخلة أو المتوسط الحالي؛ النقص بالمتوسط | المخزون مقابل حساب الفروقات (5200) أو حساب يختاره المستخدم (رأس المال للرصيد الافتتاحي) | +| إلغاء مستند | `CANCELLATION_IN` / `CANCELLATION_OUT` | نفس تكلفة الحركة الأصلية | يُعكس قيد المستند؛ وإن فرغ المستودع وبقيت قيمة تُحمَّل على تكلفة المبيعات بقيد `INVENTORY_RESIDUAL` | + +- المرتجعات تعود إلى (أو تخرج من) مستودع الحركة الأصلية. +- لا يُلغى شراء استُخدمت بضاعته؛ يُستخدم مرتجع المشتريات بدلًا من ذلك. +- مستودع الفاتورة يُختار في النموذج، وإلا فالمستودع الرئيسي. +- الجرد يُدخل الكمية المعدودة ويحسب الخادم الفرق لرصيد المستودع المختار. +- لا يتغير نوع منتج أو تتبع مخزونه ولا يُحذف بعد وجود حركات له. + +### الجداول الجديدة + +| الجدول | الغرض | +|---|---| +| `inventory_balances` | الكمية والقيمة لكل صنف ومستودع (المفتاح: الصنف + المستودع)؛ لا سالب، ولا قيمة بلا كمية | +| `stock_movements` | كل حركة: النوع، الاتجاه، الكمية، تكلفة الوحدة، الإجمالي، الرصيد بعد الحركة، المرجع (المستند والسطر)، والحركة المعكوسة عند الإلغاء | +| `stock_transfers`، `stock_transfer_items` | التحويلات (`TRF-`) | +| `stock_adjustments`، `stock_adjustment_items` | التسويات والجرد (`ADJ-`) مع الكمية المعدودة والكمية في النظام | +| حساب `5200` | فروقات وتسويات المخزون (مفتاح النظام `INVENTORY_ADJUSTMENT`) | + +**الفهارس:** حركة الصنف `(product_id, warehouse_id, created_at)`؛ المرجع `(reference_type, reference_id)`؛ السطر `reference_line_id`؛ التاريخ لكل شركة؛ إلغاء واحد فقط لكل حركة. + +### واجهات API (المرحلة 5) + +| الطريقة | المسار | الصلاحية | +|---|---|---| +| GET | `/api/inventory/balances` (warehouseId، productId) | `inventory.view` | +| GET | `/api/inventory/movements` (productId، warehouseId، dateFrom، dateTo) — حركة الصنف | `inventory.view` | +| GET | `/api/inventory/valuation` — التقييم مع مطابقة الدفتر | `inventory.view` | +| GET / POST | `/api/stock-transfers`، `/api/stock-transfers/:id` | `inventory.view` / `inventory.transfer` | +| GET / POST | `/api/stock-adjustments`، `/api/stock-adjustments/:id` | `inventory.view` / `inventory.adjust` | + +`GET /api/products` يعرض `onHand` (الكمية في كل المستودعات)، والمستندات تقبل `warehouseId`. + +## المرحلة 6: المصروفات والضريبة + +### المصروفات + +- **سند المصروف** (`EXP-` يُرقَّم عند الترحيل) يحتوي سطرًا أو أكثر، وكل سطر مرتبط بـ**فئة مصروف**، وكل فئة مرتبطة بحساب في دليل الحسابات وبمعاملة ضريبية افتراضية يمكن تغييرها في السطر. +- الفئات الافتراضية: إيجار (`6100`، خاضع)، رواتب وأجور (`6200`، خارج النطاق)، تسويق وإعلان (`6300`، خاضع)، كهرباء ومياه (`6400`، خاضع). الإيجار التجاري خاضع للنسبة الأساسية؛ إيجار السكن معفى ويُختار في السطر. +- **طرق السداد:** + - نقدًا أو بنكي: مدين المصروف + مدين ضريبة المدخلات / دائن حساب طريقة الدفع. الحالة `PAID` مباشرة. + - آجل على مورد: الطرف الدائن هو حساب الموردين (`2100`) موسومًا بالمورد، فيظهر في رصيده. الحالة `POSTED` حتى يُسدَّد بسند صرف يُخصَّص على المصروف (`documentType: EXPENSE`). +- تُجمَّع سطور القيد حسب الحساب ومركز التكلفة. +- الحساب يتم في الخادم بنفس قواعد الفواتير: المبالغ شاملة أو غير شاملة الضريبة، والتقريب مرة واحدة لكل فئة ونسبة. +- المسودة تُعدَّل وتُحذف. بعد الترحيل لا تعديل (يمنعه trigger في قاعدة البيانات)، والإلغاء بقيد عكسي وعكس الحركات الضريبية، ويُرفض الإلغاء إذا وُجدت دفعات قائمة. + +### سجل الحركات الضريبية (`tax_transactions`) + +- كل مستند يؤثر على الضريبة يكتب صفًا لكل (فئة، نسبة): فواتير المبيعات والمرتجعات (مخرجات)، فواتير المشتريات والمرتجعات والمصروفات (مدخلات). +- المرتجعات تُسجَّل بالسالب ومُعلَّمة كتعديل (`is_adjustment`) لتظهر في عمود «التعديلات» في الإقرار. +- الإلغاء يكتب صفوفًا معاكسة مرتبطة بالأصل (`reverses_id`)، والجدول للإضافة فقط. +- يُحفظ اسم الطرف ورقمه الضريبي وقت المستند. +- `taxGroups` توزّع ضريبة المستند المرحَّلة على المجموعات بحيث يساوي مجموع السجل ضريبة القيد بالضبط. +- المستندات المرحَّلة قبل هذه المرحلة ليس لها حركات ضريبية (لا ترحيل رجعي). + +### إقرار ضريبة القيمة المضافة + +`GET /api/reports/vat-return` يعرض بنود نموذج الهيئة 1–16 لفترة محددة: + +- المبيعات: الأساسية (1)، الصفرية المحلية (3)، المعفاة (5)، الإجمالي (6). +- المشتريات: الأساسية (7)، الصفرية (10)، المعفاة (11)، الإجمالي (12). +- صافي الضريبة (13 و16). القيمة السالبة رصيد لصالح المنشأة. +- البنود التي لا يفصلها النظام بعد (2، 4، 8، 9، 14، 15) تظهر صفرًا وتُذكر في `notSupported`. +- **مطابقة الدفتر:** يقارن الإقرار مع رصيد حسابي ضريبة المخرجات والمدخلات في الفترة، ويعرض الفرق. القيد اليدوي على حساب الضريبة يظهر كفرق. +- التقرير مساعد لإعداد الإقرار وليس تقديمًا للهيئة. + +### نسب الضريبة + +تُدار من صفحة الإعدادات (`tax.manage`). النسبة الجديدة تُغلق السابقة في اليوم السابق لبدايتها، وتُطبَّق على المستندات حسب تاريخها. + +### الجداول الجديدة (`0011_expenses_tax.sql`) + +| الجدول | الغرض | +|---|---| +| `expense_categories` | الفئة، الحساب، المعاملة الضريبية الافتراضية | +| `expenses` | رأس السند: التاريخ، طريقة السداد، طريقة الدفع أو المورد، المستفيد، المرجع، الرقم الضريبي للمورد، المجاميع، المدفوع والمتبقي، الحالة، القيد | +| `expense_items` | السطور: الفئة، الحساب، المبلغ، الصافي، الضريبة، مركز التكلفة | +| `tax_transactions` | سجل الحركات الضريبية (للإضافة فقط) | +| `payment_allocations.expense_id` | تخصيص سند الصرف على المصروف الآجل | + +**الفهارس:** رقم المصروف فريد لكل شركة؛ المصروفات حسب `(tenant_id, company_id, expense_date)` والمورد؛ سطور المصروف؛ تخصيصات المصروف؛ الحركات الضريبية حسب `(tenant_id, company_id, transaction_date)` والمصدر `(source_type, source_id)`؛ عكس واحد فقط لكل حركة ضريبية. + +### واجهات API (المرحلة 6) + +| الطريقة | المسار | الصلاحية | +|---|---|---| +| GET | `/api/expense-categories` | `expense.view` | +| POST / PATCH | `/api/expense-categories`، `/api/expense-categories/:id` | `account.manage` | +| GET | `/api/expenses` (status، supplierId، open، dateFrom، dateTo)، `/api/expenses/:id` | `expense.view` | +| POST | `/api/expenses/calculate` — الحساب دون حفظ | `expense.view` | +| POST | `/api/expenses` (`post: true` يتطلب `expense.post`) | `expense.create` | +| PATCH / DELETE | `/api/expenses/:id` — المسودة فقط | `expense.create` | +| POST | `/api/expenses/:id/post` | `expense.post` | +| POST | `/api/expenses/:id/cancel` | `expense.cancel` | +| GET | `/api/reports/vat-return` (dateFrom، dateTo) | `financial_report.view` | +| GET | `/api/reports/vat-transactions` (dateFrom، dateTo، direction، vatCategory) | `financial_report.view` | +| GET / POST | `/api/tax-rates` | `invoice.view` / `tax.manage` | + +## المرحلة 7: التقارير ولوحة المؤشرات + +### المبادئ + +- **كل رقم مالي من القيود المرحّلة** (`POSTED` و`REVERSED` معًا، فيتعادل القيد الملغى مع عكسه). لا يُحسب الربح من الفواتير. +- **القيد العكسي يرث أصل القيد الذي يعكسه** (نوع المستند ومعرّفه)، فيُعرف أن إلغاء فاتورة مشتريات يخص المشتريات مثلًا. +- **قيود إقفال السنة مستبعدة من تقارير الدخل** (قائمة الدخل، المصروفات، لوحة المؤشرات)، ومحتسبة في الميزانية. +- تقارير المبيعات والمشتريات تشغيلية من المستندات المُصدرة (المرتجعات بالسالب، والملغاة والمسودات مستبعدة)، وتطابق قائمة الدخل في الاختبارات. +- كل تقرير يقبل `companyId` اختياريًا، ويعمل داخل RLS للمنشأة. + +### التقارير + +| التقرير | المصدر والمحتوى | المرشحات | +|---|---|---| +| قائمة الدخل | الإيرادات، تكلفة المبيعات، مجمل الربح، المصروفات، صافي الربح، لكل حساب | الفترة، الفرع، مركز التكلفة | +| الميزانية العمومية | الأصول والالتزامات وحقوق الملكية في تاريخ، مع «أرباح الفترة غير المقفلة»، وفحص التوازن | التاريخ | +| التدفقات النقدية | الطريقة المباشرة: لكل قيد يمسّ النقدية تُنسب الحركة إلى الحسابات المقابلة (تشغيلية/استثمارية/تمويلية)، ويتحقق: أول الفترة + الصافي = آخرها | الفترة | +| دفتر الأستاذ العام | رصيد افتتاحي، الحركات برصيد متحرك، رقم المستند المصدر، حد 5000 حركة | الحساب، الفترة، الفرع، مركز التكلفة، العميل، المورد | +| تقرير اليومية | القيود المرحّلة بسطورها، بإجماليات متوازنة وترقيم صفحات | الفترة، نوع القيد | +| أعمار الذمم المدينة والدائنة | المستندات المفتوحة حسب أيام التأخير (غير مستحق، 1–30، 31–60، 61–90، أكثر من 90)؛ الرصيد من الدفتر، والفرق (دفعات مقدمة، إشعارات غير مطبقة) في عمود «غير مخصص» فيطابق كل صف الدفتر | العميل أو المورد | +| كشف حساب عميل / مورد | سطور الطرف الموسومة في الدفتر، رصيد افتتاحي ومتحرك، رقم المستند، وتمييز القيود العكسية | الطرف، الفترة | +| تقرير المبيعات | الكمية والصافي والضريبة والإجمالي، والتكلفة من حركات المخزون الفعلية، ومجمل الربح | الفترة، التجميع (عميل، صنف، شهر، مستند)، العميل، الصنف، الفرع | +| تقرير المشتريات | مثل المبيعات بلا مجمل ربح | كذلك | +| تقرير المصروفات | من حسابات المصروفات في الدفتر (سندات المصروفات، بنود فواتير المشتريات، القيود اليدوية) | الفترة، التجميع (حساب، مركز تكلفة، شهر)، الفرع، الحساب | +| ميزان المراجعة، إقرار الضريبة، تقييم المخزون، حركة الصنف | من المراحل 2 و5 و6، ومربوطة من صفحة التقارير | — | + +### لوحة المؤشرات (`GET /api/dashboard`) + +- الفترة الافتراضية: السنة المالية التي تحتوي تاريخ اليوم حتى اليوم (بتوقيت الشركة). +- مؤشرات الفترة: المبيعات (حسابات الإيرادات)، المشتريات (ما أدخلته فواتير ومرتجعات المشتريات إلى حسابات المخزون والمصروفات والتكلفة، دون الضريبة)، تكلفة المبيعات، المصروفات، صافي الربح. +- أرصدة نهاية الفترة: الذمم المدينة والدائنة (من سطور الأطراف)، النقدية (حساب النقدية وطرق الدفع النقدية)، البنك (بقية حسابات طرق الدفع)، قيمة المخزون، صافي ضريبة القيمة المضافة المستحقة. +- سلسلة 12 شهرًا: الإيرادات، التكاليف والمصروفات، صافي الربح، رصيد النقدية والبنوك في نهاية كل شهر. +- في الواجهة: بطاقات المؤشرات مع روابط للتقارير، ورسمان بياني (أعمدة شهرية ومنحنى النقدية) مع تلميحات عند التمرير وعرض كجدول. + +### الصلاحيات + +صلاحية جديدة `financial_report.view` («عرض القوائم والتقارير المالية») تفصل القوائم المالية عن التقارير التشغيلية، لأن أدوارًا تشغيلية (مدير المبيعات، المشتريات، المستودع) تملك `report.view`: + +- `financial_report.view`: قائمة الدخل، الميزانية، التدفقات النقدية، دفتر الأستاذ، اليومية، المصروفات، ميزان المراجعة، إقرار الضريبة، لوحة المؤشرات. تُمنح للمالك ومدير الشركة والمحاسب والمشاهد. +- `report.view`: المبيعات، المشتريات، أعمار الذمم، كشوف الحساب. + +### الواجهة + +- صفحة التقارير مجمّعة حسب النوع وتعرض فقط ما يسمح به دور المستخدم. +- فترات جاهزة (هذا الشهر، الشهر السابق، الربع، السنة)، وطباعة (تخفي القوائم والأزرار)، وتصدير CSV بترميز يفتحه Excel بالعربية. +- الانتقال من قائمة الدخل والميزانية إلى دفتر الأستاذ، ومن الأعمار إلى كشف الحساب، ومن الدفتر إلى القيد والمستند. + +### واجهات API (المرحلة 7) + +| الطريقة | المسار | الصلاحية | +|---|---|---| +| GET | `/api/reports/profit-loss` (dateFrom، dateTo، branchId، costCenterId) | `financial_report.view` | +| GET | `/api/reports/balance-sheet` (asOf) | `financial_report.view` | +| GET | `/api/reports/cash-flow` (dateFrom، dateTo) | `financial_report.view` | +| GET | `/api/reports/general-ledger` (accountId، dateFrom، dateTo، branchId، costCenterId، customerId، supplierId) | `financial_report.view` | +| GET | `/api/reports/journal` (dateFrom، dateTo، referenceType، limit، offset) | `financial_report.view` | +| GET | `/api/reports/expenses` (dateFrom، dateTo، groupBy، branchId، costCenterId، accountId) | `financial_report.view` | +| GET | `/api/dashboard` (dateFrom، dateTo اختياريان) | `financial_report.view` | +| GET | `/api/reports/receivables-aging`، `/api/reports/payables-aging` (partyId) | `report.view` | +| GET | `/api/reports/customer-statement`، `/api/reports/supplier-statement` (partyId، dateFrom، dateTo) | `report.view` | +| GET | `/api/reports/sales`، `/api/reports/purchases` (dateFrom، dateTo، groupBy، partyId، productId، branchId) | `report.view` | + +لا جداول جديدة في هذه المرحلة؛ التقارير تستخدم فهارس القيود الحالية (`journal_entries (tenant_id, company_id, entry_date)` وسطور الحساب والأطراف). + +## المرحلة 8: الفوترة الإلكترونية (ZATCA) + +> **حالة التكامل:** الوحدة مبنية وفق مواصفات هيئة الزكاة والضريبة والجمارك (الإصدار الثاني من الفوترة الإلكترونية) ومختبرة ببوابة بديلة داخل الاختبارات تُصدر شهادات حقيقية وتتحقق من البصمة والتوقيع. **لم تُرسل أي فاتورة إلى بوابة فاتورة الفعلية**؛ الشبكة في بيئة التطوير لا تصل إليها. قبل الإنتاج يجب: التشغيل على «بوابة المطورين» ثم «المحاكاة»، ومطابقة الملفات مع أداة التحقق الرسمية (ZATCA SDK)، وخاصة بصمة الخصائص الموقعة (SignedProperties) وشكل الـ XML. + +### المكونات (`apps/api/src/modules/zatca/`) + +| الملف | الدور | +|---|---| +| `der.ts`، `crypto.ts` | مفاتيح ECDSA على منحنى secp256k1؛ طلب شهادة CSR بصيغة PKCS#10 مع امتداد قالب الهيئة (`TSTZATCA`/`PREZATCA`/`ZATCA-Code-Signing`) وحقول SAN (الرقم التسلسلي للوحدة، الرقم الضريبي، أنواع الفواتير، العنوان، النشاط)؛ تشفير المفاتيح والأسرار AES-256-GCM؛ قراءة الشهادة (الجهة المصدرة، الرقم التسلسلي، توقيع الجهة) | +| `xml.ts` | فاتورة UBL 2.1 بملف الهيئة: النوع (388 فاتورة، 381 إشعار دائن، 383 إشعار مدين) والنوع الفرعي (`0100000` ضريبية، `0200000` مبسطة)، ICV، PIH، البائع والمشتري بالعنوان الوطني، تاريخ التوريد، المرجع وسبب الإشعار، تفصيل الضريبة مع رموز الإعفاء `VATEX-SA-*`، والسطور. سعر الوحدة يُعبَّر عنه بحيث الكمية × السعر = الصافي تمامًا (BaseQuantity عند الحاجة) | +| `sign.ts` | بصمة الفاتورة: حذف التوقيع وQR، ثم C14N، ثم SHA-256 بترميز base64؛ توقيع ECDSA على البصمة؛ كتلة XAdES؛ رمز QR للمرحلة الثانية (الحقول 1–9، والحقل 9 للمبسطة فقط) | +| `client.ts` | واجهات الهيئة: `/compliance`، `/compliance/invoices`، `/production/csids`، `/invoices/reporting/single`، `/invoices/clearance/single`، لثلاث بيئات، مع إمكانية تحويلها عبر `ZATCA_GATEWAY_URL` | +| `einvoice.ts` | تحويل فاتورة المبيعات أو المرتجع المُصدر إلى بيانات الفاتورة الإلكترونية، والتحقق من كل ما تشترطه الهيئة قبل التوقيع | +| `service.ts` | خطوات الربط، التوليد داخل معاملة الإصدار، والإرسال مع إعادة المحاولة | +| `worker.ts` | مُرسل في الخلفية للفواتير المعلقة (مهلة الإبلاغ عن المبسطة 24 ساعة) | + +### الربط (Onboarding) + +1. **إنشاء الوحدة (EGS)** لكل شركة أو فرع: يُولَّد المفتاح الخاص ويُحفظ مشفرًا، ويُنشأ CSR. يُرفض الإنشاء إذا نقص الرقم الضريبي أو السجل التجاري أو العنوان الوطني للشركة. +2. **شهادة الامتثال:** يُرسل CSR مع رمز OTP من بوابة فاتورة. +3. **فحوص الامتثال:** تُوقَّع عينة من كل نوع (فاتورة، دائن، مدين؛ ضريبي ومبسط حسب نوع الوحدة) وتُرسل للفحص. +4. **شهادة الإنتاج:** بعد نجاح الفحوص تصبح الوحدة «مفعّلة» وتبدأ توقيع الفواتير. + +### عند إصدار فاتورة مبيعات أو مرتجع + +- إذا كانت للشركة (أو الفرع) وحدة مفعّلة: يُقفل صف الوحدة، ويُعطى المستند ICV التالي وبصمة المستند السابق، ويُبنى XML ويوقَّع ويُحفظ مع QR، كل ذلك **في معاملة الإصدار نفسها**. أي نقص في البيانات (عنوان المشتري في الفاتورة الضريبية، رمز الإعفاء للصفري والمعفى) يرفض الإصدار ولا يُرحَّل شيء. +- المرتجع يرث نوع الفاتورة الأصلية (ضريبي أو مبسط) ويحمل المرجع والسبب. +- **الإلغاء ممنوع** لأي مستند دخل سلسلة الفوترة (إلا إذا رفضته الهيئة)؛ التصحيح بإشعار دائن. +- إذا لم توجد وحدة مفعّلة: تُصدر الفاتورة عاديًا وتُطبع برمز QR للمرحلة الأولى (الحقول 1–5). + +### الإرسال + +| الحالة | المعنى | +|---|---| +| `PENDING` | بانتظار الإبلاغ (مبسطة) أو الاعتماد (ضريبية) | +| `REPORTED` / `CLEARED` | قبلتها الهيئة (مع علامة إن وُجدت تحذيرات)؛ للضريبية يُحفظ XML المعتمد من الهيئة | +| `REJECTED` | رفض نهائي مع رسائل الأخطاء؛ يمكن إلغاء المستند وإصداره من جديد | + +فشل الاتصال أو أخطاء 5xx أو 401/403 تُبقي الفاتورة معلقة مع إعادة محاولة متباعدة (2، 4، 8… حتى 6 ساعات). كل استدعاء يُسجَّل في `zatca_submissions` دون شهادات أو أسرار، والرسائل في `zatca_errors`. حجز قصير للصف يمنع إرسالها مرتين. + +### الجداول (`0012_zatca.sql`) + +| الجدول | الغرض | +|---|---| +| `zatca_devices` | الوحدات: البيئة، حقول الشهادة، المفتاح الخاص والأسرار مشفرة، شهادتا الامتثال والإنتاج، نتائج الفحوص، آخر ICV وآخر بصمة. وحدة حية واحدة لكل شركة أو فرع | +| `zatca_invoices` | فاتورة إلكترونية لكل مستند: النوع، UUID، ICV، البصمة والسابقة، QR، الحالة والمحاولات. هويتها لا تتغير (trigger)، والمقبولة لا تتغير حالتها | +| `zatca_documents` | XML الموقّع، وXML المعتمد من الهيئة (للإضافة فقط) | +| `zatca_submissions`، `zatca_errors` | سجل الاستدعاءات ورسائل التحقق (للإضافة فقط) | +| `invoice_hashes` | سلسلة البصمات: صف لكل ICV، وفريد على (الوحدة، البصمة السابقة) فلا تتفرع السلسلة | +| `companies` | حقول العنوان الوطني: رقم المبنى، الشارع، الحي، الرمز البريدي، الرقم الإضافي | +| `products` | `vat_exemption_code` و`vat_exemption_reason` | + +دالة `zatca_due_invoices` (SECURITY DEFINER) تعيد معرّفات الفواتير المستحقة للمُرسل فقط، ويعمل الإرسال نفسه في سياق كل منشأة. + +### الإعدادات + +| المتغير | الوصف | +|---|---| +| `ZATCA_ENCRYPTION_KEY` | 32 بايت بترميز base64 لتشفير المفاتيح والأسرار. **إلزامي في الإنتاج** (`openssl rand -base64 32`)؛ في التطوير يُشتق من `JWT_SECRET` | +| `ZATCA_WORKER`، `ZATCA_WORKER_INTERVAL_SECONDS` | تشغيل المُرسل في الخلفية وفاصله (افتراضيًا كل 60 ثانية) | +| `ZATCA_GATEWAY_URL` | عنوان بديل للبوابة (وسيط، أو بوابة محلية للاختبار) | + +### واجهات API (المرحلة 8) + +| الطريقة | المسار | الصلاحية | +|---|---|---| +| GET / POST | `/api/zatca/devices`، `/api/zatca/devices/:id` | `zatca.view` / `zatca.manage` | +| POST | `/api/zatca/devices/:id/compliance-csid` (otp) | `zatca.manage` | +| POST | `/api/zatca/devices/:id/compliance-checks` | `zatca.manage` | +| POST | `/api/zatca/devices/:id/activate` | `zatca.manage` | +| POST | `/api/zatca/devices/:id/revoke` (reason) | `zatca.manage` | +| GET | `/api/zatca/invoices` (status، documentType) مع ملخص المعلّق والمرفوض والمتأخر | `zatca.view` | +| GET | `/api/zatca/invoices/:id` مع الاستدعاءات والرسائل | `zatca.view` | +| GET | `/api/zatca/invoices/:id/xml?kind=SIGNED\|CLEARED` | `zatca.view` | +| POST | `/api/zatca/invoices/:id/submit` | `invoice.post` | +| GET | `/api/zatca/document?type&id` — QR (المرحلة الثانية أو الأولى) للطباعة | `invoice.view` | + +### الواجهة + +- صفحة «الفوترة الإلكترونية»: إنشاء الوحدة وخطوات الربط مع حالتها، ونتائج فحوص الامتثال، وبيانات الشهادة، وسجل الفواتير الإلكترونية مع التفاصيل والرسائل وتنزيل XML. +- صفحة الفاتورة والمرتجع: حالة الفاتورة الإلكترونية وزر الإبلاغ أو طلب الاعتماد. +- طباعة «فاتورة ضريبية» أو «فاتورة ضريبية مبسطة» أو «إشعار دائن» مع رمز QR. الفاتورة الضريبية غير المعتمدة تُطبع بتنبيه أنها لا تُسلَّم للعميل. +- إعدادات الشركة: العنوان الوطني. بطاقة الصنف: رمز سبب الإعفاء أو النسبة الصفرية من قائمة الهيئة. + +## المرحلة 9: الاشتراكات ولوحة مدير المنصة + +### الباقات والاشتراكات + +- **الأسعار والحدود بيانات لا كود:** جدول `plans` يحمل السعر الشهري والسنوي، أيام التجربة، أيام السماح، والحدود: المستخدمون، الشركات، الفروع، المستودعات، المنتجات، فواتير المبيعات الشهرية، التخزين، طلبات API الشهرية. القيمة الفارغة تعني غير محدود. يعدّلها مدير المنصة فقط. +- الترحيل يضيف باقة واحدة افتراضية «الباقة التجريبية» بسعر صفر و14 يوم تجربة، ليبدأ عليها التسجيل الجديد. الباقات المدفوعة يُنشئها المدير من اللوحة. +- **كل منشأة جديدة** تبدأ اشتراكًا على الباقة الافتراضية (تجريبيًا إن كان للباقة أيام تجربة)، ويُسجَّل الحدث في `billing_events`. +- `subscription_items` تحفظ سعر الباقة وقت الاشتراك أو التغيير، فتعديل سعر الباقة لا يغيّر اشتراكات قائمة. +- يمكن للمدير تخصيص حدود منشأة بعينها (`limit_overrides`) تعلو حدود الباقة. + +### حالة الاشتراك (تُحسب من التواريخ، بلا مهام مجدولة) + +| الحالة | المعنى | الكتابة | +|---|---|---| +| `TRIALING` / `ACTIVE` | داخل الفترة | مسموحة | +| `GRACE` | انتهت الفترة وما زالت ضمن أيام السماح | مسموحة مع تنبيه | +| `EXPIRED` | تجاوزت أيام السماح | **للاطلاع فقط**: كل طلب تعديل يُرفض بـ 402 `SUBSCRIPTION_INACTIVE` | +| `NONE` | أُلغي الاشتراك ولا يوجد غيره | للاطلاع فقط | + +تبقى صفحات الدخول والخروج والاشتراك متاحة دائمًا ليتمكن المالك من التجديد. لا تُحذف أي بيانات عند الانتهاء. + +### تطبيق الحدود + +| الحد | يُفحص عند | +|---|---| +| المستخدمون | إضافة مستخدم للمنشأة | +| الشركات، الفروع، المستودعات | إنشاؤها | +| المنتجات | إنشاء منتج | +| فواتير المبيعات الشهرية | إصدار فاتورة (يُرفض الإصدار وتبقى مسودة) | +| طلبات API الشهرية | كل طلب موثّق (429)، باستثناء الدخول والاشتراك ولوحة المنصة | + +الفحص يقفل صف الاشتراك (`FOR UPDATE`)، فلا تتجاوز الطلبات المتزامنة الحد معًا. الرد 402 `PLAN_LIMIT_REACHED` مع الحد والاستخدام. طلبات API تُعدّ في الذاكرة وتُكتب كل 15 ثانية في `usage_records` عبر دالة `usage_increment`. + +### الدفع + +**الدفع الإلكتروني غير مدمج.** المالك يطلب تغيير الباقة من صفحة الاشتراك (حدث `PLAN_CHANGE_REQUESTED`)، ومدير المنصة يغيّر الباقة ويسجّل الدفعة المستلمة (تحويل بنكي…) فيمتد الاشتراك بعدد الدورات المدفوعة. الاشتراك المنتهي أو التجريبي يبدأ من يوم الدفع، والنشط يُكمل من نهاية فترته. + +### لوحة مدير المنصة (`/admin`) + +- الوصول بعلامة `users.is_platform_admin` فقط، تُقرأ من قاعدة البيانات في كل طلب. تُمنح من سطر الأوامر: + ```bash + npm run admin -w apps/api -- grant someone@example.com + ``` +- **القراءة عبر المنشآت:** سياسة RLS إضافية للقراءة فقط على كل جداول المنشآت، لا تعمل إلا داخل معاملة فيها `app.platform_admin = on`، ولا يضبطها إلا حارس اللوحة بعد التحقق من العلامة. **الكتابة** على بيانات منشأة تتم في سياق تلك المنشأة نفسها. +- الصفحات: نظرة عامة وصحة النظام، المنشآت (إنشاء مع كلمة مرور مؤقتة تُعرض مرة واحدة، إيقاف وتفعيل، تغيير الباقة، تسجيل دفعة، تمديد، إلغاء، تخصيص الحدود والخصائص)، المستخدمون (تعطيل، فك القفل، منح وسحب صلاحية المنصة)، الاشتراكات، الباقات، الإيرادات (المحصّل شهريًا والإيراد الشهري المتكرر)، الاستخدام، السجلات (إجراءات المديرين، تدقيق المنشآت، أخطاء النظام)، خصائص التشغيل. +- كل إجراء للمدير يُسجَّل في `platform_audit_logs`، وما يخص منشأة يُسجَّل أيضًا في سجل تدقيقها لتراه. +- أخطاء 500 تُسجَّل في `system_errors` (المسار، المنشأة، الرسالة، التتبع) دون أن تؤخر الرد. +- **خصائص التشغيل:** قيمة عامة لكل خاصية، ويمكن تخصيصها لمنشأة. أول خاصية `zatca_einvoicing` تتحكم في ربط وحدات الفوترة الإلكترونية وظهور صفحتها (لا تؤثر على توقيع فواتير وحدة مفعّلة). + +### الجداول الجديدة (`0013_subscriptions_admin.sql`) + +| الجدول | الغرض | +|---|---| +| `plans` | الباقات والأسعار والحدود؛ قراءة للجميع، كتابة للمدير فقط (RLS) | +| `subscriptions` | اشتراك حالي واحد لكل منشأة، والملغاة تبقى سجلًا | +| `subscription_items` | بنود الاشتراك بالسعر وقت الاشتراك | +| `usage_records` | الاستخدام الشهري المقاس | +| `billing_events` | سجل الفوترة (للإضافة فقط) | +| `feature_flags`، `tenant_feature_flags` | الخصائص وتخصيصها | +| `system_errors` | أخطاء الخادم؛ الإدخال من أي طلب، القراءة للمدير فقط | +| `platform_audit_logs` | إجراءات مديري المنصة (للإضافة فقط) | + +### واجهات API (المرحلة 9) + +| الطريقة | المسار | الصلاحية | +|---|---|---| +| GET | `/api/subscription` (الحالة، الحدود، الاستخدام، البنود، الخصائص) | أي مستخدم | +| GET | `/api/subscription/plans` | أي مستخدم | +| GET | `/api/subscription/billing-events` | `subscription.manage` | +| POST | `/api/subscription/request-change` | `subscription.manage` | +| GET | `/api/admin/overview`، `/health`، `/subscriptions`، `/revenue`، `/usage` | مدير المنصة | +| GET / POST | `/api/admin/tenants`، `/api/admin/tenants/:id` | مدير المنصة | +| POST | `/api/admin/tenants/:id/suspend`، `/activate` | مدير المنصة | +| POST | `/api/admin/tenants/:id/subscription/plan`، `/limits`، `/payment`، `/extend`، `/cancel` | مدير المنصة | +| PUT | `/api/admin/tenants/:id/features/:key` | مدير المنصة | +| GET / POST / PATCH | `/api/admin/plans` | مدير المنصة | +| GET / POST | `/api/admin/users`، `/api/admin/users/:id/status`، `/unlock`، `/platform-admin` | مدير المنصة | +| GET | `/api/admin/audit-logs`، `/platform-logs`، `/errors` | مدير المنصة | +| GET / POST / PATCH | `/api/admin/feature-flags` | مدير المنصة | + +`/api/auth/me` يعيد أيضًا حالة الاشتراك والخصائص الفعّالة، وتعرض الواجهة تنبيهًا عند قرب نهاية التجربة أو في فترة السماح أو بعد الانتهاء. + +## المرحلة 10: الاختبار والأمان والأداء والجاهزية للإنتاج + +### المراجعة الأمنية + +راجعنا الكود كاملًا بحثًا عن الثغرات. لم نجد حقن SQL ولا XSS ولا تسريبًا بين المنشآت ولا تجاوزًا للصلاحيات. وُجدت المشكلات التالية وأُصلحت جميعها مع اختبار لكل منها: + +| المشكلة | الخطورة | الإصلاح | +|---|---|---| +| الثقة بترويسة `X-Forwarded-For` من أي مصدر | عالية | الإعداد `TRUST_PROXY` (افتراضيًا معطّل): عدد الوكلاء أو عناوينهم فقط. nginx يستبدل الترويسة ولا يضيف إليها | +| ضم مستخدم موجود لمنشأة دون موافقته، وكشف اسمه للمنشأة الداعية | متوسطة | العضوية تبدأ بحالة `INVITED`، ولا يرى الداعي الاسم أو آخر دخول. يقبل المستخدم أو يرفض من شريط في الواجهة (`/api/auth/invitations`) | +| قفل الحساب يتيح لأي شخص حبس صاحب الحساب، ورمز 423 يكشف وجود الحساب | متوسطة | القفل لكل (مستخدم، عنوان IP) في جدول `login_failures`، والرد 401 نفسه في كل الحالات | +| حقن معادلات في ملفات CSV المصدّرة (`=HYPERLINK(...)`) | متوسطة | النص الذي يبدأ بـ `= + - @` يُسبق بـ `'`، والأرقام السالبة تبقى أرقامًا | +| إعادة تفعيل عضو معطّل تتجاوز حد المستخدمين في الباقة | منخفضة | فحص الحد عند التفعيل وعند قبول الدعوة | +| تعطيل عضو يملك صلاحيات أكثر من المدير الذي يعطّله | منخفضة | يلزم أن يملك المنفّذ كل صلاحيات العضو المستهدف | +| جداول الاشتراك والفوترة والخصائص قابلة للكتابة من سياق المنشأة في قاعدة البيانات | منخفضة | سياسات RLS منفصلة للقراءة والكتابة؛ الكتابة لمدير المنصة أو أثناء إنشاء المنشأة فقط. trigger يمنع دور التطبيق من تغيير حالة المنشأة أو صلاحية المنصة لمستخدم | +| لا حد لمدة الاستعلام أو طول فترة التقرير، والأرقام الضخمة ترد 500 | منخفضة | `statement_timeout` (15 ثانية → 503)، فترة التقرير 5 سنوات كحد أقصى، والرمز 22003 يرد 400 | +| تبويبان يجددان الرمز معًا فيُكشف ذلك كسرقة رمز ويُسجَّل خروج المستخدم | تشغيلية | التجديد يمر عبر Web Locks API، فتنتظر التبويبات دورها | +| فئة مصروف مرتبطة بحساب نقدية أو بنك أو حساب رقابة | منطق محاسبي | الحسابات النظامية وحسابات طرق الدفع مرفوضة لفئات المصروفات | + +ترحيل `0014_security_hardening.sql` يضيف `login_failures` والسياسات والـ triggers. + +### الأداء + +السكربت `apps/api/scripts/perf.ts` يملأ قاعدة منفصلة (اسمها ينتهي بـ `_perf`) ثم يقيس كل واجهة 12 مرة. البيانات: 200 عميل، 50 موردًا، 200 صنف، 100 فاتورة شراء، 3000 فاتورة مبيعات بثلاثة سطور مع تحصيل لنصفها ومصروف لكل خمس، أي 5200 قيد و19,950 سطرًا و4000 حركة مخزون. سرعة الإدخال 14.6 فاتورة في الثانية، والفاتورة الواحدة تشمل القيد والتكلفة والضريبة وسلسلة ZATCA. + +| الواجهة | الوسيط (ms) | p95 (ms) | +|---|---|---| +| لوحة المؤشرات | 126 | 146 | +| قائمة الدخل | 31 | 47 | +| الميزانية | 65 | 86 | +| التدفقات النقدية | 70 | 82 | +| ميزان المراجعة | 26 | 32 | +| دفتر الأستاذ | 53 (كان 2274) | 74 (كان 5220) | +| أعمار الذمم | 42 | 53 | +| إقرار الضريبة | 19 | 29 | +| قوائم الفواتير والقيود والعملاء | 11–29 | 17–47 | +| `/api/auth/me` | 6 | 10 | + +دفتر الأستاذ وكشف الحساب كانا يجلبان رقم المستند باستعلام فرعي لكل سطر. مع RLS لا يستخدم PostgreSQL الفهرس في هذا النوع من الاستعلام الفرعي، فيقرأ جداول المدفوعات والمصروفات كاملة لكل سطر. استُبدل ذلك بربط واحد مع اتحاد أرقام المستندات (`DOC_NUMBERS`)، فصار الاستعلام أسرع بـ 43 مرة. + +وفي الواجهة تُحمَّل أقسام التقارير والإدارة والفوترة الإلكترونية والمصروفات والإعدادات عند فتحها، فنزل حجم الحزمة الأولى من 529 إلى 394 كيلوبايت (110 مضغوطة). والخط العربي صار مستضافًا مع التطبيق بدل Google Fonts. + +### الجاهزية للإنتاج + +- `deploy/api.Dockerfile` و`deploy/web.Dockerfile`: بناء متعدد المراحل، اعتماديات الإنتاج فقط، مستخدم غير root، وفحص صحة. +- `docker-compose.prod.yml`: PostgreSQL، ثم مهمة الترحيلات، ثم الـ API بنظام ملفات للقراءة فقط، ثم nginx. لا ينشر منفذًا إلا nginx. +- `deploy/nginx.conf`: CSP صارمة (`script-src 'self'`، لا مصادر خارجية)، و`X-Frame-Options`، و`nosniff`، و`Referrer-Policy`، و`Permissions-Policy`. الملفات ذات البصمة تُخزَّن سنة، و`index.html` لا يُخزَّن. +- `/api/ready`: يرد 503 حتى تتطابق قاعدة البيانات مع ترحيلات النسخة. أما `/api/health` ففحص حياة فقط. +- `scripts/backup.sh` و`scripts/restore.sh`: نسخة `pg_dump` مع التحقق منها، وحذف القديم، واستعادة تحفظ الملكية والصلاحيات (فتبقى RLS سارية). +- `.github/workflows/ci.yml`: فحص الأنواع، واختبارات الـ API على PostgreSQL، واختبارات الواجهة، والبناء، وبناء الصور. يعمل حين يُنقل المشروع إلى مستودع مستقل، لأن GitHub يقرأ مجلد `.github` من جذر المستودع فقط. +- `e2e/smoke.mjs` (`npm run e2e`): اختبار في متصفح حقيقي على نسخة منشورة. يفشل عند أي خطأ في المتصفح، وقد كشف أول تشغيل له أن CSP تحجب خط Google. + +جُرِّب كل ما سبق فعليًا: بُنيت الصور، وشُغّل الإعداد كاملًا، ونجح التسجيل واختبار المتصفح عبر nginx. وتأكدنا أن القفل يسجل عنوان العميل الحقيقي ويتجاهل الترويسات المزوّرة، وأن النسخة الاحتياطية تُستعاد مع بقاء ملكية الجداول وصلاحيات دور التطبيق. التفاصيل في [دليل النشر](docs/DEPLOYMENT.md). + +### مطابقة الاختبارات المطلوبة + +| المطلوب | أين يُختبر | +|---|---| +| قيد متوازن / غير متوازن | `journal-entries.test.ts` | +| فاتورة 1000 ريال: مدين ذمم 1150 / دائن مبيعات 1000 / دائن ضريبة 150 | `journal-entries.test.ts`، `sales.test.ts`، `e2e/smoke.mjs` | +| ترحيل الفاتورة وإلغاؤها ومرتجعها | `sales.test.ts`، `inventory.test.ts` | +| الدفعات | `payments.test.ts` | +| المشتريات | `purchases.test.ts` | +| حركة المخزون والتحويل بين المستودعات | `inventory.test.ts` | +| حساب الضريبة | `calc.test.ts`، `vat-return.test.ts`، `expenses.test.ts` | +| عزل المنشآت | `tenant-isolation.test.ts` وقسم العزل في كل ملف | +| الصلاحيات | `rbac.test.ts` وقسم الصلاحيات في كل ملف، `hardening.test.ts` | +| سجل التدقيق | `audit.test.ts` | +| التقارير | `reports.test.ts`، `vat-return.test.ts` | +| الأمان والإعدادات والترحيلات والأخطاء | `hardening.test.ts`، `auth.test.ts`، `admin.test.ts` | + +![دعوة مستخدم موجود](docs/screenshots/p10-invitation.png) +![العضو المدعو قبل القبول](docs/screenshots/p10-users-invited.png) + +## تدفق المصادقة + +1. **التسجيل** ينشئ في معاملة واحدة: المستخدم، المنشأة، إعداداتها (SAR، Asia/Riyadh)، الشركة الافتراضية، الفرع الرئيسي، المستودع الرئيسي، العضوية بدور `TENANT_OWNER`، وسجل تدقيق. +2. **الدخول** يتحقق من كلمة المرور بـ argon2id. البريد غير الموجود يمر بنفس زمن التحقق ويعيد الرسالة نفسها. بعد 5 محاولات فاشلة يُقفل الحساب 15 دقيقة. +3. يُصدر **Access Token** (JWT لمدة 15 دقيقة يحمل `sub` و`tid` المنشأة و`sid` الجلسة) و**Refresh Token** عشوائي (32 بايت) يُخزن hash له فقط، ويُرسل في Cookie بخصائص `HttpOnly` و`SameSite=Strict` و`Secure` في الإنتاج، ومساره `/api/auth`. +4. **في كل طلب** يُعاد التحقق من قاعدة البيانات: الجلسة غير ملغاة، المستخدم نشط، العضوية نشطة، المنشأة غير معلقة. وتُحمَّل الصلاحيات من جديد، فتغيير الأدوار أو التعطيل أو الخروج يسري فورًا. +5. **التجديد** يدوّر الرمز. إذا قُدّم رمز سبق تدويره (سرقة أو إعادة استخدام) تُلغى كل جلسات المستخدم ويُسجل `TOKEN_REUSE_DETECTED`. +6. **تبديل المنشأة** ينقل الجلسة للمنشأة الجديدة، فيصبح الـ Access Token القديم غير صالح. +7. **تغيير كلمة المرور** يُخرج المستخدم من جميع الأجهزة الأخرى. + +الواجهة تحفظ الـ Access Token في الذاكرة فقط (لا `localStorage`)، وتستعيد الجلسة عند تحديث الصفحة عبر الـ Cookie. + +## عزل المنشآت (Tenant Isolation) + +ثلاث طبقات مستقلة: + +1. **التطبيق:** `tenant_id` يؤخذ دائمًا من الجلسة الموثقة، لا من جسم الطلب أو الرابط. كل استعلام يضيف `WHERE tenant_id = $1`. طلب سجل منشأة أخرى يعيد 404 لا 403، حتى لا يكشف وجوده. +2. **قاعدة البيانات (RLS):** الـ API يتصل بدور `alshuyukh_app` غير المميز (`NOBYPASSRLS`). كل معاملة تضبط `app.tenant_id` و`app.user_id` بـ `set_config(..., true)` فتنتهي بانتهاء المعاملة ولا تتسرب لطلب لاحق على الاتصال نفسه. سياسات RLS ترفض القراءة والكتابة خارج المنشأة حتى لو نسي الكود الفلتر. بلا سياق، النتيجة فارغة. +3. **القيود:** مفاتيح أجنبية مركّبة `(id, tenant_id)` تمنع الربط بين منشأتين. دور التطبيق لا يملك صلاحية `DELETE` على الجداول التجارية (حذف ناعم فقط)، ولا يستطيع تعديل أدوار النظام. + +## الصلاحيات (RBAC) + +- المصدر الوحيد: `apps/api/src/modules/rbac/catalog.ts`، يُزامَن مع قاعدة البيانات عند `db:migrate`. +- 45 صلاحية (منها صلاحيات المراحل القادمة، حتى تبقى الأدوار ثابتة). +- أدوار النظام: `TENANT_OWNER`، `COMPANY_ADMIN`، `ACCOUNTANT`، `SALES_MANAGER`، `SALES_EMPLOYEE`، `PURCHASE_MANAGER`، `WAREHOUSE_MANAGER`، `WAREHOUSE_EMPLOYEE`، `VIEWER`. +- `SUPER_ADMIN` ليس دورًا داخل منشأة؛ هو علامة منصة `users.is_platform_admin` ولوحته `/admin` (المرحلة 9). +- **منع تصعيد الصلاحيات:** لا يمكن منح (أو سحب) صلاحية لا تملكها، عند إنشاء دور أو تعديله أو إسناده. لا يمكن تغيير أدوارك أو حالتك بنفسك. المالك لا يُعطّل ولا يُجرّد من دور `TENANT_OWNER`. أدوار النظام غير قابلة للتعديل (ومحمية بـ RLS أيضًا). +- يمكن للمنشأة إنشاء أدوار مخصصة. + +## سجل التدقيق + +يُكتب داخل المعاملة نفسها التي تُجري التغيير، فلا يوجد تغيير بلا سجل. يسجل: `REGISTER`، `LOGIN`، `LOGIN_FAILED`، `LOGOUT`، `TOKEN_REUSE_DETECTED`، `TENANT_SWITCH`، `PASSWORD_CHANGE`، `CREATE`، `UPDATE`، `DELETE`، `SETTINGS_CHANGE`، `PERMISSION_CHANGE`، مع القيم قبل وبعد، وعنوان IP، والـ User-Agent، ومعرّف الطلب. تُحذف كلمات المرور والرموز من القيم قبل الحفظ. + +## الأمان + +argon2id لكلمات المرور · JWT قصير العمر مع جلسات قابلة للإلغاء · RBAC · عزل بثلاث طبقات · Rate limiting (عام 300/دقيقة، الدخول والتسجيل 10/دقيقة، التجديد 120/دقيقة) · Zod على كل المدخلات · SQL بمعاملات فقط، وأسماء الأعمدة في التحديثات من قائمة بيضاء · Helmet (CSP، HSTS، nosniff، frame-ancestors) · CORS بقائمة أصول محددة · حماية CSRF لمسارات الـ Cookie · إيقاف الدخول مؤقتًا لكل (مستخدم، عنوان IP) دون كشف ذلك · الثقة بالوكلاء المحددين فقط (`TRUST_PROXY`) · حد لمدة الاستعلام وطول فترة التقارير · حماية ملفات CSV من حقن المعادلات · CSP صارمة للواجهة عبر nginx · رسائل أخطاء لا تكشف التفاصيل الداخلية، والأخطاء الداخلية تُسجَّل في `system_errors`. + +## الاختبارات + +272 اختبارًا للـ API (وحدة وتكامل) على PostgreSQL حقيقي باستخدام دور التطبيق المقيد، فتُختبر سياسات RLS فعليًا. التغطية: 91% من العبارات، و82% من الفروع، و94% من الدوال، و95% من الأسطر (`npx vitest run --coverage` في `apps/api`). وفي الواجهة اختبارات لحساب المبالغ (`npm test -w apps/web`)، واختبار المتصفح `npm run e2e`. + +| الملف | ما يغطيه | +|---|---| +| `auth.test.ts` | التسجيل وما ينشئه، تخزين argon2id، التحقق من المدخلات، الدخول، تطابق رسائل الفشل، إيقاف الدخول لكل عنوان IP دون كشفه، تجاهل `X-Forwarded-For` بلا وكيل موثوق، رفض الرموز المعدلة، تدوير الرموز، كشف إعادة الاستخدام، الخروج، تغيير كلمة المرور، ترويسات الأمان | +| `tenant-isolation.test.ts` | محاولات منشأة B الوصول لبيانات A عبر المعرفات (قراءة، تعديل، حذف، فروع، مستخدمون، سجل التدقيق، تبديل، أدوار)؛ ثم مباشرة على قاعدة البيانات: الدور لا يتجاوز RLS، الاستعلام بلا فلتر، بلا سياق، الإدخال في منشأة أخرى، المفتاح المركّب، التعديل عبر المنشآت، أدوار النظام، منع الحذف | +| `rbac.test.ts` | سلامة الكتالوج، المحاسب والمشاهد، سريان تغيير الأدوار فورًا، التعطيل الفوري، منع التصعيد، حماية المالك، أدوار النظام، الأدوار المخصصة | +| `audit.test.ts` | تسجيل العمليات، القيم قبل/بعد، عدم تخزين الأسرار، منع التعديل والحذف، الترقيم | +| `journal-entries.test.ts` | مثال الفاتورة 1000 + ضريبة 150؛ الترحيل والترقيم المتسلسل؛ رفض القيد غير المتوازن مع التفاصيل؛ التراجع الكامل عند الفشل؛ دقة الكسور (0.10 + 0.20)؛ التحقق من كل سطر؛ الحسابات التجميعية وغير النشطة والأجنبية؛ غياب الفترة؛ ترقيم بلا فجوات تحت التزامن؛ تعديل وحذف المسودات؛ رفض تعديل القيد المرحّل عبر API وعبر SQL مباشر؛ فرض التوازن في قاعدة البيانات؛ العكس والتصحيح؛ الفترات المقفلة؛ الصلاحيات؛ العزل؛ سجل التدقيق؛ ميزان المراجعة | +| `chart-of-accounts.test.ts` | الدليل الافتراضي ومفاتيح النظام؛ السنة المالية التلقائية؛ تجهيز الشركات اللاحقة؛ الحسابات الفرعية وتحويل الحساب إلى تجميعي؛ منع الدوائر والتكرار وعدم تطابق النوع؛ حماية الحسابات النظامية وذات الرصيد؛ الإيقاف والحذف الناعم؛ مراكز التكلفة؛ العزل | +| `fiscal-years.test.ts` | إنشاء السنوات ومنع التداخل؛ السنة القصيرة؛ قفل شهر البداية؛ إقفال السنة بربح وبخسارة وبلا حركة؛ منع الترحيل وإعادة الفتح بعد الإقفال؛ اشتراط إقفال السنوات السابقة | +| `parties.test.ts` | لكل من العملاء والموردين: الترقيم التلقائي وتخطي الرموز المأخوذة؛ التحقق من الرقم الضريبي والسجل والهوية وصيغة العنوان الوطني والعنوان الافتراضي؛ البحث؛ التعديل واستبدال العناوين والإيقاف؛ حساب الرقابة؛ الرصيد من القيود الموسومة ومنع الحذف بعدها؛ العزل بما فيه وسم قيد بطرف من منشأة أخرى؛ الصلاحيات لكل دور؛ سجل التدقيق | +| `products.test.ts` | الوحدات الافتراضية؛ الوحدات والتصنيفات المتداخلة؛ السلع والخدمات؛ الأسعار بأربع خانات؛ التحقق والتكرار؛ الحسابات البديلة؛ البحث بالباركود؛ الحذف الناعم؛ رفض وحدات منشأة أخرى؛ الصلاحيات | +| `calc.test.ts` | مثال 1000 + 150؛ الخصم قبل الضريبة؛ التقريب مرة واحدة لكل فئة؛ الأسعار الشاملة؛ أربع خانات؛ الفئات الصفرية والمعفاة؛ رفض السطور غير الصحيحة | +| `sales.test.ts` | قيد الفاتورة المطلوب (AR 1150 / Sales 1000 / VAT 150)؛ الفاتورة المبسطة؛ الترقيم؛ الحساب دون حفظ؛ الأسعار الشاملة؛ منع التعديل بعد الإصدار عبر API و SQL؛ حد الائتمان؛ تغيير نسبة الضريبة بتاريخ؛ الإلغاء ومنعه مع الدفعات؛ المرتجع الجزئي ثم الكامل بلا فروق تقريب؛ إلغاء المرتجع؛ عروض الأسعار والتحويل؛ الصلاحيات؛ العزل؛ منع حذف المستخدَم؛ توازن الميزان | +| `payments.test.ts` | التحصيل الكامل والجزئي؛ سند واحد لعدة فواتير؛ الدفعة المقدمة ثم التخصيص؛ رفض التخصيص الزائد أو لطرف آخر أو لنوع خاطئ دون أثر؛ إلغاء السند؛ الاسترداد بعد المرتجع؛ منع تعديل السند في قاعدة البيانات؛ دفع المورد؛ طرق الدفع وحساب التسوية؛ الصلاحيات؛ العزل | +| `purchases.test.ts` | قيد فاتورة الشراء (مخزون + ضريبة مدخلات / موردون)؛ رقم فاتورة المورد مرة واحدة؛ سطور المصروفات والمعفاة؛ اشتراط حساب للخدمات؛ مرتجع المشتريات؛ الإلغاء؛ أوامر الشراء والتحويل؛ الصلاحيات | +| `inventory.test.ts` | الرصيد الافتتاحي مقابل رأس المال؛ الشراء والمتوسط؛ تكلفة المبيعات ضمن قيد الفاتورة؛ رفض البيع بلا رصيد دون أثر؛ المرتجع بتكلفة الخروج بالضبط؛ استعادة المخزون عند الإلغاء؛ تقريب 100 على 3 دون بقايا؛ مرتجع المشتريات بالمتوسط وفرق السعر؛ منع إلغاء شراء استُخدم؛ تحميل القيمة المتبقية؛ التحويلات بلا قيد؛ البيع من مستودع محدد؛ الجرد والتحقق؛ قفل نوع المنتج؛ منع تعديل الحركات وسالب الرصيد؛ الصلاحيات؛ العزل؛ مطابقة التقييم مع الدفتر بعد كل خطوة | +| `expenses.test.ts` | الفئات الافتراضية؛ قيد المصروف النقدي (مصروف + ضريبة مدخلات / نقدية) وتسجيل الحركة الضريبية؛ استخراج الضريبة من المبلغ الشامل؛ تغيير المعاملة الضريبية في السطر؛ تجميع السطور حسب الحساب ومركز التكلفة؛ المصروف الآجل على المورد وسداده بسند صرف ثم إلغاء السند؛ منع الإلغاء مع الدفعات؛ الإلغاء بقيد عكسي وعكس الضريبة؛ المسودات ومنع التعديل بعد الترحيل عبر API و SQL؛ التحقق من المدخلات؛ رفض فئة منشأة أخرى؛ الصلاحيات؛ العزل | +| `vat-return.test.ts` | بنود الإقرار من مبيعات خاضعة وصفرية ومعفاة ومرتجع وفاتورة ملغاة ومشتريات ومصروفات ومرتجع مشتريات؛ المطابقة مع الدفتر؛ كشف القيد اليدوي على حساب الضريبة؛ حصر الفترة؛ قائمة الحركات | +| `reports.test.ts` | سيناريو كامل (رأس مال، شراء، بيع، مرتجع، قبض، دفعة مقدمة، مصروف، دفع لمورد، فاتورة ملغاة) تتطابق عليه كل التقارير: قائمة الدخل؛ الميزانية متوازنة مع أرباح الفترة؛ التدفقات النقدية بالطريقة المباشرة ومطابقة الرصيد؛ دفتر الأستاذ بالرصيد المتحرك والافتتاحي وأرقام المستندات؛ اليومية؛ رفض الفترة المعكوسة؛ أعمار الذمم ومطابقتها للدفتر بما فيها الدفعة المقدمة؛ كشوف الحساب واختفاء الفاتورة الملغاة؛ المبيعات حسب الصنف مع التكلفة ومجمل الربح ومطابقتها لقائمة الدخل؛ المشتريات؛ المصروفات؛ لوحة المؤشرات والسلسلة الشهرية؛ استبعاد قيد الإقفال من الدخل وظهوره في الأرباح المحتجزة؛ فصل الصلاحيات المالية عن التشغيلية؛ العزل | +| `zatca.test.ts` | رمز QR بالعربية؛ CSR يقبله openssl بالقالب والحقول لكل بيئة؛ البصمة تستثني التوقيع وQR وتتغير بالمحتوى؛ كشف العبث بالمبلغ والتوقيع المزيف؛ BaseQuantity؛ تشفير الأسرار؛ رفض إنشاء الوحدة لنقص بيانات الشركة؛ الربط الكامل (OTP خاطئ، فحوص الامتثال الستة، التفعيل) وعدم حفظ الأسرار في السجل؛ وحدة واحدة لكل شركة؛ توقيع المبسطة عند الإصدار وسلسلة ICV/PIH؛ الإبلاغ ومنع الإلغاء والإشعار الدائن 381؛ اعتماد الضريبية وحفظ XML المعتمد؛ رفض الإصدار لنقص عنوان المشتري دون ترحيل؛ رمز الإعفاء للصفري؛ الرفض ورسائله وإتاحة الإلغاء؛ فشل الاتصال وإعادة المحاولة والتحذيرات؛ سلسلة سليمة مع إصدار متزامن؛ حماية الجداول في قاعدة البيانات؛ QR المرحلة الأولى دون وحدة؛ الصلاحيات؛ العزل؛ إلغاء الوحدة. بوابة بديلة (`test/zatca-fake.ts`) تُصدر شهادات حقيقية عبر openssl وتتحقق من البصمة والتوقيع | +| `subscriptions.test.ts` | الاشتراك التجريبي عند التسجيل وحدثه؛ كل حد (المستخدمون، الشركات، الفروع، المستودعات، المنتجات، الفواتير الشهرية) يرد 402 دون أثر جزئي؛ رفع الحد فورًا؛ عدم تجاوز الحد مع طلبات متزامنة؛ قياس طلبات API ورفضها بعد الحد مع بقاء صفحة الاشتراك؛ فترة السماح ثم القراءة فقط؛ طلب تغيير الباقة؛ تغيير الباقة وتسجيل الدفعة وعودة الكتابة؛ صلاحية طلب التغيير | +| `admin.test.ts` | المنع لغير المديرين؛ RLS يمنع المنشآت من تعديل الباقات وقراءة الأخطاء، ومن تعديل اشتراكها أو حالتها أو صلاحية المنصة عبر SQL مباشر؛ قائمة المنشآت والبحث والتفاصيل عبر المنشآت؛ إنشاء منشأة بكلمة مؤقتة إلزامية التغيير؛ الإيقاف والتفعيل وأثرهما على الأعضاء وظهورهما في سجل المنشأة؛ منع إيقاف منشأة المدير؛ تغيير الباقة والحدود والتمديد والدفع والإلغاء وإعادة البدء؛ باقة افتراضية واحدة متاحة؛ إدارة المستخدمين وصلاحية المنصة ومنع الإجراء على النفس؛ الخصائص العامة والمخصصة وأثرها على الفوترة الإلكترونية؛ الإيرادات والإيراد المتكرر والاستخدام والسجلات والأخطاء وصحة النظام | +| `companies.test.ts` | القيم السعودية الافتراضية، التحقق، التكرار، الحذف الناعم، ربط المستودع بفرع شركة أخرى، تجاهل `tenant_id` في جسم الطلب، دعوة مستخدم موجود وإخفاء بياناته حتى يقبل، القبول والرفض، العضوية في منشأتين والتبديل | +| `hardening.test.ts` | رفض إعدادات الإنتاج غير الآمنة؛ فحصا الحياة والجاهزية؛ الترحيلات لا تتكرر وترفض ترحيلًا معدّلًا؛ إخفاء الخطأ الداخلي وتسجيله؛ تحويل أخطاء القيود إلى رموز آمنة؛ رفض المدخلات المشوهة والكبيرة؛ فئات المصروفات ورفض حسابات النقدية؛ منع مدير المستخدمين من التحكم بمن يملك صلاحيات أكثر منه؛ حد فترة التقارير | + +## ملاحظات وقرارات تصميم + +- **المنشأة مقابل الشركة:** المنشأة (tenant) هي حدود العزل والاشتراك. يمكن أن تملك المنشأة أكثر من شركة. السجلات المالية في المراحل القادمة ستحمل `tenant_id` و`company_id` معًا. +- **جدول `users` بلا RLS:** الهوية عامة لأن الدخول يحتاج البحث بالبريد قبل معرفة المنشأة. لا يحتوي على بيانات تجارية، والوصول إليه يمر دائمًا عبر `user_tenants`. يمكن لاحقًا نقل البحث إلى دالة `SECURITY DEFINER` وتفعيل RLS عليه. +- **المبالغ المالية:** مخزنة `NUMERIC(18,2)` (حتى الهللة). `pg` مضبوط ليعيد `NUMERIC` كنص، والحسابات في الخادم بـ decimal.js، والـ API يقبل المبالغ نصوصًا فقط. الواجهة تعرض المجاميع أثناء الكتابة بالهللات (أعداد صحيحة) للعرض فقط، والخادم هو المرجع. +- **القيود العكسية:** الأصل يبقى بحالة `REVERSED` ويُحسب في الأرصدة مع قيده العكسي، فيبقى الدفتر كاملًا والأثر صفرًا. +- **الوقت:** كل الاتصالات بتوقيت UTC، والعرض بتوقيت المنشأة (افتراضيًا Asia/Riyadh). + +## المتبقي (TODO) + +- **المرحلة 10:** + - **لا تحقق من البريد الإلكتروني ولا استعادة لكلمة المرور**؛ يلزمهما مزود بريد. حتى ذلك الحين يعيد مدير المنصة تعيين الحساب. الدعوات داخل التطبيق فقط. + - **التكامل مع بوابة ZATCA الفعلية لم يُختبر بعد** (انظر المرحلة 8). كل اختبارات الفوترة الإلكترونية تعمل على بوابة بديلة محلية. + - جدولا `users` و`user_sessions` بلا RLS (الدخول يحتاج البحث بالبريد قبل معرفة المنشأة). الحماية في طبقة الـ API، والـ triggers تمنع تغيير حالة الحساب وصلاحية المنصة من دور التطبيق. + - لم يُجرَ اختبار اختراق خارجي ولا اختبار حمل بعدة مستخدمين متزامنين على خادم إنتاجي؛ الأرقام أعلاه لمستخدم واحد على بيانات كبيرة. + - إعداد النشر لخادم واحد. التوسع لعدة نسخ يحتاج Redis لحدود المحاولات والعدادات، وفصل مُرسل ZATCA. + - المراقبة والتنبيهات وجمع السجلات غير مدمجة (فحص `/api/ready` ولوحة صحة النظام متاحان للربط). + - ملف CI داخل مجلد المشروع لا يعمل حتى يُنقل المشروع إلى مستودع مستقل. +- دعوة المستخدمين بالبريد بدل كلمة المرور المؤقتة (مع نظام الإشعارات). +- رفع شعار الشركة (حاليًا رابط https فقط). +- نقل ملكية المنشأة. +- Rate limiting يعتمد على ذاكرة العملية؛ في الإنتاج بأكثر من نسخة يلزم Redis. +- لم تُطبَّق المصادقة الثنائية (2FA). +- **المرحلة 9:** + - **الدفع الإلكتروني غير مدمج** (مثل Moyasar أو HyperPay)؛ الدفعات تُسجَّل يدويًا من اللوحة، ولا تُصدر فاتورة ضريبية للاشتراك من المنصة. + - حد التخزين محفوظ في الباقة لكنه لا يُقاس لعدم وجود رفع ملفات بعد. + - المصادقة الثنائية لمديري المنصة غير مطبقة، ومدير المنصة يحتاج عضوية في منشأة ليسجل الدخول. + - الإيقاف ينهي جلسات المنشأة عند أول تجديد للرمز، فيحتاج أعضاؤها لتسجيل الدخول بعد إعادة التفعيل. + - لا إشعارات بالبريد قبل انتهاء الاشتراك؛ التنبيه داخل التطبيق فقط. + - عدادات طلبات API في ذاكرة العملية؛ قد تُفقد آخر 15 ثانية منها عند توقف مفاجئ، ومع عدة نسخ يكون الحد تقريبيًا. + - لا حذف نهائي لمنشأة من اللوحة (الإيقاف والإلغاء فقط)، ولا تصدير بياناتها. +- **المرحلة 8:** + - **لم يُختبر مع بوابة فاتورة الفعلية.** يلزم التحقق على بوابة المطورين والمحاكاة، ومطابقة الملفات مع ZATCA SDK (خصوصًا بصمة SignedProperties وشكل XAdES). + - **إشعار المدين (383) كمستند محاسبي غير مطبق**: الـ XML وفحوص الامتثال تدعمه، لكن لا يوجد مستند «إشعار مدين مبيعات» يُرحَّل ويُسجل في الضريبة. + - تجديد الشهادة قبل انتهائها (renewal) غير مطبق؛ تُنشأ وحدة جديدة بعد إلغاء القديمة. + - لا خصم على مستوى المستند في XML (الخصم على السطور فقط كما في المحاسبة). + - المشتري في الفاتورة الضريبية يُعرّف بالرقم الضريبي فقط؛ معرفات أخرى (CRN، هوية) للمشتري غير مرسلة. + - إرسال الفاتورة للعميل بالبريد وملف PDF/A-3 بالـ XML المضمّن غير مطبقين. + - المُرسل في الخلفية يعمل داخل عملية الـ API؛ مع عدة نسخ يكفي الحجز القصير لمنع التكرار، لكن طابور مستقل أفضل في الإنتاج. +- **المرحلة 7:** + - أعمار الذمم بتاريخ اليوم فقط؛ الأعمار بتاريخ سابق تحتاج إعادة بناء التخصيصات والمرتجعات حتى ذلك التاريخ. + - تصنيف التدفقات النقدية يعتمد على مجموعة الحساب (غير المتداولة → استثمارية، حقوق الملكية والالتزامات غير المتداولة → تمويلية)؛ الحساب بلا مجموعة يُعد تشغيليًا. الطريقة غير المباشرة غير مطبقة. + - لا مقارنة بين فترتين، ولا موازنات تقديرية. + - تقييم المخزون بتاريخ سابق غير متاح (الحالي فقط). + - التقارير تُحسب من سطور القيود مباشرة؛ مع حجم بيانات كبير يلزم جدول أرصدة شهرية مُجمّعة أو materialized view. + - التصدير CSV فقط؛ PDF و Excel غير مطبقين، والطباعة من المتصفح. + - مرشح الفرع متاح في الـ API وغير معروض في الواجهة. +- **المرحلة 6:** + - بنود الإقرار 2 (مبيعات المواطنين الصحية والتعليمية)، 4 (الصادرات)، 8 و9 (الاستيراد والتحويل العكسي)، 14 (تصحيحات الفترات السابقة) و15 (الرصيد المرحَّل) غير مدعومة وتظهر صفرًا. + - لا يُقفل الإقرار بعد تقديمه، ولا تُمنع المستندات بتاريخ فترة مُقدَّمة (يُغطّى جزئيًا بإقفال الفترة المالية). + - المصروفات المتكررة، والمرفقات (صورة الفاتورة)، وسلف الموظفين والعهد غير مطبقة. + - إعادة تصنيف سند مرحَّل تتم بالإلغاء وإعادة الإدخال. + - نسبة الضريبة واحدة للفئة `S` في كل تاريخ؛ لا نسب مخفضة. +- **المرحلة 5:** + - **متغيرات المنتج** (`product_variants`: مقاس، لون) غير مطبقة: تحتاج بُعدًا إضافيًا في سطور المستندات والحركات والأرصدة. البديل الحالي: صنف مستقل لكل متغير. + - **مواقع المستودع** (`warehouse_locations`: رفوف) غير مطبقة؛ الرصيد على مستوى المستودع. + - **FIFO** غير مطبق (الواجهة جاهزة). + - المتوسط المرجح دائم حسب ترتيب الترحيل: إدخال حركة بتاريخ سابق لا يعيد حساب تكلفة الحركات اللاحقة. + - لا يُلغى تحويل أو تسوية؛ التصحيح بمستند معاكس. + - السماح بالرصيد السالب كإعداد اختياري غير متاح. + - تنبيهات حد إعادة الطلب ونقص المخزون — مع نظام الإشعارات. + - قوائم الأسعار وتسعير العملاء. +- **المرحلة 4:** + - الخصم على مستوى المستند (لا السطر) غير مطبق. + - تعدد العملات: المستندات بعملة الشركة فقط. + - ~~منع إلغاء الفاتورة المُبلَّغة، رمز سبب الإعفاء، الطباعة ورمز QR~~ — نُفّذت في المرحلة 8. إرسال الفاتورة بالبريد ما زال غير مطبق. + - تقادم الذمم وكشف الحساب — المرحلة 7. + - تجاوز حد الائتمان بصلاحية خاصة غير متاح. +- **المرحلة 3:** + - استيراد العملاء والموردين والمنتجات من ملف Excel/CSV. +- **المرحلة 2:** + - تعدد العملات وأسعار الصرف: القيود حاليًا بعملة الشركة فقط. + - إعادة فتح سنة مالية مقفلة (عكس قيد الإقفال) غير مدعومة. + - استيراد الأرصدة الافتتاحية من ملف: حاليًا تُدخل بقيد يدوي. + - سير موافقات للقيود (من يُنشئ لا يُرحّل) غير مطبق؛ الفصل الحالي عبر صلاحيتي `journal.create` و `journal.post` فقط. + - دفتر الأستاذ وبقية التقارير في المرحلة 7. التقارير يجب أن تستثني قيد `YEAR_CLOSING` من قائمة الدخل. diff --git a/alshuyukh-accounting/apps/api/package.json b/alshuyukh-accounting/apps/api/package.json new file mode 100644 index 000000000000..1a60f07cdeff --- /dev/null +++ b/alshuyukh-accounting/apps/api/package.json @@ -0,0 +1,41 @@ +{ + "name": "@alshuyukh/api", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "tsx watch --env-file=../../.env src/server.ts", + "start": "node --env-file=../../.env dist/server.js", + "build": "tsc -p tsconfig.build.json && cp -r src/db/migrations dist/db/", + "typecheck": "tsc --noEmit", + "db:migrate": "tsx --env-file=../../.env src/db/migrate-cli.ts", + "test": "vitest run", + "admin": "tsx --env-file=../../.env src/admin-cli.ts", + "perf": "tsx --env-file=../../.env scripts/perf.ts" + }, + "dependencies": { + "@fastify/cookie": "^11.1.2", + "@fastify/cors": "^11.3.0", + "@fastify/helmet": "^13.1.1", + "@fastify/rate-limit": "^11.2.0", + "@xmldom/xmldom": "^0.9.12", + "argon2": "^0.45.1", + "decimal.js": "^10.6.0", + "fastify": "^5.12.5", + "fastify-plugin": "^5.1.0", + "jose": "^6.2.12", + "pg": "^8.23.1", + "qrcode": "^1.5.4", + "xml-crypto": "^6.3.2", + "zod": "^4.6.5" + }, + "devDependencies": { + "@types/node": "^22", + "@types/pg": "^8.23.1", + "@types/qrcode": "^1.5.6", + "@vitest/coverage-v8": "^5.0.3", + "tsx": "^4.23.15", + "typescript": "^5.9", + "vitest": "^5.0.3" + } +} diff --git a/alshuyukh-accounting/apps/api/scripts/perf.ts b/alshuyukh-accounting/apps/api/scripts/perf.ts new file mode 100644 index 000000000000..b1ab4955e01e --- /dev/null +++ b/alshuyukh-accounting/apps/api/scripts/perf.ts @@ -0,0 +1,127 @@ +/** + * Performance check. Builds one large organization through the real API code + * paths (invoices, payments, purchases, expenses), then times the heavy + * endpoints. Runs only against a database whose name ends in _perf, which it + * rebuilds from scratch: + * + * PERF_DATABASE_URL=postgres://alshuyukh_app:…/alshuyukh_perf \ + * PERF_DATABASE_URL_MIGRATE=postgres://alshuyukh_owner:…/alshuyukh_perf \ + * PERF_INVOICES=3000 npm run perf -w apps/api + * + * PERF_REUSE=1 skips the rebuild and only measures the data already there. + */ +import pg from 'pg'; +import { buildApp } from '../src/app.js'; +import { loadEnv } from '../src/config/env.js'; +import { createPool } from '../src/db/pool.js'; +import { migrate } from '../src/db/migrate.js'; + +const appUrl = process.env.PERF_DATABASE_URL; +const ownerUrl = process.env.PERF_DATABASE_URL_MIGRATE; +if (!appUrl || !ownerUrl || !/_perf$/.test(new URL(ownerUrl).pathname)) { + console.error('Set PERF_DATABASE_URL and PERF_DATABASE_URL_MIGRATE to a database whose name ends in _perf'); + process.exit(1); +} +const N = Number(process.env.PERF_INVOICES ?? 3000); +const REUSE = process.env.PERF_REUSE === '1'; +const EMAIL = 'perf@example.test'; + +if (!REUSE) { + const reset = new pg.Client({ connectionString: ownerUrl }); + await reset.connect(); + await reset.query('DROP SCHEMA public CASCADE; CREATE SCHEMA public;'); + await reset.end(); +} +await migrate(ownerUrl); +// Unlimited plan for the load (plan limits are not what is measured here). +const owner = new pg.Client({ connectionString: ownerUrl }); +await owner.connect(); +await owner.query(`UPDATE plans SET max_users = NULL, max_companies = NULL, max_branches = NULL, max_warehouses = NULL, max_products = NULL, + max_invoices_per_month = NULL, max_api_calls_per_month = NULL WHERE is_default`); + +const env = loadEnv({ ...process.env, DATABASE_URL: appUrl, NODE_ENV: 'test' }); +const pool = createPool(appUrl); +const app = await buildApp({ env, pool, logger: false }); +await app.ready(); + +let token = ''; +const call = async (method: string, url: string, payload?: unknown) => { + const res = await app.inject({ method: method as 'GET', url, payload: payload as object, headers: { authorization: `Bearer ${token}` } }); + if (res.statusCode >= 400) throw new Error(`${method} ${url}: ${res.statusCode} ${res.body.slice(0, 300)}`); + return res.json(); +}; + +const auth = REUSE + ? (await app.inject({ method: 'POST', url: '/api/auth/login', payload: { email: EMAIL, password: 'Str0ng-Passw0rd!' } })).json() + : (await app.inject({ method: 'POST', url: '/api/auth/register', payload: { fullName: 'Perf', email: EMAIL, password: 'Str0ng-Passw0rd!', tenantName: 'Perf Org', companyName: 'Perf Co' } })).json(); +token = auth.accessToken; +const [year] = (await call('GET', '/api/fiscal-years')).data; +const customers: string[] = (await call('GET', '/api/customers?limit=1')).data.map((x: { id: string }) => x.id); +if (!REUSE) { + customers.length = 0; + const day = (i: number) => { + const d = new Date(`${year.startDate}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + (i % 330)); + return d.toISOString().slice(0, 10); + }; + const t0 = Date.now(); + const methods = (await call('GET', '/api/payment-methods')).data; + const cash = methods.find((m: { code: string }) => m.code === 'CASH').id; + for (let i = 0; i < 200; i++) customers.push((await call('POST', '/api/customers', { nameAr: `عميل ${i}`, paymentTermsDays: 30 })).id); + const suppliers = []; + for (let i = 0; i < 50; i++) suppliers.push((await call('POST', '/api/suppliers', { nameAr: `مورد ${i}` })).id); + const services = []; + for (let i = 0; i < 150; i++) services.push((await call('POST', '/api/products', { nameAr: `خدمة ${i}`, productType: 'SERVICE', salePrice: String(50 + i) })).id); + const goods = []; + for (let i = 0; i < 50; i++) goods.push((await call('POST', '/api/products', { nameAr: `صنف ${i}`, productType: 'GOODS', salePrice: '100', purchasePrice: '60' })).id); + // Stock first so goods can be sold. + for (let i = 0; i < 100; i++) { + const bill = await call('POST', '/api/purchase-invoices', { partyId: suppliers[i % 50], docDate: day(0), lines: goods.slice(0, 10).map((g) => ({ productId: g, quantity: '200' })) }); + await call('POST', `/api/purchase-invoices/${bill.id}/post`); + } + console.log(`master data + purchases: ${((Date.now() - t0) / 1000).toFixed(1)}s`); + const t1 = Date.now(); + const cats = (await call('GET', '/api/expense-categories')).data; + for (let i = 0; i < N; i++) { + const inv = await call('POST', '/api/invoices', { partyId: customers[i % 200], docDate: day(i), lines: [ + { productId: services[i % 150], quantity: '2' }, { productId: services[(i * 7) % 150], quantity: '1' }, { productId: goods[i % 10], quantity: '1' }, + ] }); + const issued = await call('POST', `/api/invoices/${inv.id}/post`); + if (i % 2 === 0) { + await call('POST', '/api/payments', { direction: 'RECEIPT', customerId: customers[i % 200], paymentDate: day(i), methodId: cash, amount: issued.total, + allocations: [{ documentType: 'SALES_INVOICE', documentId: inv.id, amount: issued.total }] }); + } + if (i % 5 === 0) { + await call('POST', '/api/expenses', { expenseDate: day(i), paymentType: 'CASH', methodId: cash, post: true, lines: [{ categoryId: cats[i % cats.length].id, amount: String(100 + (i % 50)) }] }); + } + } + const seconds = (Date.now() - t1) / 1000; + const { rows: [c] } = await owner.query(`SELECT (SELECT count(*) FROM journal_entries) AS entries, (SELECT count(*) FROM journal_entry_lines) AS lines, + (SELECT count(*) FROM sales_invoices) AS invoices, (SELECT count(*) FROM stock_movements) AS movements`); + console.log(`${N} invoices (+ payments, expenses) in ${seconds.toFixed(1)}s → ${(N / seconds).toFixed(1)} invoices/s`); + console.log(`data: ${c.entries} journal entries, ${c.lines} lines, ${c.invoices} invoices, ${c.movements} stock movements`); + +} +const range = `dateFrom=${year.startDate}&dateTo=${year.endDate}`; +const cashAccount = (await call('GET', '/api/accounts?postableOnly=true')).data.find((a: { code: string }) => a.code === '1100').id; +const endpoints = [ + '/api/dashboard', `/api/reports/profit-loss?${range}`, `/api/reports/balance-sheet?asOf=${year.endDate}`, `/api/reports/cash-flow?${range}`, + `/api/reports/trial-balance?${range}`, `/api/reports/general-ledger?${range}&accountId=${cashAccount}`, `/api/reports/journal?${range}&limit=100`, + '/api/reports/receivables-aging', `/api/reports/customer-statement?${range}&partyId=${customers[0]}`, `/api/reports/sales?${range}&groupBy=product`, + `/api/reports/expenses?${range}`, `/api/reports/vat-return?${range}`, '/api/inventory/valuation', '/api/invoices?limit=50', '/api/journal-entries?limit=50', + '/api/customers?limit=50', `/api/customers/${customers[0]}`, '/api/auth/me', +]; +console.log('\nendpoint p50 ms p95 ms'); +for (const url of endpoints) { + const times: number[] = []; + for (let i = 0; i < 12; i++) { + const s = performance.now(); + await call('GET', url); + times.push(performance.now() - s); + } + times.sort((a, b) => a - b); + console.log(`${url.split('?')[0]!.padEnd(52)} ${times[5]!.toFixed(1).padStart(7)} ${times[11]!.toFixed(1).padStart(8)}`); +} +await app.close(); +await pool.end(); +await owner.end(); diff --git a/alshuyukh-accounting/apps/api/src/admin-cli.ts b/alshuyukh-accounting/apps/api/src/admin-cli.ts new file mode 100644 index 000000000000..21c30f7ee7fc --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/admin-cli.ts @@ -0,0 +1,21 @@ +/** + * Grants or revokes platform administrator access (the /admin panel). + * Runs with the migration (owner) connection, outside the API: + * npm run admin -w apps/api -- grant someone@example.com + * npm run admin -w apps/api -- revoke someone@example.com + */ +import pg from 'pg'; + +const [command, email] = process.argv.slice(2); +if (!['grant', 'revoke'].includes(command ?? '') || !email) { + console.error('Usage: admin '); + process.exit(1); +} +const url = process.env.DATABASE_URL_MIGRATE ?? process.env.DATABASE_URL; +if (!url) { console.error('DATABASE_URL_MIGRATE is not set'); process.exit(1); } +const client = new pg.Client({ connectionString: url }); +await client.connect(); +const { rowCount } = await client.query(`UPDATE users SET is_platform_admin = $2 WHERE email = $1 AND deleted_at IS NULL`, [email, command === 'grant']); +await client.end(); +if (!rowCount) { console.error(`No user with e-mail ${email}`); process.exit(1); } +console.log(`${command === 'grant' ? 'Granted' : 'Revoked'} platform administrator access for ${email}`); diff --git a/alshuyukh-accounting/apps/api/src/app.ts b/alshuyukh-accounting/apps/api/src/app.ts new file mode 100644 index 000000000000..b667894315e2 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/app.ts @@ -0,0 +1,160 @@ +import cookie from '@fastify/cookie'; +import cors from '@fastify/cors'; +import helmet from '@fastify/helmet'; +import rateLimit from '@fastify/rate-limit'; +import Fastify, { type FastifyInstance } from 'fastify'; +import type pg from 'pg'; +import type { Env } from './config/env.js'; +import { AppError } from './lib/errors.js'; +import { TokenService } from './lib/tokens.js'; +import accountsRoutes from './modules/accounting/accounts.routes.js'; +import fiscalRoutes from './modules/accounting/fiscal.routes.js'; +import journalRoutes from './modules/accounting/journal.routes.js'; +import trialBalanceRoutes from './modules/accounting/trial-balance.routes.js'; +import auditRoutes from './modules/audit/audit.routes.js'; +import { CUSTOMER, SUPPLIER, partyRoutes } from './modules/parties/parties.routes.js'; +import productsRoutes from './modules/products/products.routes.js'; +import documentsModule from './modules/documents/routes.js'; +import inventoryRoutes from './modules/inventory/routes.js'; +import expenseRoutes from './modules/expenses/routes.js'; +import taxRoutes from './modules/tax/routes.js'; +import commercialReportRoutes from './modules/reports/commercial.routes.js'; +import dashboardRoutes from './modules/reports/dashboard.routes.js'; +import { encryptionKey } from './modules/zatca/crypto.js'; +import zatcaRoutes from './modules/zatca/routes.js'; +import adminPlatformRoutes from './modules/admin/platform.routes.js'; +import adminTenantRoutes from './modules/admin/tenants.routes.js'; +import subscriptionRoutes from './modules/subscriptions/routes.js'; +import { pendingMigrations } from './db/migrate.js'; +import { flushUsage } from './modules/subscriptions/service.js'; +import { configureZatca } from './modules/zatca/service.js'; +import { setGatewayUrl } from './modules/zatca/client.js'; +import financialReportRoutes from './modules/reports/financial.routes.js'; +import partyReportRoutes from './modules/reports/parties.routes.js'; +import authRoutes from './modules/auth/auth.routes.js'; +import companiesRoutes from './modules/companies/companies.routes.js'; +import rbacRoutes from './modules/rbac/rbac.routes.js'; +import settingsRoutes from './modules/settings/settings.routes.js'; +import usersRoutes from './modules/users/users.routes.js'; +import authPlugin from './plugins/auth.js'; + +// PostgreSQL error codes mapped to client errors. +const PG_ERRORS: Record = { + '23505': [409, 'DUPLICATE', 'A record with the same unique value already exists'], + '23503': [400, 'INVALID_REFERENCE', 'A referenced record does not exist'], + '23514': [400, 'CONSTRAINT_VIOLATION', 'A value is outside the allowed range or format'], + '42501': [403, 'FORBIDDEN', 'Operation not permitted'], + '22003': [400, 'VALUE_OUT_OF_RANGE', 'A number is too large'], + '57014': [503, 'QUERY_TIMEOUT', 'The request took too long; narrow the date range or filters'], +}; + +export interface BuildOptions { + env: Env; + pool: pg.Pool; + logger?: boolean; +} + +export async function buildApp({ env, pool, logger = true }: BuildOptions): Promise { + const app = Fastify({ + logger: logger ? { level: env.NODE_ENV === 'production' ? 'info' : 'debug', redact: ['req.headers.authorization', 'req.headers.cookie'] } : false, + // Hop count → trust only that many proxies closest to us; or an explicit address list. + trustProxy: env.TRUST_PROXY === 'false' ? false + : /^\d+$/.test(env.TRUST_PROXY) ? (_address: string, hop: number) => hop < Number(env.TRUST_PROXY) + : env.TRUST_PROXY.split(',').map((s) => s.trim()), + bodyLimit: 1_048_576, + genReqId: () => crypto.randomUUID(), + }); + + configureZatca(encryptionKey(env)); + setGatewayUrl(env.ZATCA_GATEWAY_URL ?? null); + app.decorate('deps', { env, pool, tokens: new TokenService(env.JWT_SECRET, env.ACCESS_TOKEN_TTL_SECONDS) }); + + await app.register(helmet, { + contentSecurityPolicy: { directives: { defaultSrc: ["'none'"], frameAncestors: ["'none'"] } }, + }); + await app.register(cors, { + origin: env.CORS_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean), + credentials: true, + allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Protection'], + }); + await app.register(cookie); + await app.register(rateLimit, { max: env.NODE_ENV === 'test' ? 10_000 : 300, timeWindow: '1 minute' }); + await app.register(authPlugin); + + app.setErrorHandler((err: Error, req, reply) => { + if (err instanceof AppError) { + return reply.code(err.statusCode).send({ error: { code: err.code, message: err.message, details: err.details } }); + } + const pgCode = (err as { code?: string }).code; + const mapped = pgCode ? PG_ERRORS[pgCode] : undefined; + if (mapped) { + req.log.warn({ err }, 'database constraint'); + // Messages raised by our own ledger triggers are safe and useful to show. + const fromTrigger = /PL\/pgSQL function/.test((err as { where?: string }).where ?? ''); + return reply.code(mapped[0]).send({ error: { code: fromTrigger ? 'LEDGER_RULE' : mapped[1], message: fromTrigger ? err.message : mapped[2] } }); + } + const status = (err as { statusCode?: number }).statusCode; + if (status && status >= 400 && status < 500) { + return reply.code(status).send({ error: { code: (err as { code?: string }).code ?? 'BAD_REQUEST', message: err.message } }); + } + req.log.error({ err }, 'unhandled error'); + // Recorded for the platform's error view; never blocks the response. + pool.query( + `INSERT INTO system_errors (tenant_id, user_id, request_id, method, path, status_code, error_code, message, stack) VALUES ($1, $2, $3, $4, $5, 500, $6, $7, $8)`, + [req.auth?.tenantId ?? null, req.auth?.userId ?? null, String(req.id), req.method, req.url.split('?')[0]!.slice(0, 500), + (err as { code?: string }).code ?? null, err.message.slice(0, 2000), err.stack?.slice(0, 8000) ?? null], + ).catch((e) => req.log.warn({ err: e }, 'could not record system error')); + return reply.code(500).send({ error: { code: 'INTERNAL_ERROR', message: 'Internal server error' } }); + }); + + // API calls are counted in memory and written every 15 seconds (and on shutdown). + const usageTimer = setInterval(() => { void flushUsage(pool); }, 15_000); + usageTimer.unref(); + app.addHook('onClose', async () => { clearInterval(usageTimer); await flushUsage(pool); }); + + // Liveness: the process answers and reaches the database. + app.get('/api/health', async () => { + await pool.query('SELECT 1'); + return { status: 'ok' }; + }); + // Readiness: safe to send traffic — the schema matches this build. + app.get('/api/ready', async (_req, reply) => { + try { + const pending = await pendingMigrations(pool); + if (pending.length) return reply.code(503).send({ status: 'migrations_pending', pending }); + return { status: 'ready' }; + } catch { + return reply.code(503).send({ status: 'database_unavailable' }); + } + }); + + await app.register(authRoutes, { prefix: '/api/auth' }); + await app.register(async (api) => { + await api.register(usersRoutes); + await api.register(rbacRoutes); + await api.register(companiesRoutes); + await api.register(settingsRoutes); + await api.register(auditRoutes); + await api.register(accountsRoutes); + await api.register(fiscalRoutes); + await api.register(journalRoutes); + await api.register(trialBalanceRoutes); + await api.register(partyRoutes(CUSTOMER)); + await api.register(partyRoutes(SUPPLIER)); + await api.register(productsRoutes); + await api.register(documentsModule); + await api.register(inventoryRoutes); + await api.register(expenseRoutes); + await api.register(taxRoutes); + await api.register(financialReportRoutes); + await api.register(partyReportRoutes); + await api.register(commercialReportRoutes); + await api.register(dashboardRoutes); + await api.register(zatcaRoutes); + await api.register(subscriptionRoutes); + await api.register(adminTenantRoutes); + await api.register(adminPlatformRoutes); + }, { prefix: '/api' }); + + return app; +} diff --git a/alshuyukh-accounting/apps/api/src/config/env.ts b/alshuyukh-accounting/apps/api/src/config/env.ts new file mode 100644 index 000000000000..d0132ad09cd7 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/config/env.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; + +const schema = z.object({ + NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), + DATABASE_URL: z.string().min(1), + DATABASE_URL_MIGRATE: z.string().min(1).optional(), + JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'), + ACCESS_TOKEN_TTL_SECONDS: z.coerce.number().int().min(60).max(3600).default(900), + REFRESH_TOKEN_TTL_DAYS: z.coerce.number().int().min(1).max(90).default(30), + PORT: z.coerce.number().int().default(3000), + HOST: z.string().default('0.0.0.0'), + CORS_ORIGINS: z.string().default('http://localhost:5173'), + // Reverse proxies in front of the API: a hop count (e.g. 1 behind nginx) or a + // comma-separated list of proxy IPs/CIDRs. Never "true": X-Forwarded-For + // would then be taken from the client and IP rate limits could be bypassed. + TRUST_PROXY: z.string().regex(/^(false|\d{1,2}|[0-9a-fA-F.:/,\s]+)$/, 'TRUST_PROXY: false, a hop count, or proxy IPs/CIDRs').default('false'), + // Longest a single SQL statement may run (protects against runaway reports). + DB_STATEMENT_TIMEOUT_MS: z.coerce.number().int().min(1000).max(600000).default(15000), + DB_POOL_MAX: z.coerce.number().int().min(2).max(200).default(20), + LOGIN_MAX_ATTEMPTS: z.coerce.number().int().min(3).default(5), + LOGIN_LOCK_MINUTES: z.coerce.number().int().min(1).default(15), + // 32 random bytes, base64: encrypts ZATCA private keys and CSID secrets at rest. + ZATCA_ENCRYPTION_KEY: z.string().optional(), + // Background submission of pending e-invoices (reporting / clearance). + // Overrides the Fatoora gateway base URL (a proxy, or a local stand-in for testing). + ZATCA_GATEWAY_URL: z.url().optional(), + ZATCA_WORKER: z.enum(['on', 'off']).default('on'), + ZATCA_WORKER_INTERVAL_SECONDS: z.coerce.number().int().min(10).default(60), +}); + +export type Env = z.infer; + +export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { + const parsed = schema.safeParse(source); + if (!parsed.success) { + const details = parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('\n'); + throw new Error(`Invalid environment configuration:\n${details}`); + } + if (parsed.data.NODE_ENV === 'production' && parsed.data.JWT_SECRET.startsWith('change-me')) { + throw new Error('JWT_SECRET must be changed in production'); + } + if (parsed.data.NODE_ENV === 'production' && !parsed.data.ZATCA_ENCRYPTION_KEY) { + throw new Error('ZATCA_ENCRYPTION_KEY is required in production (openssl rand -base64 32)'); + } + return parsed.data; +} diff --git a/alshuyukh-accounting/apps/api/src/db/migrate-cli.ts b/alshuyukh-accounting/apps/api/src/db/migrate-cli.ts new file mode 100644 index 000000000000..dab9bc2e0245 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/db/migrate-cli.ts @@ -0,0 +1,14 @@ +import { migrate } from './migrate.js'; + +const url = process.env.DATABASE_URL_MIGRATE; +if (!url) { + console.error('DATABASE_URL_MIGRATE is not set'); + process.exit(1); +} + +migrate(url, (msg) => console.log(msg)) + .then(() => console.log('migrations complete')) + .catch((err: Error) => { + console.error(err.message); + process.exit(1); + }); diff --git a/alshuyukh-accounting/apps/api/src/db/migrate.ts b/alshuyukh-accounting/apps/api/src/db/migrate.ts new file mode 100644 index 000000000000..e5cd1ed76b02 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/db/migrate.ts @@ -0,0 +1,112 @@ +import { createHash } from 'node:crypto'; +import { readdir, readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import pg from 'pg'; +import { PERMISSIONS, SYSTEM_ROLES, resolveRolePermissions } from '../modules/rbac/catalog.js'; + +const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'migrations'); + +/** + * Applies pending SQL migrations in file-name order, each in its own + * transaction, then syncs the RBAC catalog. An already-applied migration + * whose content changed aborts the run: write a new migration instead. + */ +export async function migrate(connectionString: string, log: (msg: string) => void = () => {}): Promise { + const client = new pg.Client({ connectionString, options: '-c timezone=UTC' }); + await client.connect(); + try { + await client.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version text PRIMARY KEY, + checksum text NOT NULL, + applied_at timestamptz NOT NULL DEFAULT now() + )`); + // Serialize concurrent migration runs. + await client.query(`SELECT pg_advisory_lock(hashtext('alshuyukh_migrations'))`); + + const applied = new Map( + (await client.query<{ version: string; checksum: string }>('SELECT version, checksum FROM schema_migrations')) + .rows.map((r) => [r.version, r.checksum]), + ); + const files = (await readdir(MIGRATIONS_DIR)).filter((f) => f.endsWith('.sql')).sort(); + + for (const file of files) { + const sql = await readFile(join(MIGRATIONS_DIR, file), 'utf8'); + const checksum = createHash('sha256').update(sql).digest('hex'); + const existing = applied.get(file); + if (existing) { + if (existing !== checksum) { + throw new Error(`Migration ${file} was modified after it was applied. Create a new migration instead.`); + } + continue; + } + await client.query('BEGIN'); + try { + await client.query(sql); + await client.query('INSERT INTO schema_migrations (version, checksum) VALUES ($1, $2)', [file, checksum]); + await client.query('COMMIT'); + log(`applied ${file}`); + } catch (err) { + await client.query('ROLLBACK'); + throw new Error(`Migration ${file} failed: ${(err as Error).message}`); + } + } + + await syncRbacCatalog(client); + log('rbac catalog synced'); + } finally { + await client.query(`SELECT pg_advisory_unlock(hashtext('alshuyukh_migrations'))`).catch(() => undefined); + await client.end(); + } +} + +/** Migration files shipped with this build that the database has not applied yet. */ +export async function pendingMigrations(db: { query: pg.Pool['query'] }): Promise { + const files = (await readdir(MIGRATIONS_DIR)).filter((f) => f.endsWith('.sql')); + const { rows } = await db.query<{ version: string }>('SELECT version FROM schema_migrations'); + const applied = new Set(rows.map((r) => r.version)); + return files.filter((f) => !applied.has(f)).sort(); +} + +async function syncRbacCatalog(client: pg.Client): Promise { + await client.query('BEGIN'); + try { + for (const p of PERMISSIONS) { + await client.query( + `INSERT INTO permissions (code, module, description_ar, description_en) + VALUES ($1, $2, $3, $4) + ON CONFLICT (code) DO UPDATE SET module = EXCLUDED.module, + description_ar = EXCLUDED.description_ar, description_en = EXCLUDED.description_en`, + [p.code, p.module, p.ar, p.en], + ); + } + for (const role of SYSTEM_ROLES) { + const { rows } = await client.query<{ id: string }>( + `INSERT INTO roles (code, name_ar, name_en, is_system, tenant_id) + VALUES ($1, $2, $3, true, NULL) + ON CONFLICT (code) WHERE tenant_id IS NULL + DO UPDATE SET name_ar = EXCLUDED.name_ar, name_en = EXCLUDED.name_en + RETURNING id`, + [role.code, role.ar, role.en], + ); + const roleId = rows[0]!.id; + const codes = resolveRolePermissions(role); + await client.query( + `DELETE FROM role_permissions WHERE role_id = $1 + AND permission_id NOT IN (SELECT id FROM permissions WHERE code = ANY($2::text[]))`, + [roleId, codes], + ); + await client.query( + `INSERT INTO role_permissions (role_id, permission_id) + SELECT $1, id FROM permissions WHERE code = ANY($2::text[]) + ON CONFLICT DO NOTHING`, + [roleId, codes], + ); + } + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } +} diff --git a/alshuyukh-accounting/apps/api/src/db/migrations/0001_foundation.sql b/alshuyukh-accounting/apps/api/src/db/migrations/0001_foundation.sql new file mode 100644 index 000000000000..f322627a9637 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/db/migrations/0001_foundation.sql @@ -0,0 +1,36 @@ +-- 0001 — Foundation: extensions and shared helper functions. +-- All timestamps are stored as timestamptz (UTC). Presentation converts to the +-- company time zone (default Asia/Riyadh). + +CREATE EXTENSION IF NOT EXISTS citext; + +-- Keeps updated_at current on every UPDATE. +CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + NEW.updated_at := now(); + RETURN NEW; +END; +$$; + +-- Request context. The API sets these with set_config(..., true) at the start +-- of every transaction, so they are scoped to that transaction only. +CREATE OR REPLACE FUNCTION app_current_tenant_id() RETURNS uuid +LANGUAGE sql STABLE AS $$ + SELECT NULLIF(current_setting('app.tenant_id', true), '')::uuid +$$; + +CREATE OR REPLACE FUNCTION app_current_user_id() RETURNS uuid +LANGUAGE sql STABLE AS $$ + SELECT NULLIF(current_setting('app.user_id', true), '')::uuid +$$; + +-- Blocks UPDATE and DELETE on append-only tables (audit logs, and later +-- posted financial records). +CREATE OR REPLACE FUNCTION prevent_modification() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION '% on table % is not allowed (append-only)', TG_OP, TG_TABLE_NAME + USING ERRCODE = 'insufficient_privilege'; +END; +$$; diff --git a/alshuyukh-accounting/apps/api/src/db/migrations/0002_identity_tenancy.sql b/alshuyukh-accounting/apps/api/src/db/migrations/0002_identity_tenancy.sql new file mode 100644 index 000000000000..306800a24b1e --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/db/migrations/0002_identity_tenancy.sql @@ -0,0 +1,86 @@ +-- 0002 — Identity and multi-tenancy. +-- A tenant is the isolation boundary (one subscribing organization). +-- Users are global identities; membership in a tenant is in user_tenants. + +CREATE TABLE tenants ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL CHECK (length(btrim(name)) BETWEEN 2 AND 200), + slug citext NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9-]{1,62}$'), + status text NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'SUSPENDED', 'CANCELLED')), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz +); +CREATE UNIQUE INDEX tenants_slug_uq ON tenants (slug); +CREATE INDEX tenants_status_idx ON tenants (status); +CREATE TRIGGER tenants_updated_at BEFORE UPDATE ON tenants + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE tenant_settings ( + tenant_id uuid PRIMARY KEY REFERENCES tenants (id) ON DELETE RESTRICT, + default_currency char(3) NOT NULL DEFAULT 'SAR' CHECK (default_currency ~ '^[A-Z]{3}$'), + timezone text NOT NULL DEFAULT 'Asia/Riyadh', + locale text NOT NULL DEFAULT 'ar' CHECK (locale IN ('ar', 'en')), + fiscal_year_start_month smallint NOT NULL DEFAULT 1 CHECK (fiscal_year_start_month BETWEEN 1 AND 12), + date_format text NOT NULL DEFAULT 'YYYY-MM-DD', + extra jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE TRIGGER tenant_settings_updated_at BEFORE UPDATE ON tenant_settings + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email citext NOT NULL CHECK (position('@' IN email) > 1), + password_hash text NOT NULL, + full_name text NOT NULL CHECK (length(btrim(full_name)) BETWEEN 2 AND 200), + phone text, + status text NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'DISABLED')), + -- Platform (Super Admin) access is a platform flag, not a tenant role. + -- The /admin panel that uses it is delivered in Phase 9. + is_platform_admin boolean NOT NULL DEFAULT false, + must_change_password boolean NOT NULL DEFAULT false, + failed_login_attempts integer NOT NULL DEFAULT 0 CHECK (failed_login_attempts >= 0), + locked_until timestamptz, + last_login_at timestamptz, + password_changed_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz +); +CREATE UNIQUE INDEX users_email_uq ON users (email); +CREATE TRIGGER users_updated_at BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE user_tenants ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL REFERENCES tenants (id) ON DELETE RESTRICT, + user_id uuid NOT NULL REFERENCES users (id) ON DELETE RESTRICT, + status text NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'INVITED', 'DISABLED')), + is_owner boolean NOT NULL DEFAULT false, + joined_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT user_tenants_tenant_user_uq UNIQUE (tenant_id, user_id) +); +CREATE INDEX user_tenants_user_idx ON user_tenants (user_id); +CREATE TRIGGER user_tenants_updated_at BEFORE UPDATE ON user_tenants + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- Refresh-token sessions. Only the SHA-256 hash of the token is stored. +CREATE TABLE user_sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users (id) ON DELETE RESTRICT, + tenant_id uuid NOT NULL REFERENCES tenants (id) ON DELETE RESTRICT, + token_hash text NOT NULL, + expires_at timestamptz NOT NULL, + revoked_at timestamptz, + replaced_by uuid REFERENCES user_sessions (id), + ip_address inet, + user_agent text, + created_at timestamptz NOT NULL DEFAULT now(), + last_used_at timestamptz +); +CREATE UNIQUE INDEX user_sessions_token_hash_uq ON user_sessions (token_hash); +CREATE INDEX user_sessions_user_idx ON user_sessions (user_id) WHERE revoked_at IS NULL; diff --git a/alshuyukh-accounting/apps/api/src/db/migrations/0003_rbac.sql b/alshuyukh-accounting/apps/api/src/db/migrations/0003_rbac.sql new file mode 100644 index 000000000000..ff41a1ff2c5e --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/db/migrations/0003_rbac.sql @@ -0,0 +1,77 @@ +-- 0003 — Role-based access control. +-- System roles (tenant_id IS NULL) are shared templates managed by the +-- application catalog. Tenants can add custom roles (tenant_id = tenant). + +CREATE TABLE permissions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code text NOT NULL CHECK (code ~ '^[a-z_]+\.[a-z_]+$'), + module text NOT NULL, + description_ar text NOT NULL, + description_en text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX permissions_code_uq ON permissions (code); + +CREATE TABLE roles ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid REFERENCES tenants (id) ON DELETE RESTRICT, + code text NOT NULL CHECK (code ~ '^[A-Z][A-Z0-9_]{1,62}$'), + name_ar text NOT NULL, + name_en text NOT NULL, + description text, + is_system boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT roles_system_scope_ck CHECK ( + (is_system AND tenant_id IS NULL) OR (NOT is_system AND tenant_id IS NOT NULL) + ) +); +-- A role code is unique among system roles, and unique per tenant. +CREATE UNIQUE INDEX roles_system_code_uq ON roles (code) WHERE tenant_id IS NULL; +CREATE UNIQUE INDEX roles_tenant_code_uq ON roles (tenant_id, code) + WHERE tenant_id IS NOT NULL AND deleted_at IS NULL; +CREATE INDEX roles_tenant_idx ON roles (tenant_id); +CREATE TRIGGER roles_updated_at BEFORE UPDATE ON roles + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE role_permissions ( + role_id uuid NOT NULL REFERENCES roles (id) ON DELETE CASCADE, + permission_id uuid NOT NULL REFERENCES permissions (id) ON DELETE RESTRICT, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (role_id, permission_id) +); +CREATE INDEX role_permissions_permission_idx ON role_permissions (permission_id); + +CREATE TABLE user_roles ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + user_id uuid NOT NULL, + role_id uuid NOT NULL REFERENCES roles (id) ON DELETE RESTRICT, + created_by uuid REFERENCES users (id), + created_at timestamptz NOT NULL DEFAULT now(), + -- The user must be a member of the same tenant. + CONSTRAINT user_roles_membership_fk FOREIGN KEY (tenant_id, user_id) + REFERENCES user_tenants (tenant_id, user_id) ON DELETE RESTRICT, + CONSTRAINT user_roles_uq UNIQUE (tenant_id, user_id, role_id) +); +CREATE INDEX user_roles_role_idx ON user_roles (role_id); + +-- The assigned role must be a system role or a role of the same tenant. +CREATE OR REPLACE FUNCTION check_user_role_tenant() RETURNS trigger +LANGUAGE plpgsql AS $$ +DECLARE + role_tenant uuid; + role_found boolean; +BEGIN + SELECT true, tenant_id INTO role_found, role_tenant + FROM roles WHERE id = NEW.role_id AND deleted_at IS NULL; + IF role_found IS NULL OR (role_tenant IS NOT NULL AND role_tenant <> NEW.tenant_id) THEN + RAISE EXCEPTION 'role % is not available in tenant %', NEW.role_id, NEW.tenant_id + USING ERRCODE = 'foreign_key_violation'; + END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER user_roles_tenant_check BEFORE INSERT OR UPDATE ON user_roles + FOR EACH ROW EXECUTE FUNCTION check_user_role_tenant(); diff --git a/alshuyukh-accounting/apps/api/src/db/migrations/0004_companies.sql b/alshuyukh-accounting/apps/api/src/db/migrations/0004_companies.sql new file mode 100644 index 000000000000..b4f9d8aa1823 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/db/migrations/0004_companies.sql @@ -0,0 +1,89 @@ +-- 0004 — Companies, branches, warehouses. +-- Every company-related row carries tenant_id. Child tables use composite +-- foreign keys (parent_id, tenant_id) so a row can never point at a parent +-- that belongs to another tenant. +-- +-- fiscal_years / fiscal_periods are delivered with the Accounting Engine in +-- Phase 2, because posting rules depend on them. + +CREATE TABLE companies ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL REFERENCES tenants (id) ON DELETE RESTRICT, + name text NOT NULL CHECK (length(btrim(name)) BETWEEN 2 AND 200), + legal_name text, + commercial_registration text CHECK (commercial_registration ~ '^[0-9]{10}$'), + -- Saudi VAT registration number: 15 digits, starts and ends with 3. + vat_number text CHECK (vat_number ~ '^3[0-9]{13}3$'), + address text, + city text, + country char(2) NOT NULL DEFAULT 'SA' CHECK (country ~ '^[A-Z]{2}$'), + currency char(3) NOT NULL DEFAULT 'SAR' CHECK (currency ~ '^[A-Z]{3}$'), + timezone text NOT NULL DEFAULT 'Asia/Riyadh', + logo_url text, + email citext, + phone text, + is_active boolean NOT NULL DEFAULT true, + created_by uuid REFERENCES users (id), + updated_by uuid REFERENCES users (id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT companies_id_tenant_uq UNIQUE (id, tenant_id) +); +CREATE INDEX companies_tenant_idx ON companies (tenant_id) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX companies_tenant_name_uq ON companies (tenant_id, name) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX companies_tenant_vat_uq ON companies (tenant_id, vat_number) + WHERE vat_number IS NOT NULL AND deleted_at IS NULL; +CREATE TRIGGER companies_updated_at BEFORE UPDATE ON companies + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE branches ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + code text NOT NULL CHECK (code ~ '^[A-Za-z0-9_-]{1,20}$'), + name text NOT NULL CHECK (length(btrim(name)) BETWEEN 2 AND 200), + address text, + city text, + phone text, + is_main boolean NOT NULL DEFAULT false, + is_active boolean NOT NULL DEFAULT true, + created_by uuid REFERENCES users (id), + updated_by uuid REFERENCES users (id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT branches_company_fk FOREIGN KEY (company_id, tenant_id) + REFERENCES companies (id, tenant_id) ON DELETE RESTRICT, + CONSTRAINT branches_id_tenant_uq UNIQUE (id, tenant_id) +); +CREATE INDEX branches_company_idx ON branches (tenant_id, company_id) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX branches_code_uq ON branches (tenant_id, company_id, code) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX branches_one_main_uq ON branches (company_id) WHERE is_main AND deleted_at IS NULL; +CREATE TRIGGER branches_updated_at BEFORE UPDATE ON branches + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE warehouses ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + branch_id uuid, + code text NOT NULL CHECK (code ~ '^[A-Za-z0-9_-]{1,20}$'), + name text NOT NULL CHECK (length(btrim(name)) BETWEEN 2 AND 200), + address text, + is_active boolean NOT NULL DEFAULT true, + created_by uuid REFERENCES users (id), + updated_by uuid REFERENCES users (id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT warehouses_company_fk FOREIGN KEY (company_id, tenant_id) + REFERENCES companies (id, tenant_id) ON DELETE RESTRICT, + CONSTRAINT warehouses_branch_fk FOREIGN KEY (branch_id, tenant_id) + REFERENCES branches (id, tenant_id) ON DELETE RESTRICT, + CONSTRAINT warehouses_id_tenant_uq UNIQUE (id, tenant_id) +); +CREATE INDEX warehouses_company_idx ON warehouses (tenant_id, company_id) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX warehouses_code_uq ON warehouses (tenant_id, company_id, code) WHERE deleted_at IS NULL; +CREATE TRIGGER warehouses_updated_at BEFORE UPDATE ON warehouses + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); diff --git a/alshuyukh-accounting/apps/api/src/db/migrations/0005_audit.sql b/alshuyukh-accounting/apps/api/src/db/migrations/0005_audit.sql new file mode 100644 index 000000000000..43e189d6d90f --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/db/migrations/0005_audit.sql @@ -0,0 +1,25 @@ +-- 0005 — Audit log (append-only). +-- tenant_id is NULL only for events that happen before a tenant is known, +-- such as a failed login for an unknown e-mail address. + +CREATE TABLE audit_logs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid REFERENCES tenants (id) ON DELETE RESTRICT, + user_id uuid REFERENCES users (id) ON DELETE RESTRICT, + action text NOT NULL CHECK (action ~ '^[A-Z_]+$'), + entity_type text, + entity_id uuid, + old_values jsonb, + new_values jsonb, + ip_address inet, + user_agent text, + request_id text, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX audit_logs_tenant_created_idx ON audit_logs (tenant_id, created_at DESC); +CREATE INDEX audit_logs_entity_idx ON audit_logs (tenant_id, entity_type, entity_id); +CREATE INDEX audit_logs_user_idx ON audit_logs (user_id, created_at DESC); +CREATE INDEX audit_logs_action_idx ON audit_logs (tenant_id, action); + +CREATE TRIGGER audit_logs_append_only BEFORE UPDATE OR DELETE ON audit_logs + FOR EACH ROW EXECUTE FUNCTION prevent_modification(); diff --git a/alshuyukh-accounting/apps/api/src/db/migrations/0006_rls_and_grants.sql b/alshuyukh-accounting/apps/api/src/db/migrations/0006_rls_and_grants.sql new file mode 100644 index 000000000000..b64bc54f70d2 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/db/migrations/0006_rls_and_grants.sql @@ -0,0 +1,92 @@ +-- 0006 — Row-Level Security and runtime privileges. +-- +-- The API connects as alshuyukh_app (NOSUPERUSER, NOBYPASSRLS). Every +-- tenant-scoped query runs in a transaction that sets app.tenant_id and +-- app.user_id. These policies make PostgreSQL itself reject cross-tenant +-- access, even if application code forgets a WHERE tenant_id = ... filter. +-- +-- The schema owner (migrations) is not subject to these policies. + +-- tenants ----------------------------------------------------------------- +ALTER TABLE tenants ENABLE ROW LEVEL SECURITY; +CREATE POLICY tenants_select ON tenants FOR SELECT USING ( + id = app_current_tenant_id() + OR id IN (SELECT ut.tenant_id FROM user_tenants ut + WHERE ut.user_id = app_current_user_id() AND ut.status = 'ACTIVE') +); +CREATE POLICY tenants_insert ON tenants FOR INSERT WITH CHECK (id = app_current_tenant_id()); +CREATE POLICY tenants_update ON tenants FOR UPDATE + USING (id = app_current_tenant_id()) WITH CHECK (id = app_current_tenant_id()); + +-- tenant_settings --------------------------------------------------------- +ALTER TABLE tenant_settings ENABLE ROW LEVEL SECURITY; +CREATE POLICY tenant_settings_isolation ON tenant_settings + USING (tenant_id = app_current_tenant_id()) WITH CHECK (tenant_id = app_current_tenant_id()); + +-- user_tenants: a user can see their own memberships (for login and the +-- tenant switcher) plus all memberships of the active tenant. +ALTER TABLE user_tenants ENABLE ROW LEVEL SECURITY; +CREATE POLICY user_tenants_select ON user_tenants FOR SELECT USING ( + tenant_id = app_current_tenant_id() OR user_id = app_current_user_id() +); +CREATE POLICY user_tenants_insert ON user_tenants FOR INSERT + WITH CHECK (tenant_id = app_current_tenant_id()); +CREATE POLICY user_tenants_update ON user_tenants FOR UPDATE + USING (tenant_id = app_current_tenant_id()) WITH CHECK (tenant_id = app_current_tenant_id()); + +-- roles: system roles are readable by everyone, never writable at runtime. +ALTER TABLE roles ENABLE ROW LEVEL SECURITY; +CREATE POLICY roles_select ON roles FOR SELECT USING ( + tenant_id IS NULL OR tenant_id = app_current_tenant_id() +); +CREATE POLICY roles_insert ON roles FOR INSERT WITH CHECK (tenant_id = app_current_tenant_id()); +CREATE POLICY roles_update ON roles FOR UPDATE + USING (tenant_id = app_current_tenant_id()) WITH CHECK (tenant_id = app_current_tenant_id()); + +-- role_permissions follow the visibility and ownership of their role. +ALTER TABLE role_permissions ENABLE ROW LEVEL SECURITY; +CREATE POLICY role_permissions_select ON role_permissions FOR SELECT USING ( + EXISTS (SELECT 1 FROM roles r WHERE r.id = role_id) +); +CREATE POLICY role_permissions_insert ON role_permissions FOR INSERT WITH CHECK ( + EXISTS (SELECT 1 FROM roles r WHERE r.id = role_id AND r.tenant_id = app_current_tenant_id()) +); +CREATE POLICY role_permissions_delete ON role_permissions FOR DELETE USING ( + EXISTS (SELECT 1 FROM roles r WHERE r.id = role_id AND r.tenant_id = app_current_tenant_id()) +); + +-- Plain tenant-scoped tables ----------------------------------------------- +ALTER TABLE user_roles ENABLE ROW LEVEL SECURITY; +CREATE POLICY user_roles_isolation ON user_roles + USING (tenant_id = app_current_tenant_id()) WITH CHECK (tenant_id = app_current_tenant_id()); + +ALTER TABLE companies ENABLE ROW LEVEL SECURITY; +CREATE POLICY companies_isolation ON companies + USING (tenant_id = app_current_tenant_id()) WITH CHECK (tenant_id = app_current_tenant_id()); + +ALTER TABLE branches ENABLE ROW LEVEL SECURITY; +CREATE POLICY branches_isolation ON branches + USING (tenant_id = app_current_tenant_id()) WITH CHECK (tenant_id = app_current_tenant_id()); + +ALTER TABLE warehouses ENABLE ROW LEVEL SECURITY; +CREATE POLICY warehouses_isolation ON warehouses + USING (tenant_id = app_current_tenant_id()) WITH CHECK (tenant_id = app_current_tenant_id()); + +-- audit_logs: read own tenant; insert for own tenant or pre-tenant events. +ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY; +CREATE POLICY audit_logs_select ON audit_logs FOR SELECT USING (tenant_id = app_current_tenant_id()); +CREATE POLICY audit_logs_insert ON audit_logs FOR INSERT WITH CHECK ( + tenant_id IS NULL OR tenant_id = app_current_tenant_id() +); + +-- Runtime privileges -------------------------------------------------------- +-- No DELETE on business tables: records are soft-deleted (deleted_at). +-- Join tables (user_roles, role_permissions) allow DELETE; every change is audited. +GRANT USAGE ON SCHEMA public TO alshuyukh_app; +GRANT SELECT ON permissions TO alshuyukh_app; +GRANT SELECT, INSERT, UPDATE ON + tenants, tenant_settings, users, user_tenants, user_sessions, + roles, companies, branches, warehouses +TO alshuyukh_app; +GRANT SELECT, INSERT, DELETE ON user_roles, role_permissions TO alshuyukh_app; +GRANT SELECT, INSERT ON audit_logs TO alshuyukh_app; diff --git a/alshuyukh-accounting/apps/api/src/db/migrations/0007_accounting.sql b/alshuyukh-accounting/apps/api/src/db/migrations/0007_accounting.sql new file mode 100644 index 000000000000..72ac9ded8814 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/db/migrations/0007_accounting.sql @@ -0,0 +1,484 @@ +-- 0007 — Accounting core: fiscal calendar, chart of accounts, cost centers, +-- journal entries. +-- +-- Money is NUMERIC(18,2) (SAR to the halala). Never float. +-- Every rule that protects the ledger is enforced here in the database, not +-- only in the API: balanced posting, open-period posting, immutability of +-- posted entries, and same-company references. + +CREATE EXTENSION IF NOT EXISTS btree_gist; + +-- Branches need (id, company_id) as a composite FK target for journal lines. +ALTER TABLE branches ADD CONSTRAINT branches_id_company_uq UNIQUE (id, company_id); + +-- Fiscal calendar ------------------------------------------------------------ +CREATE TABLE fiscal_years ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + name text NOT NULL CHECK (length(btrim(name)) BETWEEN 1 AND 100), + start_date date NOT NULL, + end_date date NOT NULL, + status text NOT NULL DEFAULT 'OPEN' CHECK (status IN ('OPEN', 'CLOSED')), + closing_entry_id uuid, + closed_at timestamptz, + closed_by uuid REFERENCES users (id), + created_by uuid REFERENCES users (id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT fiscal_years_dates_ck CHECK (end_date > start_date AND end_date - start_date < 550), + CONSTRAINT fiscal_years_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT fiscal_years_id_tenant_uq UNIQUE (id, tenant_id), + CONSTRAINT fiscal_years_id_company_uq UNIQUE (id, company_id), + CONSTRAINT fiscal_years_no_overlap EXCLUDE USING gist + (company_id WITH =, daterange(start_date, end_date, '[]') WITH &&) +); +CREATE INDEX fiscal_years_company_idx ON fiscal_years (tenant_id, company_id, start_date); +CREATE TRIGGER fiscal_years_updated_at BEFORE UPDATE ON fiscal_years + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE fiscal_periods ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + fiscal_year_id uuid NOT NULL, + period_number smallint NOT NULL CHECK (period_number BETWEEN 1 AND 18), + name text NOT NULL, + start_date date NOT NULL, + end_date date NOT NULL, + status text NOT NULL DEFAULT 'OPEN' CHECK (status IN ('OPEN', 'CLOSED')), + closed_at timestamptz, + closed_by uuid REFERENCES users (id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT fiscal_periods_dates_ck CHECK (end_date >= start_date), + CONSTRAINT fiscal_periods_year_fk FOREIGN KEY (fiscal_year_id, company_id) REFERENCES fiscal_years (id, company_id), + CONSTRAINT fiscal_periods_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT fiscal_periods_number_uq UNIQUE (fiscal_year_id, period_number), + CONSTRAINT fiscal_periods_id_tenant_uq UNIQUE (id, tenant_id), + CONSTRAINT fiscal_periods_id_company_uq UNIQUE (id, company_id), + CONSTRAINT fiscal_periods_no_overlap EXCLUDE USING gist + (company_id WITH =, daterange(start_date, end_date, '[]') WITH &&) +); +CREATE INDEX fiscal_periods_lookup_idx ON fiscal_periods (company_id, start_date, end_date); +CREATE TRIGGER fiscal_periods_updated_at BEFORE UPDATE ON fiscal_periods + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- A period of a closed year cannot be reopened. +CREATE OR REPLACE FUNCTION fiscal_periods_guard() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.status = 'OPEN' AND OLD.status = 'CLOSED' AND EXISTS ( + SELECT 1 FROM fiscal_years y WHERE y.id = NEW.fiscal_year_id AND y.status = 'CLOSED' + ) THEN + RAISE EXCEPTION 'cannot reopen a period of a closed fiscal year' USING ERRCODE = 'check_violation'; + END IF; + IF NEW.start_date <> OLD.start_date OR NEW.end_date <> OLD.end_date OR NEW.fiscal_year_id <> OLD.fiscal_year_id THEN + RAISE EXCEPTION 'fiscal period dates cannot change' USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER fiscal_periods_guard BEFORE UPDATE ON fiscal_periods + FOR EACH ROW EXECUTE FUNCTION fiscal_periods_guard(); + +-- Chart of accounts ----------------------------------------------------------- +-- Groups classify accounts for financial statements (current assets, etc.). +CREATE TABLE account_groups ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + code text NOT NULL CHECK (code ~ '^[A-Z][A-Z0-9_]{1,62}$'), + name_ar text NOT NULL, + name_en text NOT NULL, + account_type text NOT NULL CHECK (account_type IN ('ASSET', 'LIABILITY', 'EQUITY', 'REVENUE', 'EXPENSE', 'COST_OF_GOODS_SOLD')), + sort_order integer NOT NULL DEFAULT 0, + is_system boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT account_groups_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT account_groups_code_uq UNIQUE (company_id, code), + CONSTRAINT account_groups_id_company_uq UNIQUE (id, company_id) +); +CREATE TRIGGER account_groups_updated_at BEFORE UPDATE ON account_groups + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE accounts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + code text NOT NULL CHECK (code ~ '^[0-9A-Za-z.-]{1,20}$'), + name_ar text NOT NULL CHECK (length(btrim(name_ar)) BETWEEN 1 AND 200), + name_en text, + account_type text NOT NULL CHECK (account_type IN ('ASSET', 'LIABILITY', 'EQUITY', 'REVENUE', 'EXPENSE', 'COST_OF_GOODS_SOLD')), + parent_id uuid, + level smallint NOT NULL DEFAULT 1 CHECK (level BETWEEN 1 AND 10), + group_id uuid, + -- Only postable (leaf) accounts accept journal lines; others are headers. + is_postable boolean NOT NULL DEFAULT true, + is_active boolean NOT NULL DEFAULT true, + is_system boolean NOT NULL DEFAULT false, + -- Stable role used by the engine (AR, AP, VAT_OUTPUT, ...). Codes may be + -- renumbered by the user; system keys never change. + system_key text CHECK (system_key ~ '^[A-Z][A-Z0-9_]{1,62}$'), + description text, + created_by uuid REFERENCES users (id), + updated_by uuid REFERENCES users (id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT accounts_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT accounts_parent_fk FOREIGN KEY (parent_id, company_id) REFERENCES accounts (id, company_id), + CONSTRAINT accounts_group_fk FOREIGN KEY (group_id, company_id) REFERENCES account_groups (id, company_id), + CONSTRAINT accounts_id_company_uq UNIQUE (id, company_id), + CONSTRAINT accounts_not_own_parent CHECK (parent_id IS NULL OR parent_id <> id) +); +CREATE UNIQUE INDEX accounts_code_uq ON accounts (company_id, code) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX accounts_system_key_uq ON accounts (company_id, system_key) + WHERE system_key IS NOT NULL AND deleted_at IS NULL; +CREATE INDEX accounts_parent_idx ON accounts (parent_id); +CREATE INDEX accounts_company_idx ON accounts (tenant_id, company_id) WHERE deleted_at IS NULL; +CREATE TRIGGER accounts_updated_at BEFORE UPDATE ON accounts + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- Keeps the tree consistent: same type as parent, parent is a header, +-- no cycles, level derived from the parent, group type matches. +CREATE OR REPLACE FUNCTION accounts_validate() RETURNS trigger +LANGUAGE plpgsql AS $$ +DECLARE + p record; + g_type text; +BEGIN + IF NEW.parent_id IS NULL THEN + NEW.level := 1; + ELSE + SELECT account_type, is_postable, level, deleted_at INTO p FROM accounts WHERE id = NEW.parent_id; + IF NOT FOUND OR p.deleted_at IS NOT NULL THEN + RAISE EXCEPTION 'parent account not found' USING ERRCODE = 'foreign_key_violation'; + END IF; + IF p.account_type <> NEW.account_type THEN + RAISE EXCEPTION 'account type must match its parent (%)', p.account_type USING ERRCODE = 'check_violation'; + END IF; + IF p.is_postable THEN + RAISE EXCEPTION 'parent account must be a header (non-postable) account' USING ERRCODE = 'check_violation'; + END IF; + IF TG_OP = 'UPDATE' AND NEW.parent_id IS DISTINCT FROM OLD.parent_id AND EXISTS ( + WITH RECURSIVE descendants AS ( + SELECT id FROM accounts WHERE parent_id = NEW.id + UNION ALL + SELECT a.id FROM accounts a JOIN descendants d ON a.parent_id = d.id + ) SELECT 1 FROM descendants WHERE id = NEW.parent_id + ) THEN + RAISE EXCEPTION 'an account cannot be moved under its own descendant' USING ERRCODE = 'check_violation'; + END IF; + NEW.level := p.level + 1; + END IF; + + IF NEW.group_id IS NOT NULL THEN + SELECT account_type INTO g_type FROM account_groups WHERE id = NEW.group_id; + IF g_type IS DISTINCT FROM NEW.account_type THEN + RAISE EXCEPTION 'account group type must match account type' USING ERRCODE = 'check_violation'; + END IF; + END IF; + + IF TG_OP = 'UPDATE' THEN + IF NEW.account_type <> OLD.account_type AND ( + EXISTS (SELECT 1 FROM journal_entry_lines l WHERE l.account_id = NEW.id) + OR EXISTS (SELECT 1 FROM accounts c WHERE c.parent_id = NEW.id AND c.deleted_at IS NULL) + ) THEN + RAISE EXCEPTION 'account type cannot change once the account has children or journal lines' USING ERRCODE = 'check_violation'; + END IF; + IF NEW.is_postable AND NOT OLD.is_postable + AND EXISTS (SELECT 1 FROM accounts c WHERE c.parent_id = NEW.id AND c.deleted_at IS NULL) THEN + RAISE EXCEPTION 'an account with children must stay a header' USING ERRCODE = 'check_violation'; + END IF; + IF NOT NEW.is_postable AND OLD.is_postable + AND EXISTS (SELECT 1 FROM journal_entry_lines l WHERE l.account_id = NEW.id) THEN + RAISE EXCEPTION 'an account with journal lines cannot become a header' USING ERRCODE = 'check_violation'; + END IF; + IF NEW.deleted_at IS NOT NULL AND OLD.deleted_at IS NULL AND ( + OLD.is_system + OR EXISTS (SELECT 1 FROM journal_entry_lines l WHERE l.account_id = NEW.id) + OR EXISTS (SELECT 1 FROM accounts c WHERE c.parent_id = NEW.id AND c.deleted_at IS NULL) + ) THEN + RAISE EXCEPTION 'system accounts and accounts with children or journal lines cannot be deleted' USING ERRCODE = 'check_violation'; + END IF; + IF OLD.is_system AND NEW.system_key IS DISTINCT FROM OLD.system_key THEN + RAISE EXCEPTION 'system key of a system account cannot change' USING ERRCODE = 'check_violation'; + END IF; + END IF; + RETURN NEW; +END; +$$; + +-- Cost centers ------------------------------------------------------------------ +CREATE TABLE cost_centers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + code text NOT NULL CHECK (code ~ '^[0-9A-Za-z_-]{1,20}$'), + name text NOT NULL CHECK (length(btrim(name)) BETWEEN 2 AND 200), + is_active boolean NOT NULL DEFAULT true, + created_by uuid REFERENCES users (id), + updated_by uuid REFERENCES users (id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT cost_centers_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT cost_centers_id_company_uq UNIQUE (id, company_id) +); +CREATE UNIQUE INDEX cost_centers_code_uq ON cost_centers (company_id, code) WHERE deleted_at IS NULL; +CREATE TRIGGER cost_centers_updated_at BEFORE UPDATE ON cost_centers + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- Journal entries ----------------------------------------------------------------- +CREATE TABLE journal_entries ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + -- Assigned at posting. Gap-free per company and fiscal year. + fiscal_year_id uuid, + fiscal_period_id uuid, + entry_number text, + entry_date date NOT NULL, + description text NOT NULL CHECK (length(btrim(description)) BETWEEN 1 AND 1000), + -- MANUAL, REVERSAL, YEAR_CLOSING, and later SALES_INVOICE, PURCHASE_INVOICE, ... + reference_type text NOT NULL DEFAULT 'MANUAL' CHECK (reference_type ~ '^[A-Z][A-Z0-9_]{1,62}$'), + reference_id uuid, + source text NOT NULL DEFAULT 'MANUAL' CHECK (source IN ('MANUAL', 'SYSTEM')), + status text NOT NULL DEFAULT 'DRAFT' CHECK (status IN ('DRAFT', 'POSTED', 'REVERSED')), + currency char(3) NOT NULL DEFAULT 'SAR' CHECK (currency ~ '^[A-Z]{3}$'), + total_debit numeric(18,2) NOT NULL DEFAULT 0, + total_credit numeric(18,2) NOT NULL DEFAULT 0, + reversal_of_id uuid, + reversed_by_entry_id uuid, + correction_of_id uuid, + created_by uuid REFERENCES users (id), + posted_by uuid REFERENCES users (id), + posted_at timestamptz, + reversed_by uuid REFERENCES users (id), + reversed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT journal_entries_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT journal_entries_year_fk FOREIGN KEY (fiscal_year_id, company_id) REFERENCES fiscal_years (id, company_id), + CONSTRAINT journal_entries_period_fk FOREIGN KEY (fiscal_period_id, company_id) REFERENCES fiscal_periods (id, company_id), + CONSTRAINT journal_entries_id_company_uq UNIQUE (id, company_id), + CONSTRAINT journal_entries_reversal_fk FOREIGN KEY (reversal_of_id, company_id) REFERENCES journal_entries (id, company_id), + CONSTRAINT journal_entries_reversed_by_fk FOREIGN KEY (reversed_by_entry_id, company_id) REFERENCES journal_entries (id, company_id), + CONSTRAINT journal_entries_correction_fk FOREIGN KEY (correction_of_id, company_id) REFERENCES journal_entries (id, company_id), + CONSTRAINT journal_entries_posted_ck CHECK ( + status = 'DRAFT' OR (entry_number IS NOT NULL AND posted_at IS NOT NULL + AND fiscal_period_id IS NOT NULL AND fiscal_year_id IS NOT NULL + AND total_debit = total_credit AND total_debit > 0) + ), + CONSTRAINT journal_entries_reversed_ck CHECK ( + status <> 'REVERSED' OR (reversed_by_entry_id IS NOT NULL AND reversed_at IS NOT NULL) + ), + CONSTRAINT journal_entries_soft_delete_ck CHECK (deleted_at IS NULL OR status = 'DRAFT') +); +CREATE UNIQUE INDEX journal_entries_number_uq ON journal_entries (company_id, fiscal_year_id, entry_number) + WHERE entry_number IS NOT NULL; +CREATE UNIQUE INDEX journal_entries_one_reversal_uq ON journal_entries (reversal_of_id) WHERE reversal_of_id IS NOT NULL; +CREATE INDEX journal_entries_date_idx ON journal_entries (tenant_id, company_id, entry_date DESC); +CREATE INDEX journal_entries_status_idx ON journal_entries (company_id, status); +CREATE INDEX journal_entries_reference_idx ON journal_entries (company_id, reference_type, reference_id); +CREATE TRIGGER journal_entries_updated_at BEFORE UPDATE ON journal_entries + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE journal_entry_lines ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + journal_entry_id uuid NOT NULL, + line_no smallint NOT NULL CHECK (line_no BETWEEN 1 AND 1000), + account_id uuid NOT NULL, + debit numeric(18,2) NOT NULL DEFAULT 0, + credit numeric(18,2) NOT NULL DEFAULT 0, + description text, + cost_center_id uuid, + branch_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT journal_lines_amounts_ck CHECK ( + debit >= 0 AND credit >= 0 AND ((debit > 0 AND credit = 0) OR (credit > 0 AND debit = 0)) + ), + CONSTRAINT journal_lines_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT journal_lines_entry_fk FOREIGN KEY (journal_entry_id, company_id) REFERENCES journal_entries (id, company_id), + CONSTRAINT journal_lines_account_fk FOREIGN KEY (account_id, company_id) REFERENCES accounts (id, company_id), + CONSTRAINT journal_lines_cost_center_fk FOREIGN KEY (cost_center_id, company_id) REFERENCES cost_centers (id, company_id), + CONSTRAINT journal_lines_branch_fk FOREIGN KEY (branch_id, company_id) REFERENCES branches (id, company_id), + CONSTRAINT journal_lines_line_no_uq UNIQUE (journal_entry_id, line_no) +); +CREATE INDEX journal_lines_account_idx ON journal_entry_lines (account_id); +CREATE INDEX journal_lines_tenant_account_idx ON journal_entry_lines (tenant_id, company_id, account_id); +CREATE INDEX journal_lines_cost_center_idx ON journal_entry_lines (cost_center_id) WHERE cost_center_id IS NOT NULL; +CREATE INDEX journal_lines_branch_idx ON journal_entry_lines (branch_id) WHERE branch_id IS NOT NULL; + +-- Now that journal_entry_lines exists, attach the account validation trigger. +CREATE TRIGGER accounts_validate BEFORE INSERT OR UPDATE ON accounts + FOR EACH ROW EXECUTE FUNCTION accounts_validate(); + +ALTER TABLE fiscal_years ADD CONSTRAINT fiscal_years_closing_entry_fk + FOREIGN KEY (closing_entry_id, company_id) REFERENCES journal_entries (id, company_id); + +-- Gap-free entry numbering per company and fiscal year. +CREATE TABLE journal_sequences ( + fiscal_year_id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + last_number integer NOT NULL DEFAULT 0 CHECK (last_number >= 0), + CONSTRAINT journal_sequences_year_fk FOREIGN KEY (fiscal_year_id, company_id) REFERENCES fiscal_years (id, company_id), + CONSTRAINT journal_sequences_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id) +); + +-- Ledger protection triggers ------------------------------------------------------ + +-- Lines can change only while their entry is a draft, and may reference only +-- active postable accounts. +CREATE OR REPLACE FUNCTION journal_lines_guard() RETURNS trigger +LANGUAGE plpgsql AS $$ +DECLARE + entry_status text; + acc record; +BEGIN + IF TG_OP IN ('UPDATE', 'DELETE') THEN + SELECT status INTO entry_status FROM journal_entries WHERE id = OLD.journal_entry_id; + IF entry_status IS DISTINCT FROM 'DRAFT' THEN + RAISE EXCEPTION 'lines of a posted journal entry cannot be changed' USING ERRCODE = 'insufficient_privilege'; + END IF; + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + END IF; + + SELECT status INTO entry_status FROM journal_entries WHERE id = NEW.journal_entry_id; + IF entry_status IS DISTINCT FROM 'DRAFT' THEN + RAISE EXCEPTION 'lines of a posted journal entry cannot be changed' USING ERRCODE = 'insufficient_privilege'; + END IF; + SELECT is_postable, is_active, deleted_at INTO acc FROM accounts WHERE id = NEW.account_id; + IF NOT FOUND OR acc.deleted_at IS NOT NULL OR NOT acc.is_active OR NOT acc.is_postable THEN + RAISE EXCEPTION 'account % is not an active postable account', NEW.account_id USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER journal_lines_guard BEFORE INSERT OR UPDATE OR DELETE ON journal_entry_lines + FOR EACH ROW EXECUTE FUNCTION journal_lines_guard(); + +-- Entries are created as drafts. Posting re-validates everything. Posted +-- entries are immutable except for being marked REVERSED. +CREATE OR REPLACE FUNCTION journal_entries_guard() RETURNS trigger +LANGUAGE plpgsql AS $$ +DECLARE + v_debit numeric(18,2); + v_credit numeric(18,2); + v_lines integer; + v_bad integer; + v_period record; + mutable text[] := ARRAY['status', 'reversed_at', 'reversed_by', 'reversed_by_entry_id', 'updated_at']; +BEGIN + IF TG_OP = 'INSERT' THEN + IF NEW.status <> 'DRAFT' THEN + RAISE EXCEPTION 'journal entries must be created as DRAFT and then posted' USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; + END IF; + + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION 'journal entries cannot be deleted' USING ERRCODE = 'insufficient_privilege'; + END IF; + + -- UPDATE + IF OLD.status = 'REVERSED' THEN + RAISE EXCEPTION 'a reversed journal entry cannot be changed' USING ERRCODE = 'insufficient_privilege'; + END IF; + + IF OLD.status = 'POSTED' THEN + IF NEW.status <> 'REVERSED' + OR (to_jsonb(NEW) - mutable) IS DISTINCT FROM (to_jsonb(OLD) - mutable) THEN + RAISE EXCEPTION 'a posted journal entry cannot be modified; reverse it instead' USING ERRCODE = 'insufficient_privilege'; + END IF; + RETURN NEW; + END IF; + + -- OLD.status = 'DRAFT' + IF OLD.deleted_at IS NOT NULL THEN + RAISE EXCEPTION 'a deleted draft cannot be changed' USING ERRCODE = 'insufficient_privilege'; + END IF; + IF NEW.status = 'REVERSED' THEN + RAISE EXCEPTION 'a draft cannot be reversed' USING ERRCODE = 'check_violation'; + END IF; + + IF NEW.status = 'POSTED' THEN + SELECT count(*), COALESCE(sum(debit), 0), COALESCE(sum(credit), 0) + INTO v_lines, v_debit, v_credit + FROM journal_entry_lines WHERE journal_entry_id = NEW.id; + IF v_lines < 2 THEN + RAISE EXCEPTION 'a journal entry needs at least two lines' USING ERRCODE = 'check_violation'; + END IF; + IF v_debit <> v_credit OR v_debit = 0 THEN + RAISE EXCEPTION 'journal entry is not balanced (debit %, credit %)', v_debit, v_credit USING ERRCODE = 'check_violation'; + END IF; + + SELECT count(*) INTO v_bad + FROM journal_entry_lines l JOIN accounts a ON a.id = l.account_id + WHERE l.journal_entry_id = NEW.id + AND (a.deleted_at IS NOT NULL OR NOT a.is_active OR NOT a.is_postable); + IF v_bad > 0 THEN + RAISE EXCEPTION 'journal entry uses inactive or non-postable accounts' USING ERRCODE = 'check_violation'; + END IF; + + SELECT p.id, p.status AS period_status, y.id AS year_id, y.status AS year_status INTO v_period + FROM fiscal_periods p JOIN fiscal_years y ON y.id = p.fiscal_year_id + WHERE p.company_id = NEW.company_id AND NEW.entry_date BETWEEN p.start_date AND p.end_date; + IF NOT FOUND THEN + RAISE EXCEPTION 'no fiscal period covers %', NEW.entry_date USING ERRCODE = 'check_violation'; + END IF; + IF v_period.period_status <> 'OPEN' OR v_period.year_status <> 'OPEN' THEN + RAISE EXCEPTION 'fiscal period for % is closed', NEW.entry_date USING ERRCODE = 'check_violation'; + END IF; + IF NEW.fiscal_period_id IS DISTINCT FROM v_period.id OR NEW.fiscal_year_id IS DISTINCT FROM v_period.year_id THEN + RAISE EXCEPTION 'fiscal period does not match the entry date' USING ERRCODE = 'check_violation'; + END IF; + + NEW.total_debit := v_debit; + NEW.total_credit := v_credit; + END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER journal_entries_guard BEFORE INSERT OR UPDATE OR DELETE ON journal_entries + FOR EACH ROW EXECUTE FUNCTION journal_entries_guard(); + +-- Row-Level Security -------------------------------------------------------------- +ALTER TABLE fiscal_years ENABLE ROW LEVEL SECURITY; +CREATE POLICY fiscal_years_isolation ON fiscal_years + USING (tenant_id = app_current_tenant_id()) WITH CHECK (tenant_id = app_current_tenant_id()); +ALTER TABLE fiscal_periods ENABLE ROW LEVEL SECURITY; +CREATE POLICY fiscal_periods_isolation ON fiscal_periods + USING (tenant_id = app_current_tenant_id()) WITH CHECK (tenant_id = app_current_tenant_id()); +ALTER TABLE account_groups ENABLE ROW LEVEL SECURITY; +CREATE POLICY account_groups_isolation ON account_groups + USING (tenant_id = app_current_tenant_id()) WITH CHECK (tenant_id = app_current_tenant_id()); +ALTER TABLE accounts ENABLE ROW LEVEL SECURITY; +CREATE POLICY accounts_isolation ON accounts + USING (tenant_id = app_current_tenant_id()) WITH CHECK (tenant_id = app_current_tenant_id()); +ALTER TABLE cost_centers ENABLE ROW LEVEL SECURITY; +CREATE POLICY cost_centers_isolation ON cost_centers + USING (tenant_id = app_current_tenant_id()) WITH CHECK (tenant_id = app_current_tenant_id()); +ALTER TABLE journal_entries ENABLE ROW LEVEL SECURITY; +CREATE POLICY journal_entries_isolation ON journal_entries + USING (tenant_id = app_current_tenant_id()) WITH CHECK (tenant_id = app_current_tenant_id()); +ALTER TABLE journal_entry_lines ENABLE ROW LEVEL SECURITY; +CREATE POLICY journal_entry_lines_isolation ON journal_entry_lines + USING (tenant_id = app_current_tenant_id()) WITH CHECK (tenant_id = app_current_tenant_id()); +ALTER TABLE journal_sequences ENABLE ROW LEVEL SECURITY; +CREATE POLICY journal_sequences_isolation ON journal_sequences + USING (tenant_id = app_current_tenant_id()) WITH CHECK (tenant_id = app_current_tenant_id()); + +-- Runtime privileges: no DELETE on the ledger. Draft lines are replaced by +-- DELETE + INSERT; the line trigger refuses that once the entry is posted. +GRANT SELECT, INSERT, UPDATE ON + fiscal_years, fiscal_periods, account_groups, accounts, cost_centers, + journal_entries, journal_sequences +TO alshuyukh_app; +GRANT SELECT, INSERT, UPDATE, DELETE ON journal_entry_lines TO alshuyukh_app; diff --git a/alshuyukh-accounting/apps/api/src/db/migrations/0008_parties_products.sql b/alshuyukh-accounting/apps/api/src/db/migrations/0008_parties_products.sql new file mode 100644 index 000000000000..237db0e859df --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/db/migrations/0008_parties_products.sql @@ -0,0 +1,269 @@ +-- 0008 — Customers, suppliers, products, units, and document numbering. +-- +-- Party balances are not stored: they come from journal lines tagged with +-- customer_id / supplier_id, so the ledger stays the single source of truth. + +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- Document numbering --------------------------------------------------------------- +-- One counter per company and document type (CUSTOMER, SUPPLIER, PRODUCT, +-- and later SALES_INVOICE, ...). Incremented under a row lock inside the +-- caller's transaction, so numbers are gap-free. +CREATE TABLE document_sequences ( + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + doc_type text NOT NULL CHECK (doc_type ~ '^[A-Z][A-Z0-9_]{1,62}$'), + prefix text NOT NULL CHECK (prefix ~ '^[A-Z0-9-]{0,12}$'), + padding smallint NOT NULL DEFAULT 5 CHECK (padding BETWEEN 1 AND 12), + last_number integer NOT NULL DEFAULT 0 CHECK (last_number >= 0), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (company_id, doc_type), + CONSTRAINT document_sequences_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id) +); +CREATE TRIGGER document_sequences_updated_at BEFORE UPDATE ON document_sequences + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- Saudi national address parts (ZATCA buyer/seller address fields). +CREATE OR REPLACE FUNCTION valid_saudi_address(building text, postal text, additional text) RETURNS boolean +LANGUAGE sql IMMUTABLE AS $$ + SELECT (building IS NULL OR building ~ '^[0-9]{4}$') + AND (postal IS NULL OR postal ~ '^[0-9]{5}$') + AND (additional IS NULL OR additional ~ '^[0-9]{4}$') +$$; + +-- Customers --------------------------------------------------------------------------- +CREATE TABLE customers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + code text NOT NULL CHECK (code ~ '^[0-9A-Za-z_-]{1,30}$'), + party_type text NOT NULL DEFAULT 'BUSINESS' CHECK (party_type IN ('BUSINESS', 'INDIVIDUAL')), + name_ar text NOT NULL CHECK (length(btrim(name_ar)) BETWEEN 2 AND 200), + name_en text, + vat_number text CHECK (vat_number ~ '^3[0-9]{13}3$'), + commercial_registration text CHECK (commercial_registration ~ '^[0-9]{10}$'), + national_id text CHECK (national_id ~ '^[12][0-9]{9}$'), + email citext, + phone text CHECK (phone ~ '^\+?[0-9 ()-]{6,20}$'), + credit_limit numeric(18,2) CHECK (credit_limit >= 0), + payment_terms_days smallint NOT NULL DEFAULT 0 CHECK (payment_terms_days BETWEEN 0 AND 365), + -- Overrides the company's ACCOUNTS_RECEIVABLE account when set. + receivable_account_id uuid, + notes text, + is_active boolean NOT NULL DEFAULT true, + created_by uuid REFERENCES users (id), + updated_by uuid REFERENCES users (id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT customers_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT customers_account_fk FOREIGN KEY (receivable_account_id, company_id) REFERENCES accounts (id, company_id), + CONSTRAINT customers_id_company_uq UNIQUE (id, company_id) +); +CREATE UNIQUE INDEX customers_code_uq ON customers (company_id, code) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX customers_vat_uq ON customers (company_id, vat_number) WHERE vat_number IS NOT NULL AND deleted_at IS NULL; +CREATE INDEX customers_company_idx ON customers (tenant_id, company_id) WHERE deleted_at IS NULL; +CREATE INDEX customers_name_trgm ON customers USING gin (name_ar gin_trgm_ops); +CREATE INDEX customers_phone_idx ON customers (company_id, phone) WHERE phone IS NOT NULL; +CREATE TRIGGER customers_updated_at BEFORE UPDATE ON customers + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE customer_addresses ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + customer_id uuid NOT NULL, + address_type text NOT NULL DEFAULT 'BILLING' CHECK (address_type IN ('BILLING', 'SHIPPING')), + is_default boolean NOT NULL DEFAULT false, + building_number text, + street text, + district text, + city text, + postal_code text, + additional_number text, + country char(2) NOT NULL DEFAULT 'SA' CHECK (country ~ '^[A-Z]{2}$'), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT customer_addresses_customer_fk FOREIGN KEY (customer_id, company_id) REFERENCES customers (id, company_id), + CONSTRAINT customer_addresses_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT customer_addresses_format_ck CHECK (valid_saudi_address(building_number, postal_code, additional_number)) +); +CREATE INDEX customer_addresses_customer_idx ON customer_addresses (customer_id) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX customer_addresses_default_uq ON customer_addresses (customer_id, address_type) + WHERE is_default AND deleted_at IS NULL; +CREATE TRIGGER customer_addresses_updated_at BEFORE UPDATE ON customer_addresses + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- Suppliers --------------------------------------------------------------------------- +CREATE TABLE suppliers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + code text NOT NULL CHECK (code ~ '^[0-9A-Za-z_-]{1,30}$'), + party_type text NOT NULL DEFAULT 'BUSINESS' CHECK (party_type IN ('BUSINESS', 'INDIVIDUAL')), + name_ar text NOT NULL CHECK (length(btrim(name_ar)) BETWEEN 2 AND 200), + name_en text, + vat_number text CHECK (vat_number ~ '^3[0-9]{13}3$'), + commercial_registration text CHECK (commercial_registration ~ '^[0-9]{10}$'), + national_id text CHECK (national_id ~ '^[12][0-9]{9}$'), + email citext, + phone text CHECK (phone ~ '^\+?[0-9 ()-]{6,20}$'), + credit_limit numeric(18,2) CHECK (credit_limit >= 0), + payment_terms_days smallint NOT NULL DEFAULT 0 CHECK (payment_terms_days BETWEEN 0 AND 365), + -- Overrides the company's ACCOUNTS_PAYABLE account when set. + payable_account_id uuid, + notes text, + is_active boolean NOT NULL DEFAULT true, + created_by uuid REFERENCES users (id), + updated_by uuid REFERENCES users (id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT suppliers_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT suppliers_account_fk FOREIGN KEY (payable_account_id, company_id) REFERENCES accounts (id, company_id), + CONSTRAINT suppliers_id_company_uq UNIQUE (id, company_id) +); +CREATE UNIQUE INDEX suppliers_code_uq ON suppliers (company_id, code) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX suppliers_vat_uq ON suppliers (company_id, vat_number) WHERE vat_number IS NOT NULL AND deleted_at IS NULL; +CREATE INDEX suppliers_company_idx ON suppliers (tenant_id, company_id) WHERE deleted_at IS NULL; +CREATE INDEX suppliers_name_trgm ON suppliers USING gin (name_ar gin_trgm_ops); +CREATE TRIGGER suppliers_updated_at BEFORE UPDATE ON suppliers + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE supplier_addresses ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + supplier_id uuid NOT NULL, + address_type text NOT NULL DEFAULT 'BILLING' CHECK (address_type IN ('BILLING', 'SHIPPING')), + is_default boolean NOT NULL DEFAULT false, + building_number text, + street text, + district text, + city text, + postal_code text, + additional_number text, + country char(2) NOT NULL DEFAULT 'SA' CHECK (country ~ '^[A-Z]{2}$'), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT supplier_addresses_supplier_fk FOREIGN KEY (supplier_id, company_id) REFERENCES suppliers (id, company_id), + CONSTRAINT supplier_addresses_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT supplier_addresses_format_ck CHECK (valid_saudi_address(building_number, postal_code, additional_number)) +); +CREATE INDEX supplier_addresses_supplier_idx ON supplier_addresses (supplier_id) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX supplier_addresses_default_uq ON supplier_addresses (supplier_id, address_type) + WHERE is_default AND deleted_at IS NULL; +CREATE TRIGGER supplier_addresses_updated_at BEFORE UPDATE ON supplier_addresses + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- Party tagging on journal lines (customer / supplier sub-ledgers) ---------------------- +ALTER TABLE journal_entry_lines + ADD COLUMN customer_id uuid, + ADD COLUMN supplier_id uuid, + ADD CONSTRAINT journal_lines_customer_fk FOREIGN KEY (customer_id, company_id) REFERENCES customers (id, company_id), + ADD CONSTRAINT journal_lines_supplier_fk FOREIGN KEY (supplier_id, company_id) REFERENCES suppliers (id, company_id), + ADD CONSTRAINT journal_lines_one_party_ck CHECK (customer_id IS NULL OR supplier_id IS NULL); +CREATE INDEX journal_lines_customer_idx ON journal_entry_lines (customer_id) WHERE customer_id IS NOT NULL; +CREATE INDEX journal_lines_supplier_idx ON journal_entry_lines (supplier_id) WHERE supplier_id IS NOT NULL; + +-- Products -------------------------------------------------------------------------- +CREATE TABLE units ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + -- UN/ECE Recommendation 20 code where one exists (PCE, KGM, LTR, ...). + code text NOT NULL CHECK (code ~ '^[A-Z0-9]{1,10}$'), + name_ar text NOT NULL CHECK (length(btrim(name_ar)) BETWEEN 1 AND 50), + name_en text, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT units_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT units_code_uq UNIQUE (company_id, code), + CONSTRAINT units_id_company_uq UNIQUE (id, company_id) +); +CREATE TRIGGER units_updated_at BEFORE UPDATE ON units + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE product_categories ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + parent_id uuid, + name_ar text NOT NULL CHECK (length(btrim(name_ar)) BETWEEN 2 AND 100), + name_en text, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT product_categories_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT product_categories_parent_fk FOREIGN KEY (parent_id, company_id) REFERENCES product_categories (id, company_id), + CONSTRAINT product_categories_id_company_uq UNIQUE (id, company_id), + CONSTRAINT product_categories_not_own_parent CHECK (parent_id IS NULL OR parent_id <> id) +); +CREATE UNIQUE INDEX product_categories_name_uq ON product_categories (company_id, COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name_ar) + WHERE deleted_at IS NULL; +CREATE TRIGGER product_categories_updated_at BEFORE UPDATE ON product_categories + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE products ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + sku text NOT NULL CHECK (sku ~ '^[0-9A-Za-z._/-]{1,40}$'), + barcode text CHECK (barcode ~ '^[0-9A-Za-z-]{4,40}$'), + name_ar text NOT NULL CHECK (length(btrim(name_ar)) BETWEEN 2 AND 200), + name_en text, + description text, + product_type text NOT NULL DEFAULT 'GOODS' CHECK (product_type IN ('GOODS', 'SERVICE')), + category_id uuid, + unit_id uuid NOT NULL, + -- Unit prices allow 4 decimals; invoice line totals are rounded to 2. + sale_price numeric(18,4) NOT NULL DEFAULT 0 CHECK (sale_price >= 0), + sale_price_includes_vat boolean NOT NULL DEFAULT false, + purchase_price numeric(18,4) NOT NULL DEFAULT 0 CHECK (purchase_price >= 0), + -- ZATCA VAT category: S standard, Z zero-rated, E exempt, O out of scope. + -- The rate itself comes from tax settings (Phase 6), never from the product. + vat_category char(1) NOT NULL DEFAULT 'S' CHECK (vat_category IN ('S', 'Z', 'E', 'O')), + track_inventory boolean NOT NULL DEFAULT true, + -- Optional overrides of the company's default SALES / purchase accounts. + sales_account_id uuid, + purchase_account_id uuid, + is_active boolean NOT NULL DEFAULT true, + created_by uuid REFERENCES users (id), + updated_by uuid REFERENCES users (id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT products_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT products_category_fk FOREIGN KEY (category_id, company_id) REFERENCES product_categories (id, company_id), + CONSTRAINT products_unit_fk FOREIGN KEY (unit_id, company_id) REFERENCES units (id, company_id), + CONSTRAINT products_sales_account_fk FOREIGN KEY (sales_account_id, company_id) REFERENCES accounts (id, company_id), + CONSTRAINT products_purchase_account_fk FOREIGN KEY (purchase_account_id, company_id) REFERENCES accounts (id, company_id), + CONSTRAINT products_id_company_uq UNIQUE (id, company_id), + -- Services never hold stock. + CONSTRAINT products_service_stock_ck CHECK (product_type = 'GOODS' OR NOT track_inventory) +); +CREATE UNIQUE INDEX products_sku_uq ON products (company_id, sku) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX products_barcode_uq ON products (company_id, barcode) WHERE barcode IS NOT NULL AND deleted_at IS NULL; +CREATE INDEX products_company_idx ON products (tenant_id, company_id) WHERE deleted_at IS NULL; +CREATE INDEX products_category_idx ON products (category_id); +CREATE INDEX products_name_trgm ON products USING gin (name_ar gin_trgm_ops); +CREATE TRIGGER products_updated_at BEFORE UPDATE ON products + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- Row-Level Security -------------------------------------------------------------------- +DO $$ +DECLARE t text; +BEGIN + FOREACH t IN ARRAY ARRAY['document_sequences', 'customers', 'customer_addresses', 'suppliers', + 'supplier_addresses', 'units', 'product_categories', 'products'] LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t); + EXECUTE format('CREATE POLICY %I ON %I USING (tenant_id = app_current_tenant_id()) WITH CHECK (tenant_id = app_current_tenant_id())', + t || '_isolation', t); + EXECUTE format('GRANT SELECT, INSERT, UPDATE ON %I TO alshuyukh_app', t); + END LOOP; +END +$$; diff --git a/alshuyukh-accounting/apps/api/src/db/migrations/0009_sales_purchases_payments.sql b/alshuyukh-accounting/apps/api/src/db/migrations/0009_sales_purchases_payments.sql new file mode 100644 index 000000000000..d6f5b6adca75 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/db/migrations/0009_sales_purchases_payments.sql @@ -0,0 +1,808 @@ +-- 0009 — Sales, purchases, payments, tax rates. +-- +-- GENERATED from one template for the six commercial documents (sales quotes, +-- invoices, returns; purchase orders, invoices, returns), which share the +-- same header/line layout. Edit with care and keep the six in sync. +-- +-- Money: NUMERIC(18,2). Quantities and unit prices: NUMERIC(18,4). +-- All line amounts are stored VAT-exclusive (see documents/calc.ts). +-- After a document leaves DRAFT, a trigger allows only its status and +-- settlement columns to change; its lines become read-only. + +-- Documents reference warehouses within the same company. +ALTER TABLE warehouses ADD CONSTRAINT warehouses_id_company_uq UNIQUE (id, company_id); + +-- Tax rates ------------------------------------------------------------------------ +-- Only the standard category (S) needs a rate row. Z, E and O are 0 by definition. +CREATE TABLE tax_rates ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + vat_category char(1) NOT NULL DEFAULT 'S' CHECK (vat_category = 'S'), + name_ar text NOT NULL, + rate numeric(7,4) NOT NULL CHECK (rate > 0 AND rate < 1), + effective_from date NOT NULL, + effective_to date, + is_active boolean NOT NULL DEFAULT true, + created_by uuid REFERENCES users (id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT tax_rates_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT tax_rates_dates_ck CHECK (effective_to IS NULL OR effective_to >= effective_from), + CONSTRAINT tax_rates_no_overlap EXCLUDE USING gist ( + company_id WITH =, vat_category WITH =, + daterange(effective_from, effective_to, '[]') WITH && + ) WHERE (is_active) +); +CREATE TRIGGER tax_rates_updated_at BEFORE UPDATE ON tax_rates FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- Payment methods ------------------------------------------------------------------ +CREATE TABLE payment_methods ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + code text NOT NULL CHECK (code ~ '^[A-Z][A-Z0-9_]{1,30}$'), + name_ar text NOT NULL CHECK (length(btrim(name_ar)) BETWEEN 2 AND 100), + method_type text NOT NULL CHECK (method_type IN ('CASH', 'BANK', 'CARD', 'STC_PAY', 'TAMARA', 'OTHER')), + -- Cash, bank, or clearing account the money goes through. + account_id uuid NOT NULL, + is_active boolean NOT NULL DEFAULT true, + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT payment_methods_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT payment_methods_account_fk FOREIGN KEY (account_id, company_id) REFERENCES accounts (id, company_id), + CONSTRAINT payment_methods_code_uq UNIQUE (company_id, code), + CONSTRAINT payment_methods_id_company_uq UNIQUE (id, company_id) +); +CREATE TRIGGER payment_methods_updated_at BEFORE UPDATE ON payment_methods FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- Document guards -------------------------------------------------------------------- +-- TG_ARGV: the columns that may still change after the document leaves DRAFT. +CREATE OR REPLACE FUNCTION commercial_doc_guard() RETURNS trigger +LANGUAGE plpgsql AS $$ +DECLARE + mutable text[] := TG_ARGV || ARRAY['updated_at']; +BEGIN + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION 'documents cannot be deleted' USING ERRCODE = 'insufficient_privilege'; + END IF; + IF TG_OP = 'INSERT' THEN + RETURN NEW; + END IF; + IF OLD.deleted_at IS NOT NULL THEN + RAISE EXCEPTION 'a deleted document cannot be changed' USING ERRCODE = 'insufficient_privilege'; + END IF; + IF OLD.status = 'CANCELLED' THEN + RAISE EXCEPTION 'a cancelled document cannot be changed' USING ERRCODE = 'insufficient_privilege'; + END IF; + IF OLD.status <> 'DRAFT' THEN + IF NEW.status = 'DRAFT' THEN + RAISE EXCEPTION 'an issued document cannot return to draft' USING ERRCODE = 'insufficient_privilege'; + END IF; + IF (to_jsonb(NEW) - mutable) IS DISTINCT FROM (to_jsonb(OLD) - mutable) THEN + RAISE EXCEPTION 'an issued document cannot be modified; cancel it or issue a return' USING ERRCODE = 'insufficient_privilege'; + END IF; + ELSIF NEW.deleted_at IS NOT NULL AND NEW.status <> 'DRAFT' THEN + RAISE EXCEPTION 'only drafts can be deleted' USING ERRCODE = 'insufficient_privilege'; + END IF; + RETURN NEW; +END; +$$; + +-- TG_ARGV[0]: the header table. Lines change only while the header is a draft. +CREATE OR REPLACE FUNCTION commercial_item_guard() RETURNS trigger +LANGUAGE plpgsql AS $$ +DECLARE + doc_status text; + doc_id uuid := CASE WHEN TG_OP = 'DELETE' THEN OLD.document_id ELSE NEW.document_id END; +BEGIN + EXECUTE format('SELECT status FROM %I WHERE id = $1', TG_ARGV[0]) INTO doc_status USING doc_id; + IF doc_status IS DISTINCT FROM 'DRAFT' THEN + RAISE EXCEPTION 'lines of an issued document cannot be changed' USING ERRCODE = 'insufficient_privilege'; + END IF; + IF TG_OP = 'UPDATE' AND NEW.document_id <> OLD.document_id THEN + RAISE EXCEPTION 'a line cannot move to another document' USING ERRCODE = 'insufficient_privilege'; + END IF; + RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END; +END; +$$; + + +-- sales_quotes ----------------------------------------------------------------------- +CREATE TABLE sales_quotes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + branch_id uuid, + warehouse_id uuid, + -- Assigned at creation. + doc_number text, + doc_date date NOT NULL, + customer_id uuid NOT NULL, + currency char(3) NOT NULL DEFAULT 'SAR' CHECK (currency ~ '^[A-Z]{3}$'), + prices_include_vat boolean NOT NULL DEFAULT false, + subtotal numeric(18,2) NOT NULL DEFAULT 0 CHECK (subtotal >= 0), + discount_total numeric(18,2) NOT NULL DEFAULT 0 CHECK (discount_total >= 0), + taxable_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (taxable_amount >= 0), + tax_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (tax_amount >= 0), + total numeric(18,2) NOT NULL DEFAULT 0 CHECK (total >= 0), + valid_until date, + converted_invoice_id uuid, + notes text, + status text NOT NULL DEFAULT 'DRAFT' CHECK (status IN ('DRAFT', 'SENT', 'ACCEPTED', 'REJECTED', 'CONVERTED', 'CANCELLED')), + -- Copy of the customer's name, VAT number and address at issue time. + party_snapshot jsonb, + journal_entry_id uuid, + created_by uuid REFERENCES users (id), + issued_by uuid REFERENCES users (id), + issued_at timestamptz, + cancelled_by uuid REFERENCES users (id), + cancelled_at timestamptz, + cancel_reason text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT sales_quotes_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT sales_quotes_branch_fk FOREIGN KEY (branch_id, company_id) REFERENCES branches (id, company_id), + CONSTRAINT sales_quotes_warehouse_fk FOREIGN KEY (warehouse_id, company_id) REFERENCES warehouses (id, company_id), + CONSTRAINT sales_quotes_customer_fk FOREIGN KEY (customer_id, company_id) REFERENCES customers (id, company_id), + CONSTRAINT sales_quotes_journal_fk FOREIGN KEY (journal_entry_id, company_id) REFERENCES journal_entries (id, company_id), + CONSTRAINT sales_quotes_id_company_uq UNIQUE (id, company_id), + CONSTRAINT sales_quotes_totals_ck CHECK (taxable_amount = subtotal - discount_total AND total = taxable_amount + tax_amount), + CONSTRAINT sales_quotes_soft_delete_ck CHECK (deleted_at IS NULL OR status = 'DRAFT'), + CONSTRAINT sales_quotes_number_ck CHECK (doc_number IS NOT NULL) +); +CREATE UNIQUE INDEX sales_quotes_number_uq ON sales_quotes (company_id, doc_number) WHERE doc_number IS NOT NULL; +CREATE INDEX sales_quotes_company_date_idx ON sales_quotes (tenant_id, company_id, doc_date DESC) WHERE deleted_at IS NULL; +CREATE INDEX sales_quotes_party_idx ON sales_quotes (customer_id, status); +CREATE INDEX sales_quotes_status_idx ON sales_quotes (company_id, status); +CREATE TRIGGER sales_quotes_updated_at BEFORE UPDATE ON sales_quotes FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +CREATE TRIGGER sales_quotes_guard BEFORE INSERT OR UPDATE OR DELETE ON sales_quotes + FOR EACH ROW EXECUTE FUNCTION commercial_doc_guard('status', 'converted_invoice_id', 'cancelled_by', 'cancelled_at', 'cancel_reason'); + +CREATE TABLE sales_quote_items ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + document_id uuid NOT NULL, + line_no smallint NOT NULL CHECK (line_no BETWEEN 1 AND 1000), + product_id uuid NOT NULL, + account_id uuid, + description text, + quantity numeric(18,4) NOT NULL CHECK (quantity > 0), + unit_id uuid, + unit_price numeric(18,4) NOT NULL CHECK (unit_price >= 0), + gross_amount numeric(18,2) NOT NULL CHECK (gross_amount >= 0), + -- Discount as entered (in the document's price basis); used to recalculate. + discount_basis numeric(18,2) NOT NULL DEFAULT 0 CHECK (discount_basis >= 0), + discount_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (discount_amount >= 0), + net_amount numeric(18,2) NOT NULL CHECK (net_amount >= 0), + vat_category char(1) NOT NULL CHECK (vat_category IN ('S', 'Z', 'E', 'O')), + vat_rate numeric(7,4) NOT NULL CHECK (vat_rate >= 0 AND vat_rate < 1), + vat_amount numeric(18,2) NOT NULL CHECK (vat_amount >= 0), + total_amount numeric(18,2) NOT NULL CHECK (total_amount >= 0), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT sales_quote_items_document_fk FOREIGN KEY (document_id, company_id) REFERENCES sales_quotes (id, company_id), + CONSTRAINT sales_quote_items_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT sales_quote_items_product_fk FOREIGN KEY (product_id, company_id) REFERENCES products (id, company_id), + CONSTRAINT sales_quote_items_account_fk FOREIGN KEY (account_id, company_id) REFERENCES accounts (id, company_id), + CONSTRAINT sales_quote_items_unit_fk FOREIGN KEY (unit_id, company_id) REFERENCES units (id, company_id), + CONSTRAINT sales_quote_items_line_uq UNIQUE (document_id, line_no), + CONSTRAINT sales_quote_items_id_company_uq UNIQUE (id, company_id), + CONSTRAINT sales_quote_items_amounts_ck CHECK (net_amount = gross_amount - discount_amount AND total_amount = net_amount + vat_amount) +); +CREATE INDEX sales_quote_items_document_idx ON sales_quote_items (document_id); +CREATE INDEX sales_quote_items_product_idx ON sales_quote_items (product_id) WHERE product_id IS NOT NULL; +CREATE TRIGGER sales_quote_items_guard BEFORE INSERT OR UPDATE OR DELETE ON sales_quote_items + FOR EACH ROW EXECUTE FUNCTION commercial_item_guard('sales_quotes'); + +-- sales_invoices ----------------------------------------------------------------------- +CREATE TABLE sales_invoices ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + branch_id uuid, + warehouse_id uuid, + -- Assigned at issue; gap-free per company. + doc_number text, + doc_date date NOT NULL, + customer_id uuid NOT NULL, + currency char(3) NOT NULL DEFAULT 'SAR' CHECK (currency ~ '^[A-Z]{3}$'), + prices_include_vat boolean NOT NULL DEFAULT false, + subtotal numeric(18,2) NOT NULL DEFAULT 0 CHECK (subtotal >= 0), + discount_total numeric(18,2) NOT NULL DEFAULT 0 CHECK (discount_total >= 0), + taxable_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (taxable_amount >= 0), + tax_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (tax_amount >= 0), + total numeric(18,2) NOT NULL DEFAULT 0 CHECK (total >= 0), + due_date date, + -- ZATCA: STANDARD (B2B, buyer VAT number) or SIMPLIFIED (B2C). + invoice_kind text NOT NULL DEFAULT 'STANDARD' CHECK (invoice_kind IN ('STANDARD', 'SIMPLIFIED')), + paid_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (paid_amount >= 0), + returned_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (returned_amount >= 0), + remaining_amount numeric(18,2) GENERATED ALWAYS AS (total - paid_amount - returned_amount) STORED CHECK (remaining_amount >= 0), + source_quote_id uuid, + notes text, + status text NOT NULL DEFAULT 'DRAFT' CHECK (status IN ('DRAFT', 'ISSUED', 'PARTIALLY_PAID', 'PAID', 'CANCELLED', 'RETURNED')), + -- Copy of the customer's name, VAT number and address at issue time. + party_snapshot jsonb, + journal_entry_id uuid, + created_by uuid REFERENCES users (id), + issued_by uuid REFERENCES users (id), + issued_at timestamptz, + cancelled_by uuid REFERENCES users (id), + cancelled_at timestamptz, + cancel_reason text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT sales_invoices_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT sales_invoices_branch_fk FOREIGN KEY (branch_id, company_id) REFERENCES branches (id, company_id), + CONSTRAINT sales_invoices_warehouse_fk FOREIGN KEY (warehouse_id, company_id) REFERENCES warehouses (id, company_id), + CONSTRAINT sales_invoices_customer_fk FOREIGN KEY (customer_id, company_id) REFERENCES customers (id, company_id), + CONSTRAINT sales_invoices_journal_fk FOREIGN KEY (journal_entry_id, company_id) REFERENCES journal_entries (id, company_id), + CONSTRAINT sales_invoices_id_company_uq UNIQUE (id, company_id), + CONSTRAINT sales_invoices_totals_ck CHECK (taxable_amount = subtotal - discount_total AND total = taxable_amount + tax_amount), + CONSTRAINT sales_invoices_soft_delete_ck CHECK (deleted_at IS NULL OR status = 'DRAFT'), + CONSTRAINT sales_invoices_issued_ck CHECK (status IN ('DRAFT') OR (doc_number IS NOT NULL AND journal_entry_id IS NOT NULL AND issued_at IS NOT NULL AND total > 0)) +); +CREATE UNIQUE INDEX sales_invoices_number_uq ON sales_invoices (company_id, doc_number) WHERE doc_number IS NOT NULL; +CREATE INDEX sales_invoices_company_date_idx ON sales_invoices (tenant_id, company_id, doc_date DESC) WHERE deleted_at IS NULL; +CREATE INDEX sales_invoices_party_idx ON sales_invoices (customer_id, status); +CREATE INDEX sales_invoices_status_idx ON sales_invoices (company_id, status); +CREATE TRIGGER sales_invoices_updated_at BEFORE UPDATE ON sales_invoices FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +CREATE TRIGGER sales_invoices_guard BEFORE INSERT OR UPDATE OR DELETE ON sales_invoices + FOR EACH ROW EXECUTE FUNCTION commercial_doc_guard('status', 'paid_amount', 'returned_amount', 'remaining_amount', 'cancelled_by', 'cancelled_at', 'cancel_reason'); + +CREATE TABLE sales_invoice_items ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + document_id uuid NOT NULL, + line_no smallint NOT NULL CHECK (line_no BETWEEN 1 AND 1000), + product_id uuid NOT NULL, + account_id uuid, + description text, + quantity numeric(18,4) NOT NULL CHECK (quantity > 0), + unit_id uuid, + unit_price numeric(18,4) NOT NULL CHECK (unit_price >= 0), + gross_amount numeric(18,2) NOT NULL CHECK (gross_amount >= 0), + -- Discount as entered (in the document's price basis); used to recalculate. + discount_basis numeric(18,2) NOT NULL DEFAULT 0 CHECK (discount_basis >= 0), + discount_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (discount_amount >= 0), + net_amount numeric(18,2) NOT NULL CHECK (net_amount >= 0), + vat_category char(1) NOT NULL CHECK (vat_category IN ('S', 'Z', 'E', 'O')), + vat_rate numeric(7,4) NOT NULL CHECK (vat_rate >= 0 AND vat_rate < 1), + vat_amount numeric(18,2) NOT NULL CHECK (vat_amount >= 0), + total_amount numeric(18,2) NOT NULL CHECK (total_amount >= 0), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT sales_invoice_items_document_fk FOREIGN KEY (document_id, company_id) REFERENCES sales_invoices (id, company_id), + CONSTRAINT sales_invoice_items_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT sales_invoice_items_product_fk FOREIGN KEY (product_id, company_id) REFERENCES products (id, company_id), + CONSTRAINT sales_invoice_items_account_fk FOREIGN KEY (account_id, company_id) REFERENCES accounts (id, company_id), + CONSTRAINT sales_invoice_items_unit_fk FOREIGN KEY (unit_id, company_id) REFERENCES units (id, company_id), + CONSTRAINT sales_invoice_items_line_uq UNIQUE (document_id, line_no), + CONSTRAINT sales_invoice_items_id_company_uq UNIQUE (id, company_id), + CONSTRAINT sales_invoice_items_amounts_ck CHECK (net_amount = gross_amount - discount_amount AND total_amount = net_amount + vat_amount) +); +CREATE INDEX sales_invoice_items_document_idx ON sales_invoice_items (document_id); +CREATE INDEX sales_invoice_items_product_idx ON sales_invoice_items (product_id) WHERE product_id IS NOT NULL; +CREATE TRIGGER sales_invoice_items_guard BEFORE INSERT OR UPDATE OR DELETE ON sales_invoice_items + FOR EACH ROW EXECUTE FUNCTION commercial_item_guard('sales_invoices'); + +-- sales_returns ----------------------------------------------------------------------- +CREATE TABLE sales_returns ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + branch_id uuid, + warehouse_id uuid, + -- Assigned at issue; gap-free per company. + doc_number text, + doc_date date NOT NULL, + customer_id uuid NOT NULL, + currency char(3) NOT NULL DEFAULT 'SAR' CHECK (currency ~ '^[A-Z]{3}$'), + prices_include_vat boolean NOT NULL DEFAULT false, + subtotal numeric(18,2) NOT NULL DEFAULT 0 CHECK (subtotal >= 0), + discount_total numeric(18,2) NOT NULL DEFAULT 0 CHECK (discount_total >= 0), + taxable_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (taxable_amount >= 0), + tax_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (tax_amount >= 0), + total numeric(18,2) NOT NULL DEFAULT 0 CHECK (total >= 0), + original_invoice_id uuid NOT NULL, + reason text NOT NULL CHECK (length(btrim(reason)) BETWEEN 3 AND 500), + -- Part of the credit applied against the original invoice; the rest is owed to the customer. + applied_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (applied_amount >= 0), + refunded_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (refunded_amount >= 0), + remaining_amount numeric(18,2) GENERATED ALWAYS AS (total - applied_amount - refunded_amount) STORED CHECK (remaining_amount >= 0), + notes text, + status text NOT NULL DEFAULT 'DRAFT' CHECK (status IN ('DRAFT', 'ISSUED', 'CANCELLED')), + -- Copy of the customer's name, VAT number and address at issue time. + party_snapshot jsonb, + journal_entry_id uuid, + created_by uuid REFERENCES users (id), + issued_by uuid REFERENCES users (id), + issued_at timestamptz, + cancelled_by uuid REFERENCES users (id), + cancelled_at timestamptz, + cancel_reason text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT sales_returns_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT sales_returns_branch_fk FOREIGN KEY (branch_id, company_id) REFERENCES branches (id, company_id), + CONSTRAINT sales_returns_warehouse_fk FOREIGN KEY (warehouse_id, company_id) REFERENCES warehouses (id, company_id), + CONSTRAINT sales_returns_customer_fk FOREIGN KEY (customer_id, company_id) REFERENCES customers (id, company_id), + CONSTRAINT sales_returns_journal_fk FOREIGN KEY (journal_entry_id, company_id) REFERENCES journal_entries (id, company_id), + CONSTRAINT sales_returns_id_company_uq UNIQUE (id, company_id), + CONSTRAINT sales_returns_totals_ck CHECK (taxable_amount = subtotal - discount_total AND total = taxable_amount + tax_amount), + CONSTRAINT sales_returns_soft_delete_ck CHECK (deleted_at IS NULL OR status = 'DRAFT'), + CONSTRAINT sales_returns_issued_ck CHECK (status IN ('DRAFT') OR (doc_number IS NOT NULL AND journal_entry_id IS NOT NULL AND issued_at IS NOT NULL AND total > 0)) +); +ALTER TABLE sales_returns ADD CONSTRAINT sales_returns_original_fk FOREIGN KEY (original_invoice_id, company_id) REFERENCES sales_invoices (id, company_id); +CREATE INDEX sales_returns_original_idx ON sales_returns (original_invoice_id); +CREATE UNIQUE INDEX sales_returns_number_uq ON sales_returns (company_id, doc_number) WHERE doc_number IS NOT NULL; +CREATE INDEX sales_returns_company_date_idx ON sales_returns (tenant_id, company_id, doc_date DESC) WHERE deleted_at IS NULL; +CREATE INDEX sales_returns_party_idx ON sales_returns (customer_id, status); +CREATE INDEX sales_returns_status_idx ON sales_returns (company_id, status); +CREATE TRIGGER sales_returns_updated_at BEFORE UPDATE ON sales_returns FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +CREATE TRIGGER sales_returns_guard BEFORE INSERT OR UPDATE OR DELETE ON sales_returns + FOR EACH ROW EXECUTE FUNCTION commercial_doc_guard('status', 'refunded_amount', 'remaining_amount', 'cancelled_by', 'cancelled_at', 'cancel_reason'); + +CREATE TABLE sales_return_items ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + document_id uuid NOT NULL, + line_no smallint NOT NULL CHECK (line_no BETWEEN 1 AND 1000), + -- The original invoice line being returned. + source_item_id uuid NOT NULL, + product_id uuid NOT NULL, + account_id uuid, + description text, + quantity numeric(18,4) NOT NULL CHECK (quantity > 0), + unit_id uuid, + unit_price numeric(18,4) NOT NULL CHECK (unit_price >= 0), + gross_amount numeric(18,2) NOT NULL CHECK (gross_amount >= 0), + -- Discount as entered (in the document's price basis); used to recalculate. + discount_basis numeric(18,2) NOT NULL DEFAULT 0 CHECK (discount_basis >= 0), + discount_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (discount_amount >= 0), + net_amount numeric(18,2) NOT NULL CHECK (net_amount >= 0), + vat_category char(1) NOT NULL CHECK (vat_category IN ('S', 'Z', 'E', 'O')), + vat_rate numeric(7,4) NOT NULL CHECK (vat_rate >= 0 AND vat_rate < 1), + vat_amount numeric(18,2) NOT NULL CHECK (vat_amount >= 0), + total_amount numeric(18,2) NOT NULL CHECK (total_amount >= 0), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT sales_return_items_document_fk FOREIGN KEY (document_id, company_id) REFERENCES sales_returns (id, company_id), + CONSTRAINT sales_return_items_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT sales_return_items_product_fk FOREIGN KEY (product_id, company_id) REFERENCES products (id, company_id), + CONSTRAINT sales_return_items_account_fk FOREIGN KEY (account_id, company_id) REFERENCES accounts (id, company_id), + CONSTRAINT sales_return_items_unit_fk FOREIGN KEY (unit_id, company_id) REFERENCES units (id, company_id), + CONSTRAINT sales_return_items_line_uq UNIQUE (document_id, line_no), + CONSTRAINT sales_return_items_id_company_uq UNIQUE (id, company_id), + CONSTRAINT sales_return_items_amounts_ck CHECK (net_amount = gross_amount - discount_amount AND total_amount = net_amount + vat_amount) +); +CREATE INDEX sales_return_items_document_idx ON sales_return_items (document_id); +CREATE INDEX sales_return_items_product_idx ON sales_return_items (product_id) WHERE product_id IS NOT NULL; +CREATE TRIGGER sales_return_items_guard BEFORE INSERT OR UPDATE OR DELETE ON sales_return_items + FOR EACH ROW EXECUTE FUNCTION commercial_item_guard('sales_returns'); + +-- purchase_orders ----------------------------------------------------------------------- +CREATE TABLE purchase_orders ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + branch_id uuid, + warehouse_id uuid, + -- Assigned at creation. + doc_number text, + doc_date date NOT NULL, + supplier_id uuid NOT NULL, + currency char(3) NOT NULL DEFAULT 'SAR' CHECK (currency ~ '^[A-Z]{3}$'), + prices_include_vat boolean NOT NULL DEFAULT false, + subtotal numeric(18,2) NOT NULL DEFAULT 0 CHECK (subtotal >= 0), + discount_total numeric(18,2) NOT NULL DEFAULT 0 CHECK (discount_total >= 0), + taxable_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (taxable_amount >= 0), + tax_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (tax_amount >= 0), + total numeric(18,2) NOT NULL DEFAULT 0 CHECK (total >= 0), + expected_date date, + converted_invoice_id uuid, + notes text, + status text NOT NULL DEFAULT 'DRAFT' CHECK (status IN ('DRAFT', 'APPROVED', 'CONVERTED', 'CANCELLED')), + -- Copy of the supplier's name, VAT number and address at issue time. + party_snapshot jsonb, + journal_entry_id uuid, + created_by uuid REFERENCES users (id), + issued_by uuid REFERENCES users (id), + issued_at timestamptz, + cancelled_by uuid REFERENCES users (id), + cancelled_at timestamptz, + cancel_reason text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT purchase_orders_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT purchase_orders_branch_fk FOREIGN KEY (branch_id, company_id) REFERENCES branches (id, company_id), + CONSTRAINT purchase_orders_warehouse_fk FOREIGN KEY (warehouse_id, company_id) REFERENCES warehouses (id, company_id), + CONSTRAINT purchase_orders_supplier_fk FOREIGN KEY (supplier_id, company_id) REFERENCES suppliers (id, company_id), + CONSTRAINT purchase_orders_journal_fk FOREIGN KEY (journal_entry_id, company_id) REFERENCES journal_entries (id, company_id), + CONSTRAINT purchase_orders_id_company_uq UNIQUE (id, company_id), + CONSTRAINT purchase_orders_totals_ck CHECK (taxable_amount = subtotal - discount_total AND total = taxable_amount + tax_amount), + CONSTRAINT purchase_orders_soft_delete_ck CHECK (deleted_at IS NULL OR status = 'DRAFT'), + CONSTRAINT purchase_orders_number_ck CHECK (doc_number IS NOT NULL) +); +CREATE UNIQUE INDEX purchase_orders_number_uq ON purchase_orders (company_id, doc_number) WHERE doc_number IS NOT NULL; +CREATE INDEX purchase_orders_company_date_idx ON purchase_orders (tenant_id, company_id, doc_date DESC) WHERE deleted_at IS NULL; +CREATE INDEX purchase_orders_party_idx ON purchase_orders (supplier_id, status); +CREATE INDEX purchase_orders_status_idx ON purchase_orders (company_id, status); +CREATE TRIGGER purchase_orders_updated_at BEFORE UPDATE ON purchase_orders FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +CREATE TRIGGER purchase_orders_guard BEFORE INSERT OR UPDATE OR DELETE ON purchase_orders + FOR EACH ROW EXECUTE FUNCTION commercial_doc_guard('status', 'converted_invoice_id', 'cancelled_by', 'cancelled_at', 'cancel_reason'); + +CREATE TABLE purchase_order_items ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + document_id uuid NOT NULL, + line_no smallint NOT NULL CHECK (line_no BETWEEN 1 AND 1000), + -- Purchase lines name a product, an account (expense/asset), or both. + product_id uuid, + account_id uuid, + description text, + quantity numeric(18,4) NOT NULL CHECK (quantity > 0), + unit_id uuid, + unit_price numeric(18,4) NOT NULL CHECK (unit_price >= 0), + gross_amount numeric(18,2) NOT NULL CHECK (gross_amount >= 0), + -- Discount as entered (in the document's price basis); used to recalculate. + discount_basis numeric(18,2) NOT NULL DEFAULT 0 CHECK (discount_basis >= 0), + discount_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (discount_amount >= 0), + net_amount numeric(18,2) NOT NULL CHECK (net_amount >= 0), + vat_category char(1) NOT NULL CHECK (vat_category IN ('S', 'Z', 'E', 'O')), + vat_rate numeric(7,4) NOT NULL CHECK (vat_rate >= 0 AND vat_rate < 1), + vat_amount numeric(18,2) NOT NULL CHECK (vat_amount >= 0), + total_amount numeric(18,2) NOT NULL CHECK (total_amount >= 0), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT purchase_order_items_document_fk FOREIGN KEY (document_id, company_id) REFERENCES purchase_orders (id, company_id), + CONSTRAINT purchase_order_items_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT purchase_order_items_product_fk FOREIGN KEY (product_id, company_id) REFERENCES products (id, company_id), + CONSTRAINT purchase_order_items_account_fk FOREIGN KEY (account_id, company_id) REFERENCES accounts (id, company_id), + CONSTRAINT purchase_order_items_unit_fk FOREIGN KEY (unit_id, company_id) REFERENCES units (id, company_id), + CONSTRAINT purchase_order_items_line_uq UNIQUE (document_id, line_no), + CONSTRAINT purchase_order_items_id_company_uq UNIQUE (id, company_id), + CONSTRAINT purchase_order_items_amounts_ck CHECK (net_amount = gross_amount - discount_amount AND total_amount = net_amount + vat_amount), + CONSTRAINT purchase_order_items_target_ck CHECK (product_id IS NOT NULL OR account_id IS NOT NULL) +); +CREATE INDEX purchase_order_items_document_idx ON purchase_order_items (document_id); +CREATE INDEX purchase_order_items_product_idx ON purchase_order_items (product_id) WHERE product_id IS NOT NULL; +CREATE TRIGGER purchase_order_items_guard BEFORE INSERT OR UPDATE OR DELETE ON purchase_order_items + FOR EACH ROW EXECUTE FUNCTION commercial_item_guard('purchase_orders'); + +-- purchase_invoices ----------------------------------------------------------------------- +CREATE TABLE purchase_invoices ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + branch_id uuid, + warehouse_id uuid, + -- Assigned at issue; gap-free per company. + doc_number text, + doc_date date NOT NULL, + supplier_id uuid NOT NULL, + currency char(3) NOT NULL DEFAULT 'SAR' CHECK (currency ~ '^[A-Z]{3}$'), + prices_include_vat boolean NOT NULL DEFAULT false, + subtotal numeric(18,2) NOT NULL DEFAULT 0 CHECK (subtotal >= 0), + discount_total numeric(18,2) NOT NULL DEFAULT 0 CHECK (discount_total >= 0), + taxable_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (taxable_amount >= 0), + tax_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (tax_amount >= 0), + total numeric(18,2) NOT NULL DEFAULT 0 CHECK (total >= 0), + due_date date, + supplier_invoice_number text CHECK (length(btrim(supplier_invoice_number)) BETWEEN 1 AND 60), + paid_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (paid_amount >= 0), + returned_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (returned_amount >= 0), + remaining_amount numeric(18,2) GENERATED ALWAYS AS (total - paid_amount - returned_amount) STORED CHECK (remaining_amount >= 0), + source_order_id uuid, + notes text, + status text NOT NULL DEFAULT 'DRAFT' CHECK (status IN ('DRAFT', 'POSTED', 'PARTIALLY_PAID', 'PAID', 'CANCELLED', 'RETURNED')), + -- Copy of the supplier's name, VAT number and address at issue time. + party_snapshot jsonb, + journal_entry_id uuid, + created_by uuid REFERENCES users (id), + issued_by uuid REFERENCES users (id), + issued_at timestamptz, + cancelled_by uuid REFERENCES users (id), + cancelled_at timestamptz, + cancel_reason text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT purchase_invoices_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT purchase_invoices_branch_fk FOREIGN KEY (branch_id, company_id) REFERENCES branches (id, company_id), + CONSTRAINT purchase_invoices_warehouse_fk FOREIGN KEY (warehouse_id, company_id) REFERENCES warehouses (id, company_id), + CONSTRAINT purchase_invoices_supplier_fk FOREIGN KEY (supplier_id, company_id) REFERENCES suppliers (id, company_id), + CONSTRAINT purchase_invoices_journal_fk FOREIGN KEY (journal_entry_id, company_id) REFERENCES journal_entries (id, company_id), + CONSTRAINT purchase_invoices_id_company_uq UNIQUE (id, company_id), + CONSTRAINT purchase_invoices_totals_ck CHECK (taxable_amount = subtotal - discount_total AND total = taxable_amount + tax_amount), + CONSTRAINT purchase_invoices_soft_delete_ck CHECK (deleted_at IS NULL OR status = 'DRAFT'), + CONSTRAINT purchase_invoices_issued_ck CHECK (status IN ('DRAFT') OR (doc_number IS NOT NULL AND journal_entry_id IS NOT NULL AND issued_at IS NOT NULL AND total > 0)) +); +CREATE UNIQUE INDEX purchase_invoices_number_uq ON purchase_invoices (company_id, doc_number) WHERE doc_number IS NOT NULL; +CREATE INDEX purchase_invoices_company_date_idx ON purchase_invoices (tenant_id, company_id, doc_date DESC) WHERE deleted_at IS NULL; +CREATE INDEX purchase_invoices_party_idx ON purchase_invoices (supplier_id, status); +CREATE INDEX purchase_invoices_status_idx ON purchase_invoices (company_id, status); +CREATE TRIGGER purchase_invoices_updated_at BEFORE UPDATE ON purchase_invoices FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +CREATE TRIGGER purchase_invoices_guard BEFORE INSERT OR UPDATE OR DELETE ON purchase_invoices + FOR EACH ROW EXECUTE FUNCTION commercial_doc_guard('status', 'paid_amount', 'returned_amount', 'remaining_amount', 'cancelled_by', 'cancelled_at', 'cancel_reason'); + +CREATE TABLE purchase_invoice_items ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + document_id uuid NOT NULL, + line_no smallint NOT NULL CHECK (line_no BETWEEN 1 AND 1000), + -- Purchase lines name a product, an account (expense/asset), or both. + product_id uuid, + account_id uuid, + description text, + quantity numeric(18,4) NOT NULL CHECK (quantity > 0), + unit_id uuid, + unit_price numeric(18,4) NOT NULL CHECK (unit_price >= 0), + gross_amount numeric(18,2) NOT NULL CHECK (gross_amount >= 0), + -- Discount as entered (in the document's price basis); used to recalculate. + discount_basis numeric(18,2) NOT NULL DEFAULT 0 CHECK (discount_basis >= 0), + discount_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (discount_amount >= 0), + net_amount numeric(18,2) NOT NULL CHECK (net_amount >= 0), + vat_category char(1) NOT NULL CHECK (vat_category IN ('S', 'Z', 'E', 'O')), + vat_rate numeric(7,4) NOT NULL CHECK (vat_rate >= 0 AND vat_rate < 1), + vat_amount numeric(18,2) NOT NULL CHECK (vat_amount >= 0), + total_amount numeric(18,2) NOT NULL CHECK (total_amount >= 0), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT purchase_invoice_items_document_fk FOREIGN KEY (document_id, company_id) REFERENCES purchase_invoices (id, company_id), + CONSTRAINT purchase_invoice_items_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT purchase_invoice_items_product_fk FOREIGN KEY (product_id, company_id) REFERENCES products (id, company_id), + CONSTRAINT purchase_invoice_items_account_fk FOREIGN KEY (account_id, company_id) REFERENCES accounts (id, company_id), + CONSTRAINT purchase_invoice_items_unit_fk FOREIGN KEY (unit_id, company_id) REFERENCES units (id, company_id), + CONSTRAINT purchase_invoice_items_line_uq UNIQUE (document_id, line_no), + CONSTRAINT purchase_invoice_items_id_company_uq UNIQUE (id, company_id), + CONSTRAINT purchase_invoice_items_amounts_ck CHECK (net_amount = gross_amount - discount_amount AND total_amount = net_amount + vat_amount), + CONSTRAINT purchase_invoice_items_target_ck CHECK (product_id IS NOT NULL OR account_id IS NOT NULL) +); +CREATE INDEX purchase_invoice_items_document_idx ON purchase_invoice_items (document_id); +CREATE INDEX purchase_invoice_items_product_idx ON purchase_invoice_items (product_id) WHERE product_id IS NOT NULL; +CREATE TRIGGER purchase_invoice_items_guard BEFORE INSERT OR UPDATE OR DELETE ON purchase_invoice_items + FOR EACH ROW EXECUTE FUNCTION commercial_item_guard('purchase_invoices'); + +-- purchase_returns ----------------------------------------------------------------------- +CREATE TABLE purchase_returns ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + branch_id uuid, + warehouse_id uuid, + -- Assigned at issue; gap-free per company. + doc_number text, + doc_date date NOT NULL, + supplier_id uuid NOT NULL, + currency char(3) NOT NULL DEFAULT 'SAR' CHECK (currency ~ '^[A-Z]{3}$'), + prices_include_vat boolean NOT NULL DEFAULT false, + subtotal numeric(18,2) NOT NULL DEFAULT 0 CHECK (subtotal >= 0), + discount_total numeric(18,2) NOT NULL DEFAULT 0 CHECK (discount_total >= 0), + taxable_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (taxable_amount >= 0), + tax_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (tax_amount >= 0), + total numeric(18,2) NOT NULL DEFAULT 0 CHECK (total >= 0), + original_invoice_id uuid NOT NULL, + reason text NOT NULL CHECK (length(btrim(reason)) BETWEEN 3 AND 500), + applied_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (applied_amount >= 0), + refunded_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (refunded_amount >= 0), + remaining_amount numeric(18,2) GENERATED ALWAYS AS (total - applied_amount - refunded_amount) STORED CHECK (remaining_amount >= 0), + notes text, + status text NOT NULL DEFAULT 'DRAFT' CHECK (status IN ('DRAFT', 'ISSUED', 'CANCELLED')), + -- Copy of the supplier's name, VAT number and address at issue time. + party_snapshot jsonb, + journal_entry_id uuid, + created_by uuid REFERENCES users (id), + issued_by uuid REFERENCES users (id), + issued_at timestamptz, + cancelled_by uuid REFERENCES users (id), + cancelled_at timestamptz, + cancel_reason text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT purchase_returns_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT purchase_returns_branch_fk FOREIGN KEY (branch_id, company_id) REFERENCES branches (id, company_id), + CONSTRAINT purchase_returns_warehouse_fk FOREIGN KEY (warehouse_id, company_id) REFERENCES warehouses (id, company_id), + CONSTRAINT purchase_returns_supplier_fk FOREIGN KEY (supplier_id, company_id) REFERENCES suppliers (id, company_id), + CONSTRAINT purchase_returns_journal_fk FOREIGN KEY (journal_entry_id, company_id) REFERENCES journal_entries (id, company_id), + CONSTRAINT purchase_returns_id_company_uq UNIQUE (id, company_id), + CONSTRAINT purchase_returns_totals_ck CHECK (taxable_amount = subtotal - discount_total AND total = taxable_amount + tax_amount), + CONSTRAINT purchase_returns_soft_delete_ck CHECK (deleted_at IS NULL OR status = 'DRAFT'), + CONSTRAINT purchase_returns_issued_ck CHECK (status IN ('DRAFT') OR (doc_number IS NOT NULL AND journal_entry_id IS NOT NULL AND issued_at IS NOT NULL AND total > 0)) +); +ALTER TABLE purchase_returns ADD CONSTRAINT purchase_returns_original_fk FOREIGN KEY (original_invoice_id, company_id) REFERENCES purchase_invoices (id, company_id); +CREATE INDEX purchase_returns_original_idx ON purchase_returns (original_invoice_id); +CREATE UNIQUE INDEX purchase_returns_number_uq ON purchase_returns (company_id, doc_number) WHERE doc_number IS NOT NULL; +CREATE INDEX purchase_returns_company_date_idx ON purchase_returns (tenant_id, company_id, doc_date DESC) WHERE deleted_at IS NULL; +CREATE INDEX purchase_returns_party_idx ON purchase_returns (supplier_id, status); +CREATE INDEX purchase_returns_status_idx ON purchase_returns (company_id, status); +CREATE TRIGGER purchase_returns_updated_at BEFORE UPDATE ON purchase_returns FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +CREATE TRIGGER purchase_returns_guard BEFORE INSERT OR UPDATE OR DELETE ON purchase_returns + FOR EACH ROW EXECUTE FUNCTION commercial_doc_guard('status', 'refunded_amount', 'remaining_amount', 'cancelled_by', 'cancelled_at', 'cancel_reason'); + +CREATE TABLE purchase_return_items ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + document_id uuid NOT NULL, + line_no smallint NOT NULL CHECK (line_no BETWEEN 1 AND 1000), + -- The original invoice line being returned. + source_item_id uuid NOT NULL, + -- Purchase lines name a product, an account (expense/asset), or both. + product_id uuid, + account_id uuid, + description text, + quantity numeric(18,4) NOT NULL CHECK (quantity > 0), + unit_id uuid, + unit_price numeric(18,4) NOT NULL CHECK (unit_price >= 0), + gross_amount numeric(18,2) NOT NULL CHECK (gross_amount >= 0), + -- Discount as entered (in the document's price basis); used to recalculate. + discount_basis numeric(18,2) NOT NULL DEFAULT 0 CHECK (discount_basis >= 0), + discount_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (discount_amount >= 0), + net_amount numeric(18,2) NOT NULL CHECK (net_amount >= 0), + vat_category char(1) NOT NULL CHECK (vat_category IN ('S', 'Z', 'E', 'O')), + vat_rate numeric(7,4) NOT NULL CHECK (vat_rate >= 0 AND vat_rate < 1), + vat_amount numeric(18,2) NOT NULL CHECK (vat_amount >= 0), + total_amount numeric(18,2) NOT NULL CHECK (total_amount >= 0), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT purchase_return_items_document_fk FOREIGN KEY (document_id, company_id) REFERENCES purchase_returns (id, company_id), + CONSTRAINT purchase_return_items_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT purchase_return_items_product_fk FOREIGN KEY (product_id, company_id) REFERENCES products (id, company_id), + CONSTRAINT purchase_return_items_account_fk FOREIGN KEY (account_id, company_id) REFERENCES accounts (id, company_id), + CONSTRAINT purchase_return_items_unit_fk FOREIGN KEY (unit_id, company_id) REFERENCES units (id, company_id), + CONSTRAINT purchase_return_items_line_uq UNIQUE (document_id, line_no), + CONSTRAINT purchase_return_items_id_company_uq UNIQUE (id, company_id), + CONSTRAINT purchase_return_items_amounts_ck CHECK (net_amount = gross_amount - discount_amount AND total_amount = net_amount + vat_amount), + CONSTRAINT purchase_return_items_target_ck CHECK (product_id IS NOT NULL OR account_id IS NOT NULL) +); +CREATE INDEX purchase_return_items_document_idx ON purchase_return_items (document_id); +CREATE INDEX purchase_return_items_product_idx ON purchase_return_items (product_id) WHERE product_id IS NOT NULL; +CREATE TRIGGER purchase_return_items_guard BEFORE INSERT OR UPDATE OR DELETE ON purchase_return_items + FOR EACH ROW EXECUTE FUNCTION commercial_item_guard('purchase_returns'); + +ALTER TABLE sales_return_items ADD CONSTRAINT sales_return_items_source_fk + FOREIGN KEY (source_item_id, company_id) REFERENCES sales_invoice_items (id, company_id); +CREATE INDEX sales_return_items_source_idx ON sales_return_items (source_item_id); +ALTER TABLE purchase_return_items ADD CONSTRAINT purchase_return_items_source_fk + FOREIGN KEY (source_item_id, company_id) REFERENCES purchase_invoice_items (id, company_id); +CREATE INDEX purchase_return_items_source_idx ON purchase_return_items (source_item_id); + +-- Conversion links +ALTER TABLE sales_quotes ADD CONSTRAINT sales_quotes_converted_fk + FOREIGN KEY (converted_invoice_id, company_id) REFERENCES sales_invoices (id, company_id); +ALTER TABLE sales_invoices ADD CONSTRAINT sales_invoices_quote_fk + FOREIGN KEY (source_quote_id, company_id) REFERENCES sales_quotes (id, company_id); +ALTER TABLE purchase_orders ADD CONSTRAINT purchase_orders_converted_fk + FOREIGN KEY (converted_invoice_id, company_id) REFERENCES purchase_invoices (id, company_id); +ALTER TABLE purchase_invoices ADD CONSTRAINT purchase_invoices_order_fk + FOREIGN KEY (source_order_id, company_id) REFERENCES purchase_orders (id, company_id); + +-- A supplier's invoice number is recorded once per supplier. +CREATE UNIQUE INDEX purchase_invoices_supplier_number_uq ON purchase_invoices (supplier_id, supplier_invoice_number) + WHERE supplier_invoice_number IS NOT NULL AND status <> 'CANCELLED' AND deleted_at IS NULL; + +-- Payments --------------------------------------------------------------------------- +CREATE TABLE payments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + branch_id uuid, + payment_number text NOT NULL, + -- RECEIPT: money in. DISBURSEMENT: money out. + direction text NOT NULL CHECK (direction IN ('RECEIPT', 'DISBURSEMENT')), + customer_id uuid, + supplier_id uuid, + payment_date date NOT NULL, + method_id uuid NOT NULL, + amount numeric(18,2) NOT NULL CHECK (amount > 0), + allocated_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (allocated_amount >= 0), + unallocated_amount numeric(18,2) GENERATED ALWAYS AS (amount - allocated_amount) STORED CHECK (unallocated_amount >= 0), + reference text, + notes text, + status text NOT NULL DEFAULT 'POSTED' CHECK (status IN ('POSTED', 'VOIDED')), + journal_entry_id uuid NOT NULL, + voided_by uuid REFERENCES users (id), + voided_at timestamptz, + void_reason text, + created_by uuid REFERENCES users (id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT payments_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT payments_branch_fk FOREIGN KEY (branch_id, company_id) REFERENCES branches (id, company_id), + CONSTRAINT payments_customer_fk FOREIGN KEY (customer_id, company_id) REFERENCES customers (id, company_id), + CONSTRAINT payments_supplier_fk FOREIGN KEY (supplier_id, company_id) REFERENCES suppliers (id, company_id), + CONSTRAINT payments_method_fk FOREIGN KEY (method_id, company_id) REFERENCES payment_methods (id, company_id), + CONSTRAINT payments_journal_fk FOREIGN KEY (journal_entry_id, company_id) REFERENCES journal_entries (id, company_id), + CONSTRAINT payments_one_party_ck CHECK ((customer_id IS NULL) <> (supplier_id IS NULL)), + CONSTRAINT payments_id_company_uq UNIQUE (id, company_id) +); +CREATE UNIQUE INDEX payments_number_uq ON payments (company_id, payment_number); +CREATE INDEX payments_company_date_idx ON payments (tenant_id, company_id, payment_date DESC); +CREATE INDEX payments_customer_idx ON payments (customer_id) WHERE customer_id IS NOT NULL; +CREATE INDEX payments_supplier_idx ON payments (supplier_id) WHERE supplier_id IS NOT NULL; +CREATE TRIGGER payments_updated_at BEFORE UPDATE ON payments FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE OR REPLACE FUNCTION payments_guard() RETURNS trigger +LANGUAGE plpgsql AS $$ +DECLARE + mutable text[] := ARRAY['status', 'allocated_amount', 'unallocated_amount', 'voided_by', 'voided_at', 'void_reason', 'updated_at']; +BEGIN + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION 'payments cannot be deleted; void them instead' USING ERRCODE = 'insufficient_privilege'; + END IF; + IF OLD.status = 'VOIDED' THEN + RAISE EXCEPTION 'a voided payment cannot be changed' USING ERRCODE = 'insufficient_privilege'; + END IF; + IF (to_jsonb(NEW) - mutable) IS DISTINCT FROM (to_jsonb(OLD) - mutable) THEN + RAISE EXCEPTION 'a payment cannot be modified; void it and record a new one' USING ERRCODE = 'insufficient_privilege'; + END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER payments_guard BEFORE UPDATE OR DELETE ON payments FOR EACH ROW EXECUTE FUNCTION payments_guard(); + +CREATE TABLE payment_allocations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + payment_id uuid NOT NULL, + sales_invoice_id uuid, + sales_return_id uuid, + purchase_invoice_id uuid, + purchase_return_id uuid, + amount numeric(18,2) NOT NULL CHECK (amount > 0), + created_by uuid REFERENCES users (id), + created_at timestamptz NOT NULL DEFAULT now(), + -- Set when the payment is voided; reversed allocations no longer count. + reversed_at timestamptz, + CONSTRAINT payment_allocations_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT payment_allocations_payment_fk FOREIGN KEY (payment_id, company_id) REFERENCES payments (id, company_id), + CONSTRAINT payment_allocations_si_fk FOREIGN KEY (sales_invoice_id, company_id) REFERENCES sales_invoices (id, company_id), + CONSTRAINT payment_allocations_sr_fk FOREIGN KEY (sales_return_id, company_id) REFERENCES sales_returns (id, company_id), + CONSTRAINT payment_allocations_pi_fk FOREIGN KEY (purchase_invoice_id, company_id) REFERENCES purchase_invoices (id, company_id), + CONSTRAINT payment_allocations_pr_fk FOREIGN KEY (purchase_return_id, company_id) REFERENCES purchase_returns (id, company_id), + CONSTRAINT payment_allocations_one_target_ck CHECK (num_nonnulls(sales_invoice_id, sales_return_id, purchase_invoice_id, purchase_return_id) = 1) +); +CREATE INDEX payment_allocations_payment_idx ON payment_allocations (payment_id); +CREATE INDEX payment_allocations_si_idx ON payment_allocations (sales_invoice_id) WHERE sales_invoice_id IS NOT NULL; +CREATE INDEX payment_allocations_sr_idx ON payment_allocations (sales_return_id) WHERE sales_return_id IS NOT NULL; +CREATE INDEX payment_allocations_pi_idx ON payment_allocations (purchase_invoice_id) WHERE purchase_invoice_id IS NOT NULL; +CREATE INDEX payment_allocations_pr_idx ON payment_allocations (purchase_return_id) WHERE purchase_return_id IS NOT NULL; + +-- Seed existing companies: the standard VAT rate and default payment methods. +-- New companies get the same seed from setupCompanyAccounting(). +INSERT INTO tax_rates (tenant_id, company_id, name_ar, rate, effective_from) +SELECT tenant_id, id, 'ضريبة القيمة المضافة - النسبة الأساسية', 0.15, DATE '2020-07-01' + FROM companies WHERE deleted_at IS NULL; + +INSERT INTO payment_methods (tenant_id, company_id, code, name_ar, method_type, account_id, sort_order) +SELECT c.tenant_id, c.id, m.code, m.name_ar, m.method_type, a.id, m.sort_order + FROM companies c + CROSS JOIN (VALUES ('CASH', 'نقدًا', 'CASH', 'CASH', 1), ('BANK', 'تحويل بنكي', 'BANK', 'BANK', 2), + ('CARD', 'بطاقة مدى / ائتمان', 'CARD', 'BANK', 3), ('STC_PAY', 'STC Pay', 'STC_PAY', 'BANK', 4), + ('TAMARA', 'تمارا', 'TAMARA', 'BANK', 5)) AS m(code, name_ar, method_type, account_key, sort_order) + JOIN accounts a ON a.company_id = c.id AND a.system_key = m.account_key AND a.deleted_at IS NULL + WHERE c.deleted_at IS NULL; + +-- Row-Level Security and privileges --------------------------------------------------- +DO $$ +DECLARE t text; +BEGIN + FOREACH t IN ARRAY ARRAY['tax_rates', 'payment_methods', 'payments', 'payment_allocations', + 'sales_quotes', 'sales_quote_items', 'sales_invoices', 'sales_invoice_items', 'sales_returns', 'sales_return_items', 'purchase_orders', 'purchase_order_items', 'purchase_invoices', 'purchase_invoice_items', 'purchase_returns', 'purchase_return_items'] LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t); + EXECUTE format('CREATE POLICY %I ON %I USING (tenant_id = app_current_tenant_id()) WITH CHECK (tenant_id = app_current_tenant_id())', + t || '_isolation', t); + EXECUTE format('GRANT SELECT, INSERT, UPDATE ON %I TO alshuyukh_app', t); + END LOOP; +END +$$; +-- Draft lines are replaced with DELETE + INSERT; the item guard refuses that after issue. +GRANT DELETE ON sales_quote_items, sales_invoice_items, sales_return_items, purchase_order_items, purchase_invoice_items, purchase_return_items TO alshuyukh_app; diff --git a/alshuyukh-accounting/apps/api/src/db/migrations/0010_inventory.sql b/alshuyukh-accounting/apps/api/src/db/migrations/0010_inventory.sql new file mode 100644 index 000000000000..f90f86ec88f6 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/db/migrations/0010_inventory.sql @@ -0,0 +1,187 @@ +-- 0010 — Inventory: balances, movements, transfers, adjustments. +-- +-- Stock is never edited directly. Every change is a row in stock_movements +-- (append-only); inventory_balances holds the running quantity and value per +-- product and warehouse and is updated in the same transaction. +-- +-- Costing: weighted average per product and warehouse. Values are kept to +-- the halala (2 decimals) so the stock ledger always equals the Inventory +-- account in the general ledger. An issue takes round(average × qty, 2); the +-- last unit out of a warehouse takes exactly the remaining value. + +ALTER TABLE companies + ADD COLUMN costing_method text NOT NULL DEFAULT 'WEIGHTED_AVERAGE' + CHECK (costing_method IN ('WEIGHTED_AVERAGE')); -- FIFO: see inventory/costing.ts + +CREATE TABLE inventory_balances ( + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + product_id uuid NOT NULL, + warehouse_id uuid NOT NULL, + quantity numeric(18,4) NOT NULL DEFAULT 0 CHECK (quantity >= 0), + value numeric(18,2) NOT NULL DEFAULT 0 CHECK (value >= 0), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (product_id, warehouse_id), + CONSTRAINT inventory_balances_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT inventory_balances_product_fk FOREIGN KEY (product_id, company_id) REFERENCES products (id, company_id), + CONSTRAINT inventory_balances_warehouse_fk FOREIGN KEY (warehouse_id, company_id) REFERENCES warehouses (id, company_id), + -- Nothing left means nothing left: no value without quantity. + CONSTRAINT inventory_balances_empty_ck CHECK (quantity > 0 OR value = 0) +); +CREATE INDEX inventory_balances_warehouse_idx ON inventory_balances (tenant_id, company_id, warehouse_id); +CREATE TRIGGER inventory_balances_updated_at BEFORE UPDATE ON inventory_balances FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE stock_movements ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + product_id uuid NOT NULL, + warehouse_id uuid NOT NULL, + movement_type text NOT NULL CHECK (movement_type IN ( + 'PURCHASE', 'SALE', 'SALE_RETURN', 'PURCHASE_RETURN', 'TRANSFER_IN', 'TRANSFER_OUT', + 'ADJUSTMENT_IN', 'ADJUSTMENT_OUT', 'CANCELLATION_IN', 'CANCELLATION_OUT')), + direction text NOT NULL CHECK (direction IN ('IN', 'OUT')), + quantity numeric(18,4) NOT NULL CHECK (quantity > 0), + unit_cost numeric(18,6) NOT NULL CHECK (unit_cost >= 0), + total_cost numeric(18,2) NOT NULL CHECK (total_cost >= 0), + -- Balance right after this movement (stock card). + balance_quantity numeric(18,4) NOT NULL, + balance_value numeric(18,2) NOT NULL, + reference_type text NOT NULL CHECK (reference_type ~ '^[A-Z][A-Z0-9_]{1,62}$'), + reference_id uuid NOT NULL, + reference_line_id uuid, + -- For cancellations: the movement being undone. + reverses_id uuid REFERENCES stock_movements (id), + movement_date date NOT NULL, + created_by uuid REFERENCES users (id), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT stock_movements_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT stock_movements_product_fk FOREIGN KEY (product_id, company_id) REFERENCES products (id, company_id), + CONSTRAINT stock_movements_warehouse_fk FOREIGN KEY (warehouse_id, company_id) REFERENCES warehouses (id, company_id), + CONSTRAINT stock_movements_direction_ck CHECK ( + (direction = 'IN') = (movement_type IN ('PURCHASE', 'SALE_RETURN', 'TRANSFER_IN', 'ADJUSTMENT_IN', 'CANCELLATION_IN')) + ) +); +CREATE INDEX stock_movements_card_idx ON stock_movements (product_id, warehouse_id, created_at); +CREATE INDEX stock_movements_reference_idx ON stock_movements (reference_type, reference_id); +CREATE INDEX stock_movements_line_idx ON stock_movements (reference_line_id) WHERE reference_line_id IS NOT NULL; +CREATE INDEX stock_movements_company_date_idx ON stock_movements (tenant_id, company_id, movement_date); +CREATE UNIQUE INDEX stock_movements_one_reversal_uq ON stock_movements (reverses_id) WHERE reverses_id IS NOT NULL; +CREATE TRIGGER stock_movements_append_only BEFORE UPDATE OR DELETE ON stock_movements + FOR EACH ROW EXECUTE FUNCTION prevent_modification(); + +-- Transfers between warehouses of the same company. Posted on creation; +-- corrected with a transfer in the opposite direction. +CREATE TABLE stock_transfers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + transfer_number text NOT NULL, + transfer_date date NOT NULL, + from_warehouse_id uuid NOT NULL, + to_warehouse_id uuid NOT NULL, + notes text, + total_cost numeric(18,2) NOT NULL DEFAULT 0, + created_by uuid REFERENCES users (id), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT stock_transfers_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT stock_transfers_from_fk FOREIGN KEY (from_warehouse_id, company_id) REFERENCES warehouses (id, company_id), + CONSTRAINT stock_transfers_to_fk FOREIGN KEY (to_warehouse_id, company_id) REFERENCES warehouses (id, company_id), + CONSTRAINT stock_transfers_distinct_ck CHECK (from_warehouse_id <> to_warehouse_id), + CONSTRAINT stock_transfers_id_company_uq UNIQUE (id, company_id) +); +CREATE UNIQUE INDEX stock_transfers_number_uq ON stock_transfers (company_id, transfer_number); +CREATE INDEX stock_transfers_date_idx ON stock_transfers (tenant_id, company_id, transfer_date DESC); +CREATE TRIGGER stock_transfers_append_only BEFORE UPDATE OR DELETE ON stock_transfers + FOR EACH ROW EXECUTE FUNCTION prevent_modification(); + +CREATE TABLE stock_transfer_items ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + transfer_id uuid NOT NULL, + line_no smallint NOT NULL, + product_id uuid NOT NULL, + quantity numeric(18,4) NOT NULL CHECK (quantity > 0), + total_cost numeric(18,2) NOT NULL CHECK (total_cost >= 0), + CONSTRAINT stock_transfer_items_transfer_fk FOREIGN KEY (transfer_id, company_id) REFERENCES stock_transfers (id, company_id), + CONSTRAINT stock_transfer_items_product_fk FOREIGN KEY (product_id, company_id) REFERENCES products (id, company_id), + CONSTRAINT stock_transfer_items_line_uq UNIQUE (transfer_id, line_no) +); +CREATE TRIGGER stock_transfer_items_append_only BEFORE UPDATE OR DELETE ON stock_transfer_items + FOR EACH ROW EXECUTE FUNCTION prevent_modification(); + +-- Adjustments (stock counts, damage, opening stock). Posted on creation with +-- a journal entry against the offset account. +CREATE TABLE stock_adjustments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + adjustment_number text NOT NULL, + adjustment_date date NOT NULL, + warehouse_id uuid NOT NULL, + reason text NOT NULL CHECK (length(btrim(reason)) BETWEEN 3 AND 500), + offset_account_id uuid NOT NULL, + total_increase numeric(18,2) NOT NULL DEFAULT 0, + total_decrease numeric(18,2) NOT NULL DEFAULT 0, + journal_entry_id uuid, + created_by uuid REFERENCES users (id), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT stock_adjustments_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT stock_adjustments_warehouse_fk FOREIGN KEY (warehouse_id, company_id) REFERENCES warehouses (id, company_id), + CONSTRAINT stock_adjustments_account_fk FOREIGN KEY (offset_account_id, company_id) REFERENCES accounts (id, company_id), + CONSTRAINT stock_adjustments_journal_fk FOREIGN KEY (journal_entry_id, company_id) REFERENCES journal_entries (id, company_id), + CONSTRAINT stock_adjustments_id_company_uq UNIQUE (id, company_id) +); +CREATE UNIQUE INDEX stock_adjustments_number_uq ON stock_adjustments (company_id, adjustment_number); +CREATE INDEX stock_adjustments_date_idx ON stock_adjustments (tenant_id, company_id, adjustment_date DESC); +CREATE TRIGGER stock_adjustments_append_only BEFORE UPDATE OR DELETE ON stock_adjustments + FOR EACH ROW EXECUTE FUNCTION prevent_modification(); + +CREATE TABLE stock_adjustment_items ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + adjustment_id uuid NOT NULL, + line_no smallint NOT NULL, + product_id uuid NOT NULL, + direction text NOT NULL CHECK (direction IN ('IN', 'OUT')), + quantity numeric(18,4) NOT NULL CHECK (quantity > 0), + -- For stock counts: what was counted and what the system held before. + counted_quantity numeric(18,4), + system_quantity numeric(18,4), + unit_cost numeric(18,6) NOT NULL CHECK (unit_cost >= 0), + total_cost numeric(18,2) NOT NULL CHECK (total_cost >= 0), + CONSTRAINT stock_adjustment_items_adjustment_fk FOREIGN KEY (adjustment_id, company_id) REFERENCES stock_adjustments (id, company_id), + CONSTRAINT stock_adjustment_items_product_fk FOREIGN KEY (product_id, company_id) REFERENCES products (id, company_id), + CONSTRAINT stock_adjustment_items_line_uq UNIQUE (adjustment_id, line_no) +); +CREATE TRIGGER stock_adjustment_items_append_only BEFORE UPDATE OR DELETE ON stock_adjustment_items + FOR EACH ROW EXECUTE FUNCTION prevent_modification(); + +-- Inventory differences account for existing companies (new companies get +-- it from the chart template). +INSERT INTO accounts (tenant_id, company_id, code, name_ar, name_en, account_type, parent_id, group_id, is_postable, is_system, system_key) +SELECT c.tenant_id, c.id, '5200', 'فروقات وتسويات المخزون', 'Inventory adjustments', 'COST_OF_GOODS_SOLD', + h.id, g.id, true, true, 'INVENTORY_ADJUSTMENT' + FROM companies c + JOIN accounts h ON h.company_id = c.id AND h.code = '5000' AND h.deleted_at IS NULL AND NOT h.is_postable + LEFT JOIN account_groups g ON g.company_id = c.id AND g.code = 'COST_OF_SALES' + WHERE NOT EXISTS (SELECT 1 FROM accounts a WHERE a.company_id = c.id AND a.system_key = 'INVENTORY_ADJUSTMENT') + AND NOT EXISTS (SELECT 1 FROM accounts a WHERE a.company_id = c.id AND a.code = '5200' AND a.deleted_at IS NULL); + +-- Row-Level Security and privileges -------------------------------------------------- +DO $$ +DECLARE t text; +BEGIN + FOREACH t IN ARRAY ARRAY['inventory_balances', 'stock_movements', 'stock_transfers', 'stock_transfer_items', + 'stock_adjustments', 'stock_adjustment_items'] LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t); + EXECUTE format('CREATE POLICY %I ON %I USING (tenant_id = app_current_tenant_id()) WITH CHECK (tenant_id = app_current_tenant_id())', + t || '_isolation', t); + EXECUTE format('GRANT SELECT, INSERT ON %I TO alshuyukh_app', t); + END LOOP; +END +$$; +-- Balances are the only inventory rows that change after insert. +GRANT UPDATE ON inventory_balances TO alshuyukh_app; diff --git a/alshuyukh-accounting/apps/api/src/db/migrations/0011_expenses_tax.sql b/alshuyukh-accounting/apps/api/src/db/migrations/0011_expenses_tax.sql new file mode 100644 index 000000000000..9adfc79f519c --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/db/migrations/0011_expenses_tax.sql @@ -0,0 +1,181 @@ +-- 0011 — Expenses and the tax ledger. +-- +-- tax_transactions is the VAT sub-ledger: one row per (document, VAT +-- category, rate) written when a taxable document posts, and a negated row +-- when it is cancelled. The VAT return is built from it and reconciled +-- against the VAT accounts in the general ledger. +-- +-- No backfill: documents posted before this migration only exist in +-- development databases. + +-- Expense categories ----------------------------------------------------------------- +CREATE TABLE expense_categories ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + code text NOT NULL CHECK (code ~ '^[A-Z][A-Z0-9_]{1,30}$'), + name_ar text NOT NULL CHECK (length(btrim(name_ar)) BETWEEN 2 AND 100), + account_id uuid NOT NULL, + -- Default VAT category for new lines (rent is often exempt, salaries out of scope). + vat_category char(1) NOT NULL DEFAULT 'S' CHECK (vat_category IN ('S', 'Z', 'E', 'O')), + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT expense_categories_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT expense_categories_account_fk FOREIGN KEY (account_id, company_id) REFERENCES accounts (id, company_id), + CONSTRAINT expense_categories_code_uq UNIQUE (company_id, code), + CONSTRAINT expense_categories_id_company_uq UNIQUE (id, company_id) +); +CREATE TRIGGER expense_categories_updated_at BEFORE UPDATE ON expense_categories FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- Expenses --------------------------------------------------------------------------- +CREATE TABLE expenses ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + branch_id uuid, + -- Assigned at posting; gap-free per company. + expense_number text, + expense_date date NOT NULL, + -- CASH / BANK: paid now through a payment method. CREDIT: owed to a supplier. + payment_type text NOT NULL CHECK (payment_type IN ('CASH', 'BANK', 'CREDIT')), + method_id uuid, + supplier_id uuid, + payee_name text, + -- The vendor's invoice number, needed to claim input VAT. + reference text, + vendor_vat_number text CHECK (vendor_vat_number ~ '^3[0-9]{13}3$'), + prices_include_vat boolean NOT NULL DEFAULT false, + subtotal numeric(18,2) NOT NULL DEFAULT 0 CHECK (subtotal >= 0), + tax_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (tax_amount >= 0), + total numeric(18,2) NOT NULL DEFAULT 0 CHECK (total >= 0), + paid_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK (paid_amount >= 0), + remaining_amount numeric(18,2) GENERATED ALWAYS AS (total - paid_amount) STORED CHECK (remaining_amount >= 0), + notes text, + status text NOT NULL DEFAULT 'DRAFT' CHECK (status IN ('DRAFT', 'POSTED', 'PARTIALLY_PAID', 'PAID', 'CANCELLED')), + journal_entry_id uuid, + created_by uuid REFERENCES users (id), + posted_by uuid REFERENCES users (id), + posted_at timestamptz, + cancelled_by uuid REFERENCES users (id), + cancelled_at timestamptz, + cancel_reason text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT expenses_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT expenses_branch_fk FOREIGN KEY (branch_id, company_id) REFERENCES branches (id, company_id), + CONSTRAINT expenses_method_fk FOREIGN KEY (method_id, company_id) REFERENCES payment_methods (id, company_id), + CONSTRAINT expenses_supplier_fk FOREIGN KEY (supplier_id, company_id) REFERENCES suppliers (id, company_id), + CONSTRAINT expenses_journal_fk FOREIGN KEY (journal_entry_id, company_id) REFERENCES journal_entries (id, company_id), + CONSTRAINT expenses_id_company_uq UNIQUE (id, company_id), + CONSTRAINT expenses_payment_ck CHECK ( + (payment_type = 'CREDIT' AND supplier_id IS NOT NULL) OR (payment_type <> 'CREDIT' AND method_id IS NOT NULL) + ), + CONSTRAINT expenses_totals_ck CHECK (total = subtotal + tax_amount), + CONSTRAINT expenses_posted_ck CHECK (status IN ('DRAFT') OR (expense_number IS NOT NULL AND journal_entry_id IS NOT NULL AND total > 0)), + CONSTRAINT expenses_soft_delete_ck CHECK (deleted_at IS NULL OR status = 'DRAFT') +); +CREATE UNIQUE INDEX expenses_number_uq ON expenses (company_id, expense_number) WHERE expense_number IS NOT NULL; +CREATE INDEX expenses_company_date_idx ON expenses (tenant_id, company_id, expense_date DESC) WHERE deleted_at IS NULL; +CREATE INDEX expenses_supplier_idx ON expenses (supplier_id) WHERE supplier_id IS NOT NULL; +CREATE TRIGGER expenses_updated_at BEFORE UPDATE ON expenses FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +CREATE TRIGGER expenses_guard BEFORE INSERT OR UPDATE OR DELETE ON expenses + FOR EACH ROW EXECUTE FUNCTION commercial_doc_guard('status', 'paid_amount', 'remaining_amount', 'cancelled_by', 'cancelled_at', 'cancel_reason'); + +CREATE TABLE expense_items ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + document_id uuid NOT NULL, + line_no smallint NOT NULL CHECK (line_no BETWEEN 1 AND 500), + category_id uuid NOT NULL, + account_id uuid NOT NULL, + description text, + cost_center_id uuid, + -- Amount as entered (in the expense's price basis). + amount numeric(18,2) NOT NULL CHECK (amount > 0), + net_amount numeric(18,2) NOT NULL CHECK (net_amount >= 0), + vat_category char(1) NOT NULL CHECK (vat_category IN ('S', 'Z', 'E', 'O')), + vat_rate numeric(7,4) NOT NULL CHECK (vat_rate >= 0 AND vat_rate < 1), + vat_amount numeric(18,2) NOT NULL CHECK (vat_amount >= 0), + total_amount numeric(18,2) NOT NULL CHECK (total_amount >= 0), + CONSTRAINT expense_items_document_fk FOREIGN KEY (document_id, company_id) REFERENCES expenses (id, company_id), + CONSTRAINT expense_items_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT expense_items_category_fk FOREIGN KEY (category_id, company_id) REFERENCES expense_categories (id, company_id), + CONSTRAINT expense_items_account_fk FOREIGN KEY (account_id, company_id) REFERENCES accounts (id, company_id), + CONSTRAINT expense_items_cost_center_fk FOREIGN KEY (cost_center_id, company_id) REFERENCES cost_centers (id, company_id), + CONSTRAINT expense_items_line_uq UNIQUE (document_id, line_no), + CONSTRAINT expense_items_amounts_ck CHECK (total_amount = net_amount + vat_amount) +); +CREATE INDEX expense_items_document_idx ON expense_items (document_id); +CREATE TRIGGER expense_items_guard BEFORE INSERT OR UPDATE OR DELETE ON expense_items + FOR EACH ROW EXECUTE FUNCTION commercial_item_guard('expenses'); + +-- Credit expenses are settled with supplier payments. +ALTER TABLE payment_allocations ADD COLUMN expense_id uuid, + ADD CONSTRAINT payment_allocations_expense_fk FOREIGN KEY (expense_id, company_id) REFERENCES expenses (id, company_id), + DROP CONSTRAINT payment_allocations_one_target_ck, + ADD CONSTRAINT payment_allocations_one_target_ck + CHECK (num_nonnulls(sales_invoice_id, sales_return_id, purchase_invoice_id, purchase_return_id, expense_id) = 1); +CREATE INDEX payment_allocations_expense_idx ON payment_allocations (expense_id) WHERE expense_id IS NOT NULL; + +-- Tax ledger ------------------------------------------------------------------------- +CREATE TABLE tax_transactions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + -- OUTPUT: VAT charged on sales. INPUT: VAT paid on purchases and expenses. + direction text NOT NULL CHECK (direction IN ('OUTPUT', 'INPUT')), + source_type text NOT NULL CHECK (source_type IN ('SALES_INVOICE', 'SALES_RETURN', 'PURCHASE_INVOICE', 'PURCHASE_RETURN', 'EXPENSE')), + source_id uuid NOT NULL, + source_number text, + journal_entry_id uuid, + transaction_date date NOT NULL, + vat_category char(1) NOT NULL CHECK (vat_category IN ('S', 'Z', 'E', 'O')), + vat_rate numeric(7,4) NOT NULL, + -- Signed: returns and cancellations are negative. + taxable_amount numeric(18,2) NOT NULL, + tax_amount numeric(18,2) NOT NULL, + -- Returns of the period are reported as adjustments. + is_adjustment boolean NOT NULL DEFAULT false, + -- Set on the negated row written when the source is cancelled. + reverses_id uuid REFERENCES tax_transactions (id), + party_name text, + party_vat_number text, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT tax_transactions_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT tax_transactions_journal_fk FOREIGN KEY (journal_entry_id, company_id) REFERENCES journal_entries (id, company_id) +); +CREATE INDEX tax_transactions_period_idx ON tax_transactions (tenant_id, company_id, transaction_date); +CREATE INDEX tax_transactions_source_idx ON tax_transactions (source_type, source_id); +CREATE UNIQUE INDEX tax_transactions_one_reversal_uq ON tax_transactions (reverses_id) WHERE reverses_id IS NOT NULL; +CREATE TRIGGER tax_transactions_append_only BEFORE UPDATE OR DELETE ON tax_transactions + FOR EACH ROW EXECUTE FUNCTION prevent_modification(); + +-- Default expense categories for existing companies (new companies get them +-- from setupCompanyAccounting). +INSERT INTO expense_categories (tenant_id, company_id, code, name_ar, account_id, vat_category) +SELECT c.tenant_id, c.id, m.code, m.name_ar, a.id, m.vat + FROM companies c + CROSS JOIN (VALUES ('RENT', 'إيجار', '6100', 'S'), ('SALARIES', 'رواتب وأجور', '6200', 'O'), + ('MARKETING', 'تسويق وإعلان', '6300', 'S'), ('UTILITIES', 'كهرباء ومياه', '6400', 'S')) + AS m(code, name_ar, account_code, vat) + JOIN accounts a ON a.company_id = c.id AND a.code = m.account_code AND a.deleted_at IS NULL AND a.is_postable + WHERE c.deleted_at IS NULL +ON CONFLICT (company_id, code) DO NOTHING; + +-- Row-Level Security and privileges --------------------------------------------------- +DO $$ +DECLARE t text; +BEGIN + FOREACH t IN ARRAY ARRAY['expense_categories', 'expenses', 'expense_items', 'tax_transactions'] LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t); + EXECUTE format('CREATE POLICY %I ON %I USING (tenant_id = app_current_tenant_id()) WITH CHECK (tenant_id = app_current_tenant_id())', + t || '_isolation', t); + END LOOP; +END +$$; +GRANT SELECT, INSERT, UPDATE ON expense_categories, expenses TO alshuyukh_app; +GRANT SELECT, INSERT, UPDATE, DELETE ON expense_items TO alshuyukh_app; +GRANT SELECT, INSERT ON tax_transactions TO alshuyukh_app; diff --git a/alshuyukh-accounting/apps/api/src/db/migrations/0012_zatca.sql b/alshuyukh-accounting/apps/api/src/db/migrations/0012_zatca.sql new file mode 100644 index 000000000000..a1410ae7f767 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/db/migrations/0012_zatca.sql @@ -0,0 +1,214 @@ +-- Phase 8: ZATCA e-invoicing (Fatoora, phase 2 "integration"). +-- +-- One EGS unit (e-invoice generation solution) per company or branch holds +-- the cryptographic stamp: an EC secp256k1 key pair, its CSR and the CSIDs +-- (certificates) issued by ZATCA. Every simplified/standard invoice and +-- credit note it produces gets the next ICV (counter) and the hash of the +-- previous document (PIH), forming a chain that cannot fork. + +-- Structured national address of the seller, required in the e-invoice. +ALTER TABLE companies + ADD COLUMN building_number text CHECK (building_number ~ '^[0-9]{4}$'), + ADD COLUMN street text CHECK (length(btrim(street)) BETWEEN 1 AND 200), + ADD COLUMN district text CHECK (length(btrim(district)) BETWEEN 1 AND 200), + ADD COLUMN postal_code text CHECK (postal_code ~ '^[0-9]{5}$'), + ADD COLUMN additional_number text CHECK (additional_number ~ '^[0-9]{4}$'); + +-- Zero-rated and exempt supplies must carry a ZATCA exemption reason code. +ALTER TABLE products + ADD COLUMN vat_exemption_code text CHECK (vat_exemption_code ~ '^VATEX-SA-[A-Z0-9-]{2,12}$'), + ADD COLUMN vat_exemption_reason text CHECK (length(btrim(vat_exemption_reason)) BETWEEN 1 AND 300); + +CREATE TABLE zatca_devices ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + branch_id uuid, + name text NOT NULL CHECK (length(btrim(name)) BETWEEN 2 AND 100), + -- DEVELOPER: developer portal (sandbox), SIMULATION, PRODUCTION (core). + environment text NOT NULL CHECK (environment IN ('DEVELOPER', 'SIMULATION', 'PRODUCTION')), + -- CSR subject / SAN fields required by ZATCA. + egs_serial text NOT NULL, -- 1-|2-|3- + common_name text NOT NULL, + organization_unit text NOT NULL, + invoice_types text NOT NULL DEFAULT '1100' CHECK (invoice_types ~ '^[01]{4}$' AND invoice_types <> '0000'), + registered_address text NOT NULL, + business_category text NOT NULL, + -- NEW → CSR generated; COMPLIANCE → compliance CSID received; + -- ACTIVE → production CSID received, signs invoices; REVOKED. + status text NOT NULL DEFAULT 'NEW' CHECK (status IN ('NEW', 'COMPLIANCE', 'ACTIVE', 'REVOKED')), + -- Secrets are encrypted by the application (AES-256-GCM); never returned by the API. + private_key_enc text NOT NULL, + public_key text NOT NULL, + csr text NOT NULL, + compliance_csid text, + compliance_secret_enc text, + compliance_request_id text, + compliance_checks jsonb NOT NULL DEFAULT '{}', + production_csid text, + production_secret_enc text, + certificate_serial text, + certificate_issuer text, + certificate_expires_at timestamptz, + -- Chain state: last ICV used and the hash of the last document. + icv bigint NOT NULL DEFAULT 0 CHECK (icv >= 0), + last_hash text NOT NULL DEFAULT 'NWZlY2ViNjZmZmM4NmYzOGQ5NTI3ODZjNmQ2OTZjNzljMmRiYzIzOWRkNGU5MWI0NjcyOWQ3M2EyN2ZiNTdlOQ==', + activated_at timestamptz, + revoked_at timestamptz, + created_by uuid REFERENCES users (id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT zatca_devices_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT zatca_devices_branch_fk FOREIGN KEY (branch_id, company_id) REFERENCES branches (id, company_id), + CONSTRAINT zatca_devices_id_company_uq UNIQUE (id, company_id), + CONSTRAINT zatca_devices_active_ck CHECK (status <> 'ACTIVE' OR (production_csid IS NOT NULL AND production_secret_enc IS NOT NULL)) +); +-- At most one live unit per company (branch_id NULL) and per branch. +CREATE UNIQUE INDEX zatca_devices_scope_uq ON zatca_devices (company_id, COALESCE(branch_id, '00000000-0000-0000-0000-000000000000'::uuid)) + WHERE status <> 'REVOKED'; +CREATE TRIGGER zatca_devices_updated_at BEFORE UPDATE ON zatca_devices FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE zatca_invoices ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + device_id uuid NOT NULL, + document_type text NOT NULL CHECK (document_type IN ('SALES_INVOICE', 'SALES_RETURN', 'SALES_DEBIT_NOTE')), + document_id uuid NOT NULL, + document_number text NOT NULL, + invoice_kind text NOT NULL CHECK (invoice_kind IN ('STANDARD', 'SIMPLIFIED')), + type_code text NOT NULL CHECK (type_code IN ('388', '381', '383')), + subtype text NOT NULL CHECK (subtype ~ '^0[12][01]{5}$'), + uuid uuid NOT NULL UNIQUE, + icv bigint NOT NULL CHECK (icv > 0), + invoice_hash text NOT NULL, + previous_hash text NOT NULL, + issue_date date NOT NULL, + issue_time text NOT NULL CHECK (issue_time ~ '^[0-9]{2}:[0-9]{2}:[0-9]{2}$'), + qr text NOT NULL, + -- PENDING: waiting for clearance (standard) or reporting (simplified). + status text NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'REPORTED', 'CLEARED', 'REJECTED')), + has_warnings boolean NOT NULL DEFAULT false, + attempts integer NOT NULL DEFAULT 0, + next_attempt_at timestamptz NOT NULL DEFAULT now(), + last_error text, + submitted_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT zatca_invoices_company_fk FOREIGN KEY (company_id, tenant_id) REFERENCES companies (id, tenant_id), + CONSTRAINT zatca_invoices_device_fk FOREIGN KEY (device_id, company_id) REFERENCES zatca_devices (id, company_id), + CONSTRAINT zatca_invoices_document_uq UNIQUE (document_type, document_id), + CONSTRAINT zatca_invoices_icv_uq UNIQUE (device_id, icv) +); +CREATE INDEX zatca_invoices_company_idx ON zatca_invoices (tenant_id, company_id, created_at DESC); +CREATE INDEX zatca_invoices_due_idx ON zatca_invoices (next_attempt_at) WHERE status = 'PENDING'; +CREATE TRIGGER zatca_invoices_updated_at BEFORE UPDATE ON zatca_invoices FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- The identity of a generated e-invoice never changes once it is in the chain. +CREATE OR REPLACE FUNCTION zatca_invoices_guard() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'zatca_invoices rows cannot be deleted'; END IF; + IF (NEW.uuid, NEW.icv, NEW.invoice_hash, NEW.previous_hash, NEW.device_id, NEW.document_id, NEW.document_type, NEW.qr, NEW.issue_date, NEW.issue_time) + IS DISTINCT FROM + (OLD.uuid, OLD.icv, OLD.invoice_hash, OLD.previous_hash, OLD.device_id, OLD.document_id, OLD.document_type, OLD.qr, OLD.issue_date, OLD.issue_time) THEN + RAISE EXCEPTION 'A generated e-invoice cannot be modified'; + END IF; + IF OLD.status IN ('REPORTED', 'CLEARED') AND NEW.status <> OLD.status THEN + RAISE EXCEPTION 'A reported or cleared e-invoice cannot change status'; + END IF; + RETURN NEW; +END $$; +CREATE TRIGGER zatca_invoices_guard BEFORE UPDATE OR DELETE ON zatca_invoices FOR EACH ROW EXECUTE FUNCTION zatca_invoices_guard(); + +-- XML artefacts: the signed invoice we generated and, for standard invoices, +-- the cleared invoice returned by ZATCA (which is the legal document). +CREATE TABLE zatca_documents ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + zatca_invoice_id uuid NOT NULL REFERENCES zatca_invoices (id), + kind text NOT NULL CHECK (kind IN ('SIGNED', 'CLEARED')), + content text NOT NULL, + sha256 text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT zatca_documents_kind_uq UNIQUE (zatca_invoice_id, kind) +); +CREATE TRIGGER zatca_documents_append_only BEFORE UPDATE OR DELETE ON zatca_documents FOR EACH ROW EXECUTE FUNCTION prevent_modification(); + +-- Every call to the ZATCA API (onboarding, compliance checks, reporting, clearance). +CREATE TABLE zatca_submissions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + device_id uuid NOT NULL REFERENCES zatca_devices (id), + zatca_invoice_id uuid REFERENCES zatca_invoices (id), + operation text NOT NULL CHECK (operation IN ('COMPLIANCE_CSID', 'COMPLIANCE_CHECK', 'PRODUCTION_CSID', 'REPORTING', 'CLEARANCE')), + http_status integer, + outcome text NOT NULL CHECK (outcome IN ('SUCCESS', 'WARNING', 'REJECTED', 'TRANSPORT_ERROR')), + -- Response without secrets or certificates. + response jsonb, + duration_ms integer, + created_by uuid REFERENCES users (id), + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX zatca_submissions_invoice_idx ON zatca_submissions (zatca_invoice_id, created_at); +CREATE INDEX zatca_submissions_device_idx ON zatca_submissions (device_id, created_at); +CREATE TRIGGER zatca_submissions_append_only BEFORE UPDATE OR DELETE ON zatca_submissions FOR EACH ROW EXECUTE FUNCTION prevent_modification(); + +CREATE TABLE zatca_errors ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + submission_id uuid NOT NULL REFERENCES zatca_submissions (id), + zatca_invoice_id uuid REFERENCES zatca_invoices (id), + level text NOT NULL CHECK (level IN ('ERROR', 'WARNING', 'INFO')), + code text, + category text, + message text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX zatca_errors_invoice_idx ON zatca_errors (zatca_invoice_id); +CREATE TRIGGER zatca_errors_append_only BEFORE UPDATE OR DELETE ON zatca_errors FOR EACH ROW EXECUTE FUNCTION prevent_modification(); + +-- The hash chain. One row per ICV; a previous hash can be followed only once, +-- so the chain of a device cannot fork. +CREATE TABLE invoice_hashes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + company_id uuid NOT NULL, + device_id uuid NOT NULL, + icv bigint NOT NULL CHECK (icv > 0), + invoice_hash text NOT NULL, + previous_hash text NOT NULL, + zatca_invoice_id uuid NOT NULL UNIQUE REFERENCES zatca_invoices (id), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT invoice_hashes_device_fk FOREIGN KEY (device_id, company_id) REFERENCES zatca_devices (id, company_id), + CONSTRAINT invoice_hashes_icv_uq UNIQUE (device_id, icv), + CONSTRAINT invoice_hashes_no_fork_uq UNIQUE (device_id, previous_hash) +); +CREATE TRIGGER invoice_hashes_append_only BEFORE UPDATE OR DELETE ON invoice_hashes FOR EACH ROW EXECUTE FUNCTION prevent_modification(); + +-- Sales returns record which kind of invoice they credit (standard or simplified). +ALTER TABLE sales_returns ADD COLUMN invoice_kind text CHECK (invoice_kind IN ('STANDARD', 'SIMPLIFIED')); + +-- Row-Level Security and privileges --------------------------------------------------- +DO $$ +DECLARE t text; +BEGIN + FOREACH t IN ARRAY ARRAY['zatca_devices', 'zatca_invoices', 'zatca_documents', 'zatca_submissions', 'zatca_errors', 'invoice_hashes'] LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t); + EXECUTE format('CREATE POLICY %I ON %I USING (tenant_id = app_current_tenant_id()) WITH CHECK (tenant_id = app_current_tenant_id())', + t || '_isolation', t); + END LOOP; +END +$$; +GRANT SELECT, INSERT, UPDATE ON zatca_devices, zatca_invoices TO alshuyukh_app; +GRANT SELECT, INSERT ON zatca_documents, zatca_submissions, zatca_errors, invoice_hashes TO alshuyukh_app; + +-- The background submitter needs to find due e-invoices across tenants. This +-- function returns only identifiers; the work itself runs in each tenant's context. +CREATE OR REPLACE FUNCTION zatca_due_invoices(max_rows integer) +RETURNS TABLE (tenant_id uuid, id uuid) LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$ + SELECT z.tenant_id, z.id FROM zatca_invoices z + WHERE z.status = 'PENDING' AND z.next_attempt_at <= now() + ORDER BY z.next_attempt_at LIMIT LEAST(max_rows, 100) +$$; +REVOKE ALL ON FUNCTION zatca_due_invoices(integer) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION zatca_due_invoices(integer) TO alshuyukh_app; diff --git a/alshuyukh-accounting/apps/api/src/db/migrations/0013_subscriptions_admin.sql b/alshuyukh-accounting/apps/api/src/db/migrations/0013_subscriptions_admin.sql new file mode 100644 index 000000000000..26b8d319b2f8 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/db/migrations/0013_subscriptions_admin.sql @@ -0,0 +1,244 @@ +-- Phase 9: SaaS subscriptions and platform administration. +-- +-- Plans, prices and limits are data edited by the platform administrator, +-- never constants in code. A limit of NULL means unlimited. + +-- Platform access ------------------------------------------------------------------------ +-- Set by the API (set_config('app.platform_admin', 'on', true)) only inside +-- /admin requests, after re-reading users.is_platform_admin from the database. +CREATE OR REPLACE FUNCTION app_is_platform_admin() RETURNS boolean LANGUAGE sql STABLE AS $$ + SELECT COALESCE(current_setting('app.platform_admin', true), '') = 'on' +$$; + +-- Plans ---------------------------------------------------------------------------------- +CREATE TABLE plans ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code text NOT NULL UNIQUE CHECK (code ~ '^[A-Z][A-Z0-9_]{1,30}$'), + name_ar text NOT NULL CHECK (length(btrim(name_ar)) BETWEEN 2 AND 100), + name_en text, + description text, + currency char(3) NOT NULL DEFAULT 'SAR' CHECK (currency ~ '^[A-Z]{3}$'), + price_monthly numeric(18,2) NOT NULL DEFAULT 0 CHECK (price_monthly >= 0), + price_yearly numeric(18,2) NOT NULL DEFAULT 0 CHECK (price_yearly >= 0), + trial_days integer NOT NULL DEFAULT 0 CHECK (trial_days BETWEEN 0 AND 365), + -- Days after the period ends during which the tenant keeps full access. + grace_days integer NOT NULL DEFAULT 7 CHECK (grace_days BETWEEN 0 AND 90), + max_users integer CHECK (max_users > 0), + max_companies integer CHECK (max_companies > 0), + max_branches integer CHECK (max_branches > 0), + max_warehouses integer CHECK (max_warehouses > 0), + max_products integer CHECK (max_products > 0), + max_invoices_per_month integer CHECK (max_invoices_per_month > 0), + max_storage_mb integer CHECK (max_storage_mb > 0), + max_api_calls_per_month integer CHECK (max_api_calls_per_month > 0), + is_public boolean NOT NULL DEFAULT true, + -- The plan new sign-ups start on (as a trial when trial_days > 0). + is_default boolean NOT NULL DEFAULT false, + is_active boolean NOT NULL DEFAULT true, + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT plans_default_active_ck CHECK (NOT is_default OR is_active) +); +CREATE UNIQUE INDEX plans_one_default_uq ON plans (is_default) WHERE is_default; +CREATE TRIGGER plans_updated_at BEFORE UPDATE ON plans FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- The default plan new sign-ups start on. Price 0 and a trial; paid plans are +-- created by the platform administrator. +INSERT INTO plans (code, name_ar, name_en, description, trial_days, max_users, max_companies, max_branches, max_warehouses, + max_products, max_invoices_per_month, max_storage_mb, max_api_calls_per_month, is_public, is_default, sort_order) +VALUES ('TRIAL', 'الباقة التجريبية', 'Trial', 'تجربة كاملة المزايا لمدة محدودة', 14, 3, 1, 2, 2, 500, 200, 500, 100000, false, true, 0); + +-- Subscriptions -------------------------------------------------------------------------- +CREATE TABLE subscriptions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL REFERENCES tenants (id), + plan_id uuid NOT NULL REFERENCES plans (id), + -- Stored status. Expiry is derived from the dates (see subscription_state). + status text NOT NULL CHECK (status IN ('TRIALING', 'ACTIVE', 'CANCELLED')), + billing_cycle text NOT NULL DEFAULT 'MONTHLY' CHECK (billing_cycle IN ('MONTHLY', 'YEARLY')), + current_period_start timestamptz NOT NULL DEFAULT now(), + current_period_end timestamptz NOT NULL, + cancelled_at timestamptz, + -- Per-tenant limit changes by the platform administrator, e.g. {"max_users": 25}. + limit_overrides jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(limit_overrides) = 'object'), + notes text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT subscriptions_period_ck CHECK (current_period_end > current_period_start), + CONSTRAINT subscriptions_id_tenant_uq UNIQUE (id, tenant_id) +); +-- One current subscription per tenant; history stays as CANCELLED rows. +CREATE UNIQUE INDEX subscriptions_current_uq ON subscriptions (tenant_id) WHERE status <> 'CANCELLED'; +CREATE INDEX subscriptions_plan_idx ON subscriptions (plan_id); +CREATE TRIGGER subscriptions_updated_at BEFORE UPDATE ON subscriptions FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- What the subscription is billed for, with the price at the time (a snapshot). +CREATE TABLE subscription_items ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + subscription_id uuid NOT NULL, + item_type text NOT NULL CHECK (item_type IN ('PLAN', 'ADDON')), + code text NOT NULL, + description text NOT NULL, + quantity integer NOT NULL DEFAULT 1 CHECK (quantity > 0), + unit_price numeric(18,2) NOT NULL CHECK (unit_price >= 0), + currency char(3) NOT NULL DEFAULT 'SAR', + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT subscription_items_sub_fk FOREIGN KEY (subscription_id, tenant_id) REFERENCES subscriptions (id, tenant_id) +); +CREATE INDEX subscription_items_sub_idx ON subscription_items (subscription_id); + +-- Metered usage per calendar month (API calls; storage once uploads exist). +CREATE TABLE usage_records ( + tenant_id uuid NOT NULL REFERENCES tenants (id), + metric text NOT NULL CHECK (metric IN ('API_CALLS', 'STORAGE_MB')), + period date NOT NULL CHECK (extract(day FROM period) = 1), + quantity bigint NOT NULL DEFAULT 0 CHECK (quantity >= 0), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_id, metric, period) +); + +-- Billing history (append-only): trials, plan changes, payments, suspensions… +CREATE TABLE billing_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL REFERENCES tenants (id), + subscription_id uuid REFERENCES subscriptions (id), + event_type text NOT NULL CHECK (event_type IN ( + 'TRIAL_STARTED', 'PLAN_CHANGED', 'PERIOD_EXTENDED', 'PAYMENT_RECORDED', 'LIMITS_CHANGED', + 'PLAN_CHANGE_REQUESTED', 'SUBSCRIPTION_CANCELLED', 'TENANT_SUSPENDED', 'TENANT_ACTIVATED')), + amount numeric(18,2) CHECK (amount >= 0), + currency char(3), + reference text, + details jsonb NOT NULL DEFAULT '{}', + created_by uuid REFERENCES users (id), + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX billing_events_tenant_idx ON billing_events (tenant_id, created_at DESC); +CREATE INDEX billing_events_payments_idx ON billing_events (created_at) WHERE event_type = 'PAYMENT_RECORDED'; +CREATE TRIGGER billing_events_append_only BEFORE UPDATE OR DELETE ON billing_events FOR EACH ROW EXECUTE FUNCTION prevent_modification(); + +-- Feature flags: a global default, optionally overridden per tenant. +CREATE TABLE feature_flags ( + key text PRIMARY KEY CHECK (key ~ '^[a-z][a-z0-9_]{1,62}$'), + name_ar text NOT NULL, + description text, + enabled boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE TRIGGER feature_flags_updated_at BEFORE UPDATE ON feature_flags FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +INSERT INTO feature_flags (key, name_ar, description, enabled) VALUES + ('zatca_einvoicing', 'الفوترة الإلكترونية', 'ربط المنشأة بمنصة فاتورة وإدارة وحدات الفوترة', true); + +CREATE TABLE tenant_feature_flags ( + tenant_id uuid NOT NULL REFERENCES tenants (id), + flag_key text NOT NULL REFERENCES feature_flags (key) ON DELETE CASCADE, + enabled boolean NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_id, flag_key) +); + +-- Unexpected server errors (5xx), for the platform's error view. +CREATE TABLE system_errors ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid, + user_id uuid, + request_id text, + method text, + path text, + status_code integer, + error_code text, + message text NOT NULL, + stack text, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX system_errors_created_idx ON system_errors (created_at DESC); + +-- What platform administrators did (plans, flags, tenants, users). +CREATE TABLE platform_audit_logs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + admin_user_id uuid NOT NULL REFERENCES users (id), + action text NOT NULL CHECK (action ~ '^[A-Z_]+$'), + entity_type text NOT NULL, + entity_id text, + target_tenant_id uuid, + old_values jsonb, + new_values jsonb, + ip_address inet, + user_agent text, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX platform_audit_logs_created_idx ON platform_audit_logs (created_at DESC); +CREATE TRIGGER platform_audit_logs_append_only BEFORE UPDATE OR DELETE ON platform_audit_logs FOR EACH ROW EXECUTE FUNCTION prevent_modification(); + +-- Every existing tenant starts a trial on the default plan. +INSERT INTO subscriptions (tenant_id, plan_id, status, current_period_start, current_period_end) +SELECT t.id, p.id, 'TRIALING', now(), now() + make_interval(days => GREATEST(p.trial_days, 1)) + FROM tenants t CROSS JOIN plans p + WHERE p.is_default AND t.deleted_at IS NULL + AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.tenant_id = t.id AND s.status <> 'CANCELLED'); + +-- Row-Level Security --------------------------------------------------------------------- +DO $$ +DECLARE t text; +BEGIN + FOREACH t IN ARRAY ARRAY['subscriptions', 'subscription_items', 'usage_records', 'billing_events', 'tenant_feature_flags'] LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t); + EXECUTE format('CREATE POLICY %I ON %I USING (tenant_id = app_current_tenant_id()) WITH CHECK (tenant_id = app_current_tenant_id())', + t || '_isolation', t); + END LOOP; +END +$$; + +-- Plans and flags are readable by every tenant; only platform administrators change them. +ALTER TABLE plans ENABLE ROW LEVEL SECURITY; +CREATE POLICY plans_read ON plans FOR SELECT USING (true); +CREATE POLICY plans_write ON plans FOR INSERT WITH CHECK (app_is_platform_admin()); +CREATE POLICY plans_update ON plans FOR UPDATE USING (app_is_platform_admin()) WITH CHECK (app_is_platform_admin()); +ALTER TABLE feature_flags ENABLE ROW LEVEL SECURITY; +CREATE POLICY feature_flags_read ON feature_flags FOR SELECT USING (true); +CREATE POLICY feature_flags_write ON feature_flags FOR INSERT WITH CHECK (app_is_platform_admin()); +CREATE POLICY feature_flags_update ON feature_flags FOR UPDATE USING (app_is_platform_admin()) WITH CHECK (app_is_platform_admin()); + +-- Errors can be logged from any request; only administrators read them. +ALTER TABLE system_errors ENABLE ROW LEVEL SECURITY; +CREATE POLICY system_errors_insert ON system_errors FOR INSERT WITH CHECK (true); +CREATE POLICY system_errors_read ON system_errors FOR SELECT USING (app_is_platform_admin()); +ALTER TABLE platform_audit_logs ENABLE ROW LEVEL SECURITY; +CREATE POLICY platform_audit_insert ON platform_audit_logs FOR INSERT WITH CHECK (app_is_platform_admin()); +CREATE POLICY platform_audit_read ON platform_audit_logs FOR SELECT USING (app_is_platform_admin()); + +-- Platform administrators may READ every tenant table (writes still go +-- through the target tenant's own context). Added to every RLS table that has +-- a tenant_id column, plus tenants and user_tenants. +DO $$ +DECLARE t text; +BEGIN + FOR t IN + SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relkind = 'r' AND c.relrowsecurity + AND (EXISTS (SELECT 1 FROM pg_attribute a WHERE a.attrelid = c.oid AND a.attname = 'tenant_id' AND NOT a.attisdropped) + OR c.relname = 'tenants') + LOOP + EXECUTE format('CREATE POLICY %I ON %I FOR SELECT USING (app_is_platform_admin())', t || '_platform_read', t); + END LOOP; +END +$$; + +GRANT SELECT, INSERT, UPDATE ON plans, feature_flags, subscriptions TO alshuyukh_app; +GRANT SELECT, INSERT, DELETE ON subscription_items TO alshuyukh_app; +GRANT SELECT, INSERT ON billing_events, platform_audit_logs, system_errors TO alshuyukh_app; +GRANT SELECT, INSERT, UPDATE, DELETE ON tenant_feature_flags TO alshuyukh_app; +GRANT SELECT ON usage_records TO alshuyukh_app; +-- For the platform health view (which migrations ran). +GRANT SELECT ON schema_migrations TO alshuyukh_app; + +-- API usage is counted in memory and flushed for many tenants at once. +CREATE OR REPLACE FUNCTION usage_increment(p_tenant uuid, p_metric text, p_period date, p_quantity bigint) +RETURNS void LANGUAGE sql SECURITY DEFINER SET search_path = public AS $$ + INSERT INTO usage_records (tenant_id, metric, period, quantity) VALUES (p_tenant, p_metric, p_period, p_quantity) + ON CONFLICT (tenant_id, metric, period) DO UPDATE SET quantity = usage_records.quantity + EXCLUDED.quantity, updated_at = now() +$$; +REVOKE ALL ON FUNCTION usage_increment(uuid, text, date, bigint) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION usage_increment(uuid, text, date, bigint) TO alshuyukh_app; diff --git a/alshuyukh-accounting/apps/api/src/db/migrations/0014_security_hardening.sql b/alshuyukh-accounting/apps/api/src/db/migrations/0014_security_hardening.sql new file mode 100644 index 000000000000..c15353d319c8 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/db/migrations/0014_security_hardening.sql @@ -0,0 +1,73 @@ +-- Phase 10: security hardening. + +-- Login lockout per (account, client IP). A lock earned from one address no +-- longer locks the real owner out from theirs, so failed logins cannot be used +-- to deny someone access. (users.failed_login_attempts/locked_until are no +-- longer used.) +CREATE TABLE login_failures ( + user_id uuid NOT NULL REFERENCES users (id), + ip text NOT NULL, + failures integer NOT NULL DEFAULT 0, + locked_until timestamptz, + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, ip) +); +GRANT SELECT, INSERT, UPDATE, DELETE ON login_failures TO alshuyukh_app; + +-- Set only by the server while creating a new organization (sign-up or by the +-- platform administrator), so the first subscription can be written. +CREATE OR REPLACE FUNCTION app_is_provisioning() RETURNS boolean LANGUAGE sql STABLE AS $$ + SELECT COALESCE(current_setting('app.provisioning', true), '') = 'on' +$$; + +-- Billing data: a tenant reads its own; only the platform (or provisioning) +-- writes it. Before this, a tenant context could in principle extend its own +-- period or change its plan. +DROP POLICY subscriptions_isolation ON subscriptions; +CREATE POLICY subscriptions_select ON subscriptions FOR SELECT USING (tenant_id = app_current_tenant_id()); +CREATE POLICY subscriptions_insert ON subscriptions FOR INSERT + WITH CHECK (tenant_id = app_current_tenant_id() AND (app_is_platform_admin() OR app_is_provisioning())); +CREATE POLICY subscriptions_update ON subscriptions FOR UPDATE + USING (tenant_id = app_current_tenant_id() AND app_is_platform_admin()) + WITH CHECK (tenant_id = app_current_tenant_id() AND app_is_platform_admin()); + +DROP POLICY subscription_items_isolation ON subscription_items; +CREATE POLICY subscription_items_select ON subscription_items FOR SELECT USING (tenant_id = app_current_tenant_id()); +CREATE POLICY subscription_items_insert ON subscription_items FOR INSERT + WITH CHECK (tenant_id = app_current_tenant_id() AND (app_is_platform_admin() OR app_is_provisioning())); +CREATE POLICY subscription_items_delete ON subscription_items FOR DELETE + USING (tenant_id = app_current_tenant_id() AND (app_is_platform_admin() OR app_is_provisioning())); + +DROP POLICY billing_events_isolation ON billing_events; +CREATE POLICY billing_events_select ON billing_events FOR SELECT USING (tenant_id = app_current_tenant_id()); +CREATE POLICY billing_events_insert ON billing_events FOR INSERT + WITH CHECK (tenant_id = app_current_tenant_id() + AND (app_is_platform_admin() OR app_is_provisioning() OR event_type = 'PLAN_CHANGE_REQUESTED')); + +DROP POLICY tenant_feature_flags_isolation ON tenant_feature_flags; +CREATE POLICY tenant_feature_flags_select ON tenant_feature_flags FOR SELECT USING (tenant_id = app_current_tenant_id()); +CREATE POLICY tenant_feature_flags_write ON tenant_feature_flags FOR ALL + USING (tenant_id = app_current_tenant_id() AND app_is_platform_admin()) + WITH CHECK (tenant_id = app_current_tenant_id() AND app_is_platform_admin()); + +-- An organization may rename itself but not change its own status, and no +-- user may make themselves a platform administrator. Enforced for the API's +-- role; maintenance through the owner role (CLI) is unaffected. +CREATE OR REPLACE FUNCTION tenants_status_guard() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.status IS DISTINCT FROM OLD.status AND current_user = 'alshuyukh_app' AND NOT app_is_platform_admin() THEN + RAISE EXCEPTION 'Only the platform administrator can change an organization''s status'; + END IF; + RETURN NEW; +END $$; +CREATE TRIGGER tenants_status_guard BEFORE UPDATE ON tenants FOR EACH ROW EXECUTE FUNCTION tenants_status_guard(); + +CREATE OR REPLACE FUNCTION users_platform_guard() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF current_user = 'alshuyukh_app' AND NOT app_is_platform_admin() + AND (NEW.is_platform_admin IS DISTINCT FROM OLD.is_platform_admin OR NEW.status IS DISTINCT FROM OLD.status) THEN + RAISE EXCEPTION 'Only the platform administrator can change this account''s status or platform access'; + END IF; + RETURN NEW; +END $$; +CREATE TRIGGER users_platform_guard BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION users_platform_guard(); diff --git a/alshuyukh-accounting/apps/api/src/db/pool.ts b/alshuyukh-accounting/apps/api/src/db/pool.ts new file mode 100644 index 000000000000..4e521e4fc6ca --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/db/pool.ts @@ -0,0 +1,19 @@ +import pg from 'pg'; + +/** Creates a pool whose sessions always use UTC. */ +export function createPool(connectionString: string, opts: { max?: number; statementTimeoutMs?: number } = {}): pg.Pool { + return new pg.Pool({ + connectionString, + max: opts.max ?? 20, + options: `-c timezone=UTC -c statement_timeout=${opts.statementTimeoutMs ?? 15000}`, + }); +} + +// Return NUMERIC as string so money is never converted to a JS float. +// Business logic converts with a decimal library (introduced in Phase 2). +pg.types.setTypeParser(pg.types.builtins.NUMERIC, (v) => v); +// Return bigint (e.g. COUNT(*)) as string; callers convert explicitly. +pg.types.setTypeParser(pg.types.builtins.INT8, (v) => v); +// Return DATE as 'YYYY-MM-DD' text. The default converts it to a JS Date in +// the server's local time zone, which can shift a calendar date by a day. +pg.types.setTypeParser(pg.types.builtins.DATE, (v) => v); diff --git a/alshuyukh-accounting/apps/api/src/db/tx.ts b/alshuyukh-accounting/apps/api/src/db/tx.ts new file mode 100644 index 000000000000..696dd8a60e8a --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/db/tx.ts @@ -0,0 +1,39 @@ +import type pg from 'pg'; + +export type Db = pg.PoolClient; + +export interface DbContext { + tenantId?: string | null; + userId?: string | null; +} + +/** + * Runs `fn` inside a transaction with the request context applied. + * + * `app.tenant_id` and `app.user_id` are set with is_local = true, so they + * exist only for this transaction and cannot leak to the next request that + * reuses the pooled connection. Row-Level Security policies read them. + */ +export async function withTx(pool: pg.Pool, ctx: DbContext, fn: (db: Db) => Promise): Promise { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query( + `SELECT set_config('app.tenant_id', $1, true), set_config('app.user_id', $2, true)`, + [ctx.tenantId ?? '', ctx.userId ?? ''], + ); + const result = await fn(client); + await client.query('COMMIT'); + return result; + } catch (err) { + await client.query('ROLLBACK').catch(() => undefined); + throw err; + } finally { + client.release(); + } +} + +/** Switches the tenant context inside an open transaction (used during registration). */ +export async function setTenantContext(db: Db, tenantId: string): Promise { + await db.query(`SELECT set_config('app.tenant_id', $1, true)`, [tenantId]); +} diff --git a/alshuyukh-accounting/apps/api/src/lib/dates.ts b/alshuyukh-accounting/apps/api/src/lib/dates.ts new file mode 100644 index 000000000000..4118f1f92085 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/lib/dates.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +/** Calendar date (YYYY-MM-DD) that actually exists. */ +export const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine((s) => { + const d = new Date(`${s}T00:00:00Z`); + return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s; +}, 'Invalid date'); + +/** Today's date in the given time zone, e.g. Asia/Riyadh. */ +export function todayIn(timeZone: string): string { + return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()); +} + +/** Adds whole months to a YYYY-MM-DD date that is the 1st of a month. */ +export function addMonths(date: string, months: number): string { + const d = new Date(`${date}T00:00:00Z`); + d.setUTCMonth(d.getUTCMonth() + months); + return d.toISOString().slice(0, 10); +} + +export function addDays(date: string, days: number): string { + const d = new Date(`${date}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} diff --git a/alshuyukh-accounting/apps/api/src/lib/errors.ts b/alshuyukh-accounting/apps/api/src/lib/errors.ts new file mode 100644 index 000000000000..3cf67d3767ed --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/lib/errors.ts @@ -0,0 +1,20 @@ +export class AppError extends Error { + constructor( + public readonly statusCode: number, + public readonly code: string, + message: string, + public readonly details?: unknown, + ) { + super(message); + } +} + +export const badRequest = (code: string, message: string, details?: unknown) => + new AppError(400, code, message, details); +export const unauthorized = (message = 'Authentication required') => + new AppError(401, 'UNAUTHORIZED', message); +export const forbidden = (message = 'You do not have permission to perform this action') => + new AppError(403, 'FORBIDDEN', message); +// Cross-tenant lookups return 404, never 403, so record existence does not leak. +export const notFound = (entity = 'Resource') => new AppError(404, 'NOT_FOUND', `${entity} not found`); +export const conflict = (code: string, message: string) => new AppError(409, code, message); diff --git a/alshuyukh-accounting/apps/api/src/lib/money.ts b/alshuyukh-accounting/apps/api/src/lib/money.ts new file mode 100644 index 000000000000..8622813ed4b6 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/lib/money.ts @@ -0,0 +1,20 @@ +import { Decimal } from 'decimal.js'; +import { z } from 'zod'; + +/** + * Money handling. Amounts travel as strings ("1150.00") in the API and come + * back from PostgreSQL NUMERIC as strings. Arithmetic uses decimal.js; a JS + * number is never used for money. + */ +Decimal.set({ precision: 40, rounding: Decimal.ROUND_HALF_UP }); + +export { Decimal }; + +/** Positive or zero amount with at most 2 decimal places, as a string. */ +export const amountString = z + .string() + .trim() + .regex(/^\d{1,16}(\.\d{1,2})?$/, 'Amount must be a non-negative number with at most 2 decimal places, sent as a string'); + +export const toMoney = (v: Decimal.Value) => new Decimal(v).toFixed(2); +export const sum = (values: Decimal.Value[]) => values.reduce((acc, v) => acc.plus(v), new Decimal(0)); diff --git a/alshuyukh-accounting/apps/api/src/lib/password.ts b/alshuyukh-accounting/apps/api/src/lib/password.ts new file mode 100644 index 000000000000..1764ae0d3018 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/lib/password.ts @@ -0,0 +1,19 @@ +import argon2 from 'argon2'; + +// OWASP-recommended argon2id parameters. +const OPTIONS = { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 } as const; + +export const hashPassword = (plain: string) => argon2.hash(plain, OPTIONS); + +export async function verifyPassword(hash: string, plain: string): Promise { + try { + return await argon2.verify(hash, plain); + } catch { + return false; + } +} + +// Verifying against this hash keeps response time similar when the e-mail +// does not exist, so login timing does not reveal registered addresses. +let dummyHash: Promise | undefined; +export const getDummyHash = () => (dummyHash ??= hashPassword('dummy-password-for-timing-0')); diff --git a/alshuyukh-accounting/apps/api/src/lib/sequences.ts b/alshuyukh-accounting/apps/api/src/lib/sequences.ts new file mode 100644 index 000000000000..221ea33d209d --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/lib/sequences.ts @@ -0,0 +1,41 @@ +import type { Db } from '../db/tx.js'; + +export interface SequenceDefaults { prefix: string; padding?: number } + +/** + * Returns the next number of a per-company document sequence, e.g. + * "CUS-00001". The counter row is locked until the caller's transaction ends, + * so numbers are gap-free and never reused (a rollback also rolls back the + * increment). + */ +export async function nextDocumentNumber( + db: Db, tenantId: string, companyId: string, docType: string, defaults: SequenceDefaults, +): Promise { + await db.query( + `INSERT INTO document_sequences (tenant_id, company_id, doc_type, prefix, padding) + VALUES ($1, $2, $3, $4, $5) ON CONFLICT (company_id, doc_type) DO NOTHING`, + [tenantId, companyId, docType, defaults.prefix, defaults.padding ?? 5]); + const { rows: [row] } = await db.query<{ prefix: string; padding: number; last_number: number }>( + `UPDATE document_sequences SET last_number = last_number + 1 + WHERE company_id = $1 AND doc_type = $2 RETURNING prefix, padding, last_number`, + [companyId, docType]); + const n = String(row!.last_number).padStart(row!.padding, '0'); + return row!.prefix ? `${row!.prefix}-${n}` : n; +} + +/** + * Generates a code that is not already taken in `table` (a user may have + * typed a code that matches a future generated one). Master-data codes only; + * legal document numbers must not skip, so they use nextDocumentNumber alone. + */ +export async function nextFreeCode( + db: Db, tenantId: string, companyId: string, docType: string, defaults: SequenceDefaults, + table: 'customers' | 'suppliers' | 'products', column: 'code' | 'sku', +): Promise { + for (let i = 0; i < 1000; i++) { + const code = await nextDocumentNumber(db, tenantId, companyId, docType, defaults); + const taken = await db.query(`SELECT 1 FROM ${table} WHERE company_id = $1 AND ${column} = $2 AND deleted_at IS NULL`, [companyId, code]); + if (!taken.rowCount) return code; + } + throw new Error(`Could not generate a free ${docType} code`); +} diff --git a/alshuyukh-accounting/apps/api/src/lib/tokens.ts b/alshuyukh-accounting/apps/api/src/lib/tokens.ts new file mode 100644 index 000000000000..b1be0053bd26 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/lib/tokens.ts @@ -0,0 +1,48 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { SignJWT, jwtVerify } from 'jose'; +import { unauthorized } from './errors.js'; + +export interface AccessClaims { + sub: string; // user id + tid: string; // active tenant id + sid: string; // session id +} + +export class TokenService { + private readonly key: Uint8Array; + + constructor(secret: string, private readonly accessTtlSeconds: number) { + this.key = new TextEncoder().encode(secret); + } + + signAccess(claims: AccessClaims): Promise { + return new SignJWT({ tid: claims.tid, sid: claims.sid, typ: 'access' }) + .setProtectedHeader({ alg: 'HS256' }) + .setSubject(claims.sub) + .setIssuer('alshuyukh') + .setAudience('alshuyukh-api') + .setIssuedAt() + .setExpirationTime(`${this.accessTtlSeconds}s`) + .sign(this.key); + } + + async verifyAccess(token: string): Promise { + try { + const { payload } = await jwtVerify(token, this.key, { + issuer: 'alshuyukh', + audience: 'alshuyukh-api', + algorithms: ['HS256'], + }); + if (payload.typ !== 'access' || typeof payload.sub !== 'string' || + typeof payload.tid !== 'string' || typeof payload.sid !== 'string') { + throw new Error('bad claims'); + } + return { sub: payload.sub, tid: payload.tid, sid: payload.sid }; + } catch { + throw unauthorized('Invalid or expired token'); + } + } +} + +export const newRefreshToken = () => randomBytes(32).toString('base64url'); +export const hashToken = (token: string) => createHash('sha256').update(token).digest('hex'); diff --git a/alshuyukh-accounting/apps/api/src/lib/validation.ts b/alshuyukh-accounting/apps/api/src/lib/validation.ts new file mode 100644 index 000000000000..8d61e7f7c9f8 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/lib/validation.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; +import { badRequest } from './errors.js'; + +/** Parses untrusted input. Throws a 400 with field-level details on failure. */ +export function parse(schema: T, data: unknown): z.infer { + const result = schema.safeParse(data); + if (!result.success) { + throw badRequest( + 'VALIDATION_ERROR', + 'Invalid request', + result.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })), + ); + } + return result.data; +} + +export const uuidParam = z.object({ id: z.uuid() }); + +const TIMEZONES = new Set(Intl.supportedValuesOf('timeZone')); +export const timezone = z.string().refine((tz) => TIMEZONES.has(tz), 'Unknown time zone'); + +/** Trims, and turns empty strings into null for optional text fields. */ +export const optionalText = (max = 500) => + z.preprocess( + (v) => (typeof v === 'string' && v.trim() === '' ? null : typeof v === 'string' ? v.trim() : v), + z.string().max(max).nullable().optional(), + ); + +export const password = z + .string() + .min(10, 'Password must be at least 10 characters') + .max(200) + .refine((p) => /[A-Za-z]/.test(p) && /[0-9]/.test(p), 'Password must contain letters and digits'); diff --git a/alshuyukh-accounting/apps/api/src/modules/accounting/accounts.routes.ts b/alshuyukh-accounting/apps/api/src/modules/accounting/accounts.routes.ts new file mode 100644 index 000000000000..0a00bf50e555 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/accounting/accounts.routes.ts @@ -0,0 +1,242 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import type { Db } from '../../db/tx.js'; +import { badRequest, conflict, notFound } from '../../lib/errors.js'; +import { Decimal } from '../../lib/money.js'; +import { optionalText, parse, uuidParam } from '../../lib/validation.js'; +import { requirePermission } from '../../plugins/auth.js'; +import { writeAudit } from '../audit/audit.service.js'; +import { ACCOUNT_TYPES } from './chart-template.js'; +import { resolveCompanyId } from './company-context.js'; +import { setupCompanyAccounting } from './setup.js'; + +const ACCOUNT_SELECT = ` + SELECT a.id, a.company_id AS "companyId", a.code, a.name_ar AS "nameAr", a.name_en AS "nameEn", + a.account_type AS "type", a.parent_id AS "parentId", a.level, a.group_id AS "groupId", + a.is_postable AS "isPostable", a.is_active AS "isActive", a.is_system AS "isSystem", + a.system_key AS "systemKey", a.description, + COALESCE(b.debit, 0)::numeric(18,2)::text AS "totalDebit", + COALESCE(b.credit, 0)::numeric(18,2)::text AS "totalCredit", + COALESCE(b.debit - b.credit, 0)::numeric(18,2)::text AS "balance" + FROM accounts a + LEFT JOIN LATERAL ( + SELECT sum(l.debit) AS debit, sum(l.credit) AS credit + FROM journal_entry_lines l JOIN journal_entries e ON e.id = l.journal_entry_id + WHERE l.account_id = a.id AND e.status IN ('POSTED', 'REVERSED') + ) b ON true`; + +type AccountRow = { id: string; companyId: string; isSystem: boolean; isActive: boolean; isPostable: boolean; balance: string; type: string }; + +async function findAccount(db: Db, tenantId: string, id: string): Promise { + const { rows } = await db.query(`${ACCOUNT_SELECT} WHERE a.tenant_id = $1 AND a.id = $2 AND a.deleted_at IS NULL`, [tenantId, id]); + return rows[0]; +} + +const accountCode = z.string().trim().regex(/^[0-9A-Za-z.-]{1,20}$/, 'Code: up to 20 letters, digits, dots or dashes'); + +const createBody = z.object({ + companyId: z.uuid().optional(), + code: accountCode, + nameAr: z.string().trim().min(1).max(200), + nameEn: optionalText(200), + type: z.enum(ACCOUNT_TYPES as [string, ...string[]]).optional(), + parentId: z.uuid().nullish(), + groupId: z.uuid().nullish(), + isPostable: z.boolean().default(true), + description: optionalText(1000), +}); + +const updateBody = z.object({ + code: accountCode, + nameAr: z.string().trim().min(1).max(200), + nameEn: optionalText(200), + parentId: z.uuid().nullable(), + groupId: z.uuid().nullable(), + isPostable: z.boolean(), + isActive: z.boolean(), + description: optionalText(1000), +}).partial(); + +export default async function accountsRoutes(app: FastifyInstance) { + const canView = requirePermission(app, 'account.view'); + const canManage = requirePermission(app, 'account.manage'); + + /** Seeds the default chart and current fiscal year for a company that has none (idempotent). */ + app.post('/accounting/setup', { preHandler: requirePermission(app, 'account.manage', 'fiscal.manage') }, async (req) => { + const body = parse(z.object({ companyId: z.uuid().optional() }), req.body ?? {}); + const a = req.auth!; + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, a.tenantId, body.companyId); + const { rows: [c] } = await db.query<{ timezone: string }>(`SELECT timezone FROM companies WHERE id = $1`, [companyId]); + const result = await setupCompanyAccounting(db, { tenantId: a.tenantId, companyId, userId: a.userId }, c!.timezone); + if (result.chartCreated || result.fiscalYearCreated) { + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'CREATE', entityType: 'accounting_setup', entityId: companyId, newValues: result }, req.auditMeta()); + } + return { companyId, ...result }; + }); + }); + + app.get('/account-groups', { preHandler: canView }, async (req) => { + const q = parse(z.object({ companyId: z.uuid().optional() }), req.query); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const { rows } = await db.query( + `SELECT id, code, name_ar AS "nameAr", name_en AS "nameEn", account_type AS "type", sort_order AS "sortOrder" + FROM account_groups WHERE tenant_id = $1 AND company_id = $2 ORDER BY sort_order`, [req.auth!.tenantId, companyId]); + return { data: rows }; + }); + }); + + app.get('/accounts', { preHandler: canView }, async (req) => { + const q = parse(z.object({ + companyId: z.uuid().optional(), + includeInactive: z.enum(['true', 'false']).optional(), + postableOnly: z.enum(['true', 'false']).optional(), + }), req.query); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const { rows } = await db.query( + `${ACCOUNT_SELECT} + WHERE a.tenant_id = $1 AND a.company_id = $2 AND a.deleted_at IS NULL + AND ($3::boolean OR a.is_active) AND (NOT $4::boolean OR a.is_postable) + ORDER BY a.code`, + [req.auth!.tenantId, companyId, q.includeInactive === 'true', q.postableOnly === 'true']); + return { data: rows }; + }); + }); + + app.get('/accounts/:id', { preHandler: canView }, async (req) => { + const { id } = parse(uuidParam, req.params); + const account = await req.tenantTx((db) => findAccount(db, req.auth!.tenantId, id)); + if (!account) throw notFound('Account'); + return account; + }); + + app.post('/accounts', { preHandler: canManage }, async (req, reply) => { + const body = parse(createBody, req.body); + const a = req.auth!; + const account = await req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, a.tenantId, body.companyId); + let type = body.type; + if (body.parentId) { + const { rows: [parent] } = await db.query<{ account_type: string }>( + `SELECT account_type FROM accounts WHERE company_id = $1 AND id = $2 AND deleted_at IS NULL`, [companyId, body.parentId]); + if (!parent) throw badRequest('INVALID_PARENT', 'Parent account does not exist in this company'); + if (type && type !== parent.account_type) throw badRequest('TYPE_MISMATCH', 'Account type must match its parent'); + type = parent.account_type; + } + if (!type) throw badRequest('TYPE_REQUIRED', 'type is required for a top-level account'); + if (body.parentId) { + // A postable parent with no postings can become a header automatically. + const { rows: [p] } = await db.query<{ is_postable: boolean; has_lines: boolean }>( + `SELECT is_postable, EXISTS (SELECT 1 FROM journal_entry_lines WHERE account_id = $1) AS has_lines FROM accounts WHERE id = $1`, [body.parentId]); + if (p!.is_postable) { + if (p!.has_lines) throw conflict('PARENT_HAS_POSTINGS', 'The parent account has journal lines and cannot become a header'); + await db.query(`UPDATE accounts SET is_postable = false, updated_by = $2 WHERE id = $1`, [body.parentId, a.userId]); + } + } + const { rows: [row] } = await db.query<{ id: string }>( + `INSERT INTO accounts (tenant_id, company_id, code, name_ar, name_en, account_type, parent_id, group_id, + is_postable, description, created_by, updated_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11) RETURNING id`, + [a.tenantId, companyId, body.code, body.nameAr, body.nameEn ?? null, type, body.parentId ?? null, + body.groupId ?? null, body.isPostable, body.description ?? null, a.userId]); + const created = await findAccount(db, a.tenantId, row!.id); + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'CREATE', entityType: 'account', entityId: row!.id, newValues: created }, req.auditMeta()); + return created; + }); + reply.code(201); + return account; + }); + + app.patch('/accounts/:id', { preHandler: canManage }, async (req) => { + const { id } = parse(uuidParam, req.params); + const body = parse(updateBody, req.body); + const a = req.auth!; + return req.tenantTx(async (db) => { + const before = await findAccount(db, a.tenantId, id); + if (!before) throw notFound('Account'); + if (body.isActive === false && before.isActive) { + if (before.isSystem) throw conflict('SYSTEM_ACCOUNT', 'System accounts are used by the accounting engine and cannot be deactivated'); + if (!new Decimal(before.balance).isZero()) throw conflict('ACCOUNT_HAS_BALANCE', 'Only accounts with a zero balance can be deactivated'); + } + await db.query( + `UPDATE accounts SET + code = COALESCE($2, code), name_ar = COALESCE($3, name_ar), + name_en = CASE WHEN $4::boolean THEN $5 ELSE name_en END, + parent_id = CASE WHEN $6::boolean THEN $7::uuid ELSE parent_id END, + group_id = CASE WHEN $8::boolean THEN $9::uuid ELSE group_id END, + is_postable = COALESCE($10, is_postable), is_active = COALESCE($11, is_active), + description = CASE WHEN $12::boolean THEN $13 ELSE description END, + updated_by = $14 + WHERE tenant_id = $1 AND id = $15`, + [a.tenantId, body.code ?? null, body.nameAr ?? null, body.nameEn !== undefined, body.nameEn ?? null, + body.parentId !== undefined, body.parentId ?? null, body.groupId !== undefined, body.groupId ?? null, + body.isPostable ?? null, body.isActive ?? null, body.description !== undefined, body.description ?? null, + a.userId, id]); + const after = await findAccount(db, a.tenantId, id); + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'UPDATE', entityType: 'account', entityId: id, oldValues: before, newValues: after }, req.auditMeta()); + return after; + }); + }); + + app.delete('/accounts/:id', { preHandler: canManage }, async (req, reply) => { + const { id } = parse(uuidParam, req.params); + const a = req.auth!; + await req.tenantTx(async (db) => { + const before = await findAccount(db, a.tenantId, id); + if (!before) throw notFound('Account'); + if (before.isSystem) throw conflict('SYSTEM_ACCOUNT', 'System accounts cannot be deleted'); + const used = await db.query(`SELECT 1 FROM journal_entry_lines WHERE account_id = $1 LIMIT 1`, [id]); + if (used.rowCount) throw conflict('ACCOUNT_IN_USE', 'Accounts with journal lines cannot be deleted. Deactivate it instead.'); + const children = await db.query(`SELECT 1 FROM accounts WHERE parent_id = $1 AND deleted_at IS NULL LIMIT 1`, [id]); + if (children.rowCount) throw conflict('ACCOUNT_HAS_CHILDREN', 'Delete or move the sub-accounts first'); + await db.query(`UPDATE accounts SET deleted_at = now(), is_active = false, updated_by = $2 WHERE id = $1`, [id, a.userId]); + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'DELETE', entityType: 'account', entityId: id, oldValues: before }, req.auditMeta()); + }); + return reply.code(204).send(); + }); + + // Cost centers ---------------------------------------------------------------- + const CC_SELECT = `SELECT id, company_id AS "companyId", code, name, is_active AS "isActive" FROM cost_centers`; + + app.get('/cost-centers', { preHandler: canView }, async (req) => { + const q = parse(z.object({ companyId: z.uuid().optional() }), req.query); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const { rows } = await db.query(`${CC_SELECT} WHERE tenant_id = $1 AND company_id = $2 AND deleted_at IS NULL ORDER BY code`, [req.auth!.tenantId, companyId]); + return { data: rows }; + }); + }); + + app.post('/cost-centers', { preHandler: canManage }, async (req, reply) => { + const body = parse(z.object({ companyId: z.uuid().optional(), code: z.string().regex(/^[0-9A-Za-z_-]{1,20}$/), name: z.string().trim().min(2).max(200) }), req.body); + const a = req.auth!; + const cc = await req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, a.tenantId, body.companyId); + const { rows: [row] } = await db.query<{ id: string }>( + `INSERT INTO cost_centers (tenant_id, company_id, code, name, created_by, updated_by) VALUES ($1, $2, $3, $4, $5, $5) RETURNING id`, + [a.tenantId, companyId, body.code, body.name, a.userId]); + const created = (await db.query(`${CC_SELECT} WHERE id = $1`, [row!.id])).rows[0]; + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'CREATE', entityType: 'cost_center', entityId: row!.id, newValues: created }, req.auditMeta()); + return created; + }); + reply.code(201); + return cc; + }); + + app.patch('/cost-centers/:id', { preHandler: canManage }, async (req) => { + const { id } = parse(uuidParam, req.params); + const body = parse(z.object({ name: z.string().trim().min(2).max(200), isActive: z.boolean() }).partial(), req.body); + const a = req.auth!; + return req.tenantTx(async (db) => { + const before = (await db.query(`${CC_SELECT} WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL`, [a.tenantId, id])).rows[0]; + if (!before) throw notFound('Cost center'); + await db.query(`UPDATE cost_centers SET name = COALESCE($2, name), is_active = COALESCE($3, is_active), updated_by = $4 WHERE id = $1`, + [id, body.name ?? null, body.isActive ?? null, a.userId]); + const after = (await db.query(`${CC_SELECT} WHERE id = $1`, [id])).rows[0]; + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'UPDATE', entityType: 'cost_center', entityId: id, oldValues: before, newValues: after }, req.auditMeta()); + return after; + }); + }); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/accounting/chart-template.ts b/alshuyukh-accounting/apps/api/src/modules/accounting/chart-template.ts new file mode 100644 index 000000000000..40d2b643235c --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/accounting/chart-template.ts @@ -0,0 +1,72 @@ +/** + * Default chart of accounts created for every new company. + * Users can rename, renumber, extend, or deactivate accounts. The engine + * finds accounts by systemKey, never by code. + */ + +export type AccountType = 'ASSET' | 'LIABILITY' | 'EQUITY' | 'REVENUE' | 'EXPENSE' | 'COST_OF_GOODS_SOLD'; + +export const ACCOUNT_TYPES: readonly AccountType[] = ['ASSET', 'LIABILITY', 'EQUITY', 'REVENUE', 'EXPENSE', 'COST_OF_GOODS_SOLD']; + +/** Types whose balances close into retained earnings at year end. */ +export const PROFIT_AND_LOSS_TYPES: readonly AccountType[] = ['REVENUE', 'EXPENSE', 'COST_OF_GOODS_SOLD']; + +/** Debit-normal types; the rest are credit-normal. */ +export const DEBIT_NORMAL: ReadonlySet = new Set(['ASSET', 'EXPENSE', 'COST_OF_GOODS_SOLD']); + +export interface GroupTemplate { code: string; ar: string; en: string; type: AccountType; sort: number } + +export const DEFAULT_GROUPS: readonly GroupTemplate[] = [ + { code: 'CURRENT_ASSETS', ar: 'الأصول المتداولة', en: 'Current assets', type: 'ASSET', sort: 10 }, + { code: 'NON_CURRENT_ASSETS', ar: 'الأصول غير المتداولة', en: 'Non-current assets', type: 'ASSET', sort: 20 }, + { code: 'CURRENT_LIABILITIES', ar: 'الالتزامات المتداولة', en: 'Current liabilities', type: 'LIABILITY', sort: 30 }, + { code: 'NON_CURRENT_LIABILITIES', ar: 'الالتزامات غير المتداولة', en: 'Non-current liabilities', type: 'LIABILITY', sort: 40 }, + { code: 'EQUITY', ar: 'حقوق الملكية', en: 'Equity', type: 'EQUITY', sort: 50 }, + { code: 'OPERATING_REVENUE', ar: 'الإيرادات التشغيلية', en: 'Operating revenue', type: 'REVENUE', sort: 60 }, + { code: 'OTHER_REVENUE', ar: 'إيرادات أخرى', en: 'Other revenue', type: 'REVENUE', sort: 70 }, + { code: 'COST_OF_SALES', ar: 'تكلفة المبيعات', en: 'Cost of sales', type: 'COST_OF_GOODS_SOLD', sort: 80 }, + { code: 'OPERATING_EXPENSES', ar: 'المصروفات التشغيلية', en: 'Operating expenses', type: 'EXPENSE', sort: 90 }, + { code: 'OTHER_EXPENSES', ar: 'مصروفات أخرى', en: 'Other expenses', type: 'EXPENSE', sort: 100 }, +]; + +export interface AccountTemplate { + code: string; + ar: string; + en: string; + type: AccountType; + parent?: string; + group?: string; + postable: boolean; + systemKey?: string; +} + +export const DEFAULT_ACCOUNTS: readonly AccountTemplate[] = [ + { code: '1000', ar: 'الأصول', en: 'Assets', type: 'ASSET', postable: false }, + { code: '1100', ar: 'النقدية', en: 'Cash', type: 'ASSET', parent: '1000', group: 'CURRENT_ASSETS', postable: true, systemKey: 'CASH' }, + { code: '1200', ar: 'البنك', en: 'Bank', type: 'ASSET', parent: '1000', group: 'CURRENT_ASSETS', postable: true, systemKey: 'BANK' }, + { code: '1300', ar: 'العملاء', en: 'Accounts receivable', type: 'ASSET', parent: '1000', group: 'CURRENT_ASSETS', postable: true, systemKey: 'ACCOUNTS_RECEIVABLE' }, + { code: '1400', ar: 'المخزون', en: 'Inventory', type: 'ASSET', parent: '1000', group: 'CURRENT_ASSETS', postable: true, systemKey: 'INVENTORY' }, + + { code: '2000', ar: 'الالتزامات', en: 'Liabilities', type: 'LIABILITY', postable: false }, + { code: '2100', ar: 'الموردون', en: 'Accounts payable', type: 'LIABILITY', parent: '2000', group: 'CURRENT_LIABILITIES', postable: true, systemKey: 'ACCOUNTS_PAYABLE' }, + { code: '2200', ar: 'ضريبة القيمة المضافة', en: 'Value added tax', type: 'LIABILITY', parent: '2000', postable: false }, + { code: '2210', ar: 'ضريبة القيمة المضافة - المخرجات', en: 'VAT output', type: 'LIABILITY', parent: '2200', group: 'CURRENT_LIABILITIES', postable: true, systemKey: 'VAT_OUTPUT' }, + { code: '2220', ar: 'ضريبة القيمة المضافة - المدخلات', en: 'VAT input', type: 'LIABILITY', parent: '2200', group: 'CURRENT_LIABILITIES', postable: true, systemKey: 'VAT_INPUT' }, + + { code: '3000', ar: 'حقوق الملكية', en: 'Equity', type: 'EQUITY', postable: false }, + { code: '3100', ar: 'رأس المال', en: 'Capital', type: 'EQUITY', parent: '3000', group: 'EQUITY', postable: true, systemKey: 'CAPITAL' }, + { code: '3200', ar: 'الأرباح المحتجزة', en: 'Retained earnings', type: 'EQUITY', parent: '3000', group: 'EQUITY', postable: true, systemKey: 'RETAINED_EARNINGS' }, + + { code: '4000', ar: 'الإيرادات', en: 'Revenue', type: 'REVENUE', postable: false }, + { code: '4100', ar: 'المبيعات', en: 'Sales', type: 'REVENUE', parent: '4000', group: 'OPERATING_REVENUE', postable: true, systemKey: 'SALES' }, + + { code: '5000', ar: 'تكلفة المبيعات', en: 'Cost of goods sold', type: 'COST_OF_GOODS_SOLD', postable: false }, + { code: '5100', ar: 'تكلفة البضاعة المباعة', en: 'Cost of goods sold', type: 'COST_OF_GOODS_SOLD', parent: '5000', group: 'COST_OF_SALES', postable: true, systemKey: 'COGS' }, + { code: '5200', ar: 'فروقات وتسويات المخزون', en: 'Inventory adjustments', type: 'COST_OF_GOODS_SOLD', parent: '5000', group: 'COST_OF_SALES', postable: true, systemKey: 'INVENTORY_ADJUSTMENT' }, + + { code: '6000', ar: 'المصروفات', en: 'Expenses', type: 'EXPENSE', postable: false }, + { code: '6100', ar: 'الإيجار', en: 'Rent', type: 'EXPENSE', parent: '6000', group: 'OPERATING_EXPENSES', postable: true }, + { code: '6200', ar: 'الرواتب', en: 'Salaries', type: 'EXPENSE', parent: '6000', group: 'OPERATING_EXPENSES', postable: true }, + { code: '6300', ar: 'التسويق', en: 'Marketing', type: 'EXPENSE', parent: '6000', group: 'OPERATING_EXPENSES', postable: true }, + { code: '6400', ar: 'الكهرباء', en: 'Electricity', type: 'EXPENSE', parent: '6000', group: 'OPERATING_EXPENSES', postable: true }, +]; diff --git a/alshuyukh-accounting/apps/api/src/modules/accounting/company-context.ts b/alshuyukh-accounting/apps/api/src/modules/accounting/company-context.ts new file mode 100644 index 000000000000..094a576762a0 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/accounting/company-context.ts @@ -0,0 +1,17 @@ +import type { Db } from '../../db/tx.js'; +import { badRequest, notFound } from '../../lib/errors.js'; + +/** + * Resolves the company a request works on. When the tenant has a single + * company, companyId may be omitted. + */ +export async function resolveCompanyId(db: Db, tenantId: string, companyId?: string | null): Promise { + if (companyId) { + const { rowCount } = await db.query(`SELECT 1 FROM companies WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL`, [tenantId, companyId]); + if (!rowCount) throw notFound('Company'); + return companyId; + } + const { rows } = await db.query<{ id: string }>(`SELECT id FROM companies WHERE tenant_id = $1 AND deleted_at IS NULL LIMIT 2`, [tenantId]); + if (rows.length === 1) return rows[0]!.id; + throw badRequest('COMPANY_REQUIRED', 'companyId is required because this organization has more than one company'); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/accounting/engine.ts b/alshuyukh-accounting/apps/api/src/modules/accounting/engine.ts new file mode 100644 index 000000000000..25bc30d95e9a --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/accounting/engine.ts @@ -0,0 +1,423 @@ +import type { Db } from '../../db/tx.js'; +import { AppError, badRequest, conflict, notFound } from '../../lib/errors.js'; +import { Decimal, sum, toMoney } from '../../lib/money.js'; +import { writeAudit, type AuditMeta } from '../audit/audit.service.js'; +import { PROFIT_AND_LOSS_TYPES } from './chart-template.js'; + +/** + * Accounting Engine. + * + * Every financial event in the system becomes a journal entry through this + * module. All functions take the caller's transaction (`db`) so a business + * document and its journal entry commit or roll back together. + * + * Life cycle: DRAFT → POSTED → (REVERSED). Posted entries are immutable; the + * database refuses changes even if this code had a bug. Corrections are made + * with a reversal entry followed by a new correcting entry. + */ + +export interface EngineContext { + tenantId: string; + userId: string; + meta?: AuditMeta; +} + +export interface LineInput { + accountId: string; + debit?: string; + credit?: string; + description?: string | null; + costCenterId?: string | null; + branchId?: string | null; + /** Sub-ledger tag: the customer or supplier this line belongs to. */ + customerId?: string | null; + supplierId?: string | null; +} + +export interface EntryInput { + companyId: string; + entryDate: string; + description: string; + lines: LineInput[]; + referenceType?: string; + referenceId?: string | null; + source?: 'MANUAL' | 'SYSTEM'; + correctionOfId?: string | null; +} + +interface NormalizedLine extends Required> { debit: string; credit: string } + +const unbalanced = (debit: Decimal, credit: Decimal) => + new AppError(400, 'UNBALANCED_ENTRY', `Journal entry is not balanced: debit ${toMoney(debit)}, credit ${toMoney(credit)}`, + { totalDebit: toMoney(debit), totalCredit: toMoney(credit), difference: toMoney(debit.minus(credit).abs()) }); + +/** Checks line shape and references. Balance is checked at posting. */ +async function normalizeLines(db: Db, companyId: string, lines: LineInput[]): Promise { + if (lines.length < 2) throw badRequest('TOO_FEW_LINES', 'A journal entry needs at least two lines'); + if (lines.length > 1000) throw badRequest('TOO_MANY_LINES', 'A journal entry can have at most 1000 lines'); + + const normalized = lines.map((l, i) => { + const debit = new Decimal(l.debit || '0'); + const credit = new Decimal(l.credit || '0'); + if (debit.isNegative() || credit.isNegative()) throw badRequest('INVALID_AMOUNT', `Line ${i + 1}: amounts cannot be negative`); + if (debit.decimalPlaces() > 2 || credit.decimalPlaces() > 2) throw badRequest('INVALID_AMOUNT', `Line ${i + 1}: at most 2 decimal places`); + if (l.customerId && l.supplierId) throw badRequest('INVALID_LINE', `Line ${i + 1}: a line can belong to a customer or a supplier, not both`); + if (debit.isZero() === credit.isZero()) { + throw badRequest('INVALID_LINE', `Line ${i + 1}: enter either a debit or a credit amount, not both or neither`); + } + return { + accountId: l.accountId, debit: toMoney(debit), credit: toMoney(credit), + description: l.description ?? null, costCenterId: l.costCenterId ?? null, branchId: l.branchId ?? null, + customerId: l.customerId ?? null, supplierId: l.supplierId ?? null, + }; + }); + + const accountIds = [...new Set(normalized.map((l) => l.accountId))]; + const { rows: accounts } = await db.query<{ id: string; is_postable: boolean; is_active: boolean; code: string }>( + `SELECT id, is_postable, is_active, code FROM accounts + WHERE company_id = $1 AND id = ANY($2::uuid[]) AND deleted_at IS NULL`, + [companyId, accountIds]); + const byId = new Map(accounts.map((a) => [a.id, a])); + normalized.forEach((l, i) => { + const a = byId.get(l.accountId); + if (!a) throw badRequest('INVALID_ACCOUNT', `Line ${i + 1}: account does not exist in this company`); + if (!a.is_postable) throw badRequest('ACCOUNT_NOT_POSTABLE', `Line ${i + 1}: account ${a.code} is a header account`); + if (!a.is_active) throw badRequest('ACCOUNT_INACTIVE', `Line ${i + 1}: account ${a.code} is inactive`); + }); + + const costCenters = [...new Set(normalized.map((l) => l.costCenterId).filter(Boolean))] as string[]; + if (costCenters.length) { + const { rowCount } = await db.query( + `SELECT 1 FROM cost_centers WHERE company_id = $1 AND id = ANY($2::uuid[]) AND deleted_at IS NULL AND is_active`, + [companyId, costCenters]); + if (rowCount !== costCenters.length) throw badRequest('INVALID_COST_CENTER', 'A cost center does not exist or is inactive'); + } + const branches = [...new Set(normalized.map((l) => l.branchId).filter(Boolean))] as string[]; + if (branches.length) { + const { rowCount } = await db.query( + `SELECT 1 FROM branches WHERE company_id = $1 AND id = ANY($2::uuid[]) AND deleted_at IS NULL`, + [companyId, branches]); + if (rowCount !== branches.length) throw badRequest('INVALID_BRANCH', 'A branch does not exist in this company'); + } + for (const [table, key, code, label] of [ + ['customers', 'customerId', 'INVALID_CUSTOMER', 'customer'], + ['suppliers', 'supplierId', 'INVALID_SUPPLIER', 'supplier'], + ] as const) { + const ids = [...new Set(normalized.map((l) => l[key]).filter(Boolean))] as string[]; + if (!ids.length) continue; + const { rowCount } = await db.query( + `SELECT 1 FROM ${table} WHERE company_id = $1 AND id = ANY($2::uuid[]) AND deleted_at IS NULL`, [companyId, ids]); + if (rowCount !== ids.length) throw badRequest(code, `A ${label} does not exist in this company`); + } + return normalized; +} + +async function insertLines(db: Db, tenantId: string, companyId: string, entryId: string, lines: NormalizedLine[]) { + // One statement for all lines keeps large entries fast. + await db.query( + `INSERT INTO journal_entry_lines + (tenant_id, company_id, journal_entry_id, line_no, account_id, debit, credit, description, + cost_center_id, branch_id, customer_id, supplier_id) + SELECT $1, $2, $3, l.ord::smallint, l.account_id, l.debit, l.credit, l.description, + l.cost_center_id, l.branch_id, l.customer_id, l.supplier_id + FROM unnest($4::uuid[], $5::numeric[], $6::numeric[], $7::text[], $8::uuid[], $9::uuid[], $10::uuid[], $11::uuid[]) + WITH ORDINALITY AS l(account_id, debit, credit, description, cost_center_id, branch_id, customer_id, supplier_id, ord)`, + [tenantId, companyId, entryId, + lines.map((l) => l.accountId), lines.map((l) => l.debit), lines.map((l) => l.credit), + lines.map((l) => l.description), lines.map((l) => l.costCenterId), lines.map((l) => l.branchId), + lines.map((l) => l.customerId), lines.map((l) => l.supplierId)]); +} + +async function assertCompany(db: Db, tenantId: string, companyId: string): Promise<{ currency: string }> { + const { rows: [c] } = await db.query<{ currency: string }>( + `SELECT currency FROM companies WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL`, [tenantId, companyId]); + if (!c) throw notFound('Company'); + return c; +} + +export async function createDraft(db: Db, ctx: EngineContext, input: EntryInput): Promise { + const company = await assertCompany(db, ctx.tenantId, input.companyId); + const lines = await normalizeLines(db, input.companyId, input.lines); + const { rows: [entry] } = await db.query<{ id: string }>( + `INSERT INTO journal_entries (tenant_id, company_id, entry_date, description, reference_type, reference_id, + source, currency, correction_of_id, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`, + [ctx.tenantId, input.companyId, input.entryDate, input.description, input.referenceType ?? 'MANUAL', + input.referenceId ?? null, input.source ?? 'MANUAL', company.currency, input.correctionOfId ?? null, ctx.userId]); + await insertLines(db, ctx.tenantId, input.companyId, entry!.id, lines); + await writeAudit(db, { + tenantId: ctx.tenantId, userId: ctx.userId, action: 'CREATE', entityType: 'journal_entry', entityId: entry!.id, + newValues: { ...input, lines }, + }, ctx.meta); + return entry!.id; +} + +async function lockEntry(db: Db, tenantId: string, id: string) { + const { rows: [e] } = await db.query<{ + id: string; company_id: string; status: string; entry_date: string; description: string; + source: string; reference_type: string; deleted_at: Date | null; fiscal_year_id: string | null; + }>( + `SELECT id, company_id, status, to_char(entry_date, 'YYYY-MM-DD') AS entry_date, description, source, + reference_type, deleted_at, fiscal_year_id + FROM journal_entries WHERE tenant_id = $1 AND id = $2 FOR UPDATE`, + [tenantId, id]); + if (!e || e.deleted_at) throw notFound('Journal entry'); + return e; +} + +export async function updateDraft(db: Db, ctx: EngineContext, id: string, input: Partial>) { + const e = await lockEntry(db, ctx.tenantId, id); + if (e.status !== 'DRAFT') throw conflict('ENTRY_NOT_DRAFT', 'Only draft entries can be edited. Reverse a posted entry instead.'); + if (e.source !== 'MANUAL') throw conflict('SYSTEM_ENTRY', 'System-generated entries cannot be edited here'); + const before = await getEntry(db, ctx.tenantId, id); + await db.query( + `UPDATE journal_entries SET entry_date = COALESCE($2, entry_date), description = COALESCE($3, description) WHERE id = $1`, + [id, input.entryDate ?? null, input.description ?? null]); + if (input.lines) { + const lines = await normalizeLines(db, e.company_id, input.lines); + await db.query(`DELETE FROM journal_entry_lines WHERE journal_entry_id = $1`, [id]); + await insertLines(db, ctx.tenantId, e.company_id, id, lines); + } + const after = await getEntry(db, ctx.tenantId, id); + await writeAudit(db, { tenantId: ctx.tenantId, userId: ctx.userId, action: 'UPDATE', entityType: 'journal_entry', entityId: id, oldValues: before, newValues: after }, ctx.meta); + return after; +} + +export async function deleteDraft(db: Db, ctx: EngineContext, id: string) { + const e = await lockEntry(db, ctx.tenantId, id); + if (e.status !== 'DRAFT') throw conflict('ENTRY_NOT_DRAFT', 'Posted entries cannot be deleted. Reverse them instead.'); + if (e.source !== 'MANUAL') throw conflict('SYSTEM_ENTRY', 'System-generated entries cannot be deleted here'); + await db.query(`UPDATE journal_entries SET deleted_at = now() WHERE id = $1`, [id]); + await writeAudit(db, { tenantId: ctx.tenantId, userId: ctx.userId, action: 'DELETE', entityType: 'journal_entry', entityId: id }, ctx.meta); +} + +/** + * Posts a draft: validates balance, accounts and the fiscal period, assigns + * the next gap-free number, and makes the entry immutable. + */ +export async function post(db: Db, ctx: EngineContext, id: string) { + const e = await lockEntry(db, ctx.tenantId, id); + if (e.status !== 'DRAFT') throw conflict('ENTRY_NOT_DRAFT', 'This entry is already posted'); + + // FOR SHARE blocks a concurrent period close until this transaction ends. + const { rows: [period] } = await db.query<{ id: string; status: string; year_id: string; year_status: string; year_start: string }>( + `SELECT p.id, p.status, y.id AS year_id, y.status AS year_status, to_char(y.start_date, 'YYYY') AS year_start + FROM fiscal_periods p JOIN fiscal_years y ON y.id = p.fiscal_year_id + WHERE p.company_id = $1 AND $2::date BETWEEN p.start_date AND p.end_date + FOR SHARE OF p, y`, + [e.company_id, e.entry_date]); + if (!period) throw badRequest('NO_FISCAL_PERIOD', `No fiscal period covers ${e.entry_date}. Create the fiscal year first.`); + if (period.status !== 'OPEN' || period.year_status !== 'OPEN') { + throw conflict('PERIOD_CLOSED', `The fiscal period for ${e.entry_date} is closed`); + } + + const { rows: lines } = await db.query<{ debit: string; credit: string; is_postable: boolean; is_active: boolean; deleted_at: Date | null; code: string }>( + `SELECT l.debit, l.credit, a.is_postable, a.is_active, a.deleted_at, a.code + FROM journal_entry_lines l JOIN accounts a ON a.id = l.account_id + WHERE l.journal_entry_id = $1`, + [id]); + if (lines.length < 2) throw badRequest('TOO_FEW_LINES', 'A journal entry needs at least two lines'); + const bad = lines.find((l) => !l.is_postable || !l.is_active || l.deleted_at); + if (bad) throw badRequest('ACCOUNT_INACTIVE', `Account ${bad.code} can no longer receive postings`); + const debit = sum(lines.map((l) => l.debit)); + const credit = sum(lines.map((l) => l.credit)); + if (!debit.equals(credit) || debit.isZero()) throw unbalanced(debit, credit); + + const { rows: [seq] } = await db.query<{ last_number: number }>( + `UPDATE journal_sequences SET last_number = last_number + 1 WHERE fiscal_year_id = $1 RETURNING last_number`, + [period.year_id]); + if (!seq) throw new AppError(500, 'SEQUENCE_MISSING', 'Journal sequence for the fiscal year is missing'); + const number = `JV-${period.year_start}-${String(seq.last_number).padStart(6, '0')}`; + + await db.query( + `UPDATE journal_entries + SET status = 'POSTED', entry_number = $2, fiscal_year_id = $3, fiscal_period_id = $4, + posted_by = $5, posted_at = now() + WHERE id = $1`, + [id, number, period.year_id, period.id, ctx.userId]); + await writeAudit(db, { + tenantId: ctx.tenantId, userId: ctx.userId, action: 'POST', entityType: 'journal_entry', entityId: id, + newValues: { entryNumber: number, totalDebit: toMoney(debit), totalCredit: toMoney(credit) }, + }, ctx.meta); + return getEntry(db, ctx.tenantId, id); +} + +/** Creates and posts an entry in one step. Used by sales, purchases, payments, etc. */ +export async function postEntry(db: Db, ctx: EngineContext, input: EntryInput) { + const id = await createDraft(db, ctx, input); + return post(db, ctx, id); +} + +export interface ReverseOptions { + date?: string; + reason: string; + /** System documents (invoices…) reverse their own entries; manual reversal is limited to manual entries. */ + allowSystem?: boolean; +} + +/** + * Reverses a posted entry with a mirror entry (debits and credits swapped) + * and marks the original REVERSED. The original stays in the ledger. + */ +export async function reverse(db: Db, ctx: EngineContext, id: string, opts: ReverseOptions) { + const e = await lockEntry(db, ctx.tenantId, id); + if (e.status === 'REVERSED') throw conflict('ALREADY_REVERSED', 'This entry has already been reversed'); + if (e.status !== 'POSTED') throw conflict('ENTRY_NOT_POSTED', 'Only posted entries can be reversed. Delete or edit the draft instead.'); + if (e.reference_type === 'REVERSAL') { + throw conflict('CANNOT_REVERSE_REVERSAL', 'A reversal entry cannot be reversed. Post a new entry instead.'); + } + if (e.source === 'SYSTEM' && !opts.allowSystem) { + throw conflict('SYSTEM_ENTRY', 'This entry was generated by a document. Cancel or return the document instead.'); + } + + const { rows: lines } = await db.query<{ account_id: string; debit: string; credit: string; description: string | null; cost_center_id: string | null; branch_id: string | null; customer_id: string | null; supplier_id: string | null }>( + `SELECT account_id, debit, credit, description, cost_center_id, branch_id, customer_id, supplier_id + FROM journal_entry_lines WHERE journal_entry_id = $1 ORDER BY line_no`, [id]); + + const reversalId = await createDraft(db, ctx, { + companyId: e.company_id, + entryDate: opts.date ?? e.entry_date, + description: `عكس القيد: ${e.description} — ${opts.reason}`.slice(0, 1000), + referenceType: 'REVERSAL', + referenceId: id, + source: e.source as 'MANUAL' | 'SYSTEM', + lines: lines.map((l) => ({ + accountId: l.account_id, debit: l.credit, credit: l.debit, + description: l.description, costCenterId: l.cost_center_id, branchId: l.branch_id, + customerId: l.customer_id, supplierId: l.supplier_id, + })), + }); + await db.query(`UPDATE journal_entries SET reversal_of_id = $2 WHERE id = $1`, [reversalId, id]); + const reversal = await post(db, ctx, reversalId); + + await db.query( + `UPDATE journal_entries SET status = 'REVERSED', reversed_by_entry_id = $2, reversed_at = now(), reversed_by = $3 WHERE id = $1`, + [id, reversalId, ctx.userId]); + await writeAudit(db, { + tenantId: ctx.tenantId, userId: ctx.userId, action: 'REVERSE', entityType: 'journal_entry', entityId: id, + newValues: { reversalEntryId: reversalId, reversalNumber: reversal.entryNumber, reason: opts.reason }, + }, ctx.meta); + return reversal; +} + +/** + * Closes a fiscal year: transfers the net of all revenue, cost of sales and + * expense accounts to retained earnings with a posted closing entry, then + * closes every period and the year. + */ +export async function closeFiscalYear(db: Db, ctx: EngineContext, yearId: string) { + const { rows: [year] } = await db.query<{ id: string; company_id: string; status: string; start_date: string; end_date: string }>( + `SELECT id, company_id, status, to_char(start_date, 'YYYY-MM-DD') AS start_date, to_char(end_date, 'YYYY-MM-DD') AS end_date + FROM fiscal_years WHERE tenant_id = $1 AND id = $2 FOR UPDATE`, [ctx.tenantId, yearId]); + if (!year) throw notFound('Fiscal year'); + if (year.status === 'CLOSED') throw conflict('YEAR_CLOSED', 'This fiscal year is already closed'); + + const earlierOpen = await db.query( + `SELECT 1 FROM fiscal_years WHERE company_id = $1 AND end_date < $2 AND status = 'OPEN' LIMIT 1`, [year.company_id, year.start_date]); + if (earlierOpen.rowCount) throw conflict('EARLIER_YEAR_OPEN', 'Close earlier fiscal years first'); + + const drafts = await db.query( + `SELECT 1 FROM journal_entries WHERE company_id = $1 AND status = 'DRAFT' AND deleted_at IS NULL + AND entry_date BETWEEN $2 AND $3 LIMIT 1`, [year.company_id, year.start_date, year.end_date]); + if (drafts.rowCount) throw conflict('DRAFTS_EXIST', 'Post or delete the draft entries of this year before closing it'); + + const { rows: [re] } = await db.query<{ id: string }>( + `SELECT id FROM accounts WHERE company_id = $1 AND system_key = 'RETAINED_EARNINGS' AND deleted_at IS NULL AND is_active AND is_postable`, + [year.company_id]); + if (!re) throw conflict('RETAINED_EARNINGS_MISSING', 'An active retained earnings account (system key RETAINED_EARNINGS) is required'); + + const { rows: balances } = await db.query<{ account_id: string; net: string }>( + `SELECT l.account_id, sum(l.debit - l.credit) AS net + FROM journal_entry_lines l + JOIN journal_entries e ON e.id = l.journal_entry_id + JOIN accounts a ON a.id = l.account_id + WHERE e.company_id = $1 AND e.status IN ('POSTED', 'REVERSED') + AND e.entry_date BETWEEN $2 AND $3 + AND a.account_type = ANY($4::text[]) + GROUP BY l.account_id + HAVING sum(l.debit - l.credit) <> 0`, + [year.company_id, year.start_date, year.end_date, PROFIT_AND_LOSS_TYPES]); + + let closingEntryId: string | null = null; + if (balances.length) { + const lines: LineInput[] = balances.map((b) => { + const net = new Decimal(b.net); + return net.greaterThan(0) ? { accountId: b.account_id, credit: toMoney(net) } : { accountId: b.account_id, debit: toMoney(net.abs()) }; + }); + // Positive total = expenses exceed revenue (a loss) → debit retained earnings. + const result = sum(balances.map((b) => b.net)); + if (!result.isZero()) { + lines.push(result.greaterThan(0) ? { accountId: re.id, debit: toMoney(result) } : { accountId: re.id, credit: toMoney(result.abs()) }); + } + const closing = await postEntry(db, ctx, { + companyId: year.company_id, entryDate: year.end_date, description: 'قيد إقفال السنة المالية', + referenceType: 'YEAR_CLOSING', referenceId: year.id, source: 'SYSTEM', lines, + }); + closingEntryId = closing.id; + } + + await db.query( + `UPDATE fiscal_periods SET status = 'CLOSED', closed_at = COALESCE(closed_at, now()), closed_by = COALESCE(closed_by, $2) + WHERE fiscal_year_id = $1 AND status = 'OPEN'`, [year.id, ctx.userId]); + await db.query( + `UPDATE fiscal_years SET status = 'CLOSED', closing_entry_id = $2, closed_at = now(), closed_by = $3 WHERE id = $1`, + [year.id, closingEntryId, ctx.userId]); + await writeAudit(db, { + tenantId: ctx.tenantId, userId: ctx.userId, action: 'CLOSE', entityType: 'fiscal_year', entityId: year.id, + newValues: { closingEntryId }, + }, ctx.meta); + return { closingEntryId }; +} + +export interface EntryView { + id: string; + companyId: string; + entryNumber: string | null; + entryDate: string; + description: string; + referenceType: string; + referenceId: string | null; + source: string; + status: string; + currency: string; + totalDebit: string; + totalCredit: string; + fiscalPeriodId: string | null; + reversalOfId: string | null; + reversedByEntryId: string | null; + correctionOfId: string | null; + createdBy: string | null; + postedAt: Date | null; + postedBy: string | null; + reversedAt: Date | null; + createdAt: Date; + lines: { + id: string; lineNo: number; accountId: string; accountCode: string; accountName: string; + debit: string; credit: string; description: string | null; costCenterId: string | null; branchId: string | null; + customerId: string | null; supplierId: string | null; + }[]; +} + +export async function getEntry(db: Db, tenantId: string, id: string): Promise { + const { rows: [e] } = await db.query>( + `SELECT e.id, e.company_id AS "companyId", e.entry_number AS "entryNumber", + to_char(e.entry_date, 'YYYY-MM-DD') AS "entryDate", e.description, + e.reference_type AS "referenceType", e.reference_id AS "referenceId", e.source, e.status, e.currency, + CASE WHEN e.status = 'DRAFT' THEN COALESCE((SELECT sum(debit) FROM journal_entry_lines WHERE journal_entry_id = e.id), 0) ELSE e.total_debit END::numeric(18,2)::text AS "totalDebit", + CASE WHEN e.status = 'DRAFT' THEN COALESCE((SELECT sum(credit) FROM journal_entry_lines WHERE journal_entry_id = e.id), 0) ELSE e.total_credit END::numeric(18,2)::text AS "totalCredit", + e.fiscal_period_id AS "fiscalPeriodId", e.reversal_of_id AS "reversalOfId", + e.reversed_by_entry_id AS "reversedByEntryId", e.correction_of_id AS "correctionOfId", + e.created_by AS "createdBy", e.posted_at AS "postedAt", e.posted_by AS "postedBy", + e.reversed_at AS "reversedAt", e.created_at AS "createdAt" + FROM journal_entries e WHERE e.tenant_id = $1 AND e.id = $2 AND e.deleted_at IS NULL`, + [tenantId, id]); + if (!e) throw notFound('Journal entry'); + const { rows: lines } = await db.query( + `SELECT l.id, l.line_no AS "lineNo", l.account_id AS "accountId", a.code AS "accountCode", a.name_ar AS "accountName", + l.debit::text AS debit, l.credit::text AS credit, l.description, + l.cost_center_id AS "costCenterId", l.branch_id AS "branchId", + l.customer_id AS "customerId", l.supplier_id AS "supplierId" + FROM journal_entry_lines l JOIN accounts a ON a.id = l.account_id + WHERE l.journal_entry_id = $1 ORDER BY l.line_no`, [id]); + return { ...e, lines }; +} diff --git a/alshuyukh-accounting/apps/api/src/modules/accounting/fiscal.routes.ts b/alshuyukh-accounting/apps/api/src/modules/accounting/fiscal.routes.ts new file mode 100644 index 000000000000..0ba7bce851c7 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/accounting/fiscal.routes.ts @@ -0,0 +1,83 @@ +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { z } from 'zod'; +import { isoDate } from '../../lib/dates.js'; +import { conflict, notFound } from '../../lib/errors.js'; +import { parse, uuidParam } from '../../lib/validation.js'; +import { requirePermission } from '../../plugins/auth.js'; +import { writeAudit } from '../audit/audit.service.js'; +import { resolveCompanyId } from './company-context.js'; +import { closeFiscalYear } from './engine.js'; +import { createFiscalYear } from './setup.js'; + +export default async function fiscalRoutes(app: FastifyInstance) { + const canView = requirePermission(app, 'account.view'); + const canManage = requirePermission(app, 'fiscal.manage'); + + app.get('/fiscal-years', { preHandler: canView }, async (req) => { + const q = parse(z.object({ companyId: z.uuid().optional() }), req.query); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const { rows } = await db.query( + `SELECT y.id, y.name, to_char(y.start_date, 'YYYY-MM-DD') AS "startDate", to_char(y.end_date, 'YYYY-MM-DD') AS "endDate", + y.status, y.closing_entry_id AS "closingEntryId", y.closed_at AS "closedAt", + COALESCE(json_agg(json_build_object( + 'id', p.id, 'periodNumber', p.period_number, 'name', p.name, + 'startDate', to_char(p.start_date, 'YYYY-MM-DD'), 'endDate', to_char(p.end_date, 'YYYY-MM-DD'), + 'status', p.status) ORDER BY p.period_number), '[]') AS periods + FROM fiscal_years y LEFT JOIN fiscal_periods p ON p.fiscal_year_id = y.id + WHERE y.tenant_id = $1 AND y.company_id = $2 + GROUP BY y.id ORDER BY y.start_date DESC`, + [req.auth!.tenantId, companyId]); + return { data: rows }; + }); + }); + + app.post('/fiscal-years', { preHandler: canManage }, async (req, reply) => { + const body = parse(z.object({ + companyId: z.uuid().optional(), + startDate: isoDate, + endDate: isoDate.optional(), + name: z.string().trim().min(1).max(100).optional(), + }), req.body); + const a = req.auth!; + const id = await req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, a.tenantId, body.companyId); + const yearId = await createFiscalYear(db, { tenantId: a.tenantId, companyId, userId: a.userId }, body.startDate, body.endDate, body.name); + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'CREATE', entityType: 'fiscal_year', entityId: yearId, newValues: body }, req.auditMeta()); + return yearId; + }); + reply.code(201); + return { id }; + }); + + app.post('/fiscal-years/:id/close', { preHandler: canManage }, async (req) => { + const { id } = parse(uuidParam, req.params); + const a = req.auth!; + return req.tenantTx((db) => closeFiscalYear(db, { tenantId: a.tenantId, userId: a.userId, meta: req.auditMeta() }, id)); + }); + + const setPeriodStatus = (status: 'OPEN' | 'CLOSED') => async (req: FastifyRequest) => { + const { id } = parse(uuidParam, req.params); + const a = req.auth!; + return req.tenantTx(async (db) => { + // FOR UPDATE waits for postings that hold the period FOR SHARE. + const { rows: [p] } = await db.query<{ status: string; year_status: string }>( + `SELECT p.status, y.status AS year_status FROM fiscal_periods p JOIN fiscal_years y ON y.id = p.fiscal_year_id + WHERE p.tenant_id = $1 AND p.id = $2 FOR UPDATE OF p`, [a.tenantId, id]); + if (!p) throw notFound('Fiscal period'); + if (p.year_status === 'CLOSED') throw conflict('YEAR_CLOSED', 'The fiscal year is closed'); + if (p.status === status) throw conflict('NO_CHANGE', `The period is already ${status.toLowerCase()}`); + await db.query( + `UPDATE fiscal_periods SET status = $2, + closed_at = CASE WHEN $2 = 'CLOSED' THEN now() END, closed_by = CASE WHEN $2 = 'CLOSED' THEN $3::uuid END + WHERE id = $1`, [id, status, a.userId]); + await writeAudit(db, { + tenantId: a.tenantId, userId: a.userId, action: status === 'CLOSED' ? 'CLOSE' : 'REOPEN', + entityType: 'fiscal_period', entityId: id, oldValues: { status: p.status }, newValues: { status }, + }, req.auditMeta()); + return { id, status }; + }); + }; + app.post('/fiscal-periods/:id/close', { preHandler: canManage }, setPeriodStatus('CLOSED')); + app.post('/fiscal-periods/:id/reopen', { preHandler: canManage }, setPeriodStatus('OPEN')); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/accounting/journal.routes.ts b/alshuyukh-accounting/apps/api/src/modules/accounting/journal.routes.ts new file mode 100644 index 000000000000..2cd18f7ebdf4 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/accounting/journal.routes.ts @@ -0,0 +1,134 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { isoDate } from '../../lib/dates.js'; +import { forbidden } from '../../lib/errors.js'; +import { amountString } from '../../lib/money.js'; +import { parse, uuidParam } from '../../lib/validation.js'; +import { requirePermission } from '../../plugins/auth.js'; +import { resolveCompanyId } from './company-context.js'; +import { createDraft, deleteDraft, getEntry, post, reverse, updateDraft, type EngineContext } from './engine.js'; + +const line = z.object({ + accountId: z.uuid(), + debit: amountString.optional(), + credit: amountString.optional(), + description: z.string().trim().max(500).nullish(), + costCenterId: z.uuid().nullish(), + branchId: z.uuid().nullish(), + customerId: z.uuid().nullish(), + supplierId: z.uuid().nullish(), +}); +const lines = z.array(line).min(2).max(1000); + +const createBody = z.object({ + companyId: z.uuid().optional(), + entryDate: isoDate, + description: z.string().trim().min(1).max(1000), + lines, + /** Post immediately (requires journal.post). */ + post: z.boolean().default(false), +}); + +const listQuery = z.object({ + companyId: z.uuid().optional(), + status: z.enum(['DRAFT', 'POSTED', 'REVERSED']).optional(), + dateFrom: isoDate.optional(), + dateTo: isoDate.optional(), + accountId: z.uuid().optional(), + referenceType: z.string().regex(/^[A-Z_]+$/).optional(), + search: z.string().trim().max(100).optional(), + limit: z.coerce.number().int().min(1).max(200).default(50), + offset: z.coerce.number().int().min(0).default(0), +}); + +export default async function journalRoutes(app: FastifyInstance) { + const ctxOf = (req: { auth: { tenantId: string; userId: string } | null; auditMeta: () => EngineContext['meta'] }): EngineContext => + ({ tenantId: req.auth!.tenantId, userId: req.auth!.userId, meta: req.auditMeta() }); + + app.get('/journal-entries', { preHandler: requirePermission(app, 'journal.view') }, async (req) => { + const q = parse(listQuery, req.query); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const { rows } = await db.query<{ total: string }>( + `SELECT e.id, e.entry_number AS "entryNumber", to_char(e.entry_date, 'YYYY-MM-DD') AS "entryDate", + e.description, e.reference_type AS "referenceType", e.source, e.status, + CASE WHEN e.status = 'DRAFT' THEN COALESCE((SELECT sum(debit) FROM journal_entry_lines WHERE journal_entry_id = e.id), 0) + ELSE e.total_debit END::numeric(18,2)::text AS "totalDebit", + e.posted_at AS "postedAt", e.created_at AS "createdAt", + count(*) OVER () AS total + FROM journal_entries e + WHERE e.tenant_id = $1 AND e.company_id = $2 AND e.deleted_at IS NULL + AND ($3::text IS NULL OR e.status = $3) + AND ($4::date IS NULL OR e.entry_date >= $4) + AND ($5::date IS NULL OR e.entry_date <= $5) + AND ($6::uuid IS NULL OR EXISTS (SELECT 1 FROM journal_entry_lines l WHERE l.journal_entry_id = e.id AND l.account_id = $6)) + AND ($7::text IS NULL OR e.reference_type = $7) + AND ($8::text IS NULL OR e.description ILIKE '%' || $8 || '%' OR e.entry_number ILIKE '%' || $8 || '%') + ORDER BY e.entry_date DESC, e.entry_number DESC NULLS FIRST, e.created_at DESC + LIMIT $9 OFFSET $10`, + [req.auth!.tenantId, companyId, q.status ?? null, q.dateFrom ?? null, q.dateTo ?? null, q.accountId ?? null, + q.referenceType ?? null, q.search?.replace(/[%_\\]/g, (m) => `\\${m}`) ?? null, q.limit, q.offset]); + return { data: rows.map(({ total: _t, ...r }) => r), total: Number(rows[0]?.total ?? 0) }; + }); + }); + + app.get('/journal-entries/:id', { preHandler: requirePermission(app, 'journal.view') }, async (req) => { + const { id } = parse(uuidParam, req.params); + return req.tenantTx((db) => getEntry(db, req.auth!.tenantId, id)); + }); + + app.post('/journal-entries', { preHandler: requirePermission(app, 'journal.create') }, async (req, reply) => { + const body = parse(createBody, req.body); + if (body.post && !req.auth!.permissions.has('journal.post')) throw forbidden('Missing permission: journal.post'); + const ctx = ctxOf(req); + const entry = await req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, ctx.tenantId, body.companyId); + const id = await createDraft(db, ctx, { companyId, entryDate: body.entryDate, description: body.description, lines: body.lines }); + return body.post ? post(db, ctx, id) : getEntry(db, ctx.tenantId, id); + }); + reply.code(201); + return entry; + }); + + app.patch('/journal-entries/:id', { preHandler: requirePermission(app, 'journal.create') }, async (req) => { + const { id } = parse(uuidParam, req.params); + const body = parse(z.object({ entryDate: isoDate, description: z.string().trim().min(1).max(1000), lines }).partial(), req.body); + return req.tenantTx((db) => updateDraft(db, ctxOf(req), id, body)); + }); + + app.delete('/journal-entries/:id', { preHandler: requirePermission(app, 'journal.create') }, async (req, reply) => { + const { id } = parse(uuidParam, req.params); + await req.tenantTx((db) => deleteDraft(db, ctxOf(req), id)); + return reply.code(204).send(); + }); + + app.post('/journal-entries/:id/post', { preHandler: requirePermission(app, 'journal.post') }, async (req) => { + const { id } = parse(uuidParam, req.params); + return req.tenantTx((db) => post(db, ctxOf(req), id)); + }); + + /** + * Reverses a posted manual entry. With `correction`, also creates the + * correcting entry as a draft linked to the original, in the same transaction. + */ + app.post('/journal-entries/:id/reverse', { preHandler: requirePermission(app, 'journal.reverse') }, async (req) => { + const { id } = parse(uuidParam, req.params); + const body = parse(z.object({ + reason: z.string().trim().min(3).max(500), + date: isoDate.optional(), + correction: z.object({ entryDate: isoDate, description: z.string().trim().min(1).max(1000), lines }).optional(), + }), req.body); + if (body.correction && !req.auth!.permissions.has('journal.create')) throw forbidden('Missing permission: journal.create'); + const ctx = ctxOf(req); + return req.tenantTx(async (db) => { + const original = await getEntry(db, ctx.tenantId, id); + const reversal = await reverse(db, ctx, id, { reason: body.reason, date: body.date }); + let correction = null; + if (body.correction) { + const cid = await createDraft(db, ctx, { companyId: original.companyId, ...body.correction, correctionOfId: id }); + correction = await getEntry(db, ctx.tenantId, cid); + } + return { original: await getEntry(db, ctx.tenantId, id), reversal, correction }; + }); + }); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/accounting/setup.ts b/alshuyukh-accounting/apps/api/src/modules/accounting/setup.ts new file mode 100644 index 000000000000..56f6721be2ab --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/accounting/setup.ts @@ -0,0 +1,161 @@ +import type { Db } from '../../db/tx.js'; +import { addDays, addMonths, todayIn } from '../../lib/dates.js'; +import { badRequest, conflict } from '../../lib/errors.js'; +import { DEFAULT_ACCOUNTS, DEFAULT_GROUPS } from './chart-template.js'; + +export interface CompanyRef { tenantId: string; companyId: string; userId: string } + +/** Creates the default account groups and chart of accounts. Skips if the company already has accounts. */ +export async function seedDefaultChart(db: Db, ref: CompanyRef): Promise { + const existing = await db.query(`SELECT 1 FROM accounts WHERE company_id = $1 AND deleted_at IS NULL LIMIT 1`, [ref.companyId]); + if (existing.rowCount) return false; + + const groupIds = new Map(); + for (const g of DEFAULT_GROUPS) { + const { rows: [row] } = await db.query<{ id: string }>( + `INSERT INTO account_groups (tenant_id, company_id, code, name_ar, name_en, account_type, sort_order, is_system) + VALUES ($1, $2, $3, $4, $5, $6, $7, true) + ON CONFLICT (company_id, code) DO UPDATE SET code = EXCLUDED.code + RETURNING id`, + [ref.tenantId, ref.companyId, g.code, g.ar, g.en, g.type, g.sort]); + groupIds.set(g.code, row!.id); + } + + const accountIds = new Map(); + for (const a of DEFAULT_ACCOUNTS) { + const { rows: [row] } = await db.query<{ id: string }>( + `INSERT INTO accounts (tenant_id, company_id, code, name_ar, name_en, account_type, parent_id, group_id, + is_postable, is_system, system_key, created_by, updated_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, true, $10, $11, $11) RETURNING id`, + [ref.tenantId, ref.companyId, a.code, a.ar, a.en, a.type, + a.parent ? accountIds.get(a.parent)! : null, a.group ? groupIds.get(a.group)! : null, + a.postable, a.systemKey ?? null, ref.userId]); + accountIds.set(a.code, row!.id); + } + return true; +} + +const MONTHS_AR = ['يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر']; + +/** + * Creates a fiscal year with monthly periods. `startDate` must be the 1st of + * a month. Without `endDate` the year is 12 months. The last period may be + * shorter or longer only through an explicit endDate (first/last years). + */ +export async function createFiscalYear(db: Db, ref: CompanyRef, startDate: string, endDate?: string, name?: string) { + if (!startDate.endsWith('-01')) throw badRequest('INVALID_FISCAL_START', 'A fiscal year must start on the first day of a month'); + const end = endDate ?? addDays(addMonths(startDate, 12), -1); + if (end <= startDate) throw badRequest('INVALID_FISCAL_END', 'Fiscal year end must be after its start'); + + const overlap = await db.query( + `SELECT 1 FROM fiscal_years WHERE company_id = $1 AND daterange(start_date, end_date, '[]') && daterange($2::date, $3::date, '[]')`, + [ref.companyId, startDate, end]); + if (overlap.rowCount) throw conflict('FISCAL_YEAR_OVERLAP', 'This fiscal year overlaps an existing one'); + + const yearName = name ?? (startDate.slice(0, 4) === end.slice(0, 4) ? startDate.slice(0, 4) : `${startDate.slice(0, 4)}/${end.slice(0, 4)}`); + const { rows: [year] } = await db.query<{ id: string }>( + `INSERT INTO fiscal_years (tenant_id, company_id, name, start_date, end_date, created_by) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, + [ref.tenantId, ref.companyId, yearName, startDate, end, ref.userId]); + + let periodStart = startDate; + let n = 1; + while (periodStart <= end) { + const monthEnd = addDays(addMonths(periodStart, 1), -1); + const periodEnd = monthEnd > end ? end : monthEnd; + const month = Number(periodStart.slice(5, 7)); + await db.query( + `INSERT INTO fiscal_periods (tenant_id, company_id, fiscal_year_id, period_number, name, start_date, end_date) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ref.tenantId, ref.companyId, year!.id, n, `${MONTHS_AR[month - 1]} ${periodStart.slice(0, 4)}`, periodStart, periodEnd]); + periodStart = addDays(periodEnd, 1); + n += 1; + if (n > 18) throw badRequest('FISCAL_YEAR_TOO_LONG', 'A fiscal year can have at most 18 periods'); + } + await db.query(`INSERT INTO journal_sequences (fiscal_year_id, tenant_id, company_id) VALUES ($1, $2, $3)`, + [year!.id, ref.tenantId, ref.companyId]); + return year!.id; +} + +/** Start date (1st of month) of the fiscal year that contains `today`. */ +export function currentFiscalYearStart(today: string, startMonth: number): string { + const year = Number(today.slice(0, 4)); + const month = Number(today.slice(5, 7)); + const startYear = month >= startMonth ? year : year - 1; + return `${startYear}-${String(startMonth).padStart(2, '0')}-01`; +} + +// UN/ECE Recommendation 20 codes, used later in ZATCA XML. +const DEFAULT_UNITS: readonly [string, string, string][] = [ + ['PCE', 'حبة', 'Piece'], ['BX', 'كرتون', 'Box'], ['KGM', 'كيلوجرام', 'Kilogram'], ['LTR', 'لتر', 'Litre'], + ['MTR', 'متر', 'Metre'], ['SET', 'طقم', 'Set'], ['HUR', 'ساعة', 'Hour'], ['DAY', 'يوم', 'Day'], +]; + +/** Creates the default units of measure. Existing units are kept. */ +export async function seedDefaultUnits(db: Db, ref: CompanyRef): Promise { + for (const [code, ar, en] of DEFAULT_UNITS) { + await db.query( + `INSERT INTO units (tenant_id, company_id, code, name_ar, name_en) VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (company_id, code) DO NOTHING`, + [ref.tenantId, ref.companyId, code, ar, en]); + } +} + +/** Standard VAT rate (configurable later in tax settings) and default payment methods. */ +export async function seedTaxAndPayments(db: Db, ref: CompanyRef): Promise { + const hasRate = await db.query(`SELECT 1 FROM tax_rates WHERE company_id = $1 LIMIT 1`, [ref.companyId]); + if (!hasRate.rowCount) { + await db.query( + `INSERT INTO tax_rates (tenant_id, company_id, name_ar, rate, effective_from, created_by) + VALUES ($1, $2, 'ضريبة القيمة المضافة - النسبة الأساسية', 0.15, DATE '2020-07-01', $3)`, + [ref.tenantId, ref.companyId, ref.userId]); + } + const methods: [string, string, string, string, number][] = [ + ['CASH', 'نقدًا', 'CASH', 'CASH', 1], ['BANK', 'تحويل بنكي', 'BANK', 'BANK', 2], + ['CARD', 'بطاقة مدى / ائتمان', 'CARD', 'BANK', 3], ['STC_PAY', 'STC Pay', 'STC_PAY', 'BANK', 4], + ['TAMARA', 'تمارا', 'TAMARA', 'BANK', 5], + ]; + for (const [code, name, type, accountKey, sort] of methods) { + await db.query( + `INSERT INTO payment_methods (tenant_id, company_id, code, name_ar, method_type, account_id, sort_order) + SELECT $1, $2, $3, $4, $5, a.id, $7 FROM accounts a + WHERE a.company_id = $2 AND a.system_key = $6 AND a.deleted_at IS NULL + ON CONFLICT (company_id, code) DO NOTHING`, + [ref.tenantId, ref.companyId, code, name, type, accountKey, sort]); + } +} + +/** Default expense categories mapped to the template expense accounts. */ +export async function seedExpenseCategories(db: Db, ref: CompanyRef): Promise { + const categories: [string, string, string, string][] = [ + ['RENT', 'إيجار', '6100', 'S'], ['SALARIES', 'رواتب وأجور', '6200', 'O'], + ['MARKETING', 'تسويق وإعلان', '6300', 'S'], ['UTILITIES', 'كهرباء ومياه', '6400', 'S'], + ]; + for (const [code, name, accountCode, vat] of categories) { + await db.query( + `INSERT INTO expense_categories (tenant_id, company_id, code, name_ar, account_id, vat_category) + SELECT $1, $2, $3, $4, a.id, $6 FROM accounts a + WHERE a.company_id = $2 AND a.code = $5 AND a.deleted_at IS NULL AND a.is_postable + ON CONFLICT (company_id, code) DO NOTHING`, + [ref.tenantId, ref.companyId, code, name, accountCode, vat]); + } +} + +/** + * Prepares a new company for accounting: default chart and the current + * fiscal year. Safe to call again; existing setup is left as is. + */ +export async function setupCompanyAccounting(db: Db, ref: CompanyRef, timezone = 'Asia/Riyadh') { + const chartCreated = await seedDefaultChart(db, ref); + await seedDefaultUnits(db, ref); + await seedTaxAndPayments(db, ref); + await seedExpenseCategories(db, ref); + let fiscalYearId: string | null = null; + const hasYear = await db.query(`SELECT 1 FROM fiscal_years WHERE company_id = $1 LIMIT 1`, [ref.companyId]); + if (!hasYear.rowCount) { + const { rows: [s] } = await db.query<{ fiscal_year_start_month: number }>( + `SELECT fiscal_year_start_month FROM tenant_settings WHERE tenant_id = $1`, [ref.tenantId]); + fiscalYearId = await createFiscalYear(db, ref, currentFiscalYearStart(todayIn(timezone), s?.fiscal_year_start_month ?? 1)); + } + return { chartCreated, fiscalYearCreated: fiscalYearId !== null }; +} diff --git a/alshuyukh-accounting/apps/api/src/modules/accounting/trial-balance.routes.ts b/alshuyukh-accounting/apps/api/src/modules/accounting/trial-balance.routes.ts new file mode 100644 index 000000000000..ad5c3bc5c2c9 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/accounting/trial-balance.routes.ts @@ -0,0 +1,70 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { isoDate } from '../../lib/dates.js'; +import { badRequest } from '../../lib/errors.js'; +import { Decimal, sum, toMoney } from '../../lib/money.js'; +import { parse } from '../../lib/validation.js'; +import { requirePermission } from '../../plugins/auth.js'; +import { resolveCompanyId } from './company-context.js'; + +/** + * Trial balance from posted journal lines only (reversed entries and their + * reversals both count, so they cancel out). Other reports arrive in Phase 7. + */ +export default async function trialBalanceRoutes(app: FastifyInstance) { + app.get('/reports/trial-balance', { preHandler: requirePermission(app, 'financial_report.view') }, async (req) => { + const q = parse(z.object({ + companyId: z.uuid().optional(), + dateFrom: isoDate, + dateTo: isoDate, + includeZero: z.enum(['true', 'false']).optional(), + branchId: z.uuid().optional(), + }), req.query); + if (q.dateFrom > q.dateTo) throw badRequest('INVALID_RANGE', 'dateFrom must be on or before dateTo'); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const { rows } = await db.query<{ + accountId: string; code: string; nameAr: string; type: string; + openingDebit: string; openingCredit: string; periodDebit: string; periodCredit: string; + }>( + `WITH movements AS ( + SELECT l.account_id, + sum(CASE WHEN e.entry_date < $3 THEN l.debit - l.credit ELSE 0 END) AS opening, + sum(CASE WHEN e.entry_date >= $3 THEN l.debit ELSE 0 END) AS period_debit, + sum(CASE WHEN e.entry_date >= $3 THEN l.credit ELSE 0 END) AS period_credit + FROM journal_entry_lines l JOIN journal_entries e ON e.id = l.journal_entry_id + WHERE e.tenant_id = $1 AND e.company_id = $2 AND e.status IN ('POSTED', 'REVERSED') + AND e.entry_date <= $4 AND ($6::uuid IS NULL OR l.branch_id = $6) + GROUP BY l.account_id) + SELECT a.id AS "accountId", a.code, a.name_ar AS "nameAr", a.account_type AS type, + GREATEST(COALESCE(m.opening, 0), 0)::numeric(18,2)::text AS "openingDebit", + GREATEST(-COALESCE(m.opening, 0), 0)::numeric(18,2)::text AS "openingCredit", + COALESCE(m.period_debit, 0)::numeric(18,2)::text AS "periodDebit", + COALESCE(m.period_credit, 0)::numeric(18,2)::text AS "periodCredit" + FROM accounts a LEFT JOIN movements m ON m.account_id = a.id + WHERE a.tenant_id = $1 AND a.company_id = $2 AND a.is_postable AND a.deleted_at IS NULL + AND ($5::boolean OR m.account_id IS NOT NULL) + ORDER BY a.code`, + [req.auth!.tenantId, companyId, q.dateFrom, q.dateTo, q.includeZero === 'true', q.branchId ?? null]); + + const data = rows.map((r) => { + const closing = new Decimal(r.openingDebit).minus(r.openingCredit).plus(r.periodDebit).minus(r.periodCredit); + return { + ...r, + closingDebit: toMoney(Decimal.max(closing, 0)), + closingCredit: toMoney(Decimal.max(closing.neg(), 0)), + }; + }); + const total = (k: keyof (typeof data)[number]) => toMoney(sum(data.map((r) => r[k] as string))); + const totals = { + openingDebit: total('openingDebit'), openingCredit: total('openingCredit'), + periodDebit: total('periodDebit'), periodCredit: total('periodCredit'), + closingDebit: total('closingDebit'), closingCredit: total('closingCredit'), + }; + return { + companyId, dateFrom: q.dateFrom, dateTo: q.dateTo, data, totals, + balanced: totals.openingDebit === totals.openingCredit && totals.periodDebit === totals.periodCredit && totals.closingDebit === totals.closingCredit, + }; + }); + }); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/admin/guard.ts b/alshuyukh-accounting/apps/api/src/modules/admin/guard.ts new file mode 100644 index 000000000000..54236b367a67 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/admin/guard.ts @@ -0,0 +1,40 @@ +import type { FastifyInstance, FastifyRequest, preHandlerAsyncHookHandler } from 'fastify'; +import { withTx, type Db } from '../../db/tx.js'; +import { forbidden } from '../../lib/errors.js'; + +/** + * Platform administration (the SaaS operator, not a tenant role). The flag is + * re-read from the database on every request by authenticate(); only then do + * admin transactions turn on app.platform_admin, which lets RLS show every + * tenant's rows read-only. Writes run in the target tenant's own context. + */ +export function requirePlatformAdmin(app: FastifyInstance): preHandlerAsyncHookHandler { + return async (req) => { + await app.authenticate(req); + if (!req.auth!.isPlatformAdmin) throw forbidden('Platform administrators only'); + }; +} + +export function platformTx(app: FastifyInstance, req: FastifyRequest, fn: (db: Db) => Promise, tenantId: string | null = null): Promise { + if (!req.auth?.isPlatformAdmin) throw forbidden('Platform administrators only'); + return withTx(app.deps.pool, { tenantId, userId: req.auth.userId }, async (db) => { + await db.query(`SELECT set_config('app.platform_admin', 'on', true)`); + return fn(db); + }); +} + +export async function platformAudit(db: Db, req: FastifyRequest, e: { + action: string; entityType: string; entityId?: string | null; targetTenantId?: string | null; oldValues?: unknown; newValues?: unknown; +}) { + await db.query( + `INSERT INTO platform_audit_logs (admin_user_id, action, entity_type, entity_id, target_tenant_id, old_values, new_values, ip_address, user_agent) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [req.auth!.userId, e.action, e.entityType, e.entityId ?? null, e.targetTenantId ?? null, + e.oldValues === undefined ? null : JSON.stringify(e.oldValues), e.newValues === undefined ? null : JSON.stringify(e.newValues), + req.ip, req.headers['user-agent'] ?? null]); +} + +/** Subscription state in SQL, matching viewOf() in the subscriptions service. */ +export const STATE_SQL = `CASE WHEN s.id IS NULL THEN 'NONE' + WHEN now() <= s.current_period_end THEN s.status + WHEN now() <= s.current_period_end + make_interval(days => p.grace_days) THEN 'GRACE' ELSE 'EXPIRED' END`; diff --git a/alshuyukh-accounting/apps/api/src/modules/admin/platform.routes.ts b/alshuyukh-accounting/apps/api/src/modules/admin/platform.routes.ts new file mode 100644 index 000000000000..c4c0ecaf5f99 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/admin/platform.routes.ts @@ -0,0 +1,262 @@ +import { readFileSync } from 'node:fs'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { badRequest, notFound } from '../../lib/errors.js'; +import { amountString } from '../../lib/money.js'; +import { optionalText, parse, uuidParam } from '../../lib/validation.js'; +import { PLAN_PUBLIC_SELECT } from '../subscriptions/routes.js'; +import { LIMIT_KEYS, monthStart } from '../subscriptions/service.js'; +import { platformAudit, platformTx, requirePlatformAdmin, STATE_SQL } from './guard.js'; + +const started = Date.now(); +const version = (() => { try { return JSON.parse(readFileSync(new URL('../../../package.json', import.meta.url), 'utf8')).version as string; } catch { return 'unknown'; } })(); + +/** Effective limit of a subscription row in SQL: an override key replaces the plan value (null = unlimited). */ +const limitSql = (k: string) => `CASE WHEN s.limit_overrides ? '${k}' THEN (s.limit_overrides->>'${k}')::int ELSE p.${k} END`; +/** Monthly value of a subscription: its items, yearly prices spread over 12 months. */ +const MONTHLY_VALUE = `COALESCE((SELECT sum(i.unit_price * i.quantity) FROM subscription_items i WHERE i.subscription_id = s.id), 0) + / CASE WHEN s.billing_cycle = 'YEARLY' THEN 12 ELSE 1 END`; + +const planFields = { + code: z.string().trim().toUpperCase().regex(/^[A-Z][A-Z0-9_]{1,30}$/), + nameAr: z.string().trim().min(2).max(100), nameEn: optionalText(100), description: optionalText(1000), + currency: z.string().regex(/^[A-Z]{3}$/).default('SAR'), + priceMonthly: amountString, priceYearly: amountString, + trialDays: z.number().int().min(0).max(365), graceDays: z.number().int().min(0).max(90), + ...Object.fromEntries(LIMIT_KEYS.map((k) => [k, z.number().int().positive().nullable()])) as Record<(typeof LIMIT_KEYS)[number], z.ZodNullable>, + isPublic: z.boolean(), isActive: z.boolean(), isDefault: z.boolean(), sortOrder: z.number().int().min(0).max(1000), +}; +const PLAN_COLUMNS: Record = { + code: 'code', nameAr: 'name_ar', nameEn: 'name_en', description: 'description', currency: 'currency', priceMonthly: 'price_monthly', + priceYearly: 'price_yearly', trialDays: 'trial_days', graceDays: 'grace_days', isPublic: 'is_public', isActive: 'is_active', + isDefault: 'is_default', sortOrder: 'sort_order', ...Object.fromEntries(LIMIT_KEYS.map((k) => [k, k])), +}; + +export default async function adminPlatformRoutes(app: FastifyInstance) { + const guard = requirePlatformAdmin(app); + + app.get('/admin/overview', { preHandler: guard }, async (req) => platformTx(app, req, async (db) => { + const { rows: [o] } = await db.query>( + `SELECT (SELECT count(*) FROM tenants WHERE deleted_at IS NULL) AS tenants, + (SELECT count(*) FROM tenants WHERE status = 'SUSPENDED' AND deleted_at IS NULL) AS suspended, + (SELECT count(*) FROM users WHERE deleted_at IS NULL) AS users, + (SELECT count(*) FROM tenants WHERE created_at >= now() - interval '30 days') AS "newTenants30d", + (SELECT count(*) FROM system_errors WHERE created_at >= now() - interval '24 hours') AS "errors24h", + (SELECT COALESCE(sum(amount), 0) FROM billing_events WHERE event_type = 'PAYMENT_RECORDED' AND created_at >= ($1::date::timestamp AT TIME ZONE 'Asia/Riyadh')) AS "revenueThisMonth"`, + [monthStart()]); + const { rows: states } = await db.query<{ state: string; count: string; mrr: string }>( + `SELECT ${STATE_SQL} AS state, count(*) AS count, COALESCE(sum(${MONTHLY_VALUE}), 0)::numeric(18,2)::text AS mrr + FROM subscriptions s JOIN plans p ON p.id = s.plan_id WHERE s.status <> 'CANCELLED' GROUP BY 1`); + const mrr = states.filter((s) => s.state === 'ACTIVE' || s.state === 'GRACE').reduce((a, s) => a + Number(s.mrr), 0); + return { + ...Object.fromEntries(Object.entries(o!).map(([k, v]) => [k, k === 'revenueThisMonth' ? Number(v).toFixed(2) : Number(v)])), + subscriptions: Object.fromEntries(states.map((s) => [s.state, Number(s.count)])), mrr: mrr.toFixed(2), + }; + })); + + // Users -------------------------------------------------------------------------------- + app.get('/admin/users', { preHandler: guard }, async (req) => { + const q = parse(z.object({ search: z.string().trim().max(100).optional(), limit: z.coerce.number().int().min(1).max(200).default(50), offset: z.coerce.number().int().min(0).default(0) }), req.query); + return platformTx(app, req, async (db) => { + const { rows } = await db.query<{ total: string }>( + `SELECT count(*) OVER () AS total, u.id, u.email, u.full_name AS "fullName", u.status, u.is_platform_admin AS "isPlatformAdmin", + (SELECT max(f.locked_until) FROM login_failures f WHERE f.user_id = u.id) AS "lockedUntil", + COALESCE((SELECT sum(f.failures) FROM login_failures f WHERE f.user_id = u.id), 0)::int AS "failedLogins", u.last_login_at AS "lastLoginAt", u.created_at AS "createdAt", + COALESCE((SELECT json_agg(json_build_object('tenantId', t.id, 'name', t.name, 'isOwner', ut.is_owner) ORDER BY t.name) + FROM user_tenants ut JOIN tenants t ON t.id = ut.tenant_id WHERE ut.user_id = u.id), '[]') AS tenants + FROM users u WHERE u.deleted_at IS NULL AND ($1::text IS NULL OR u.email ILIKE '%' || $1 || '%' OR u.full_name ILIKE '%' || $1 || '%') + ORDER BY u.created_at DESC LIMIT $2 OFFSET $3`, [q.search || null, q.limit, q.offset]); + return { data: rows.map(({ total: _t, ...r }) => r), total: Number(rows[0]?.total ?? 0) }; + }); + }); + + const userAction = (path: string, body: z.ZodTypeAny, apply: (db: any, id: string, b: any, self: boolean) => Promise>) => + app.post(`/admin/users/:id/${path}`, { preHandler: guard }, async (req) => { + const { id } = parse(uuidParam, req.params); + const b = parse(body, req.body ?? {}); + return platformTx(app, req, async (db) => { + const { rows: [u] } = await db.query(`SELECT id, status, is_platform_admin FROM users WHERE id = $1 AND deleted_at IS NULL`, [id]); + if (!u) throw notFound('User'); + const changes = await apply(db, id, b, id === req.auth!.userId); + await platformAudit(db, req, { action: `USER_${path.toUpperCase().replace('-', '_')}`, entityType: 'user', entityId: id, oldValues: u, newValues: changes }); + return { id, ...changes }; + }); + }); + userAction('status', z.object({ status: z.enum(['ACTIVE', 'DISABLED']) }), async (db, id, b, self) => { + if (self && b.status === 'DISABLED') throw badRequest('SELF', 'You cannot disable your own account'); + await db.query(`UPDATE users SET status = $2 WHERE id = $1`, [id, b.status]); + return { status: b.status }; + }); + userAction('unlock', z.object({}), async (db, id) => { + await db.query(`DELETE FROM login_failures WHERE user_id = $1`, [id]); + return { unlocked: true }; + }); + userAction('platform-admin', z.object({ grant: z.boolean() }), async (db, id, b, self) => { + if (self && !b.grant) throw badRequest('SELF', 'You cannot remove your own platform access'); + await db.query(`UPDATE users SET is_platform_admin = $2 WHERE id = $1`, [id, b.grant]); + return { isPlatformAdmin: b.grant }; + }); + + // Plans -------------------------------------------------------------------------------- + app.get('/admin/plans', { preHandler: guard }, async (req) => platformTx(app, req, async (db) => ({ + data: (await db.query(`${PLAN_PUBLIC_SELECT.replace('FROM plans', `, (SELECT count(*) FROM subscriptions s WHERE s.plan_id = plans.id AND s.status <> 'CANCELLED')::int AS subscribers FROM plans`)} + ORDER BY sort_order, price_monthly`)).rows, + }))); + + const savePlan = async (req: Parameters[1], id: string | null, body: Record) => platformTx(app, req, async (db) => { + const before = id ? (await db.query(`SELECT * FROM plans WHERE id = $1`, [id])).rows[0] : null; + if (id && !before) throw notFound('Plan'); + const isDefault = body.isDefault ?? before?.is_default; + if (isDefault && (body.isActive ?? before?.is_active) === false) throw badRequest('DEFAULT_PLAN', 'The default plan must stay active'); + if (before?.is_default && body.isDefault === false) throw badRequest('DEFAULT_PLAN', 'Choose another default plan instead'); + if (body.isDefault === true) await db.query(`UPDATE plans SET is_default = false WHERE is_default AND id IS DISTINCT FROM $1`, [id]); + const entries = Object.entries(body).filter(([k, v]) => PLAN_COLUMNS[k] && v !== undefined); + let planId = id; + if (id) { + if (entries.length) await db.query(`UPDATE plans SET ${entries.map(([k], i) => `${PLAN_COLUMNS[k]} = $${i + 2}`).join(', ')} WHERE id = $1`, [id, ...entries.map(([, v]) => v)]); + } else { + const { rows: [p] } = await db.query<{ id: string }>( + `INSERT INTO plans (${entries.map(([k]) => PLAN_COLUMNS[k]).join(', ')}) VALUES (${entries.map((_, i) => `$${i + 1}`).join(', ')}) RETURNING id`, + entries.map(([, v]) => v)); + planId = p!.id; + } + const after = (await db.query(`${PLAN_PUBLIC_SELECT} WHERE id = $1`, [planId])).rows[0]; + await platformAudit(db, req, { action: id ? 'UPDATE' : 'CREATE', entityType: 'plan', entityId: planId, oldValues: before ?? undefined, newValues: after }); + return after; + }); + app.post('/admin/plans', { preHandler: guard }, async (req, reply) => { + const body = parse(z.object(planFields), req.body); + reply.code(201); + return savePlan(req, null, body); + }); + app.patch('/admin/plans/:id', { preHandler: guard }, async (req) => { + const { id } = parse(uuidParam, req.params); + // Prices changed here apply to new subscriptions and plan changes; current ones keep their snapshot. + return savePlan(req, id, parse(z.object(planFields).partial(), req.body)); + }); + + // Subscriptions, revenue, usage --------------------------------------------------------- + app.get('/admin/subscriptions', { preHandler: guard }, async (req) => { + const q = parse(z.object({ state: z.enum(['TRIALING', 'ACTIVE', 'GRACE', 'EXPIRED']).optional() }), req.query); + return platformTx(app, req, async (db) => ({ + data: (await db.query( + `SELECT * FROM (SELECT s.id, s.tenant_id AS "tenantId", t.name AS "tenantName", t.status AS "tenantStatus", p.name_ar AS "planName", + s.billing_cycle AS "billingCycle", ${STATE_SQL} AS state, s.current_period_end AS "periodEnd", + (${MONTHLY_VALUE})::numeric(18,2)::text AS "monthlyValue" + FROM subscriptions s JOIN plans p ON p.id = s.plan_id JOIN tenants t ON t.id = s.tenant_id + WHERE s.status <> 'CANCELLED' AND t.deleted_at IS NULL) x + WHERE ($1::text IS NULL OR state = $1) ORDER BY "periodEnd"`, [q.state ?? null])).rows, + })); + }); + + app.get('/admin/revenue', { preHandler: guard }, async (req) => platformTx(app, req, async (db) => { + const { rows: monthly } = await db.query( + `SELECT to_char(created_at AT TIME ZONE 'Asia/Riyadh', 'YYYY-MM') AS month, sum(amount)::numeric(18,2)::text AS amount, count(*)::int AS payments + FROM billing_events WHERE event_type = 'PAYMENT_RECORDED' AND created_at >= now() - interval '12 months' GROUP BY 1 ORDER BY 1`); + const { rows: byPlan } = await db.query( + `SELECT p.name_ar AS "planName", count(*)::int AS subscriptions, sum(${MONTHLY_VALUE})::numeric(18,2)::text AS mrr + FROM subscriptions s JOIN plans p ON p.id = s.plan_id + WHERE s.status <> 'CANCELLED' AND ${STATE_SQL} IN ('ACTIVE', 'GRACE') GROUP BY p.name_ar ORDER BY 3 DESC`); + const { rows: payments } = await db.query( + `SELECT b.id, b.tenant_id AS "tenantId", t.name AS "tenantName", b.amount::text, b.currency, b.reference, b.created_at AS "createdAt" + FROM billing_events b JOIN tenants t ON t.id = b.tenant_id WHERE b.event_type = 'PAYMENT_RECORDED' ORDER BY b.created_at DESC LIMIT 50`); + return { monthly, byPlan, payments }; + })); + + app.get('/admin/usage', { preHandler: guard }, async (req) => platformTx(app, req, async (db) => ({ + data: (await db.query( + `SELECT t.id AS "tenantId", t.name AS "tenantName", p.name_ar AS "planName", + (SELECT count(*) FROM user_tenants ut WHERE ut.tenant_id = t.id AND ut.status = 'ACTIVE')::int AS users, ${limitSql('max_users')} AS "maxUsers", + (SELECT count(*) FROM companies c WHERE c.tenant_id = t.id AND c.deleted_at IS NULL)::int AS companies, ${limitSql('max_companies')} AS "maxCompanies", + (SELECT count(*) FROM products x WHERE x.tenant_id = t.id AND x.deleted_at IS NULL)::int AS products, ${limitSql('max_products')} AS "maxProducts", + (SELECT count(*) FROM sales_invoices i WHERE i.tenant_id = t.id AND i.status <> 'DRAFT' AND i.issued_at >= ($1::date::timestamp AT TIME ZONE 'Asia/Riyadh'))::int AS invoices, + ${limitSql('max_invoices_per_month')} AS "maxInvoices", + COALESCE((SELECT quantity FROM usage_records u WHERE u.tenant_id = t.id AND u.metric = 'API_CALLS' AND u.period = $1::date), 0)::int AS "apiCalls", + ${limitSql('max_api_calls_per_month')} AS "maxApiCalls" + FROM tenants t LEFT JOIN subscriptions s ON s.tenant_id = t.id AND s.status <> 'CANCELLED' LEFT JOIN plans p ON p.id = s.plan_id + WHERE t.deleted_at IS NULL ORDER BY "apiCalls" DESC, t.name LIMIT 500`, [monthStart()])).rows, + }))); + + // Logs and errors ------------------------------------------------------------------------ + app.get('/admin/audit-logs', { preHandler: guard }, async (req) => { + const q = parse(z.object({ tenantId: z.uuid().optional(), action: z.string().regex(/^[A-Z_]+$/).optional(), limit: z.coerce.number().int().min(1).max(200).default(100), offset: z.coerce.number().int().min(0).default(0) }), req.query); + return platformTx(app, req, async (db) => ({ + data: (await db.query( + `SELECT a.id, a.tenant_id AS "tenantId", t.name AS "tenantName", u.email AS "userEmail", a.action, a.entity_type AS "entityType", + a.entity_id AS "entityId", a.ip_address AS "ipAddress", a.created_at AS "createdAt" + FROM audit_logs a JOIN tenants t ON t.id = a.tenant_id LEFT JOIN users u ON u.id = a.user_id + WHERE ($1::uuid IS NULL OR a.tenant_id = $1) AND ($2::text IS NULL OR a.action = $2) + ORDER BY a.created_at DESC LIMIT $3 OFFSET $4`, [q.tenantId ?? null, q.action ?? null, q.limit, q.offset])).rows, + })); + }); + + app.get('/admin/platform-logs', { preHandler: guard }, async (req) => platformTx(app, req, async (db) => ({ + data: (await db.query( + `SELECT l.id, u.email AS "adminEmail", l.action, l.entity_type AS "entityType", l.entity_id AS "entityId", l.target_tenant_id AS "tenantId", + t.name AS "tenantName", l.new_values AS "newValues", l.created_at AS "createdAt" + FROM platform_audit_logs l JOIN users u ON u.id = l.admin_user_id LEFT JOIN tenants t ON t.id = l.target_tenant_id + ORDER BY l.created_at DESC LIMIT 200`)).rows, + }))); + + app.get('/admin/errors', { preHandler: guard }, async (req) => platformTx(app, req, async (db) => ({ + data: (await db.query( + `SELECT e.id, e.tenant_id AS "tenantId", t.name AS "tenantName", e.request_id AS "requestId", e.method, e.path, e.status_code AS "statusCode", + e.error_code AS "errorCode", e.message, e.stack, e.created_at AS "createdAt" + FROM system_errors e LEFT JOIN tenants t ON t.id = e.tenant_id ORDER BY e.created_at DESC LIMIT 200`)).rows, + }))); + + // Feature flags -------------------------------------------------------------------------- + app.get('/admin/feature-flags', { preHandler: guard }, async (req) => platformTx(app, req, async (db) => ({ + data: (await db.query( + `SELECT f.key, f.name_ar AS "nameAr", f.description, f.enabled, + (SELECT count(*) FROM tenant_feature_flags t WHERE t.flag_key = f.key)::int AS overrides + FROM feature_flags f ORDER BY f.key`)).rows, + }))); + app.post('/admin/feature-flags', { preHandler: guard }, async (req, reply) => { + const body = parse(z.object({ key: z.string().regex(/^[a-z][a-z0-9_]{1,62}$/), nameAr: z.string().trim().min(2).max(100), description: optionalText(500), enabled: z.boolean().default(false) }), req.body); + const flag = await platformTx(app, req, async (db) => { + await db.query(`INSERT INTO feature_flags (key, name_ar, description, enabled) VALUES ($1, $2, $3, $4)`, [body.key, body.nameAr, body.description ?? null, body.enabled]); + await platformAudit(db, req, { action: 'CREATE', entityType: 'feature_flag', entityId: body.key, newValues: body }); + return body; + }); + reply.code(201); + return flag; + }); + app.patch('/admin/feature-flags/:key', { preHandler: guard }, async (req) => { + const { key } = parse(z.object({ key: z.string().regex(/^[a-z][a-z0-9_]{1,62}$/) }), req.params); + const body = parse(z.object({ nameAr: z.string().trim().min(2).max(100), description: optionalText(500), enabled: z.boolean() }).partial(), req.body); + return platformTx(app, req, async (db) => { + const { rows: [before] } = await db.query(`SELECT key, name_ar, description, enabled FROM feature_flags WHERE key = $1`, [key]); + if (!before) throw notFound('Feature flag'); + await db.query(`UPDATE feature_flags SET name_ar = COALESCE($2, name_ar), description = COALESCE($3, description), enabled = COALESCE($4, enabled) WHERE key = $1`, + [key, body.nameAr ?? null, body.description ?? null, body.enabled ?? null]); + await platformAudit(db, req, { action: 'UPDATE', entityType: 'feature_flag', entityId: key, oldValues: before, newValues: body }); + return { key, ...body }; + }); + }); + + // System health -------------------------------------------------------------------------- + app.get('/admin/health', { preHandler: guard }, async (req) => { + const t0 = performance.now(); + await app.deps.pool.query('SELECT 1'); + const dbLatencyMs = Math.round((performance.now() - t0) * 10) / 10; + return platformTx(app, req, async (db) => { + const { rows: [d] } = await db.query>( + `SELECT version() AS "postgres", pg_database_size(current_database()) AS "databaseBytes", + (SELECT count(*) FROM schema_migrations) AS migrations, (SELECT max(version) FROM schema_migrations) AS "lastMigration", + (SELECT count(*) FROM zatca_invoices WHERE status = 'PENDING') AS "zatcaPending", + (SELECT count(*) FROM zatca_invoices WHERE status = 'PENDING' AND invoice_kind = 'SIMPLIFIED' AND created_at < now() - interval '24 hours') AS "zatcaOverdue", + (SELECT count(*) FROM system_errors WHERE created_at >= now() - interval '24 hours') AS "errors24h"`); + const pool = app.deps.pool; + const mem = process.memoryUsage(); + return { + status: 'ok', version, node: process.version, uptimeSeconds: Math.round((Date.now() - started) / 1000), + memoryMb: { rss: Math.round(mem.rss / 1048576), heapUsed: Math.round(mem.heapUsed / 1048576) }, + database: { latencyMs: dbLatencyMs, postgres: d!.postgres!.split(' on ')[0], sizeMb: Math.round(Number(d!.databaseBytes) / 1048576), + migrations: Number(d!.migrations), lastMigration: d!.lastMigration, pool: { total: pool.totalCount, idle: pool.idleCount, waiting: pool.waitingCount } }, + zatca: { pending: Number(d!.zatcaPending), overdue: Number(d!.zatcaOverdue), worker: app.deps.env.ZATCA_WORKER }, + errors24h: Number(d!.errors24h), + }; + }); + }); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/admin/tenants.routes.ts b/alshuyukh-accounting/apps/api/src/modules/admin/tenants.routes.ts new file mode 100644 index 000000000000..ec86573c66fb --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/admin/tenants.routes.ts @@ -0,0 +1,203 @@ +import { randomBytes, randomUUID } from 'node:crypto'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { badRequest, notFound } from '../../lib/errors.js'; +import { amountString, Decimal, toMoney } from '../../lib/money.js'; +import { hashPassword } from '../../lib/password.js'; +import { parse, uuidParam } from '../../lib/validation.js'; +import { writeAudit } from '../audit/audit.service.js'; +import { provisionTenant } from '../auth/auth.service.js'; +import { + addPeriod, billingEvent, changePlan, currentSubscription, featuresOf, LIMIT_KEYS, startSubscription, usageOf, +} from '../subscriptions/service.js'; +import { platformAudit, platformTx, requirePlatformAdmin, STATE_SQL } from './guard.js'; + +const limitOverrides = z.object(Object.fromEntries(LIMIT_KEYS.map((k) => [k, z.number().int().positive().nullable().optional()]))).strict(); + +/** Organizations (tenants) and their subscriptions, for the platform administrator. */ +export default async function adminTenantRoutes(app: FastifyInstance) { + const guard = requirePlatformAdmin(app); + + app.get('/admin/tenants', { preHandler: guard }, async (req) => { + const q = parse(z.object({ + search: z.string().trim().max(100).optional(), status: z.enum(['ACTIVE', 'SUSPENDED', 'CANCELLED']).optional(), + limit: z.coerce.number().int().min(1).max(200).default(50), offset: z.coerce.number().int().min(0).default(0), + }), req.query); + return platformTx(app, req, async (db) => { + const { rows } = await db.query<{ total: string }>( + `SELECT count(*) OVER () AS total, t.id, t.name, t.slug, t.status, t.created_at AS "createdAt", + (SELECT u.email FROM user_tenants ut JOIN users u ON u.id = ut.user_id WHERE ut.tenant_id = t.id AND ut.is_owner ORDER BY ut.created_at LIMIT 1) AS "ownerEmail", + p.name_ar AS "planName", ${STATE_SQL} AS "subscriptionState", s.current_period_end AS "periodEnd", + (SELECT count(*) FROM user_tenants ut WHERE ut.tenant_id = t.id AND ut.status = 'ACTIVE')::int AS users, + (SELECT count(*) FROM companies c WHERE c.tenant_id = t.id AND c.deleted_at IS NULL)::int AS companies + FROM tenants t + LEFT JOIN subscriptions s ON s.tenant_id = t.id AND s.status <> 'CANCELLED' + LEFT JOIN plans p ON p.id = s.plan_id + WHERE t.deleted_at IS NULL AND ($1::text IS NULL OR t.name ILIKE '%' || $1 || '%' OR t.slug ILIKE '%' || $1 || '%' + OR EXISTS (SELECT 1 FROM user_tenants ut JOIN users u ON u.id = ut.user_id WHERE ut.tenant_id = t.id AND u.email ILIKE '%' || $1 || '%')) + AND ($2::text IS NULL OR t.status = $2) + ORDER BY t.created_at DESC LIMIT $3 OFFSET $4`, + [q.search || null, q.status ?? null, q.limit, q.offset]); + return { data: rows.map(({ total: _t, ...r }) => r), total: Number(rows[0]?.total ?? 0) }; + }); + }); + + app.get('/admin/tenants/:id', { preHandler: guard }, async (req) => { + const { id } = parse(uuidParam, req.params); + return platformTx(app, req, async (db) => { + const { rows: [t] } = await db.query(`SELECT id, name, slug, status, created_at AS "createdAt" FROM tenants WHERE id = $1 AND deleted_at IS NULL`, [id]); + if (!t) throw notFound('Tenant'); + const sub = await currentSubscription(db, id); + const [items, events, members, companies, overrides] = await Promise.all([ + db.query(`SELECT code, description, quantity, unit_price::text AS "unitPrice", currency FROM subscription_items WHERE subscription_id = $1`, [sub.id]), + db.query(`SELECT b.id, b.event_type AS "eventType", b.amount::text, b.currency, b.reference, b.details, b.created_at AS "createdAt", u.email AS "byEmail" + FROM billing_events b LEFT JOIN users u ON u.id = b.created_by WHERE b.tenant_id = $1 ORDER BY b.created_at DESC LIMIT 50`, [id]), + db.query(`SELECT u.id, u.email, u.full_name AS "fullName", u.status AS "userStatus", ut.status AS "memberStatus", ut.is_owner AS "isOwner", u.last_login_at AS "lastLoginAt" + FROM user_tenants ut JOIN users u ON u.id = ut.user_id WHERE ut.tenant_id = $1 ORDER BY ut.is_owner DESC, u.email`, [id]), + db.query(`SELECT id, name, vat_number AS "vatNumber", commercial_registration AS "commercialRegistration", city FROM companies WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY created_at`, [id]), + db.query(`SELECT flag_key AS key, enabled FROM tenant_feature_flags WHERE tenant_id = $1`, [id]), + ]); + return { + ...t, subscription: { ...sub, items: items.rows }, usage: await usageOf(db, id), billingEvents: events.rows, members: members.rows, + companies: companies.rows, features: await featuresOf(db, id), featureOverrides: overrides.rows, + }; + }); + }); + + /** Creates an organization with its owner; the temporary password is shown once. */ + app.post('/admin/tenants', { preHandler: guard }, async (req, reply) => { + const body = parse(z.object({ + tenantName: z.string().trim().min(2).max(200), companyName: z.string().trim().min(2).max(200), + ownerName: z.string().trim().min(2).max(200), ownerEmail: z.string().trim().toLowerCase().pipe(z.email().max(254)), + planId: z.uuid().nullish(), + }), req.body); + const tenantId = randomUUID(); + const temporaryPassword = `${randomBytes(9).toString('base64url')}A1!`; + const passwordHash = await hashPassword(temporaryPassword); + await platformTx(app, req, async (db) => { + await provisionTenant(db, { + tenantId, passwordHash, slug: `org-${tenantId.slice(0, 8)}`, tenantName: body.tenantName, companyName: body.companyName, + fullName: body.ownerName, email: body.ownerEmail, password: '', mustChangePassword: true, planId: body.planId ?? null, createdBy: req.auth!.userId, + }, req.auditMeta()); + await platformAudit(db, req, { action: 'CREATE', entityType: 'tenant', entityId: tenantId, targetTenantId: tenantId, newValues: { ...body } }); + }, tenantId); + reply.code(201); + return { tenantId, ownerEmail: body.ownerEmail, temporaryPassword }; + }); + + for (const [action, status, event] of [['suspend', 'SUSPENDED', 'TENANT_SUSPENDED'], ['activate', 'ACTIVE', 'TENANT_ACTIVATED']] as const) { + app.post(`/admin/tenants/:id/${action}`, { preHandler: guard }, async (req) => { + const { id } = parse(uuidParam, req.params); + const { reason } = parse(z.object({ reason: z.string().trim().min(3).max(500) }), req.body); + if (action === 'suspend' && id === req.auth!.tenantId) throw badRequest('OWN_TENANT', 'You cannot suspend the organization you are signed in to'); + return platformTx(app, req, async (db) => { + const { rows: [t] } = await db.query<{ status: string }>(`SELECT status FROM tenants WHERE id = $1 AND deleted_at IS NULL`, [id]); + if (!t) throw notFound('Tenant'); + await db.query(`UPDATE tenants SET status = $2 WHERE id = $1`, [id, status]); + const sub = await currentSubscription(db, id); + await billingEvent(db, { tenantId: id, subscriptionId: sub.id, type: event, userId: req.auth!.userId, details: { reason } }); + await writeAudit(db, { tenantId: id, userId: req.auth!.userId, action: 'SETTINGS_CHANGE', entityType: 'tenant', entityId: id, oldValues: { status: t.status }, newValues: { status, reason, by: 'platform' } }, req.auditMeta()); + await platformAudit(db, req, { action: action.toUpperCase(), entityType: 'tenant', entityId: id, targetTenantId: id, oldValues: { status: t.status }, newValues: { status, reason } }); + return { id, status }; + }, id); + }); + } + + /** Change plan (or start a new subscription when none is current). */ + app.post('/admin/tenants/:id/subscription/plan', { preHandler: guard }, async (req) => { + const { id } = parse(uuidParam, req.params); + const body = parse(z.object({ planId: z.uuid(), billingCycle: z.enum(['MONTHLY', 'YEARLY']).default('MONTHLY') }), req.body); + return platformTx(app, req, async (db) => { + const before = await currentSubscription(db, id); + const after = before.id ? await changePlan(db, id, req.auth!.userId, body.planId, body.billingCycle) + : (await startSubscription(db, id, req.auth!.userId, body.planId), await currentSubscription(db, id)); + await platformAudit(db, req, { action: 'PLAN_CHANGE', entityType: 'subscription', entityId: after.id, targetTenantId: id, oldValues: { plan: before.planCode }, newValues: { plan: after.planCode, billingCycle: body.billingCycle } }); + return after; + }, id); + }); + + /** Per-tenant limits that replace the plan's (null = unlimited); the whole object is replaced. */ + app.post('/admin/tenants/:id/subscription/limits', { preHandler: guard }, async (req) => { + const { id } = parse(uuidParam, req.params); + const { overrides } = parse(z.object({ overrides: limitOverrides }), req.body); + return platformTx(app, req, async (db) => { + const sub = await currentSubscription(db, id, true); + if (!sub.id) throw notFound('Subscription'); + await db.query(`UPDATE subscriptions SET limit_overrides = $2 WHERE id = $1`, [sub.id, JSON.stringify(overrides)]); + await billingEvent(db, { tenantId: id, subscriptionId: sub.id, type: 'LIMITS_CHANGED', userId: req.auth!.userId, details: { from: sub.limitOverrides, to: overrides } }); + await platformAudit(db, req, { action: 'LIMITS_CHANGE', entityType: 'subscription', entityId: sub.id, targetTenantId: id, oldValues: sub.limitOverrides, newValues: overrides }); + return currentSubscription(db, id); + }, id); + }); + + /** + * Records a payment received outside the system (bank transfer…) and extends + * the period by whole billing cycles. Online payment is not integrated. + */ + app.post('/admin/tenants/:id/subscription/payment', { preHandler: guard }, async (req) => { + const { id } = parse(uuidParam, req.params); + const body = parse(z.object({ amount: amountString, reference: z.string().trim().min(2).max(100), cycles: z.number().int().min(1).max(36).default(1) }), req.body); + if (!new Decimal(body.amount).greaterThan(0)) throw badRequest('INVALID_AMOUNT', 'Amount must be greater than zero'); + return platformTx(app, req, async (db) => { + const sub = await currentSubscription(db, id, true); + if (!sub.id) throw badRequest('NO_SUBSCRIPTION', 'Choose a plan for this organization first'); + // An expired or trial subscription starts paying from today; an active one + // (or one in its grace days) continues from the end of its period. + const restart = sub.state === 'EXPIRED' || sub.status === 'TRIALING'; + const end = addPeriod(restart ? new Date() : sub.periodEnd!, sub.billingCycle as 'MONTHLY' | 'YEARLY', body.cycles); + await db.query( + `UPDATE subscriptions SET status = 'ACTIVE', current_period_start = CASE WHEN $3 THEN now() ELSE current_period_start END, current_period_end = $2 WHERE id = $1`, + [sub.id, end, restart]); + const { rows: [p] } = await db.query<{ currency: string }>(`SELECT currency FROM plans WHERE id = $1`, [sub.planId]); + await billingEvent(db, { tenantId: id, subscriptionId: sub.id, type: 'PAYMENT_RECORDED', userId: req.auth!.userId, amount: toMoney(body.amount), + currency: p!.currency, reference: body.reference, details: { cycles: body.cycles, billingCycle: sub.billingCycle, periodEnd: end } }); + await platformAudit(db, req, { action: 'PAYMENT', entityType: 'subscription', entityId: sub.id, targetTenantId: id, newValues: { ...body, periodEnd: end } }); + return currentSubscription(db, id); + }, id); + }); + + /** Extends the current period (e.g. a longer trial) without a payment. */ + app.post('/admin/tenants/:id/subscription/extend', { preHandler: guard }, async (req) => { + const { id } = parse(uuidParam, req.params); + const body = parse(z.object({ days: z.number().int().min(1).max(365), reason: z.string().trim().min(3).max(500) }), req.body); + return platformTx(app, req, async (db) => { + const sub = await currentSubscription(db, id, true); + if (!sub.id) throw badRequest('NO_SUBSCRIPTION', 'Choose a plan for this organization first'); + const base = sub.periodEnd! > new Date() ? sub.periodEnd! : new Date(); + const end = new Date(base.getTime() + body.days * 86_400_000); + await db.query(`UPDATE subscriptions SET current_period_end = $2 WHERE id = $1`, [sub.id, end]); + await billingEvent(db, { tenantId: id, subscriptionId: sub.id, type: 'PERIOD_EXTENDED', userId: req.auth!.userId, details: { days: body.days, reason: body.reason, periodEnd: end } }); + await platformAudit(db, req, { action: 'EXTEND', entityType: 'subscription', entityId: sub.id, targetTenantId: id, newValues: { ...body, periodEnd: end } }); + return currentSubscription(db, id); + }, id); + }); + + app.post('/admin/tenants/:id/subscription/cancel', { preHandler: guard }, async (req) => { + const { id } = parse(uuidParam, req.params); + const { reason } = parse(z.object({ reason: z.string().trim().min(3).max(500) }), req.body); + return platformTx(app, req, async (db) => { + const sub = await currentSubscription(db, id, true); + if (!sub.id) throw notFound('Subscription'); + await db.query(`UPDATE subscriptions SET status = 'CANCELLED', cancelled_at = now() WHERE id = $1`, [sub.id]); + await billingEvent(db, { tenantId: id, subscriptionId: sub.id, type: 'SUBSCRIPTION_CANCELLED', userId: req.auth!.userId, details: { reason } }); + await platformAudit(db, req, { action: 'CANCEL', entityType: 'subscription', entityId: sub.id, targetTenantId: id, newValues: { reason } }); + return currentSubscription(db, id); + }, id); + }); + + /** Per-tenant feature flag; enabled: null removes the override. */ + app.put('/admin/tenants/:id/features/:key', { preHandler: guard }, async (req) => { + const { id, key } = parse(z.object({ id: z.uuid(), key: z.string().regex(/^[a-z][a-z0-9_]{1,62}$/) }), req.params); + const { enabled } = parse(z.object({ enabled: z.boolean().nullable() }), req.body); + return platformTx(app, req, async (db) => { + const { rowCount } = await db.query(`SELECT 1 FROM feature_flags WHERE key = $1`, [key]); + if (!rowCount) throw notFound('Feature flag'); + if (enabled === null) await db.query(`DELETE FROM tenant_feature_flags WHERE tenant_id = $1 AND flag_key = $2`, [id, key]); + else await db.query( + `INSERT INTO tenant_feature_flags (tenant_id, flag_key, enabled) VALUES ($1, $2, $3) + ON CONFLICT (tenant_id, flag_key) DO UPDATE SET enabled = EXCLUDED.enabled, updated_at = now()`, [id, key, enabled]); + await platformAudit(db, req, { action: 'FEATURE_OVERRIDE', entityType: 'feature_flag', entityId: key, targetTenantId: id, newValues: { enabled } }); + return { features: await featuresOf(db, id) }; + }, id); + }); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/audit/audit.routes.ts b/alshuyukh-accounting/apps/api/src/modules/audit/audit.routes.ts new file mode 100644 index 000000000000..2ad49eb4ed10 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/audit/audit.routes.ts @@ -0,0 +1,55 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { parse } from '../../lib/validation.js'; +import { requirePermission } from '../../plugins/auth.js'; + +const query = z.object({ + action: z.string().regex(/^[A-Z_]+$/).optional(), + entityType: z.string().max(50).optional(), + entityId: z.uuid().optional(), + userId: z.uuid().optional(), + from: z.iso.datetime({ offset: true }).optional(), + to: z.iso.datetime({ offset: true }).optional(), + // Keyset pagination: pass the previous page's nextCursor. + cursor: z.string().regex(/^[^|]+\|[0-9a-f-]{36}$/).optional(), + limit: z.coerce.number().int().min(1).max(200).default(50), +}); + +export default async function auditRoutes(app: FastifyInstance) { + app.get('/audit-logs', { preHandler: requirePermission(app, 'audit.view') }, async (req) => { + const q = parse(query, req.query); + const where = ['a.tenant_id = $1']; + const params: unknown[] = [req.auth!.tenantId]; + const add = (sql: string, value: unknown) => { params.push(value); where.push(sql.replace('?', `$${params.length}`)); }; + if (q.action) add('a.action = ?', q.action); + if (q.entityType) add('a.entity_type = ?', q.entityType); + if (q.entityId) add('a.entity_id = ?', q.entityId); + if (q.userId) add('a.user_id = ?', q.userId); + if (q.from) add('a.created_at >= ?', q.from); + if (q.to) add('a.created_at <= ?', q.to); + if (q.cursor) { + const [ts, id] = q.cursor.split('|'); + params.push(ts, id); + where.push(`(a.created_at, a.id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)`); + } + params.push(q.limit + 1); + return req.tenantTx(async (db) => { + const { rows } = await db.query<{ id: string; createdAt: Date }>( + `SELECT a.id, a.action, a.entity_type AS "entityType", a.entity_id AS "entityId", + a.user_id AS "userId", u.full_name AS "userName", a.old_values AS "oldValues", + a.new_values AS "newValues", host(a.ip_address) AS "ipAddress", a.user_agent AS "userAgent", + a.created_at AS "createdAt" + FROM audit_logs a LEFT JOIN users u ON u.id = a.user_id + WHERE ${where.join(' AND ')} + ORDER BY a.created_at DESC, a.id DESC + LIMIT $${params.length}`, + params); + const page = rows.slice(0, q.limit); + const last = page[page.length - 1]; + return { + data: page, + nextCursor: rows.length > q.limit && last ? `${last.createdAt.toISOString()}|${last.id}` : null, + }; + }); + }); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/audit/audit.service.ts b/alshuyukh-accounting/apps/api/src/modules/audit/audit.service.ts new file mode 100644 index 000000000000..721d32c11dc6 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/audit/audit.service.ts @@ -0,0 +1,57 @@ +import type { Db } from '../../db/tx.js'; + +export type AuditAction = + | 'REGISTER' | 'LOGIN' | 'LOGIN_FAILED' | 'LOGOUT' | 'TOKEN_REUSE_DETECTED' + | 'TENANT_SWITCH' | 'PASSWORD_CHANGE' + | 'CREATE' | 'UPDATE' | 'DELETE' + | 'SETTINGS_CHANGE' | 'PERMISSION_CHANGE' + | 'POST' | 'REVERSE' | 'CLOSE' | 'REOPEN' | 'CANCEL' | 'PAYMENT' | 'VOID'; + +export interface AuditMeta { + ip?: string | null; + userAgent?: string | null; + requestId?: string | null; +} + +export interface AuditEntry { + tenantId: string | null; + userId: string | null; + action: AuditAction; + entityType?: string | null; + entityId?: string | null; + oldValues?: unknown; + newValues?: unknown; +} + +const SECRET_KEYS = new Set(['password', 'password_hash', 'passwordHash', 'token', 'token_hash']); + +function redact(value: unknown): unknown { + if (value === null || value === undefined) return null; + if (Array.isArray(value)) return value.map(redact); + if (typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .filter(([k]) => !SECRET_KEYS.has(k)) + .map(([k, v]) => [k, redact(v)]), + ); + } + return value; +} + +/** + * Writes an audit record inside the caller's transaction, so the audit row + * commits or rolls back together with the change it describes. + */ +export async function writeAudit(db: Db, entry: AuditEntry, meta: AuditMeta = {}): Promise { + await db.query( + `INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, + old_values, new_values, ip_address, user_agent, request_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + [ + entry.tenantId, entry.userId, entry.action, entry.entityType ?? null, entry.entityId ?? null, + entry.oldValues === undefined ? null : JSON.stringify(redact(entry.oldValues)), + entry.newValues === undefined ? null : JSON.stringify(redact(entry.newValues)), + meta.ip ?? null, meta.userAgent?.slice(0, 500) ?? null, meta.requestId ?? null, + ], + ); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/auth/auth.routes.ts b/alshuyukh-accounting/apps/api/src/modules/auth/auth.routes.ts new file mode 100644 index 000000000000..2e3fbd41f9c8 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/auth/auth.routes.ts @@ -0,0 +1,170 @@ +import { assertWithinLimit, currentSubscription, featuresOf } from '../subscriptions/service.js'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { z } from 'zod'; +import { withTx } from '../../db/tx.js'; +import { badRequest, notFound, unauthorized } from '../../lib/errors.js'; +import { writeAudit } from '../audit/audit.service.js'; +import { parse, password } from '../../lib/validation.js'; +import { requireAuth } from '../../plugins/auth.js'; +import { AuthService, type IssuedTokens } from './auth.service.js'; + +export const REFRESH_COOKIE = 'ash_rt'; +const CSRF_HEADER = 'x-csrf-protection'; + +const email = z.string().trim().toLowerCase().pipe(z.email().max(254)); + +const registerBody = z.object({ + fullName: z.string().trim().min(2).max(200), + email, + password, + tenantName: z.string().trim().min(2).max(200), + companyName: z.string().trim().min(2).max(200), + vatNumber: z.string().regex(/^3\d{13}3$/, 'Saudi VAT number must be 15 digits starting and ending with 3').nullish(), + commercialRegistration: z.string().regex(/^\d{10}$/, 'Commercial registration must be 10 digits').nullish(), +}); + +const loginBody = z.object({ + email, + password: z.string().min(1).max(200), + tenantId: z.uuid().optional(), +}); + +/** + * The refresh token travels only in an HttpOnly, SameSite=Strict cookie + * scoped to /api/auth. Endpoints that read it also require a custom header, + * which a cross-site form cannot send and which triggers a CORS preflight + * for scripts on other origins (CSRF protection). + */ +function requireCsrfHeader(req: FastifyRequest) { + if (req.headers[CSRF_HEADER] !== '1') throw badRequest('CSRF_HEADER_MISSING', `Missing ${CSRF_HEADER} header`); +} + +export default async function authRoutes(app: FastifyInstance) { + const service = new AuthService(app.deps); + const { env } = app.deps; + const isTest = env.NODE_ENV === 'test'; + // Credential endpoints: tight per-IP limit against password guessing. + const strictLimit = { rateLimit: { max: isTest ? 1000 : 10, timeWindow: '1 minute' } }; + // Refresh runs on every page load, and an office may share one IP. + const refreshLimit = { rateLimit: { max: isTest ? 1000 : 120, timeWindow: '1 minute' } }; + + const setRefreshCookie = (reply: FastifyReply, token: string) => + reply.setCookie(REFRESH_COOKIE, token, { + httpOnly: true, + secure: env.NODE_ENV === 'production', + sameSite: 'strict', + path: '/api/auth', + maxAge: env.REFRESH_TOKEN_TTL_DAYS * 86400, + }); + + const respond = async (reply: FastifyReply, t: IssuedTokens) => { + setRefreshCookie(reply, t.refreshToken); + return { accessToken: t.accessToken, expiresIn: t.expiresIn, tenantId: t.tenantId, userId: t.userId }; + }; + + app.post('/register', { config: strictLimit }, async (req, reply) => { + const body = parse(registerBody, req.body); + reply.code(201); + return respond(reply, await service.register(body, req.auditMeta())); + }); + + app.post('/login', { config: strictLimit }, async (req, reply) => { + const body = parse(loginBody, req.body); + return respond(reply, await service.login(body.email, body.password, body.tenantId, req.auditMeta())); + }); + + app.post('/refresh', { config: refreshLimit }, async (req, reply) => { + requireCsrfHeader(req); + const token = req.cookies[REFRESH_COOKIE]; + if (!token) throw unauthorized('Missing refresh token'); + try { + return await respond(reply, await service.refresh(token, req.auditMeta())); + } catch (err) { + reply.clearCookie(REFRESH_COOKIE, { path: '/api/auth' }); + throw err; + } + }); + + app.post('/logout', { preHandler: requireAuth(app) }, async (req, reply) => { + requireCsrfHeader(req); + const a = req.auth!; + await service.logout(a.sessionId, a.userId, a.tenantId, req.auditMeta()); + reply.clearCookie(REFRESH_COOKIE, { path: '/api/auth' }); + return reply.code(204).send(); + }); + + app.post('/switch-tenant', { preHandler: requireAuth(app) }, async (req) => { + const { tenantId } = parse(z.object({ tenantId: z.uuid() }), req.body); + const a = req.auth!; + return service.switchTenant(a.sessionId, a.userId, a.tenantId, tenantId, req.auditMeta()); + }); + + /** Organizations that invited the signed-in user (an existing account is never added without consent). */ + app.get('/invitations', { preHandler: requireAuth(app) }, async (req) => { + const a = req.auth!; + const ids = await withTx(app.deps.pool, { tenantId: a.tenantId, userId: a.userId }, async (db) => + (await db.query<{ tenant_id: string; invited_at: Date }>( + `SELECT tenant_id, joined_at AS invited_at FROM user_tenants WHERE user_id = $1 AND status = 'INVITED'`, [a.userId])).rows); + const data = []; + // The tenant's name is visible only in that tenant's own context. + for (const inv of ids) { + const name = await withTx(app.deps.pool, { tenantId: inv.tenant_id, userId: a.userId }, async (db) => + (await db.query<{ name: string }>(`SELECT name FROM tenants WHERE id = $1 AND status = 'ACTIVE' AND deleted_at IS NULL`, [inv.tenant_id])).rows[0]?.name); + if (name) data.push({ tenantId: inv.tenant_id, tenantName: name, invitedAt: inv.invited_at }); + } + return { data }; + }); + + for (const action of ['accept', 'decline'] as const) { + app.post(`/invitations/:tenantId/${action}`, { preHandler: requireAuth(app) }, async (req, reply) => { + const { tenantId } = parse(z.object({ tenantId: z.uuid() }), req.params); + const a = req.auth!; + await withTx(app.deps.pool, { tenantId, userId: a.userId }, async (db) => { + const { rows: [m] } = await db.query(`SELECT 1 FROM user_tenants WHERE tenant_id = $1 AND user_id = $2 AND status = 'INVITED' FOR UPDATE`, [tenantId, a.userId]); + if (!m) throw notFound('Invitation'); + if (action === 'accept') await assertWithinLimit(db, tenantId, 'max_users'); + await db.query(`UPDATE user_tenants SET status = $3 WHERE tenant_id = $1 AND user_id = $2`, [tenantId, a.userId, action === 'accept' ? 'ACTIVE' : 'DISABLED']); + await writeAudit(db, { tenantId, userId: a.userId, action: 'UPDATE', entityType: 'user', entityId: a.userId, newValues: { invitation: action === 'accept' ? 'ACCEPTED' : 'DECLINED' } }, req.auditMeta()); + }); + return reply.code(204).send(); + }); + } + + app.post('/change-password', { preHandler: requireAuth(app), config: strictLimit }, async (req, reply) => { + const body = parse(z.object({ currentPassword: z.string().min(1).max(200), newPassword: password }), req.body); + const a = req.auth!; + await service.changePassword(a.userId, a.tenantId, a.sessionId, body.currentPassword, body.newPassword, req.auditMeta()); + return reply.code(204).send(); + }); + + app.get('/me', { preHandler: requireAuth(app) }, async (req) => { + const a = req.auth!; + const profile = await withTx(app.deps.pool, { tenantId: a.tenantId, userId: a.userId }, async (db) => { + const { rows: [u] } = await db.query<{ id: string; email: string; full_name: string; must_change_password: boolean }>( + `SELECT id, email, full_name, must_change_password FROM users WHERE id = $1`, [a.userId]); + const { rows: roles } = await db.query<{ id: string; code: string; name_ar: string; name_en: string }>( + `SELECT r.id, r.code, r.name_ar, r.name_en FROM user_roles ur JOIN roles r ON r.id = ur.role_id + WHERE ur.tenant_id = $1 AND ur.user_id = $2 AND r.deleted_at IS NULL ORDER BY r.code`, + [a.tenantId, a.userId]); + const { rows: [tenant] } = await db.query<{ id: string; name: string; status: string }>( + `SELECT id, name, status FROM tenants WHERE id = $1`, [a.tenantId]); + const sub = await currentSubscription(db, a.tenantId); + return { u: u!, roles, tenant: tenant!, sub, features: await featuresOf(db, a.tenantId) }; + }); + return { + user: { + id: profile.u.id, email: profile.u.email, fullName: profile.u.full_name, + mustChangePassword: profile.u.must_change_password, isPlatformAdmin: a.isPlatformAdmin, + }, + tenant: { ...profile.tenant, isOwner: a.isOwner }, + roles: profile.roles.map((r) => ({ id: r.id, code: r.code, nameAr: r.name_ar, nameEn: r.name_en })), + permissions: [...a.permissions].sort(), + memberships: await service.listMemberships(a.userId), + subscription: { + state: profile.sub.state, writable: profile.sub.writable, planName: profile.sub.planName, + periodEnd: profile.sub.periodEnd, graceEnd: profile.sub.graceEnd, + }, + features: profile.features, + }; + }); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/auth/auth.service.ts b/alshuyukh-accounting/apps/api/src/modules/auth/auth.service.ts new file mode 100644 index 000000000000..69171f8f14ac --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/auth/auth.service.ts @@ -0,0 +1,334 @@ +import { startSubscription } from '../subscriptions/service.js'; +import { randomUUID } from 'node:crypto'; +import { withTx, type Db } from '../../db/tx.js'; +import { AppError, conflict, forbidden, unauthorized } from '../../lib/errors.js'; +import { getDummyHash, hashPassword, verifyPassword } from '../../lib/password.js'; +import { hashToken, newRefreshToken } from '../../lib/tokens.js'; +import type { Deps } from '../../types.js'; +import { setupCompanyAccounting } from '../accounting/setup.js'; +import { writeAudit, type AuditMeta } from '../audit/audit.service.js'; + +export interface Membership { + tenantId: string; + tenantName: string; + tenantStatus: string; + isOwner: boolean; +} + +export interface IssuedTokens { + accessToken: string; + refreshToken: string; + expiresIn: number; + tenantId: string; + userId: string; +} + +export interface RegisterInput { + fullName: string; + email: string; + password: string; + tenantName: string; + companyName: string; + vatNumber?: string | null; + commercialRegistration?: string | null; +} + +export class AuthService { + constructor(private readonly deps: Deps) {} + + /** Self-service sign-up: creates the user, tenant, default company, main branch and warehouse. */ + async register(input: RegisterInput, meta: AuditMeta): Promise { + const passwordHash = await hashPassword(input.password); + const tenantId = randomUUID(); + const slug = `org-${tenantId.slice(0, 8)}`; + + const userId = await withTx(this.deps.pool, { tenantId, userId: null }, (db) => + provisionTenant(db, { ...input, tenantId, passwordHash, slug }, meta)); + + return this.issueSession(userId, tenantId, meta); + } + + async login(email: string, password: string, tenantId: string | undefined, meta: AuditMeta): Promise { + const { env, pool } = this.deps; + + const ip = meta.ip ?? 'unknown'; + const user = await withTx(pool, {}, async (db) => { + const { rows } = await db.query<{ + id: string; password_hash: string; status: string; deleted_at: Date | null; locked_until: Date | null; + }>( + `SELECT u.id, u.password_hash, u.status, u.deleted_at, + (SELECT f.locked_until FROM login_failures f WHERE f.user_id = u.id AND f.ip = $2) AS locked_until + FROM users u WHERE u.email = $1`, + [email, ip], + ); + return rows[0]; + }); + + if (!user) { + await verifyPassword(await getDummyHash(), password); + await this.auditFailedLogin(null, email, 'unknown_email', meta); + throw unauthorized('Invalid e-mail or password'); + } + // The password is always checked (same timing) and every failure looks the + // same, so a lock reveals neither that the account exists nor that it is locked. + const ok = await verifyPassword(user.password_hash, password); + if (user.locked_until && user.locked_until > new Date()) { + await this.auditFailedLogin(user.id, email, 'locked', meta); + throw unauthorized('Invalid e-mail or password'); + } + if (!ok) { + // Locks only this account from this address; the owner can still sign in from elsewhere. + await withTx(pool, { userId: user.id }, async (db) => { + await db.query( + `INSERT INTO login_failures (user_id, ip, failures, locked_until) VALUES ($1, $2, 1, NULL) + ON CONFLICT (user_id, ip) DO UPDATE SET + failures = CASE WHEN login_failures.locked_until < now() THEN 1 ELSE login_failures.failures + 1 END, + locked_until = CASE WHEN (CASE WHEN login_failures.locked_until < now() THEN 1 ELSE login_failures.failures + 1 END) >= $3 + THEN now() + make_interval(mins => $4) ELSE NULL END, + updated_at = now()`, + [user.id, ip, env.LOGIN_MAX_ATTEMPTS, env.LOGIN_LOCK_MINUTES], + ); + }); + await this.auditFailedLogin(user.id, email, 'bad_password', meta); + throw unauthorized('Invalid e-mail or password'); + } + if (user.status !== 'ACTIVE' || user.deleted_at) { + await this.auditFailedLogin(user.id, email, 'disabled', meta); + throw unauthorized('Account is disabled'); + } + + const memberships = await this.listMemberships(user.id); + const active = memberships.filter((m) => m.tenantStatus === 'ACTIVE'); + const target = tenantId ? active.find((m) => m.tenantId === tenantId) : active[0]; + if (!target) { + await this.auditFailedLogin(user.id, email, 'no_active_membership', meta); + throw forbidden('No active organization is available for this account'); + } + + await withTx(pool, { userId: user.id }, (db) => + db.query(`WITH c AS (DELETE FROM login_failures WHERE user_id = $1 AND ip = $2) UPDATE users SET last_login_at = now() WHERE id = $1`, [user.id, ip]), + ); + return this.issueSession(user.id, target.tenantId, meta, 'LOGIN'); + } + + /** + * Rotates the refresh token. Presenting an already-rotated token means it + * was stolen or replayed, so every session of that user is revoked. + */ + async refresh(refreshToken: string, meta: AuditMeta): Promise { + const tokenHash = hashToken(refreshToken); + const { pool, env, tokens } = this.deps; + const newToken = newRefreshToken(); + + const result = await withTx(pool, {}, async (db) => { + const { rows: [s] } = await db.query<{ + id: string; user_id: string; tenant_id: string; expires_at: Date; revoked_at: Date | null; replaced_by: string | null; + }>( + `SELECT id, user_id, tenant_id, expires_at, revoked_at, replaced_by + FROM user_sessions WHERE token_hash = $1 FOR UPDATE`, + [tokenHash], + ); + if (!s) throw unauthorized('Invalid refresh token'); + if (s.revoked_at) { + // Handled after this transaction, because throwing here would roll back the revocation. + if (s.replaced_by) return { kind: 'reused', session: { id: s.id, user_id: s.user_id, tenant_id: s.tenant_id } } as const; + throw unauthorized('Invalid refresh token'); + } + if (s.expires_at < new Date()) throw unauthorized('Refresh token expired'); + + await db.query(`SELECT set_config('app.tenant_id', $1, true), set_config('app.user_id', $2, true)`, [s.tenant_id, s.user_id]); + await this.assertCanUseTenant(db, s.user_id, s.tenant_id); + + const { rows: [next] } = await db.query<{ id: string }>( + `INSERT INTO user_sessions (user_id, tenant_id, token_hash, expires_at, ip_address, user_agent) + VALUES ($1, $2, $3, now() + make_interval(days => $4), $5, $6) RETURNING id`, + [s.user_id, s.tenant_id, hashToken(newToken), env.REFRESH_TOKEN_TTL_DAYS, meta.ip ?? null, meta.userAgent ?? null], + ); + await db.query( + `UPDATE user_sessions SET revoked_at = now(), replaced_by = $2, last_used_at = now() WHERE id = $1`, + [s.id, next!.id], + ); + return { + kind: 'ok', + tokens: { + accessToken: await tokens.signAccess({ sub: s.user_id, tid: s.tenant_id, sid: next!.id }), + refreshToken: newToken, + expiresIn: env.ACCESS_TOKEN_TTL_SECONDS, + tenantId: s.tenant_id, + userId: s.user_id, + }, + } as const; + }); + + if (result.kind === 'reused') { + const s = result.session; + await withTx(pool, { tenantId: s.tenant_id, userId: s.user_id }, async (db) => { + await db.query(`UPDATE user_sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL`, [s.user_id]); + await writeAudit(db, { tenantId: s.tenant_id, userId: s.user_id, action: 'TOKEN_REUSE_DETECTED', entityType: 'session', entityId: s.id }, meta); + }); + throw unauthorized('Invalid refresh token'); + } + return result.tokens; + } + + async logout(sessionId: string, userId: string, tenantId: string, meta: AuditMeta): Promise { + await withTx(this.deps.pool, { tenantId, userId }, async (db) => { + await db.query(`UPDATE user_sessions SET revoked_at = now() WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL`, [sessionId, userId]); + await writeAudit(db, { tenantId, userId, action: 'LOGOUT', entityType: 'session', entityId: sessionId }, meta); + }); + } + + /** Moves the current session to another tenant the user belongs to. */ + async switchTenant(sessionId: string, userId: string, fromTenantId: string, toTenantId: string, meta: AuditMeta) { + const { pool, tokens, env } = this.deps; + await withTx(pool, { tenantId: toTenantId, userId }, async (db) => { + await this.assertCanUseTenant(db, userId, toTenantId); + await db.query(`UPDATE user_sessions SET tenant_id = $3 WHERE id = $1 AND user_id = $2`, [sessionId, userId, toTenantId]); + await writeAudit(db, { + tenantId: toTenantId, userId, action: 'TENANT_SWITCH', entityType: 'tenant', entityId: toTenantId, + oldValues: { tenantId: fromTenantId }, newValues: { tenantId: toTenantId }, + }, meta); + }); + return { + accessToken: await tokens.signAccess({ sub: userId, tid: toTenantId, sid: sessionId }), + expiresIn: env.ACCESS_TOKEN_TTL_SECONDS, + tenantId: toTenantId, + }; + } + + async changePassword(userId: string, tenantId: string, sessionId: string, current: string, next: string, meta: AuditMeta) { + const { pool } = this.deps; + const hash = await withTx(pool, { userId }, async (db) => + (await db.query<{ password_hash: string }>('SELECT password_hash FROM users WHERE id = $1', [userId])).rows[0]?.password_hash); + if (!hash || !(await verifyPassword(hash, current))) throw badCurrentPassword(); + if (current === next) throw new AppError(400, 'PASSWORD_REUSED', 'New password must differ from the current password'); + const newHash = await hashPassword(next); + await withTx(pool, { tenantId, userId }, async (db) => { + await db.query( + `UPDATE users SET password_hash = $2, password_changed_at = now(), must_change_password = false WHERE id = $1`, + [userId, newHash], + ); + // Sign out every other device. + await db.query(`UPDATE user_sessions SET revoked_at = now() WHERE user_id = $1 AND id <> $2 AND revoked_at IS NULL`, [userId, sessionId]); + await writeAudit(db, { tenantId, userId, action: 'PASSWORD_CHANGE', entityType: 'user', entityId: userId }, meta); + }); + } + + async listMemberships(userId: string): Promise { + return withTx(this.deps.pool, { userId }, async (db) => { + const { rows } = await db.query<{ tenant_id: string; name: string; status: string; is_owner: boolean }>( + `SELECT t.id AS tenant_id, t.name, t.status, ut.is_owner + FROM user_tenants ut JOIN tenants t ON t.id = ut.tenant_id + WHERE ut.user_id = $1 AND ut.status = 'ACTIVE' AND t.deleted_at IS NULL + ORDER BY ut.joined_at`, + [userId], + ); + return rows.map((r) => ({ tenantId: r.tenant_id, tenantName: r.name, tenantStatus: r.status, isOwner: r.is_owner })); + }); + } + + private async assertCanUseTenant(db: Db, userId: string, tenantId: string): Promise { + const { rows: [r] } = await db.query<{ member_status: string; tenant_status: string; user_status: string }>( + `SELECT ut.status AS member_status, t.status AS tenant_status, u.status AS user_status + FROM user_tenants ut JOIN tenants t ON t.id = ut.tenant_id JOIN users u ON u.id = ut.user_id + WHERE ut.user_id = $1 AND ut.tenant_id = $2 AND u.deleted_at IS NULL`, + [userId, tenantId], + ); + // Not-a-member and no-such-tenant look identical to the caller. + if (!r || r.member_status !== 'ACTIVE' || r.user_status !== 'ACTIVE') throw forbidden('No access to this organization'); + if (r.tenant_status !== 'ACTIVE') throw new AppError(403, 'TENANT_SUSPENDED', 'This organization is suspended'); + } + + private async issueSession(userId: string, tenantId: string, meta: AuditMeta, auditAction?: 'LOGIN'): Promise { + const { pool, env, tokens } = this.deps; + const refreshToken = newRefreshToken(); + const sessionId = await withTx(pool, { tenantId, userId }, async (db) => { + const { rows: [s] } = await db.query<{ id: string }>( + `INSERT INTO user_sessions (user_id, tenant_id, token_hash, expires_at, ip_address, user_agent) + VALUES ($1, $2, $3, now() + make_interval(days => $4), $5, $6) RETURNING id`, + [userId, tenantId, hashToken(refreshToken), env.REFRESH_TOKEN_TTL_DAYS, meta.ip ?? null, meta.userAgent ?? null], + ); + if (auditAction) { + await writeAudit(db, { tenantId, userId, action: auditAction, entityType: 'session', entityId: s!.id }, meta); + } + return s!.id; + }); + return { + accessToken: await tokens.signAccess({ sub: userId, tid: tenantId, sid: sessionId }), + refreshToken, + expiresIn: env.ACCESS_TOKEN_TTL_SECONDS, + tenantId, + userId, + }; + } + + private async auditFailedLogin(userId: string | null, email: string, reason: string, meta: AuditMeta) { + await withTx(this.deps.pool, { userId }, (db) => + writeAudit(db, { tenantId: null, userId, action: 'LOGIN_FAILED', newValues: { email, reason } }, meta)); + } +} + +const badCurrentPassword = () => new AppError(400, 'INVALID_CURRENT_PASSWORD', 'Current password is incorrect'); + +export interface ProvisionInput extends RegisterInput { + tenantId: string; passwordHash: string; slug: string; + /** Admin-created owners must change the temporary password at first sign-in. */ + mustChangePassword?: boolean; + planId?: string | null; + createdBy?: string | null; +} + +/** + * Creates the owner user, the tenant, its default company, main branch, + * warehouse, chart of accounts and subscription. Used by self-service sign-up + * and by the platform administrator. Runs in the caller's transaction. + */ +export async function provisionTenant(db: Db, input: ProvisionInput, meta: AuditMeta): Promise { + const { tenantId, passwordHash, slug } = input; + // Lets RLS accept the organization's first subscription rows (see migration 0014). + await db.query(`SELECT set_config('app.provisioning', 'on', true)`); + const existing = await db.query('SELECT 1 FROM users WHERE email = $1', [input.email]); + if (existing.rowCount) throw conflict('EMAIL_TAKEN', 'An account with this e-mail already exists'); + + const { rows: [user] } = await db.query<{ id: string }>( + `INSERT INTO users (email, password_hash, full_name, must_change_password) VALUES ($1, $2, $3, $4) RETURNING id`, + [input.email, passwordHash, input.fullName, input.mustChangePassword ?? false], + ); + const uid = user!.id; + await db.query(`SELECT set_config('app.user_id', $1, true)`, [uid]); + + await db.query(`INSERT INTO tenants (id, name, slug) VALUES ($1, $2, $3)`, [tenantId, input.tenantName, slug]); + await db.query(`INSERT INTO tenant_settings (tenant_id) VALUES ($1)`, [tenantId]); + await db.query( + `INSERT INTO user_tenants (tenant_id, user_id, is_owner) VALUES ($1, $2, true)`, + [tenantId, uid], + ); + await db.query( + `INSERT INTO user_roles (tenant_id, user_id, role_id, created_by) + SELECT $1, $2, id, $2 FROM roles WHERE code = 'TENANT_OWNER' AND tenant_id IS NULL`, + [tenantId, uid], + ); + const { rows: [company] } = await db.query<{ id: string }>( + `INSERT INTO companies (tenant_id, name, legal_name, vat_number, commercial_registration, created_by) + VALUES ($1, $2, $2, $3, $4, $5) RETURNING id`, + [tenantId, input.companyName, input.vatNumber ?? null, input.commercialRegistration ?? null, uid], + ); + const { rows: [branch] } = await db.query<{ id: string }>( + `INSERT INTO branches (tenant_id, company_id, code, name, is_main, created_by) + VALUES ($1, $2, 'MAIN', 'الفرع الرئيسي', true, $3) RETURNING id`, + [tenantId, company!.id, uid], + ); + await db.query( + `INSERT INTO warehouses (tenant_id, company_id, branch_id, code, name, created_by) + VALUES ($1, $2, $3, 'MAIN', 'المستودع الرئيسي', $4)`, + [tenantId, company!.id, branch!.id, uid], + ); + await setupCompanyAccounting(db, { tenantId, companyId: company!.id, userId: uid }); + // Every new organization starts on the default plan (a trial when the plan has trial days). + await startSubscription(db, tenantId, input.createdBy ?? uid, input.planId ?? null); + await writeAudit(db, { + tenantId, userId: uid, action: 'REGISTER', entityType: 'tenant', entityId: tenantId, + newValues: { tenantName: input.tenantName, companyName: input.companyName, email: input.email }, + }, meta); + return uid; +} diff --git a/alshuyukh-accounting/apps/api/src/modules/companies/companies.routes.ts b/alshuyukh-accounting/apps/api/src/modules/companies/companies.routes.ts new file mode 100644 index 000000000000..0b82b42f2a84 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/companies/companies.routes.ts @@ -0,0 +1,263 @@ +import { assertWithinLimit } from '../subscriptions/service.js'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import type { Db } from '../../db/tx.js'; +import { conflict, notFound } from '../../lib/errors.js'; +import { optionalText, parse, timezone, uuidParam } from '../../lib/validation.js'; +import { requirePermission } from '../../plugins/auth.js'; +import { setupCompanyAccounting } from '../accounting/setup.js'; +import { writeAudit } from '../audit/audit.service.js'; + +const companyFields = { + name: z.string().trim().min(2).max(200), + legalName: optionalText(200), + commercialRegistration: z.string().regex(/^\d{10}$/, 'Commercial registration must be 10 digits').nullish(), + vatNumber: z.string().regex(/^3\d{13}3$/, 'Saudi VAT number must be 15 digits starting and ending with 3').nullish(), + address: optionalText(500), + city: optionalText(100), + // Saudi national address, required for e-invoicing. + buildingNumber: z.string().regex(/^\d{4}$/, 'Building number must be 4 digits').nullish(), + street: optionalText(200), + district: optionalText(200), + postalCode: z.string().regex(/^\d{5}$/, 'Postal code must be 5 digits').nullish(), + additionalNumber: z.string().regex(/^\d{4}$/, 'Additional number must be 4 digits').nullish(), + country: z.string().regex(/^[A-Z]{2}$/).optional(), + currency: z.string().regex(/^[A-Z]{3}$/).optional(), + timezone: timezone.optional(), + // TODO(Storage): logo upload endpoint. For now only an https URL is accepted. + logoUrl: z.url({ protocol: /^https$/ }).max(1000).nullish(), + email: z.string().trim().toLowerCase().pipe(z.email().max(254)).nullish(), + phone: z.string().regex(/^\+?[0-9 ()-]{6,20}$/).nullish(), +}; +const companyCreate = z.object(companyFields); +const companyUpdate = z.object({ ...companyFields, isActive: z.boolean() }).partial(); + +const COMPANY_COLUMNS: Record = { + name: 'name', legalName: 'legal_name', commercialRegistration: 'commercial_registration', + vatNumber: 'vat_number', address: 'address', city: 'city', country: 'country', currency: 'currency', + buildingNumber: 'building_number', street: 'street', district: 'district', postalCode: 'postal_code', additionalNumber: 'additional_number', + timezone: 'timezone', logoUrl: 'logo_url', email: 'email', phone: 'phone', isActive: 'is_active', +}; + +const COMPANY_SELECT = `SELECT id, name, legal_name AS "legalName", commercial_registration AS "commercialRegistration", + vat_number AS "vatNumber", address, city, building_number AS "buildingNumber", street, district, + postal_code AS "postalCode", additional_number AS "additionalNumber", country, currency, timezone, logo_url AS "logoUrl", email, phone, + is_active AS "isActive", created_at AS "createdAt", updated_at AS "updatedAt" FROM companies`; + +const branchFields = { + code: z.string().regex(/^[A-Za-z0-9_-]{1,20}$/), + name: z.string().trim().min(2).max(200), + address: optionalText(500), + city: optionalText(100), + phone: z.string().regex(/^\+?[0-9 ()-]{6,20}$/).nullish(), +}; +const BRANCH_COLUMNS: Record = { code: 'code', name: 'name', address: 'address', city: 'city', phone: 'phone', isActive: 'is_active' }; +const BRANCH_SELECT = `SELECT id, company_id AS "companyId", code, name, address, city, phone, is_main AS "isMain", + is_active AS "isActive", created_at AS "createdAt", updated_at AS "updatedAt" FROM branches`; + +const warehouseFields = { + code: z.string().regex(/^[A-Za-z0-9_-]{1,20}$/), + name: z.string().trim().min(2).max(200), + branchId: z.uuid().nullish(), + address: optionalText(500), +}; +const WAREHOUSE_COLUMNS: Record = { code: 'code', name: 'name', branchId: 'branch_id', address: 'address', isActive: 'is_active' }; +const WAREHOUSE_SELECT = `SELECT id, company_id AS "companyId", branch_id AS "branchId", code, name, address, + is_active AS "isActive", created_at AS "createdAt", updated_at AS "updatedAt" FROM warehouses`; + +/** Builds "col = $n" pairs only from whitelisted columns, so no input reaches SQL text. */ +function buildSet(body: Record, columns: Record, startAt: number) { + const sets: string[] = []; + const values: unknown[] = []; + for (const [key, col] of Object.entries(columns)) { + if (body[key] !== undefined) { + values.push(body[key]); + sets.push(`${col} = $${startAt + values.length - 1}`); + } + } + return { sets, values }; +} + +async function findOne(db: Db, select: string, tenantId: string, id: string): Promise { + const { rows } = await db.query(`${select} WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL`, [tenantId, id]); + return rows[0] as T | undefined; +} + +async function assertCompany(db: Db, tenantId: string, companyId: string) { + if (!(await findOne(db, COMPANY_SELECT, tenantId, companyId))) throw notFound('Company'); +} + +export default async function companiesRoutes(app: FastifyInstance) { + const canView = requirePermission(app, 'company.view'); + const canManage = requirePermission(app, 'company.manage'); + + // Companies --------------------------------------------------------------- + app.get('/companies', { preHandler: canView }, async (req) => + req.tenantTx(async (db) => { + const { rows } = await db.query(`${COMPANY_SELECT} WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY created_at`, [req.auth!.tenantId]); + return { data: rows }; + })); + + app.get('/companies/:id', { preHandler: canView }, async (req) => { + const { id } = parse(uuidParam, req.params); + const company = await req.tenantTx((db) => findOne(db, COMPANY_SELECT, req.auth!.tenantId, id)); + if (!company) throw notFound('Company'); + return company; + }); + + app.post('/companies', { preHandler: canManage }, async (req, reply) => { + const body = parse(companyCreate, req.body); + const a = req.auth!; + const company = await req.tenantTx(async (db) => { + await assertWithinLimit(db, a.tenantId, 'max_companies'); + const { sets, values } = buildSet(body, COMPANY_COLUMNS, 4); + const cols = sets.map((s) => s.split(' = ')[0]); + const { rows: [c] } = await db.query<{ id: string }>( + `INSERT INTO companies (tenant_id, created_by, updated_by${cols.map((c) => `, ${c}`).join('')}) + VALUES ($1, $2, $3${values.map((_, i) => `, $${i + 4}`).join('')}) RETURNING id`, + [a.tenantId, a.userId, a.userId, ...values]); + const created = await findOne<{ timezone: string }>(db, COMPANY_SELECT, a.tenantId, c!.id); + await setupCompanyAccounting(db, { tenantId: a.tenantId, companyId: c!.id, userId: a.userId }, created!.timezone); + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'CREATE', entityType: 'company', entityId: c!.id, newValues: created }, req.auditMeta()); + return created; + }); + reply.code(201); + return company; + }); + + app.patch('/companies/:id', { preHandler: canManage }, async (req) => { + const { id } = parse(uuidParam, req.params); + const body = parse(companyUpdate, req.body); + const a = req.auth!; + return req.tenantTx(async (db) => { + const before = await findOne(db, COMPANY_SELECT, a.tenantId, id); + if (!before) throw notFound('Company'); + const { sets, values } = buildSet(body, COMPANY_COLUMNS, 4); + if (sets.length) { + await db.query(`UPDATE companies SET ${sets.join(', ')}, updated_by = $3 WHERE tenant_id = $1 AND id = $2`, + [a.tenantId, id, a.userId, ...values]); + } + const after = await findOne(db, COMPANY_SELECT, a.tenantId, id); + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'UPDATE', entityType: 'company', entityId: id, oldValues: before, newValues: after }, req.auditMeta()); + return after; + }); + }); + + app.delete('/companies/:id', { preHandler: canManage }, async (req, reply) => { + const { id } = parse(uuidParam, req.params); + const a = req.auth!; + await req.tenantTx(async (db) => { + const before = await findOne(db, COMPANY_SELECT, a.tenantId, id); + if (!before) throw notFound('Company'); + const { rows: [count] } = await db.query<{ n: string }>( + `SELECT count(*) AS n FROM companies WHERE tenant_id = $1 AND deleted_at IS NULL`, [a.tenantId]); + if (Number(count!.n) <= 1) throw conflict('LAST_COMPANY', 'An organization must keep at least one company'); + const posted = await db.query(`SELECT 1 FROM journal_entries WHERE company_id = $1 AND status <> 'DRAFT' LIMIT 1`, [id]); + if (posted.rowCount) throw conflict('COMPANY_HAS_LEDGER', 'A company with posted journal entries cannot be deleted. Deactivate it instead.'); + await db.query(`UPDATE companies SET deleted_at = now(), is_active = false, updated_by = $3 WHERE tenant_id = $1 AND id = $2`, [a.tenantId, id, a.userId]); + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'DELETE', entityType: 'company', entityId: id, oldValues: before }, req.auditMeta()); + }); + return reply.code(204).send(); + }); + + // Branches ---------------------------------------------------------------- + app.get('/companies/:id/branches', { preHandler: canView }, async (req) => { + const { id } = parse(uuidParam, req.params); + return req.tenantTx(async (db) => { + await assertCompany(db, req.auth!.tenantId, id); + const { rows } = await db.query(`${BRANCH_SELECT} WHERE tenant_id = $1 AND company_id = $2 AND deleted_at IS NULL ORDER BY is_main DESC, code`, [req.auth!.tenantId, id]); + return { data: rows }; + }); + }); + + app.post('/companies/:id/branches', { preHandler: canManage }, async (req, reply) => { + const { id: companyId } = parse(uuidParam, req.params); + const body = parse(z.object(branchFields), req.body); + const a = req.auth!; + const branch = await req.tenantTx(async (db) => { + await assertWithinLimit(db, a.tenantId, 'max_branches'); + await assertCompany(db, a.tenantId, companyId); + const { rows: [b] } = await db.query<{ id: string }>( + `INSERT INTO branches (tenant_id, company_id, code, name, address, city, phone, created_by, updated_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) RETURNING id`, + [a.tenantId, companyId, body.code, body.name, body.address ?? null, body.city ?? null, body.phone ?? null, a.userId]); + const created = await findOne(db, BRANCH_SELECT, a.tenantId, b!.id); + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'CREATE', entityType: 'branch', entityId: b!.id, newValues: created }, req.auditMeta()); + return created; + }); + reply.code(201); + return branch; + }); + + app.patch('/branches/:id', { preHandler: canManage }, async (req) => { + const { id } = parse(uuidParam, req.params); + const body = parse(z.object({ ...branchFields, isActive: z.boolean() }).partial(), req.body); + const a = req.auth!; + return req.tenantTx(async (db) => { + const before = await findOne(db, BRANCH_SELECT, a.tenantId, id); + if (!before) throw notFound('Branch'); + const { sets, values } = buildSet(body, BRANCH_COLUMNS, 4); + if (sets.length) { + await db.query(`UPDATE branches SET ${sets.join(', ')}, updated_by = $3 WHERE tenant_id = $1 AND id = $2`, [a.tenantId, id, a.userId, ...values]); + } + const after = await findOne(db, BRANCH_SELECT, a.tenantId, id); + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'UPDATE', entityType: 'branch', entityId: id, oldValues: before, newValues: after }, req.auditMeta()); + return after; + }); + }); + + // Warehouses -------------------------------------------------------------- + // Stock itself is handled by the Inventory Engine (Phase 5). + app.get('/companies/:id/warehouses', { preHandler: canView }, async (req) => { + const { id } = parse(uuidParam, req.params); + return req.tenantTx(async (db) => { + await assertCompany(db, req.auth!.tenantId, id); + const { rows } = await db.query(`${WAREHOUSE_SELECT} WHERE tenant_id = $1 AND company_id = $2 AND deleted_at IS NULL ORDER BY code`, [req.auth!.tenantId, id]); + return { data: rows }; + }); + }); + + app.post('/companies/:id/warehouses', { preHandler: canManage }, async (req, reply) => { + const { id: companyId } = parse(uuidParam, req.params); + const body = parse(z.object(warehouseFields), req.body); + const a = req.auth!; + const warehouse = await req.tenantTx(async (db) => { + await assertWithinLimit(db, a.tenantId, 'max_warehouses'); + await assertCompany(db, a.tenantId, companyId); + if (body.branchId) { + const b = await db.query(`SELECT 1 FROM branches WHERE tenant_id = $1 AND id = $2 AND company_id = $3 AND deleted_at IS NULL`, [a.tenantId, body.branchId, companyId]); + if (!b.rowCount) throw notFound('Branch'); + } + const { rows: [w] } = await db.query<{ id: string }>( + `INSERT INTO warehouses (tenant_id, company_id, branch_id, code, name, address, created_by, updated_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $7) RETURNING id`, + [a.tenantId, companyId, body.branchId ?? null, body.code, body.name, body.address ?? null, a.userId]); + const created = await findOne(db, WAREHOUSE_SELECT, a.tenantId, w!.id); + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'CREATE', entityType: 'warehouse', entityId: w!.id, newValues: created }, req.auditMeta()); + return created; + }); + reply.code(201); + return warehouse; + }); + + app.patch('/warehouses/:id', { preHandler: canManage }, async (req) => { + const { id } = parse(uuidParam, req.params); + const body = parse(z.object({ ...warehouseFields, isActive: z.boolean() }).partial(), req.body); + const a = req.auth!; + return req.tenantTx(async (db) => { + const before = await findOne<{ companyId: string }>(db, WAREHOUSE_SELECT, a.tenantId, id); + if (!before) throw notFound('Warehouse'); + if (body.branchId) { + const b = await db.query(`SELECT 1 FROM branches WHERE tenant_id = $1 AND id = $2 AND company_id = $3 AND deleted_at IS NULL`, [a.tenantId, body.branchId, before.companyId]); + if (!b.rowCount) throw notFound('Branch'); + } + const { sets, values } = buildSet(body, WAREHOUSE_COLUMNS, 4); + if (sets.length) { + await db.query(`UPDATE warehouses SET ${sets.join(', ')}, updated_by = $3 WHERE tenant_id = $1 AND id = $2`, [a.tenantId, id, a.userId, ...values]); + } + const after = await findOne(db, WAREHOUSE_SELECT, a.tenantId, id); + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'UPDATE', entityType: 'warehouse', entityId: id, oldValues: before, newValues: after }, req.auditMeta()); + return after; + }); + }); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/documents/calc.ts b/alshuyukh-accounting/apps/api/src/modules/documents/calc.ts new file mode 100644 index 000000000000..fba28e7b5905 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/documents/calc.ts @@ -0,0 +1,142 @@ +import { Decimal, toMoney } from '../../lib/money.js'; + +/** + * Line and document totals for every commercial document (quotes, invoices, + * returns, purchase orders and bills). Pure functions: no database access. + * + * Rounding (ZATCA-consistent): + * - Each line is rounded to 2 decimals: gross, discount, taxable (net). + * - Document VAT is computed once per (category, rate) group: + * round(sum of line taxable amounts × rate, 2) + * Line VAT amounts are informative and may differ from the document VAT + * by a halala; the document VAT is what is posted and reported. + * - When prices include VAT, the line taxable amount is + * round((qty × price − discount) / (1 + rate), 2) + * so all stored line amounts are VAT-exclusive. + */ + +export type VatCategory = 'S' | 'Z' | 'E' | 'O'; + +export interface CalcLineInput { + quantity: string; // up to 4 decimals + unitPrice: string; // up to 4 decimals, in the price basis of the document + discountAmount?: string; // in the price basis, up to 2 decimals + discountPercent?: string; // 0–100, up to 2 decimals + vatCategory: VatCategory; + vatRate: string; // fraction, e.g. "0.15" +} + +export interface CalcLine { + quantity: string; + unitPrice: string; + grossAmount: string; // VAT-exclusive, before discount + discountAmount: string; // VAT-exclusive + netAmount: string; // taxable amount + vatCategory: VatCategory; + vatRate: string; + vatAmount: string; // informative line VAT + totalAmount: string; // net + line VAT +} + +export interface CalcTotals { + subtotal: string; + discountTotal: string; + taxableAmount: string; + taxAmount: string; + total: string; + taxBreakdown: { vatCategory: VatCategory; vatRate: string; taxableAmount: string; taxAmount: string }[]; +} + +export class CalcError extends Error { + constructor(public readonly line: number, message: string) { + super(`Line ${line}: ${message}`); + } +} + +const round2 = (d: Decimal) => d.toDecimalPlaces(2, Decimal.ROUND_HALF_UP); + +export function calculateLine(input: CalcLineInput, pricesIncludeVat: boolean, lineNo: number): CalcLine { + const qty = new Decimal(input.quantity); + const price = new Decimal(input.unitPrice); + const rate = new Decimal(input.vatRate); + if (!qty.greaterThan(0)) throw new CalcError(lineNo, 'quantity must be greater than zero'); + if (price.isNegative()) throw new CalcError(lineNo, 'unit price cannot be negative'); + if (rate.isNegative() || rate.greaterThan(1)) throw new CalcError(lineNo, 'invalid VAT rate'); + if (input.vatCategory !== 'S' && !rate.isZero()) throw new CalcError(lineNo, `VAT category ${input.vatCategory} must have a zero rate`); + if (input.vatCategory === 'S' && rate.isZero()) throw new CalcError(lineNo, 'standard-rated lines need a positive VAT rate'); + + const grossBasis = round2(qty.times(price)); + let discountBasis = new Decimal(0); + if (input.discountAmount && input.discountPercent) throw new CalcError(lineNo, 'use either a discount amount or a discount percent'); + if (input.discountAmount) discountBasis = new Decimal(input.discountAmount); + if (input.discountPercent) { + const pct = new Decimal(input.discountPercent); + if (pct.isNegative() || pct.greaterThan(100)) throw new CalcError(lineNo, 'discount percent must be between 0 and 100'); + discountBasis = round2(grossBasis.times(pct).dividedBy(100)); + } + if (discountBasis.isNegative()) throw new CalcError(lineNo, 'discount cannot be negative'); + if (discountBasis.greaterThan(grossBasis)) throw new CalcError(lineNo, 'discount cannot exceed the line amount'); + + let gross: Decimal; + let discount: Decimal; + let net: Decimal; + if (pricesIncludeVat) { + const divisor = rate.plus(1); + net = round2(grossBasis.minus(discountBasis).dividedBy(divisor)); + discount = round2(discountBasis.dividedBy(divisor)); + gross = net.plus(discount); + } else { + gross = grossBasis; + discount = discountBasis; + net = gross.minus(discount); + } + const vat = round2(net.times(rate)); + return { + quantity: qty.toString(), + unitPrice: price.toString(), + grossAmount: toMoney(gross), + discountAmount: toMoney(discount), + netAmount: toMoney(net), + vatCategory: input.vatCategory, + vatRate: rate.toString(), + vatAmount: toMoney(vat), + totalAmount: toMoney(net.plus(vat)), + }; +} + +/** Document totals from already-calculated lines (also used for returns). */ +export function totalsOf(lines: Pick[]): CalcTotals { + const groups = new Map(); + let subtotal = new Decimal(0); + let discount = new Decimal(0); + for (const l of lines) { + subtotal = subtotal.plus(l.grossAmount); + discount = discount.plus(l.discountAmount); + const rate = new Decimal(l.vatRate); + const key = `${l.vatCategory}|${rate.toString()}`; + const g = groups.get(key) ?? { vatCategory: l.vatCategory, vatRate: rate, taxable: new Decimal(0) }; + g.taxable = g.taxable.plus(l.netAmount); + groups.set(key, g); + } + const taxBreakdown = [...groups.values()].map((g) => ({ + vatCategory: g.vatCategory, + vatRate: g.vatRate.toString(), + taxableAmount: toMoney(g.taxable), + taxAmount: toMoney(round2(g.taxable.times(g.vatRate))), + })); + const taxable = subtotal.minus(discount); + const tax = taxBreakdown.reduce((s, g) => s.plus(g.taxAmount), new Decimal(0)); + return { + subtotal: toMoney(subtotal), + discountTotal: toMoney(discount), + taxableAmount: toMoney(taxable), + taxAmount: toMoney(tax), + total: toMoney(taxable.plus(tax)), + taxBreakdown, + }; +} + +export function calculateDocument(lines: CalcLineInput[], pricesIncludeVat: boolean) { + const calculated = lines.map((l, i) => calculateLine(l, pricesIncludeVat, i + 1)); + return { lines: calculated, totals: totalsOf(calculated) }; +} diff --git a/alshuyukh-accounting/apps/api/src/modules/documents/drafts.ts b/alshuyukh-accounting/apps/api/src/modules/documents/drafts.ts new file mode 100644 index 000000000000..6fd8460b1a44 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/documents/drafts.ts @@ -0,0 +1,479 @@ +import type { Db } from '../../db/tx.js'; +import { AppError, badRequest, conflict, notFound } from '../../lib/errors.js'; +import { Decimal, toMoney } from '../../lib/money.js'; +import { nextDocumentNumber } from '../../lib/sequences.js'; +import { addDays } from '../../lib/dates.js'; +import { writeAudit, type AuditMeta } from '../audit/audit.service.js'; +import { CalcError, calculateLine, totalsOf, type CalcLine, type CalcTotals, type VatCategory } from './calc.js'; +import { KINDS, partyColumn, partyTable, type DocKind } from './kinds.js'; + +export interface Ctx { tenantId: string; userId: string; meta?: AuditMeta } + +export interface LineInput { + productId?: string | null; + accountId?: string | null; + description?: string | null; + quantity: string; + unitPrice?: string; + discountAmount?: string; + discountPercent?: string; + unitId?: string | null; + vatCategory?: VatCategory; +} + +export interface DraftInput { + companyId: string; + partyId: string; + docDate: string; + dueDate?: string | null; + validUntil?: string | null; + expectedDate?: string | null; + supplierInvoiceNumber?: string | null; + branchId?: string | null; + warehouseId?: string | null; + pricesIncludeVat?: boolean; + notes?: string | null; + sourceId?: string | null; + lines: LineInput[]; +} + +export interface ReturnInput { + originalInvoiceId: string; + docDate: string; + reason: string; + notes?: string | null; + lines: { sourceItemId: string; quantity: string }[]; +} + +export interface BuiltLine extends CalcLine { + productId: string | null; + accountId: string | null; + sourceItemId: string | null; + description: string | null; + unitId: string | null; + discountBasis: string; +} + +/** The standard VAT rate effective on `date`. Z, E and O are always 0. */ +export async function vatRateFor(db: Db, companyId: string, category: VatCategory, date: string): Promise { + if (category !== 'S') return '0'; + const { rows: [r] } = await db.query<{ rate: string }>( + `SELECT rate::text FROM tax_rates + WHERE company_id = $1 AND vat_category = 'S' AND is_active + AND effective_from <= $2 AND (effective_to IS NULL OR effective_to >= $2)`, + [companyId, date]); + if (!r) throw conflict('TAX_RATE_MISSING', `No standard VAT rate is configured for ${date}`); + return r.rate; +} + +const calcError = (e: unknown) => { + if (e instanceof CalcError) return badRequest('INVALID_LINE', e.message); + return e; +}; + +/** Turns line input into calculated lines, filling defaults from the product. */ +export async function buildLines(db: Db, kind: DocKind, companyId: string, input: Pick): Promise { + if (!input.lines.length) throw badRequest('NO_LINES', 'A document needs at least one line'); + const includeVat = input.pricesIncludeVat ?? false; + const productIds = [...new Set(input.lines.map((l) => l.productId).filter(Boolean))] as string[]; + const { rows: products } = await db.query<{ + id: string; name_ar: string; unit_id: string; sale_price: string; sale_price_includes_vat: boolean; + purchase_price: string; vat_category: VatCategory; is_active: boolean; + }>( + `SELECT id, name_ar, unit_id, sale_price::text, sale_price_includes_vat, purchase_price::text, vat_category, is_active + FROM products WHERE company_id = $1 AND id = ANY($2::uuid[]) AND deleted_at IS NULL`, + [companyId, productIds]); + const byId = new Map(products.map((p) => [p.id, p])); + + const accountIds = [...new Set(input.lines.map((l) => l.accountId).filter(Boolean))] as string[]; + if (accountIds.length) { + if (kind.party === 'customer') throw badRequest('INVALID_LINE', 'Sales lines must reference a product'); + const { rows } = await db.query<{ id: string }>( + `SELECT id FROM accounts WHERE company_id = $1 AND id = ANY($2::uuid[]) AND deleted_at IS NULL AND is_active AND is_postable + AND account_type IN ('ASSET', 'EXPENSE', 'COST_OF_GOODS_SOLD')`, [companyId, accountIds]); + if (rows.length !== accountIds.length) throw badRequest('INVALID_ACCOUNT', 'Purchase lines need an active postable asset, expense or cost account'); + } + const unitIds = [...new Set(input.lines.map((l) => l.unitId).filter(Boolean))] as string[]; + if (unitIds.length) { + const { rowCount } = await db.query(`SELECT 1 FROM units WHERE company_id = $1 AND id = ANY($2::uuid[])`, [companyId, unitIds]); + if (rowCount !== unitIds.length) throw badRequest('INVALID_UNIT', 'Unit does not exist in this company'); + } + + const built: BuiltLine[] = []; + for (const [i, l] of input.lines.entries()) { + const n = i + 1; + const product = l.productId ? byId.get(l.productId) : undefined; + if (l.productId && !product) throw badRequest('INVALID_PRODUCT', `Line ${n}: product does not exist in this company`); + if (product && !product.is_active) throw badRequest('PRODUCT_INACTIVE', `Line ${n}: product is inactive`); + if (!product && !l.accountId) throw badRequest('INVALID_LINE', `Line ${n}: choose a product${kind.party === 'supplier' ? ' or an account' : ''}`); + + const category: VatCategory = product?.vat_category ?? l.vatCategory ?? 'S'; + const rate = await vatRateFor(db, companyId, category, input.docDate); + let unitPrice = l.unitPrice; + if (unitPrice === undefined) { + if (!product) throw badRequest('INVALID_LINE', `Line ${n}: unitPrice is required`); + if (kind.party === 'customer') { + // Convert the product's list price to the document's price basis. + let p = new Decimal(product.sale_price); + if (product.sale_price_includes_vat && !includeVat) p = p.dividedBy(new Decimal(rate).plus(1)); + if (!product.sale_price_includes_vat && includeVat) p = p.times(new Decimal(rate).plus(1)); + unitPrice = p.toDecimalPlaces(4).toString(); + } else { + unitPrice = new Decimal(product.purchase_price).toString(); + } + } + try { + const calc = calculateLine({ + quantity: l.quantity, unitPrice, discountAmount: l.discountAmount, discountPercent: l.discountPercent, + vatCategory: category, vatRate: rate, + }, includeVat, n); + const discountBasis = l.discountPercent + ? toMoney(new Decimal(l.quantity).times(unitPrice).toDecimalPlaces(2).times(l.discountPercent).dividedBy(100)) + : toMoney(l.discountAmount ?? '0'); + built.push({ + ...calc, + productId: product?.id ?? null, + accountId: l.accountId ?? null, + sourceItemId: null, + description: l.description ?? product?.name_ar ?? null, + unitId: l.unitId ?? product?.unit_id ?? null, + discountBasis, + }); + } catch (e) { + throw calcError(e); + } + } + return built; +} + +interface OriginalItem { + id: string; line_no: number; product_id: string | null; account_id: string | null; description: string | null; + quantity: string; unit_id: string | null; unit_price: string; discount_basis: string; gross_amount: string; + discount_amount: string; net_amount: string; vat_category: VatCategory; vat_rate: string; + returned_qty: string; returned_net: string; returned_discount: string; returned_basis: string; +} + +/** + * Computes a return from the original invoice lines. Amounts are prorated by + * quantity; returning the last remaining quantity of a line takes exactly + * what is left, and the last return of an invoice takes exactly the VAT that + * is left, so a full return always nets the invoice to zero. + */ +export async function buildReturn(db: Db, kind: DocKind, tenantId: string, input: ReturnInput, excludeReturnId: string | null) { + const orig = KINDS[kind.originalKind!]; + const { rows: [invoice] } = await db.query<{ + id: string; company_id: string; status: string; party_id: string; tax_amount: string; prices_include_vat: boolean; + branch_id: string | null; warehouse_id: string | null; doc_date: string; + }>( + `SELECT id, company_id, status, ${partyColumn(kind)} AS party_id, tax_amount::text, prices_include_vat, branch_id, warehouse_id, + to_char(doc_date, 'YYYY-MM-DD') AS doc_date + FROM ${orig.table} WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL`, + [tenantId, input.originalInvoiceId]); + if (!invoice) throw notFound(orig.label); + if (invoice.status === 'DRAFT' || invoice.status === 'CANCELLED') throw conflict('INVOICE_NOT_ISSUED', 'Only issued invoices can be returned'); + if (input.docDate < invoice.doc_date) throw badRequest('INVALID_DATE', 'A return cannot be dated before its invoice'); + + const { rows: items } = await db.query( + `SELECT i.id, i.line_no, i.product_id, i.account_id, i.description, i.quantity::text, i.unit_id, i.unit_price::text, + i.discount_basis::text, i.gross_amount::text, i.discount_amount::text, i.net_amount::text, i.vat_category, i.vat_rate::text, + COALESCE(r.qty, 0)::text AS returned_qty, COALESCE(r.net, 0)::text AS returned_net, + COALESCE(r.discount, 0)::text AS returned_discount, COALESCE(r.basis, 0)::text AS returned_basis + FROM ${orig.itemsTable} i + LEFT JOIN LATERAL ( + SELECT sum(ri.quantity) AS qty, sum(ri.net_amount) AS net, sum(ri.discount_amount) AS discount, sum(ri.discount_basis) AS basis + FROM ${kind.itemsTable} ri JOIN ${kind.table} rd ON rd.id = ri.document_id + WHERE ri.source_item_id = i.id AND rd.status = 'ISSUED' AND rd.id IS DISTINCT FROM $2 + ) r ON true + WHERE i.document_id = $1 ORDER BY i.line_no`, + [invoice.id, excludeReturnId]); + const byId = new Map(items.map((i) => [i.id, i])); + const { rows: [prior] } = await db.query<{ tax: string }>( + `SELECT COALESCE(sum(tax_amount), 0)::text AS tax FROM ${kind.table} + WHERE original_invoice_id = $1 AND status = 'ISSUED' AND id IS DISTINCT FROM $2`, [invoice.id, excludeReturnId]); + + if (!input.lines.length) throw badRequest('NO_LINES', 'Choose at least one line to return'); + const seen = new Set(); + const lines: BuiltLine[] = input.lines.map((l, idx) => { + const n = idx + 1; + const o = byId.get(l.sourceItemId); + if (!o) throw badRequest('INVALID_LINE', `Line ${n}: the line does not belong to the original invoice`); + if (seen.has(o.id)) throw badRequest('INVALID_LINE', `Line ${n}: the same invoice line appears twice`); + seen.add(o.id); + const q = new Decimal(l.quantity); + const remaining = new Decimal(o.quantity).minus(o.returned_qty); + if (!q.greaterThan(0)) throw badRequest('INVALID_LINE', `Line ${n}: quantity must be greater than zero`); + if (q.greaterThan(remaining)) { + throw badRequest('RETURN_EXCEEDS_INVOICE', `Line ${n}: only ${remaining.toString()} can still be returned`); + } + let net: Decimal; let discount: Decimal; let basis: Decimal; + if (q.equals(remaining)) { + net = new Decimal(o.net_amount).minus(o.returned_net); + discount = new Decimal(o.discount_amount).minus(o.returned_discount); + basis = new Decimal(o.discount_basis).minus(o.returned_basis); + } else { + const share = q.dividedBy(o.quantity); + net = new Decimal(o.net_amount).times(share).toDecimalPlaces(2); + discount = new Decimal(o.discount_amount).times(share).toDecimalPlaces(2); + basis = new Decimal(o.discount_basis).times(share).toDecimalPlaces(2); + } + const vat = net.times(o.vat_rate).toDecimalPlaces(2); + return { + quantity: q.toString(), unitPrice: o.unit_price, grossAmount: toMoney(net.plus(discount)), discountAmount: toMoney(discount), + netAmount: toMoney(net), vatCategory: o.vat_category, vatRate: o.vat_rate, vatAmount: toMoney(vat), totalAmount: toMoney(net.plus(vat)), + productId: o.product_id, accountId: o.account_id, sourceItemId: o.id, description: o.description, unitId: o.unit_id, + discountBasis: toMoney(basis), + }; + }); + + const totals: CalcTotals = totalsOf(lines); + const taxLeft = new Decimal(invoice.tax_amount).minus(prior!.tax); + const fullyReturned = items.every((o) => { + const line = lines.find((l) => l.sourceItemId === o.id); + return new Decimal(o.returned_qty).plus(line?.quantity ?? 0).equals(o.quantity); + }); + let tax = new Decimal(totals.taxAmount); + if (fullyReturned || tax.greaterThan(taxLeft)) tax = taxLeft; + totals.taxAmount = toMoney(tax); + totals.total = toMoney(new Decimal(totals.taxableAmount).plus(tax)); + return { invoice, lines, totals, fullyReturned }; +} + +async function checkParty(db: Db, kind: DocKind, companyId: string, partyId: string) { + const { rows: [p] } = await db.query<{ is_active: boolean }>( + `SELECT is_active FROM ${partyTable(kind)} WHERE company_id = $1 AND id = $2 AND deleted_at IS NULL`, [companyId, partyId]); + if (!p) throw badRequest('INVALID_PARTY', `${kind.party === 'customer' ? 'Customer' : 'Supplier'} does not exist in this company`); + if (!p.is_active) throw badRequest('PARTY_INACTIVE', `${kind.party === 'customer' ? 'Customer' : 'Supplier'} is inactive`); +} + +async function checkBranchWarehouse(db: Db, companyId: string, branchId?: string | null, warehouseId?: string | null) { + if (branchId) { + const b = await db.query(`SELECT 1 FROM branches WHERE company_id = $1 AND id = $2 AND deleted_at IS NULL`, [companyId, branchId]); + if (!b.rowCount) throw badRequest('INVALID_BRANCH', 'Branch does not exist in this company'); + } + if (warehouseId) { + const w = await db.query(`SELECT 1 FROM warehouses WHERE company_id = $1 AND id = $2 AND deleted_at IS NULL`, [companyId, warehouseId]); + if (!w.rowCount) throw badRequest('INVALID_WAREHOUSE', 'Warehouse does not exist in this company'); + } +} + +export async function insertItems(db: Db, kind: DocKind, tenantId: string, companyId: string, documentId: string, lines: BuiltLine[]): Promise { + const sourceCol = kind.isReturn ? ', source_item_id' : ''; + const ids: string[] = []; + for (const [i, l] of lines.entries()) { + const { rows: [row] } = await db.query<{ id: string }>( + `INSERT INTO ${kind.itemsTable} (tenant_id, company_id, document_id, line_no, product_id, account_id, description, + quantity, unit_id, unit_price, gross_amount, discount_basis, discount_amount, net_amount, vat_category, vat_rate, + vat_amount, total_amount${sourceCol}) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18${kind.isReturn ? ', $19' : ''}) + RETURNING id`, + [tenantId, companyId, documentId, i + 1, l.productId, l.accountId, l.description, l.quantity, l.unitId, l.unitPrice, + l.grossAmount, l.discountBasis, l.discountAmount, l.netAmount, l.vatCategory, l.vatRate, l.vatAmount, l.totalAmount, + ...(kind.isReturn ? [l.sourceItemId] : [])]); + ids.push(row!.id); + } + return ids; +} + +const totalsParams = (t: CalcTotals) => [t.subtotal, t.discountTotal, t.taxableAmount, t.taxAmount, t.total]; + +/** Creates a draft (or, for quotes and orders, a numbered open document). */ +export async function createDraft(db: Db, kind: DocKind, ctx: Ctx, input: DraftInput): Promise { + if (kind.isReturn) throw new AppError(500, 'INTERNAL', 'use createReturnDraft'); + await checkParty(db, kind, input.companyId, input.partyId); + await checkBranchWarehouse(db, input.companyId, input.branchId, input.warehouseId); + const lines = await buildLines(db, kind, input.companyId, input); + const totals = totalsOf(lines); + const { rows: [company] } = await db.query<{ currency: string }>(`SELECT currency FROM companies WHERE id = $1`, [input.companyId]); + const number = kind.legal ? null : await nextDocumentNumber(db, ctx.tenantId, input.companyId, kind.key, { prefix: kind.prefix, padding: 6 }); + + const extra: Record = {}; + if (kind.key === 'SALES_QUOTE') extra.valid_until = input.validUntil ?? null; + if (kind.key === 'PURCHASE_ORDER') extra.expected_date = input.expectedDate ?? null; + if (kind.key === 'SALES_INVOICE' || kind.key === 'PURCHASE_INVOICE') extra.due_date = input.dueDate ?? null; + if (kind.key === 'PURCHASE_INVOICE') extra.supplier_invoice_number = input.supplierInvoiceNumber ?? null; + if (kind.key === 'SALES_INVOICE') extra.source_quote_id = input.sourceId ?? null; + if (kind.key === 'PURCHASE_INVOICE') extra.source_order_id = input.sourceId ?? null; + const extraCols = Object.keys(extra); + + const { rows: [doc] } = await db.query<{ id: string }>( + `INSERT INTO ${kind.table} (tenant_id, company_id, ${partyColumn(kind)}, doc_number, doc_date, branch_id, warehouse_id, + currency, prices_include_vat, notes, subtotal, discount_total, taxable_amount, tax_amount, total, created_by + ${extraCols.map((c) => `, ${c}`).join('')}) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16 + ${extraCols.map((_, i) => `, $${17 + i}`).join('')}) + RETURNING id`, + [ctx.tenantId, input.companyId, input.partyId, number, input.docDate, input.branchId ?? null, input.warehouseId ?? null, + company!.currency, input.pricesIncludeVat ?? false, input.notes ?? null, ...totalsParams(totals), ctx.userId, + ...Object.values(extra)]); + await insertItems(db, kind, ctx.tenantId, input.companyId, doc!.id, lines); + await writeAudit(db, { tenantId: ctx.tenantId, userId: ctx.userId, action: 'CREATE', entityType: kind.entity, entityId: doc!.id, newValues: { ...input, totals } }, ctx.meta); + return doc!.id; +} + +export async function createReturnDraft(db: Db, kind: DocKind, ctx: Ctx, input: ReturnInput): Promise { + const { invoice, lines, totals } = await buildReturn(db, kind, ctx.tenantId, input, null); + const { rows: [company] } = await db.query<{ currency: string }>(`SELECT currency FROM companies WHERE id = $1`, [invoice.company_id]); + const { rows: [doc] } = await db.query<{ id: string }>( + `INSERT INTO ${kind.table} (tenant_id, company_id, ${partyColumn(kind)}, original_invoice_id, reason, doc_date, branch_id, + warehouse_id, currency, prices_include_vat, notes, subtotal, discount_total, taxable_amount, tax_amount, total, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING id`, + [ctx.tenantId, invoice.company_id, invoice.party_id, invoice.id, input.reason, input.docDate, invoice.branch_id, + invoice.warehouse_id, company!.currency, invoice.prices_include_vat, input.notes ?? null, ...totalsParams(totals), ctx.userId]); + await insertItems(db, kind, ctx.tenantId, invoice.company_id, doc!.id, lines); + await writeAudit(db, { tenantId: ctx.tenantId, userId: ctx.userId, action: 'CREATE', entityType: kind.entity, entityId: doc!.id, newValues: { ...input, totals } }, ctx.meta); + return doc!.id; +} + +export async function lockDocument(db: Db, kind: DocKind, tenantId: string, id: string) { + const { rows: [d] } = await db.query<{ + id: string; company_id: string; status: string; doc_number: string | null; doc_date: string; party_id: string; + total: string; tax_amount: string; taxable_amount: string; branch_id: string | null; journal_entry_id: string | null; + deleted_at: Date | null; [k: string]: unknown; + }>( + `SELECT *, to_char(doc_date, 'YYYY-MM-DD') AS doc_date, ${partyColumn(kind)} AS party_id, + total::text, tax_amount::text, taxable_amount::text + FROM ${kind.table} WHERE tenant_id = $1 AND id = $2 FOR UPDATE`, [tenantId, id]); + if (!d || d.deleted_at) throw notFound(kind.label); + return d; +} + +/** Replaces a draft's header fields and lines. Quotes and orders stay editable until sent/approved. */ +export type UpdateInput = Partial> & Partial> & { lines?: LineInput[] | ReturnInput['lines'] }; + +export async function updateDraft(db: Db, kind: DocKind, ctx: Ctx, id: string, input: UpdateInput) { + const d = await lockDocument(db, kind, ctx.tenantId, id); + if (d.status !== 'DRAFT') throw conflict('DOCUMENT_NOT_DRAFT', 'Only drafts can be edited'); + const before = await loadDocument(db, kind, ctx.tenantId, id); + + let lines: BuiltLine[]; + let totals: CalcTotals; + if (kind.isReturn) { + const r = await buildReturn(db, kind, ctx.tenantId, { + originalInvoiceId: d.original_invoice_id as string, + docDate: input.docDate ?? d.doc_date, + reason: input.reason ?? (d.reason as string), + lines: input.lines as ReturnInput['lines'] ?? before.lines.map((l) => ({ sourceItemId: l.sourceItemId!, quantity: l.quantity })), + }, id); + ({ lines, totals } = r); + await db.query(`UPDATE ${kind.table} SET doc_date = $2, reason = COALESCE($3, reason), notes = COALESCE($4, notes) WHERE id = $1`, + [id, input.docDate ?? d.doc_date, input.reason ?? null, input.notes ?? null]); + } else { + if (input.partyId) await checkParty(db, kind, d.company_id, input.partyId); + await checkBranchWarehouse(db, d.company_id, input.branchId, input.warehouseId); + const pricesIncludeVat = input.pricesIncludeVat ?? (d.prices_include_vat as boolean); + const docDate = input.docDate ?? d.doc_date; + const lineInput: LineInput[] = (input.lines as LineInput[] | undefined) ?? before.lines.map((l) => ({ + productId: l.productId, accountId: l.accountId, description: l.description, quantity: l.quantity, + unitPrice: l.unitPrice, discountAmount: l.discountBasis, unitId: l.unitId, vatCategory: l.vatCategory, + })); + lines = await buildLines(db, kind, d.company_id, { lines: lineInput, docDate, pricesIncludeVat }); + totals = totalsOf(lines); + const sets: [string, unknown][] = [ + [partyColumn(kind), input.partyId ?? d.party_id], ['doc_date', docDate], ['prices_include_vat', pricesIncludeVat], + ['branch_id', input.branchId !== undefined ? input.branchId : d.branch_id], + ['warehouse_id', input.warehouseId !== undefined ? input.warehouseId : d.warehouse_id], + ['notes', input.notes !== undefined ? input.notes : d.notes], + ]; + if (kind.key === 'SALES_QUOTE' && input.validUntil !== undefined) sets.push(['valid_until', input.validUntil]); + if (kind.key === 'PURCHASE_ORDER' && input.expectedDate !== undefined) sets.push(['expected_date', input.expectedDate]); + if ((kind.key === 'SALES_INVOICE' || kind.key === 'PURCHASE_INVOICE') && input.dueDate !== undefined) sets.push(['due_date', input.dueDate]); + if (kind.key === 'PURCHASE_INVOICE' && input.supplierInvoiceNumber !== undefined) sets.push(['supplier_invoice_number', input.supplierInvoiceNumber]); + await db.query(`UPDATE ${kind.table} SET ${sets.map(([c], i) => `${c} = $${i + 2}`).join(', ')} WHERE id = $1`, + [id, ...sets.map(([, v]) => v)]); + } + await db.query(`DELETE FROM ${kind.itemsTable} WHERE document_id = $1`, [id]); + await insertItems(db, kind, ctx.tenantId, d.company_id, id, lines); + await db.query( + `UPDATE ${kind.table} SET subtotal = $2, discount_total = $3, taxable_amount = $4, tax_amount = $5, total = $6 WHERE id = $1`, + [id, ...totalsParams(totals)]); + const after = await loadDocument(db, kind, ctx.tenantId, id); + await writeAudit(db, { tenantId: ctx.tenantId, userId: ctx.userId, action: 'UPDATE', entityType: kind.entity, entityId: id, oldValues: before, newValues: after }, ctx.meta); + return after; +} + +export async function deleteDraft(db: Db, kind: DocKind, ctx: Ctx, id: string) { + const d = await lockDocument(db, kind, ctx.tenantId, id); + if (d.status !== 'DRAFT') throw conflict('DOCUMENT_NOT_DRAFT', kind.legal ? 'Issued documents cannot be deleted. Cancel them or issue a return.' : 'Only drafts can be deleted. Cancel it instead.'); + await db.query(`UPDATE ${kind.table} SET deleted_at = now() WHERE id = $1`, [id]); + await writeAudit(db, { tenantId: ctx.tenantId, userId: ctx.userId, action: 'DELETE', entityType: kind.entity, entityId: id }, ctx.meta); +} + +export interface DocumentView { + id: string; companyId: string; number: string | null; date: string; partyId: string; partyName: string; status: string; + total: string; [k: string]: unknown; + lines: { + id: string; lineNo: number; productId: string | null; productName: string | null; sku: string | null; accountId: string | null; + sourceItemId: string | null; description: string | null; quantity: string; unitId: string | null; unitCode: string | null; + unitPrice: string; grossAmount: string; discountBasis: string; discountAmount: string; netAmount: string; + vatCategory: VatCategory; vatRate: string; vatAmount: string; totalAmount: string; + }[]; +} + +export function headerSelect(kind: DocKind) { + const extra: string[] = []; + if (kind.key === 'SALES_QUOTE') extra.push(`to_char(d.valid_until, 'YYYY-MM-DD') AS "validUntil"`, `d.converted_invoice_id AS "convertedInvoiceId"`); + if (kind.key === 'PURCHASE_ORDER') extra.push(`to_char(d.expected_date, 'YYYY-MM-DD') AS "expectedDate"`, `d.converted_invoice_id AS "convertedInvoiceId"`); + if (kind.key === 'SALES_INVOICE' || kind.key === 'PURCHASE_INVOICE') { + extra.push(`to_char(d.due_date, 'YYYY-MM-DD') AS "dueDate"`, `d.paid_amount::text AS "paidAmount"`, + `d.returned_amount::text AS "returnedAmount"`, `d.remaining_amount::text AS "remainingAmount"`); + } + if (kind.key === 'SALES_INVOICE') extra.push(`d.invoice_kind AS "invoiceKind"`, `d.source_quote_id AS "sourceQuoteId"`); + if (kind.key === 'SALES_RETURN') extra.push(`d.invoice_kind AS "invoiceKind"`); + if (kind.key === 'PURCHASE_INVOICE') extra.push(`d.supplier_invoice_number AS "supplierInvoiceNumber"`, `d.source_order_id AS "sourceOrderId"`); + if (kind.isReturn) { + extra.push(`d.original_invoice_id AS "originalInvoiceId"`, `d.reason`, `d.applied_amount::text AS "appliedAmount"`, + `d.refunded_amount::text AS "refundedAmount"`, `d.remaining_amount::text AS "remainingAmount"`, + `(SELECT doc_number FROM ${KINDS[kind.originalKind!].table} WHERE id = d.original_invoice_id) AS "originalInvoiceNumber"`); + } + return ` + SELECT d.id, d.company_id AS "companyId", d.doc_number AS number, to_char(d.doc_date, 'YYYY-MM-DD') AS date, + d.${partyColumn(kind)} AS "partyId", p.name_ar AS "partyName", p.code AS "partyCode", d.status, + d.branch_id AS "branchId", d.warehouse_id AS "warehouseId", d.currency, d.prices_include_vat AS "pricesIncludeVat", + d.subtotal::text, d.discount_total::text AS "discountTotal", d.taxable_amount::text AS "taxableAmount", + d.tax_amount::text AS "taxAmount", d.total::text, d.notes, d.party_snapshot AS "partySnapshot", + d.journal_entry_id AS "journalEntryId", d.issued_at AS "issuedAt", d.cancelled_at AS "cancelledAt", + d.cancel_reason AS "cancelReason", d.created_at AS "createdAt" + ${extra.map((e) => `, ${e}`).join('')} + FROM ${kind.table} d JOIN ${partyTable(kind)} p ON p.id = d.${partyColumn(kind)}`; +} + +export async function loadDocument(db: Db, kind: DocKind, tenantId: string, id: string): Promise { + const { rows: [doc] } = await db.query>( + `${headerSelect(kind)} WHERE d.tenant_id = $1 AND d.id = $2 AND d.deleted_at IS NULL`, [tenantId, id]); + if (!doc) throw notFound(kind.label); + const { rows: lines } = await db.query( + `SELECT i.id, i.line_no AS "lineNo", i.product_id AS "productId", pr.name_ar AS "productName", pr.sku, + i.account_id AS "accountId", ${kind.isReturn ? 'i.source_item_id' : 'NULL::uuid'} AS "sourceItemId", + i.description, i.quantity::text, i.unit_id AS "unitId", u.code AS "unitCode", i.unit_price::text AS "unitPrice", + i.gross_amount::text AS "grossAmount", i.discount_basis::text AS "discountBasis", i.discount_amount::text AS "discountAmount", + i.net_amount::text AS "netAmount", i.vat_category AS "vatCategory", i.vat_rate::text AS "vatRate", + i.vat_amount::text AS "vatAmount", i.total_amount::text AS "totalAmount" + FROM ${kind.itemsTable} i + LEFT JOIN products pr ON pr.id = i.product_id + LEFT JOIN units u ON u.id = i.unit_id + WHERE i.document_id = $1 ORDER BY i.line_no`, [id]); + const view = { ...doc, lines } as DocumentView; + if (kind.key === 'SALES_INVOICE' || kind.key === 'PURCHASE_INVOICE' || kind.isReturn) { + const col = { SALES_INVOICE: 'sales_invoice_id', PURCHASE_INVOICE: 'purchase_invoice_id', SALES_RETURN: 'sales_return_id', PURCHASE_RETURN: 'purchase_return_id' }[kind.key as string]!; + const { rows: allocations } = await db.query( + `SELECT a.id, a.amount::text, a.created_at AS "createdAt", p.id AS "paymentId", p.payment_number AS "paymentNumber", + to_char(p.payment_date, 'YYYY-MM-DD') AS "paymentDate" + FROM payment_allocations a JOIN payments p ON p.id = a.payment_id + WHERE a.${col} = $1 AND a.reversed_at IS NULL ORDER BY p.payment_date`, [id]); + view.allocations = allocations; + } + if (kind.key === 'SALES_INVOICE' || kind.key === 'PURCHASE_INVOICE') { + const ret = kind.key === 'SALES_INVOICE' ? KINDS.SALES_RETURN : KINDS.PURCHASE_RETURN; + const { rows: returns } = await db.query( + `SELECT id, doc_number AS number, to_char(doc_date, 'YYYY-MM-DD') AS date, status, total::text + FROM ${ret.table} WHERE original_invoice_id = $1 AND deleted_at IS NULL ORDER BY created_at`, [id]); + view.returns = returns; + } + return view; +} + +/** Default due date: document date + the party's payment terms. */ +export async function defaultDueDate(db: Db, kind: DocKind, partyId: string, docDate: string): Promise { + const { rows: [p] } = await db.query<{ payment_terms_days: number }>(`SELECT payment_terms_days FROM ${partyTable(kind)} WHERE id = $1`, [partyId]); + return addDays(docDate, p?.payment_terms_days ?? 0); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/documents/kinds.ts b/alshuyukh-accounting/apps/api/src/modules/documents/kinds.ts new file mode 100644 index 000000000000..e4ae777b66df --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/documents/kinds.ts @@ -0,0 +1,59 @@ +/** + * The six commercial documents share one implementation. Each kind declares + * its tables, party, numbering, permissions, and lifecycle here. + */ + +export type DocKey = 'SALES_QUOTE' | 'SALES_INVOICE' | 'SALES_RETURN' | 'PURCHASE_ORDER' | 'PURCHASE_INVOICE' | 'PURCHASE_RETURN'; + +export interface DocKind { + key: DocKey; + path: string; + table: string; + itemsTable: string; + party: 'customer' | 'supplier'; + /** Invoices and returns: number at issue, journal entry, settlement tracking. */ + legal: boolean; + isReturn: boolean; + /** For returns: the invoice kind being returned. */ + originalKind?: DocKey; + prefix: string; + /** Status set when the document is issued / posted. */ + issuedStatus: string; + entity: string; + label: string; + labelAr: string; + perm: { view: string; create: string; edit: string; delete: string; issue: string; cancel: string }; +} + +const SALES_PERM = { view: 'invoice.view', create: 'invoice.create', edit: 'invoice.edit', delete: 'invoice.delete', issue: 'invoice.post', cancel: 'invoice.cancel' }; +const PURCHASE_PERM = { view: 'purchase.view', create: 'purchase.create', edit: 'purchase.create', delete: 'purchase.create', issue: 'purchase.post', cancel: 'purchase.cancel' }; + +export const KINDS: Record = { + SALES_QUOTE: { + key: 'SALES_QUOTE', path: 'sales-quotes', table: 'sales_quotes', itemsTable: 'sales_quote_items', party: 'customer', + legal: false, isReturn: false, prefix: 'QT', issuedStatus: 'SENT', entity: 'sales_quote', label: 'Quote', labelAr: 'عرض سعر', perm: SALES_PERM, + }, + SALES_INVOICE: { + key: 'SALES_INVOICE', path: 'invoices', table: 'sales_invoices', itemsTable: 'sales_invoice_items', party: 'customer', + legal: true, isReturn: false, prefix: 'INV', issuedStatus: 'ISSUED', entity: 'sales_invoice', label: 'Invoice', labelAr: 'فاتورة مبيعات', perm: SALES_PERM, + }, + SALES_RETURN: { + key: 'SALES_RETURN', path: 'sales-returns', table: 'sales_returns', itemsTable: 'sales_return_items', party: 'customer', + legal: true, isReturn: true, originalKind: 'SALES_INVOICE', prefix: 'CN', issuedStatus: 'ISSUED', entity: 'sales_return', label: 'Sales return', labelAr: 'مرتجع مبيعات (إشعار دائن)', perm: SALES_PERM, + }, + PURCHASE_ORDER: { + key: 'PURCHASE_ORDER', path: 'purchase-orders', table: 'purchase_orders', itemsTable: 'purchase_order_items', party: 'supplier', + legal: false, isReturn: false, prefix: 'PO', issuedStatus: 'APPROVED', entity: 'purchase_order', label: 'Purchase order', labelAr: 'أمر شراء', perm: PURCHASE_PERM, + }, + PURCHASE_INVOICE: { + key: 'PURCHASE_INVOICE', path: 'purchase-invoices', table: 'purchase_invoices', itemsTable: 'purchase_invoice_items', party: 'supplier', + legal: true, isReturn: false, prefix: 'PINV', issuedStatus: 'POSTED', entity: 'purchase_invoice', label: 'Purchase invoice', labelAr: 'فاتورة مشتريات', perm: PURCHASE_PERM, + }, + PURCHASE_RETURN: { + key: 'PURCHASE_RETURN', path: 'purchase-returns', table: 'purchase_returns', itemsTable: 'purchase_return_items', party: 'supplier', + legal: true, isReturn: true, originalKind: 'PURCHASE_INVOICE', prefix: 'DN', issuedStatus: 'ISSUED', entity: 'purchase_return', label: 'Purchase return', labelAr: 'مرتجع مشتريات (إشعار مدين)', perm: PURCHASE_PERM, + }, +}; + +export const partyColumn = (k: DocKind) => `${k.party}_id`; +export const partyTable = (k: DocKind) => (k.party === 'customer' ? 'customers' : 'suppliers'); diff --git a/alshuyukh-accounting/apps/api/src/modules/documents/payments.ts b/alshuyukh-accounting/apps/api/src/modules/documents/payments.ts new file mode 100644 index 000000000000..71c22dcd7379 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/documents/payments.ts @@ -0,0 +1,228 @@ +import { randomUUID } from 'node:crypto'; +import type { Db } from '../../db/tx.js'; +import { badRequest, conflict, notFound } from '../../lib/errors.js'; +import { Decimal, toMoney } from '../../lib/money.js'; +import { nextDocumentNumber } from '../../lib/sequences.js'; +import { postEntry, reverse } from '../accounting/engine.js'; +import { writeAudit } from '../audit/audit.service.js'; +import type { Ctx } from './drafts.js'; +import { KINDS, type DocKind } from './kinds.js'; +import { controlAccount, refreshInvoiceStatus } from './posting.js'; + +/** + * Payments move money between a cash/bank account (from the payment method) + * and the party's control account: + * + * Receipt from customer Dr Cash/Bank / Cr Receivable (customer) + * Refund to customer Dr Receivable (customer) / Cr Cash/Bank + * Payment to supplier Dr Payable (supplier) / Cr Cash/Bank + * Refund from supplier Dr Cash/Bank / Cr Payable (supplier) + * + * Allocation links a payment to the documents it settles. It does not post + * anything: the ledger is already right per party. An unallocated remainder + * stays on the party's account as an advance. + */ + +export type Direction = 'RECEIPT' | 'DISBURSEMENT'; +export type TargetType = 'SALES_INVOICE' | 'SALES_RETURN' | 'PURCHASE_INVOICE' | 'PURCHASE_RETURN' | 'EXPENSE'; + +export interface AllocationInput { documentType: TargetType; documentId: string; amount: string } + +export interface PaymentInput { + companyId: string; + direction: Direction; + customerId?: string | null; + supplierId?: string | null; + paymentDate: string; + methodId: string; + amount: string; + reference?: string | null; + notes?: string | null; + branchId?: string | null; + allocations?: AllocationInput[]; +} + +const TARGET_COLUMN: Record = { + SALES_INVOICE: 'sales_invoice_id', SALES_RETURN: 'sales_return_id', + PURCHASE_INVOICE: 'purchase_invoice_id', PURCHASE_RETURN: 'purchase_return_id', EXPENSE: 'expense_id', +}; + +/** Which documents a payment may settle, by party and direction. */ +function allowedTargets(party: 'customer' | 'supplier', direction: Direction): TargetType[] { + if (party === 'customer') return direction === 'RECEIPT' ? ['SALES_INVOICE'] : ['SALES_RETURN']; + return direction === 'DISBURSEMENT' ? ['PURCHASE_INVOICE', 'EXPENSE'] : ['PURCHASE_RETURN']; +} + +/** Where a settlement target lives and how its paid amount and status are kept. */ +interface Target { table: string; partyColumn: string; settleColumn: string; numberColumn: string; refresh(db: Db, id: string): Promise } + +function target(type: TargetType): Target { + if (type === 'EXPENSE') { + return { + table: 'expenses', partyColumn: 'supplier_id', settleColumn: 'paid_amount', numberColumn: 'expense_number', + refresh: async (db, id) => { + await db.query( + `UPDATE expenses SET status = CASE WHEN remaining_amount = 0 THEN 'PAID' WHEN paid_amount > 0 THEN 'PARTIALLY_PAID' ELSE 'POSTED' END + WHERE id = $1 AND status <> 'CANCELLED'`, [id]); + }, + }; + } + const kind: DocKind = KINDS[type]; + return { + table: kind.table, partyColumn: `${kind.party}_id`, settleColumn: kind.isReturn ? 'refunded_amount' : 'paid_amount', numberColumn: 'doc_number', + refresh: (db, id) => (kind.isReturn ? Promise.resolve() : refreshInvoiceStatus(db, kind, id)), + }; +} + +export async function createPayment(db: Db, ctx: Ctx, input: PaymentInput) { + const party = input.customerId ? 'customer' : 'supplier'; + const partyId = (input.customerId ?? input.supplierId)!; + if (!!input.customerId === !!input.supplierId) throw badRequest('INVALID_PARTY', 'Choose either a customer or a supplier'); + const { rows: [p] } = await db.query<{ is_active: boolean }>( + `SELECT is_active FROM ${party}s WHERE company_id = $1 AND id = $2 AND deleted_at IS NULL`, [input.companyId, partyId]); + if (!p) throw badRequest('INVALID_PARTY', `The ${party} does not exist in this company`); + + const { rows: [method] } = await db.query<{ account_id: string; is_active: boolean; name_ar: string }>( + `SELECT account_id, is_active, name_ar FROM payment_methods WHERE company_id = $1 AND id = $2`, [input.companyId, input.methodId]); + if (!method) throw badRequest('INVALID_METHOD', 'Payment method does not exist in this company'); + if (!method.is_active) throw badRequest('METHOD_INACTIVE', 'Payment method is inactive'); + if (input.branchId) { + const b = await db.query(`SELECT 1 FROM branches WHERE company_id = $1 AND id = $2`, [input.companyId, input.branchId]); + if (!b.rowCount) throw badRequest('INVALID_BRANCH', 'Branch does not exist in this company'); + } + + const number = await nextDocumentNumber(db, ctx.tenantId, input.companyId, input.direction === 'RECEIPT' ? 'RECEIPT' : 'DISBURSEMENT', + { prefix: input.direction === 'RECEIPT' ? 'RCPT' : 'PAY', padding: 6 }); + const control = await controlAccount(db, party, input.companyId, partyId); + const tag = party === 'customer' ? { customerId: partyId } : { supplierId: partyId }; + const moneyIn = input.direction === 'RECEIPT'; + const paymentId = randomUUID(); + const entry = await postEntry(db, ctx, { + companyId: input.companyId, entryDate: input.paymentDate, + description: `${moneyIn ? 'سند قبض' : 'سند صرف'} ${number}${input.reference ? ` — ${input.reference}` : ''}`, + referenceType: moneyIn ? 'PAYMENT_RECEIPT' : 'PAYMENT_DISBURSEMENT', referenceId: paymentId, source: 'SYSTEM', + lines: [ + { accountId: method.account_id, ...(moneyIn ? { debit: input.amount } : { credit: input.amount }), branchId: input.branchId ?? null }, + { accountId: control, ...(moneyIn ? { credit: input.amount } : { debit: input.amount }), ...tag, branchId: input.branchId ?? null }, + ], + }); + const { rows: [row] } = await db.query<{ id: string }>( + `INSERT INTO payments (id, tenant_id, company_id, branch_id, payment_number, direction, customer_id, supplier_id, payment_date, + method_id, amount, reference, notes, journal_entry_id, created_by) + VALUES ($15, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id`, + [ctx.tenantId, input.companyId, input.branchId ?? null, number, input.direction, input.customerId ?? null, input.supplierId ?? null, + input.paymentDate, input.methodId, input.amount, input.reference ?? null, input.notes ?? null, entry.id, ctx.userId, paymentId]); + await writeAudit(db, { + tenantId: ctx.tenantId, userId: ctx.userId, action: 'PAYMENT', entityType: 'payment', entityId: row!.id, + newValues: { number, direction: input.direction, party, partyId, amount: input.amount, journalEntryId: entry.id }, + }, ctx.meta); + if (input.allocations?.length) await allocate(db, ctx, row!.id, input.allocations); + return loadPayment(db, ctx.tenantId, row!.id); +} + +/** Settles documents with an existing payment's unallocated amount. */ +export async function allocate(db: Db, ctx: Ctx, paymentId: string, allocations: AllocationInput[]) { + const { rows: [pay] } = await db.query<{ + id: string; company_id: string; status: string; direction: Direction; customer_id: string | null; supplier_id: string | null; + unallocated_amount: string; payment_date: string; + }>( + `SELECT id, company_id, status, direction, customer_id, supplier_id, unallocated_amount::text, payment_date + FROM payments WHERE tenant_id = $1 AND id = $2 FOR UPDATE`, [ctx.tenantId, paymentId]); + if (!pay) throw notFound('Payment'); + if (pay.status !== 'POSTED') throw conflict('PAYMENT_VOIDED', 'This payment is voided'); + const party = pay.customer_id ? 'customer' : 'supplier'; + const partyId = (pay.customer_id ?? pay.supplier_id)!; + const expected = allowedTargets(party, pay.direction); + + const total = allocations.reduce((s, a) => s.plus(a.amount), new Decimal(0)); + if (total.greaterThan(pay.unallocated_amount)) { + throw badRequest('OVER_ALLOCATED', `Allocations (${toMoney(total)}) exceed the unallocated amount (${pay.unallocated_amount})`); + } + const seen = new Set(); + for (const a of allocations) { + if (!expected.includes(a.documentType)) throw badRequest('INVALID_ALLOCATION', `This payment can only settle: ${expected.join(', ')}`); + if (seen.has(a.documentId)) throw badRequest('INVALID_ALLOCATION', 'The same document appears twice'); + seen.add(a.documentId); + const amount = new Decimal(a.amount); + if (!amount.greaterThan(0)) throw badRequest('INVALID_ALLOCATION', 'Allocation amounts must be positive'); + const tg = target(a.documentType); + const { rows: [doc] } = await db.query<{ status: string; party_id: string; remaining_amount: string; doc_number: string }>( + `SELECT status, ${tg.partyColumn} AS party_id, remaining_amount::text, ${tg.numberColumn} AS doc_number + FROM ${tg.table} WHERE company_id = $1 AND id = $2 AND deleted_at IS NULL FOR UPDATE`, [pay.company_id, a.documentId]); + if (!doc || doc.party_id !== partyId) throw badRequest('INVALID_ALLOCATION', `Document does not belong to this ${party}`); + if (doc.status === 'DRAFT' || doc.status === 'CANCELLED') throw conflict('DOCUMENT_NOT_OPEN', `${doc.doc_number ?? 'The draft'} is not open for settlement`); + if (amount.greaterThan(doc.remaining_amount)) { + throw badRequest('OVER_ALLOCATED', `${doc.doc_number}: only ${doc.remaining_amount} is still open`); + } + await db.query(`UPDATE ${tg.table} SET ${tg.settleColumn} = ${tg.settleColumn} + $2 WHERE id = $1`, [a.documentId, toMoney(amount)]); + await tg.refresh(db, a.documentId); + await db.query( + `INSERT INTO payment_allocations (tenant_id, company_id, payment_id, ${TARGET_COLUMN[a.documentType]}, amount, created_by) + VALUES ($1, $2, $3, $4, $5, $6)`, [ctx.tenantId, pay.company_id, paymentId, a.documentId, toMoney(amount), ctx.userId]); + } + await db.query(`UPDATE payments SET allocated_amount = allocated_amount + $2 WHERE id = $1`, [paymentId, toMoney(total)]); + await writeAudit(db, { tenantId: ctx.tenantId, userId: ctx.userId, action: 'UPDATE', entityType: 'payment', entityId: paymentId, newValues: { allocations } }, ctx.meta); + return loadPayment(db, ctx.tenantId, paymentId); +} + +/** Voids a payment: reverses its entry and releases every allocation. */ +export async function voidPayment(db: Db, ctx: Ctx, paymentId: string, reason: string) { + const { rows: [pay] } = await db.query<{ status: string; journal_entry_id: string; payment_date: string; payment_number: string }>( + `SELECT status, journal_entry_id, payment_date, payment_number FROM payments WHERE tenant_id = $1 AND id = $2 FOR UPDATE`, + [ctx.tenantId, paymentId]); + if (!pay) throw notFound('Payment'); + if (pay.status === 'VOIDED') throw conflict('ALREADY_VOIDED', 'This payment is already voided'); + + const { rows: allocations } = await db.query<{ id: string; amount: string; target: TargetType; document_id: string }>( + `SELECT id, amount::text, + CASE WHEN sales_invoice_id IS NOT NULL THEN 'SALES_INVOICE' WHEN sales_return_id IS NOT NULL THEN 'SALES_RETURN' + WHEN purchase_invoice_id IS NOT NULL THEN 'PURCHASE_INVOICE' WHEN expense_id IS NOT NULL THEN 'EXPENSE' + ELSE 'PURCHASE_RETURN' END AS target, + COALESCE(sales_invoice_id, sales_return_id, purchase_invoice_id, purchase_return_id, expense_id) AS document_id + FROM payment_allocations WHERE payment_id = $1 AND reversed_at IS NULL`, [paymentId]); + for (const a of allocations) { + const tg = target(a.target); + await db.query(`SELECT 1 FROM ${tg.table} WHERE id = $1 FOR UPDATE`, [a.document_id]); + await db.query(`UPDATE ${tg.table} SET ${tg.settleColumn} = ${tg.settleColumn} - $2 WHERE id = $1`, [a.document_id, a.amount]); + await tg.refresh(db, a.document_id); + await db.query(`UPDATE payment_allocations SET reversed_at = now() WHERE id = $1`, [a.id]); + } + await reverse(db, ctx, pay.journal_entry_id, { reason: `إلغاء ${pay.payment_number}: ${reason}`, date: pay.payment_date, allowSystem: true }); + await db.query( + `UPDATE payments SET status = 'VOIDED', allocated_amount = 0, voided_by = $2, voided_at = now(), void_reason = $3 WHERE id = $1`, + [paymentId, ctx.userId, reason]); + await writeAudit(db, { tenantId: ctx.tenantId, userId: ctx.userId, action: 'VOID', entityType: 'payment', entityId: paymentId, newValues: { reason } }, ctx.meta); + return loadPayment(db, ctx.tenantId, paymentId); +} + +export const PAYMENT_SELECT = ` + SELECT p.id, p.company_id AS "companyId", p.payment_number AS number, p.direction, p.payment_date AS date, + p.customer_id AS "customerId", p.supplier_id AS "supplierId", + COALESCE(c.name_ar, s.name_ar) AS "partyName", p.method_id AS "methodId", m.name_ar AS "methodName", + p.amount::text, p.allocated_amount::text AS "allocatedAmount", p.unallocated_amount::text AS "unallocatedAmount", + p.reference, p.notes, p.status, p.journal_entry_id AS "journalEntryId", p.void_reason AS "voidReason", + p.created_at AS "createdAt" + FROM payments p + JOIN payment_methods m ON m.id = p.method_id + LEFT JOIN customers c ON c.id = p.customer_id + LEFT JOIN suppliers s ON s.id = p.supplier_id`; + +export async function loadPayment(db: Db, tenantId: string, id: string) { + const { rows: [p] } = await db.query(`${PAYMENT_SELECT} WHERE p.tenant_id = $1 AND p.id = $2`, [tenantId, id]); + if (!p) throw notFound('Payment'); + const { rows: allocations } = await db.query( + `SELECT a.id, a.amount::text, a.reversed_at AS "reversedAt", + COALESCE(si.doc_number, sr.doc_number, pi.doc_number, pr.doc_number, ex.expense_number) AS "documentNumber", + CASE WHEN a.sales_invoice_id IS NOT NULL THEN 'SALES_INVOICE' WHEN a.sales_return_id IS NOT NULL THEN 'SALES_RETURN' + WHEN a.purchase_invoice_id IS NOT NULL THEN 'PURCHASE_INVOICE' WHEN a.expense_id IS NOT NULL THEN 'EXPENSE' + ELSE 'PURCHASE_RETURN' END AS "documentType", + COALESCE(a.sales_invoice_id, a.sales_return_id, a.purchase_invoice_id, a.purchase_return_id, a.expense_id) AS "documentId" + FROM payment_allocations a + LEFT JOIN sales_invoices si ON si.id = a.sales_invoice_id + LEFT JOIN sales_returns sr ON sr.id = a.sales_return_id + LEFT JOIN purchase_invoices pi ON pi.id = a.purchase_invoice_id + LEFT JOIN purchase_returns pr ON pr.id = a.purchase_return_id + LEFT JOIN expenses ex ON ex.id = a.expense_id + WHERE a.payment_id = $1 ORDER BY a.created_at`, [id]); + return { ...p, allocations }; +} diff --git a/alshuyukh-accounting/apps/api/src/modules/documents/posting.ts b/alshuyukh-accounting/apps/api/src/modules/documents/posting.ts new file mode 100644 index 000000000000..467e615caaa8 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/documents/posting.ts @@ -0,0 +1,401 @@ +import { assertWithinLimit } from '../subscriptions/service.js'; +import { assertCancellable, generateForDocument } from '../zatca/service.js'; +import type { Db } from '../../db/tx.js'; +import { badRequest, conflict } from '../../lib/errors.js'; +import { Decimal, toMoney } from '../../lib/money.js'; +import { nextDocumentNumber } from '../../lib/sequences.js'; +import { postEntry, reverse, type LineInput as JournalLine } from '../accounting/engine.js'; +import { writeAudit } from '../audit/audit.service.js'; +import { issue, receive, resolveWarehouse, reverseDocumentMovements, trackedProducts, type Ref } from '../inventory/engine.js'; +import { recordTax, reverseTax, taxGroups, type TaxSource } from '../tax/ledger.js'; +import { totalsOf } from './calc.js'; +import { + buildLines, buildReturn, createDraft, defaultDueDate, insertItems, loadDocument, lockDocument, + type BuiltLine, type Ctx, type LineInput, +} from './drafts.js'; +import { KINDS, partyColumn, partyTable, type DocKind } from './kinds.js'; + +/** + * Issuing documents and the journal entries they create. + * + * Sales invoice Dr Receivable (customer) / Cr Revenue per account, Cr VAT output + * Sales return Dr Revenue, Dr VAT output / Cr Receivable (customer) + * Purchase invoice Dr Inventory/Expense per account, Dr VAT input / Cr Payable (supplier) + * Purchase return Dr Payable (supplier) / Cr Inventory/Expense, Cr VAT input + * + * Stocked goods also move inventory, in the same entry: + * Sales invoice Dr Cost of goods sold / Cr Inventory (average cost) + * Sales return Dr Inventory / Cr Cost of goods sold (the cost the goods left at) + * Purchase invoice Inventory is the line account (purchase price) + * Purchase return Cr Inventory at average cost; the difference from the + * purchase price goes to cost of goods sold + * + * The document, its stock movements and its entry are written in the same transaction. + */ + +export async function systemAccount(db: Db, companyId: string, key: string): Promise { + const { rows: [a] } = await db.query<{ id: string }>( + `SELECT id FROM accounts WHERE company_id = $1 AND system_key = $2 AND deleted_at IS NULL AND is_active AND is_postable`, + [companyId, key]); + if (!a) throw conflict('SYSTEM_ACCOUNT_MISSING', `The ${key} account is missing or inactive`); + return a.id; +} + +/** The party's own control account, or the company default (receivable / payable). */ +export async function controlAccount(db: Db, party: 'customer' | 'supplier', companyId: string, partyId: string): Promise { + const column = party === 'customer' ? 'receivable_account_id' : 'payable_account_id'; + const { rows: [p] } = await db.query<{ account_id: string | null }>( + `SELECT ${column} AS account_id FROM ${party}s WHERE id = $1`, [partyId]); + return p?.account_id ?? systemAccount(db, companyId, party === 'customer' ? 'ACCOUNTS_RECEIVABLE' : 'ACCOUNTS_PAYABLE'); +} + +/** Posting account of every line: revenue for sales, inventory/expense for purchases. */ +async function lineAccounts(db: Db, kind: DocKind, companyId: string, lines: BuiltLine[]): Promise { + const productIds = [...new Set(lines.map((l) => l.productId).filter(Boolean))] as string[]; + const { rows } = await db.query<{ id: string; sales_account_id: string | null; purchase_account_id: string | null; track_inventory: boolean; name_ar: string }>( + `SELECT id, sales_account_id, purchase_account_id, track_inventory, name_ar FROM products WHERE id = ANY($1::uuid[])`, [productIds]); + const products = new Map(rows.map((p) => [p.id, p])); + const result: string[] = []; + for (const [i, l] of lines.entries()) { + if (l.accountId) { result.push(l.accountId); continue; } + const p = products.get(l.productId!)!; + if (kind.party === 'customer') { + result.push(p.sales_account_id ?? await systemAccount(db, companyId, 'SALES')); + } else if (p.track_inventory) { + // Stocked goods always go through the Inventory account so the stock + // ledger and the general ledger stay equal. + result.push(await systemAccount(db, companyId, 'INVENTORY')); + } else if (p.purchase_account_id) { + result.push(p.purchase_account_id); + } else { + throw badRequest('ACCOUNT_REQUIRED', `Line ${i + 1}: choose an expense account for "${p.name_ar}" or set a purchase account on the product`); + } + } + return result; +} + +async function partySnapshot(db: Db, kind: DocKind, partyId: string) { + const { rows: [p] } = await db.query( + `SELECT p.code, p.name_ar AS "nameAr", p.name_en AS "nameEn", p.vat_number AS "vatNumber", + p.commercial_registration AS "commercialRegistration", p.party_type AS "partyType", + (SELECT jsonb_build_object('buildingNumber', a.building_number, 'street', a.street, 'district', a.district, + 'city', a.city, 'postalCode', a.postal_code, 'additionalNumber', a.additional_number, 'country', a.country) + FROM ${kind.party}_addresses a + WHERE a.${kind.party}_id = p.id AND a.address_type = 'BILLING' AND a.is_default AND a.deleted_at IS NULL) AS address + FROM ${partyTable(kind)} p WHERE p.id = $1`, [partyId]); + return p; +} + +/** Re-derives an invoice's status from its payments and issued returns. */ +export async function refreshInvoiceStatus(db: Db, kind: DocKind, invoiceId: string) { + const ret = kind.key === 'SALES_INVOICE' ? KINDS.SALES_RETURN : KINDS.PURCHASE_RETURN; + const { rows: [inv] } = await db.query<{ status: string; paid_amount: string; remaining_amount: string; fully_returned: boolean }>( + `SELECT d.status, d.paid_amount::text, d.remaining_amount::text, + NOT EXISTS ( + SELECT 1 FROM ${kind.itemsTable} i + WHERE i.document_id = d.id AND i.quantity > COALESCE(( + SELECT sum(ri.quantity) FROM ${ret.itemsTable} ri JOIN ${ret.table} r ON r.id = ri.document_id + WHERE ri.source_item_id = i.id AND r.status = 'ISSUED'), 0) + ) AS fully_returned + FROM ${kind.table} d WHERE d.id = $1`, [invoiceId]); + if (!inv || inv.status === 'CANCELLED' || inv.status === 'DRAFT') return; + const status = inv.fully_returned ? 'RETURNED' + : new Decimal(inv.remaining_amount).isZero() ? 'PAID' + : new Decimal(inv.paid_amount).greaterThan(0) ? 'PARTIALLY_PAID' + : kind.issuedStatus; + if (status !== inv.status) await db.query(`UPDATE ${kind.table} SET status = $2 WHERE id = $1`, [invoiceId, status]); +} + +/** Recalculates a draft with current tax rates right before issue. */ +async function recalculate(db: Db, kind: DocKind, ctx: Ctx, d: Awaited>) { + const current = await loadDocument(db, kind, ctx.tenantId, d.id); + let lines: BuiltLine[]; + let totals; + let fullyReturned = false; + if (kind.isReturn) { + const r = await buildReturn(db, kind, ctx.tenantId, { + originalInvoiceId: d.original_invoice_id as string, docDate: d.doc_date, reason: d.reason as string, + lines: current.lines.map((l) => ({ sourceItemId: l.sourceItemId!, quantity: l.quantity })), + }, d.id); + ({ lines, totals, fullyReturned } = r); + } else { + const input: LineInput[] = current.lines.map((l) => ({ + productId: l.productId, accountId: l.accountId, description: l.description, quantity: l.quantity, + unitPrice: l.unitPrice, discountAmount: l.discountBasis, unitId: l.unitId, vatCategory: l.vatCategory, + })); + lines = await buildLines(db, kind, d.company_id, { lines: input, docDate: d.doc_date, pricesIncludeVat: d.prices_include_vat as boolean }); + totals = totalsOf(lines); + } + return { lines, totals, fullyReturned }; +} + +export async function issueDocument(db: Db, kind: DocKind, ctx: Ctx, id: string) { + if (!kind.legal) throw badRequest('NOT_POSTABLE', `${kind.label}s do not post to the ledger`); + const d = await lockDocument(db, kind, ctx.tenantId, id); + if (d.status !== 'DRAFT') throw conflict('DOCUMENT_NOT_DRAFT', 'This document has already been issued'); + if (kind.key === 'SALES_INVOICE') await assertWithinLimit(db, ctx.tenantId, 'max_invoices_per_month'); + + const { lines, totals } = await recalculate(db, kind, ctx, d); + if (!new Decimal(totals.total).greaterThan(0)) throw badRequest('ZERO_TOTAL', 'A document with a zero total cannot be issued'); + + const { rows: [party] } = await db.query<{ is_active: boolean; credit_limit: string | null; vat_number: string | null }>( + `SELECT is_active, credit_limit::text, vat_number FROM ${partyTable(kind)} WHERE id = $1`, [d.party_id]); + if (!party!.is_active && !kind.isReturn) throw conflict('PARTY_INACTIVE', `The ${kind.party} is inactive`); + + if (kind.key === 'SALES_INVOICE' && party!.credit_limit !== null) { + const { rows: [bal] } = await db.query<{ balance: string }>( + `SELECT COALESCE(sum(l.debit - l.credit), 0)::text AS balance + FROM journal_entry_lines l JOIN journal_entries e ON e.id = l.journal_entry_id + WHERE l.customer_id = $1 AND e.status IN ('POSTED', 'REVERSED')`, [d.party_id]); + const exposure = new Decimal(bal!.balance).plus(totals.total); + if (exposure.greaterThan(party!.credit_limit)) { + throw conflict('CREDIT_LIMIT_EXCEEDED', `This invoice takes the customer's balance to ${toMoney(exposure)}, above the credit limit of ${toMoney(party!.credit_limit)}`); + } + } + + // Lines are final: store them with their posting accounts. + const accounts = await lineAccounts(db, kind, d.company_id, lines); + lines.forEach((l, i) => { l.accountId = accounts[i]!; }); + await db.query(`DELETE FROM ${kind.itemsTable} WHERE document_id = $1`, [id]); + const lineIds = await insertItems(db, kind, ctx.tenantId, d.company_id, id, lines); + + const number = await nextDocumentNumber(db, ctx.tenantId, d.company_id, kind.key, { prefix: kind.prefix, padding: 6 }); + const control = await controlAccount(db, kind.party, d.company_id, d.party_id); + const vatAccount = await systemAccount(db, d.company_id, kind.party === 'customer' ? 'VAT_OUTPUT' : 'VAT_INPUT'); + const tag = kind.party === 'customer' ? { customerId: d.party_id } : { supplierId: d.party_id }; + const branchId = d.branch_id; + + // Revenue / cost side, grouped per account. + const perAccount = new Map(); + const addTo = (accountId: string, amount: Decimal.Value) => perAccount.set(accountId, (perAccount.get(accountId) ?? new Decimal(0)).plus(amount)); + lines.forEach((l) => addTo(l.accountId!, l.netAmount)); + + // Stock movements. `cogs` > 0 means Dr Cost of goods sold / Cr Inventory. + const stock = await moveStock(db, kind, ctx, d, lines, lineIds); + const cogs = stock.cogs; + if (stock.warehouseId) d.warehouse_id = stock.warehouseId; + if (kind.key === 'PURCHASE_RETURN' && stock.costs.size) { + // Inventory leaves at average cost; the gap to the purchase price is a cost difference. + // The line accounts are credited: Inventory at cost, the difference to cost of goods sold + // (a credit when the purchase price was above average, a debit when below). + const inventory = await systemAccount(db, d.company_id, 'INVENTORY'); + const cogsAccount = await systemAccount(db, d.company_id, 'COGS'); + for (const [i, l] of lines.entries()) { + const cost = stock.costs.get(i); + if (cost === undefined) continue; + addTo(inventory, new Decimal(cost).minus(l.netAmount)); + addTo(cogsAccount, new Decimal(l.netAmount).minus(cost)); + } + } + + // Sales invoice & purchase return credit the line accounts; the other two debit them. + const linesSideCredit = kind.key === 'SALES_INVOICE' || kind.key === 'PURCHASE_RETURN'; + /** A signed amount on the given side; negative amounts flip to the other side. */ + const side = (credit: boolean, amount: Decimal.Value) => { + const a = new Decimal(amount); + return (credit !== a.isNegative()) ? { credit: toMoney(a.abs()) } : { debit: toMoney(a.abs()) }; + }; + const journal: JournalLine[] = [ + { accountId: control, ...side(!linesSideCredit, totals.total), ...tag, branchId }, + ...[...perAccount.entries()].filter(([, v]) => !v.isZero()).map(([accountId, v]) => ({ accountId, ...side(linesSideCredit, v), branchId })), + ]; + if (new Decimal(totals.taxAmount).greaterThan(0)) journal.push({ accountId: vatAccount, ...side(linesSideCredit, totals.taxAmount), branchId }); + if (!cogs.isZero()) { + journal.push({ accountId: await systemAccount(db, d.company_id, 'COGS'), ...side(false, cogs), branchId }); + journal.push({ accountId: await systemAccount(db, d.company_id, 'INVENTORY'), ...side(true, cogs), branchId }); + } + + const entry = await postEntry(db, ctx, { + companyId: d.company_id, entryDate: d.doc_date, description: `${kind.labelAr} ${number}`, + referenceType: kind.key, referenceId: id, source: 'SYSTEM', lines: journal, + }); + + const sets: [string, unknown][] = [ + ['status', kind.issuedStatus], ['doc_number', number], ['journal_entry_id', entry.id], ['issued_by', ctx.userId], + ['issued_at', new Date()], ['party_snapshot', JSON.stringify(await partySnapshot(db, kind, d.party_id))], + ['subtotal', totals.subtotal], ['discount_total', totals.discountTotal], ['taxable_amount', totals.taxableAmount], + ['tax_amount', totals.taxAmount], ['total', totals.total], ['warehouse_id', d.warehouse_id], + ]; + if (kind.key === 'SALES_INVOICE' || kind.key === 'PURCHASE_INVOICE') { + sets.push(['due_date', d.due_date ?? await defaultDueDate(db, kind, d.party_id, d.doc_date)]); + } + if (kind.key === 'SALES_INVOICE') sets.push(['invoice_kind', party!.vat_number ? 'STANDARD' : 'SIMPLIFIED']); + if (kind.key === 'SALES_RETURN') { + // A credit note follows the kind of the invoice it credits (standard or simplified). + const { rows: [o] } = await db.query<{ invoice_kind: string | null }>(`SELECT invoice_kind FROM sales_invoices WHERE id = $1`, [d.original_invoice_id]); + sets.push(['invoice_kind', o?.invoice_kind ?? 'SIMPLIFIED']); + } + + let originalKind: DocKind | null = null; + if (kind.isReturn) { + // Apply the credit to the original invoice up to what is still open on it. + originalKind = KINDS[kind.originalKind!]; + const { rows: [orig] } = await db.query<{ remaining_amount: string; status: string }>( + `SELECT remaining_amount::text, status FROM ${originalKind.table} WHERE id = $1 FOR UPDATE`, [d.original_invoice_id]); + if (orig!.status === 'CANCELLED') throw conflict('INVOICE_CANCELLED', 'The original invoice was cancelled'); + const applied = Decimal.min(totals.total, orig!.remaining_amount); + sets.push(['applied_amount', toMoney(applied)]); + await db.query(`UPDATE ${originalKind.table} SET returned_amount = returned_amount + $2 WHERE id = $1`, [d.original_invoice_id, toMoney(applied)]); + } + const snapshot = JSON.parse(sets.find(([c]) => c === 'party_snapshot')![1] as string) as { nameAr: string; vatNumber: string | null }; + await db.query(`UPDATE ${kind.table} SET ${sets.map(([c], i) => `${c} = $${i + 2}`).join(', ')} WHERE id = $1`, [id, ...sets.map(([, v]) => v)]); + if (originalKind) await refreshInvoiceStatus(db, originalKind, d.original_invoice_id as string); + + await recordTax(db, { + tenantId: ctx.tenantId, companyId: d.company_id, sourceType: kind.key as TaxSource, sourceId: id, sourceNumber: number, + journalEntryId: entry.id, date: d.doc_date, groups: taxGroups(lines, totals.taxAmount), + partyName: snapshot.nameAr, partyVatNumber: snapshot.vatNumber, + }); + + // E-invoice (ZATCA) for sales documents when the company has an active unit. + const zatcaInvoiceId = kind.key === 'SALES_INVOICE' || kind.key === 'SALES_RETURN' + ? await generateForDocument(db, ctx, kind.key, id, { companyId: d.company_id, branchId: d.branch_id as string | null }) + : null; + + await writeAudit(db, { + tenantId: ctx.tenantId, userId: ctx.userId, action: 'POST', entityType: kind.entity, entityId: id, + newValues: { number, total: totals.total, taxAmount: totals.taxAmount, journalEntryId: entry.id, zatcaInvoiceId }, + }, ctx.meta); + return loadDocument(db, kind, ctx.tenantId, id); +} + +/** + * Cancels an issued invoice or return that nothing has been settled + * against: its journal entry is reversed on the original date. + * Sales documents in the e-invoicing chain cannot be cancelled; they are corrected with a credit note. + */ +export async function cancelDocument(db: Db, kind: DocKind, ctx: Ctx, id: string, reason: string) { + const d = await lockDocument(db, kind, ctx.tenantId, id); + if (!kind.legal) { + const cancellable = kind.key === 'SALES_QUOTE' ? ['DRAFT', 'SENT', 'ACCEPTED'] : ['DRAFT', 'APPROVED']; + if (!cancellable.includes(d.status)) throw conflict('CANNOT_CANCEL', `A ${d.status.toLowerCase()} ${kind.label.toLowerCase()} cannot be cancelled`); + } else { + if (d.status === 'DRAFT') throw conflict('DOCUMENT_NOT_ISSUED', 'Delete the draft instead'); + if (d.status === 'CANCELLED') throw conflict('ALREADY_CANCELLED', 'This document is already cancelled'); + if (kind.key === 'SALES_INVOICE' || kind.key === 'SALES_RETURN') await assertCancellable(db, kind.key, id); + if (kind.isReturn) { + if (new Decimal(d.refunded_amount as string).greaterThan(0)) throw conflict('HAS_PAYMENTS', 'Void the refund payments first'); + } else { + if (new Decimal(d.paid_amount as string).greaterThan(0)) throw conflict('HAS_PAYMENTS', 'Void the payments allocated to this invoice first'); + const ret = kind.key === 'SALES_INVOICE' ? KINDS.SALES_RETURN : KINDS.PURCHASE_RETURN; + const returns = await db.query(`SELECT 1 FROM ${ret.table} WHERE original_invoice_id = $1 AND status = 'ISSUED' LIMIT 1`, [id]); + if (returns.rowCount) throw conflict('HAS_RETURNS', 'Cancel the returns issued against this invoice first'); + } + await reverse(db, ctx, d.journal_entry_id!, { reason: `إلغاء ${kind.labelAr} ${d.doc_number}: ${reason}`, date: d.doc_date, allowSystem: true }); + await reverseTax(db, kind.key as TaxSource, id); + const residual = await reverseDocumentMovements(db, stockRef(ctx, d.company_id, d.doc_date, kind.key, id), kind.key, id); + if (new Decimal(residual).greaterThan(0)) { + // The warehouse emptied with value left over: expense it so the ledgers stay equal. + await postEntry(db, ctx, { + companyId: d.company_id, entryDate: d.doc_date, description: `تسوية قيمة مخزون بعد إلغاء ${kind.labelAr} ${d.doc_number}`, + referenceType: 'INVENTORY_RESIDUAL', referenceId: id, source: 'SYSTEM', + lines: [ + { accountId: await systemAccount(db, d.company_id, 'COGS'), debit: residual }, + { accountId: await systemAccount(db, d.company_id, 'INVENTORY'), credit: residual }, + ], + }); + } + } + await db.query(`UPDATE ${kind.table} SET status = 'CANCELLED', cancelled_by = $2, cancelled_at = now(), cancel_reason = $3 WHERE id = $1`, + [id, ctx.userId, reason]); + if (kind.isReturn) { + const orig = KINDS[kind.originalKind!]; + await db.query(`SELECT 1 FROM ${orig.table} WHERE id = $1 FOR UPDATE`, [d.original_invoice_id]); + await db.query(`UPDATE ${orig.table} SET returned_amount = returned_amount - $2 WHERE id = $1`, [d.original_invoice_id, d.applied_amount]); + await refreshInvoiceStatus(db, orig, d.original_invoice_id as string); + } + await writeAudit(db, { tenantId: ctx.tenantId, userId: ctx.userId, action: 'CANCEL', entityType: kind.entity, entityId: id, newValues: { reason } }, ctx.meta); + return loadDocument(db, kind, ctx.tenantId, id); +} + +const stockRef = (ctx: Ctx, companyId: string, date: string, referenceType: string, referenceId: string): Ref => + ({ tenantId: ctx.tenantId, companyId, userId: ctx.userId, date, referenceType, referenceId }); + +/** + * Records the stock movements of an invoice or return being issued. + * Returns the net cost moved to cost of goods sold and, for purchase + * returns, the cost of each line. + */ +async function moveStock(db: Db, kind: DocKind, ctx: Ctx, d: Awaited>, lines: BuiltLine[], lineIds: string[]) { + const tracked = await trackedProducts(db, lines.map((l) => l.productId)); + const result = { cogs: new Decimal(0), costs: new Map(), warehouseId: null as string | null }; + if (!tracked.size) return result; + const ref = stockRef(ctx, d.company_id, d.doc_date, kind.key, d.id); + const docWarehouse = await resolveWarehouse(db, d.company_id, d.warehouse_id as string | null); + result.warehouseId = docWarehouse; + + for (const [i, l] of lines.entries()) { + if (!l.productId || !tracked.has(l.productId)) continue; + const move = { productId: l.productId, quantity: l.quantity, lineId: lineIds[i]! }; + if (kind.key === 'SALES_INVOICE') { + const { cost } = await issue(db, ref, { ...move, warehouseId: docWarehouse, type: 'SALE' }); + result.cogs = result.cogs.plus(cost); + } else if (kind.key === 'PURCHASE_INVOICE') { + await receive(db, ref, { ...move, warehouseId: docWarehouse, type: 'PURCHASE' }, l.netAmount); + } else { + // Returns go back to (or leave from) the warehouse of the original movement. + const origType = kind.key === 'SALES_RETURN' ? 'SALE' : 'PURCHASE'; + const { rows: [orig] } = await db.query<{ warehouse_id: string; quantity: string; total_cost: string }>( + `SELECT warehouse_id, quantity::text, total_cost::text FROM stock_movements + WHERE reference_line_id = $1 AND movement_type = $2 LIMIT 1`, [l.sourceItemId, origType]); + const warehouseId = orig?.warehouse_id ?? docWarehouse; + if (kind.key === 'PURCHASE_RETURN') { + const { cost } = await issue(db, ref, { ...move, warehouseId, type: 'PURCHASE_RETURN' }); + result.costs.set(i, cost); + continue; + } + if (!orig) continue; // sold before stock tracking: nothing to bring back at cost + // Sales return: the cost the goods left at, prorated; the last return takes the rest. + const { rows: [prior] } = await db.query<{ qty: string; cost: string }>( + `SELECT COALESCE(sum(m.quantity), 0)::text AS qty, COALESCE(sum(m.total_cost), 0)::text AS cost + FROM stock_movements m + JOIN ${kind.itemsTable} ri ON ri.id = m.reference_line_id + JOIN ${kind.table} r ON r.id = ri.document_id + WHERE ri.source_item_id = $1 AND m.movement_type = 'SALE_RETURN' AND r.status = 'ISSUED'`, [l.sourceItemId]); + const remainingQty = new Decimal(orig.quantity).minus(prior!.qty); + const cost = new Decimal(l.quantity).equals(remainingQty) + ? new Decimal(orig.total_cost).minus(prior!.cost) + : new Decimal(orig.total_cost).times(l.quantity).dividedBy(orig.quantity).toDecimalPlaces(2, Decimal.ROUND_HALF_UP); + await receive(db, ref, { ...move, warehouseId, type: 'SALE_RETURN' }, toMoney(cost)); + result.cogs = result.cogs.minus(cost); + } + } + return result; +} + +const TRANSITIONS: Record> = { + SALES_QUOTE: { DRAFT: ['SENT'], SENT: ['ACCEPTED', 'REJECTED'] }, + PURCHASE_ORDER: { DRAFT: ['APPROVED'] }, +}; + +/** Moves a quote or purchase order along its workflow. */ +export async function setStatus(db: Db, kind: DocKind, ctx: Ctx, id: string, status: string) { + const d = await lockDocument(db, kind, ctx.tenantId, id); + const allowed = TRANSITIONS[kind.key]?.[d.status] ?? []; + if (!allowed.includes(status)) throw conflict('INVALID_TRANSITION', `Cannot move from ${d.status} to ${status}`); + await db.query(`UPDATE ${kind.table} SET status = $2 WHERE id = $1`, [id, status]); + await writeAudit(db, { tenantId: ctx.tenantId, userId: ctx.userId, action: 'UPDATE', entityType: kind.entity, entityId: id, oldValues: { status: d.status }, newValues: { status } }, ctx.meta); + return loadDocument(db, kind, ctx.tenantId, id); +} + +/** Copies a quote into a draft invoice, or an approved order into a draft purchase invoice. */ +export async function convertDocument(db: Db, kind: DocKind, ctx: Ctx, id: string, docDate: string) { + const target = kind.key === 'SALES_QUOTE' ? KINDS.SALES_INVOICE : kind.key === 'PURCHASE_ORDER' ? KINDS.PURCHASE_INVOICE : null; + if (!target) throw badRequest('NOT_CONVERTIBLE', 'Only quotes and purchase orders can be converted'); + const d = await lockDocument(db, kind, ctx.tenantId, id); + const ok = kind.key === 'SALES_QUOTE' ? ['DRAFT', 'SENT', 'ACCEPTED'] : ['APPROVED']; + if (!ok.includes(d.status)) throw conflict('INVALID_TRANSITION', `A ${d.status.toLowerCase()} ${kind.label.toLowerCase()} cannot be converted`); + const source = await loadDocument(db, kind, ctx.tenantId, id); + const invoiceId = await createDraft(db, target, ctx, { + companyId: d.company_id, partyId: d.party_id, docDate, branchId: d.branch_id, warehouseId: d.warehouse_id as string | null, + pricesIncludeVat: d.prices_include_vat as boolean, notes: d.notes as string | null, sourceId: id, + lines: source.lines.map((l) => ({ + productId: l.productId, accountId: l.accountId, description: l.description, quantity: l.quantity, + unitPrice: l.unitPrice, discountAmount: l.discountBasis, unitId: l.unitId, vatCategory: l.vatCategory, + })), + }); + await db.query(`UPDATE ${kind.table} SET status = 'CONVERTED', converted_invoice_id = $2 WHERE id = $1`, [id, invoiceId]); + await writeAudit(db, { tenantId: ctx.tenantId, userId: ctx.userId, action: 'UPDATE', entityType: kind.entity, entityId: id, newValues: { status: 'CONVERTED', invoiceId } }, ctx.meta); + return loadDocument(db, target, ctx.tenantId, invoiceId); +} + diff --git a/alshuyukh-accounting/apps/api/src/modules/documents/routes.ts b/alshuyukh-accounting/apps/api/src/modules/documents/routes.ts new file mode 100644 index 000000000000..0052a6185ff7 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/documents/routes.ts @@ -0,0 +1,357 @@ +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { z } from 'zod'; +import { isoDate, todayIn } from '../../lib/dates.js'; +import { badRequest, notFound } from '../../lib/errors.js'; +import { amountString } from '../../lib/money.js'; +import { parse, uuidParam } from '../../lib/validation.js'; +import { requirePermission } from '../../plugins/auth.js'; +import { resolveCompanyId } from '../accounting/company-context.js'; +import { writeAudit } from '../audit/audit.service.js'; +import { CalcError, totalsOf } from './calc.js'; +import { buildLines, buildReturn, createDraft, createReturnDraft, deleteDraft, headerSelect, loadDocument, updateDraft, type Ctx } from './drafts.js'; +import { KINDS, partyColumn, type DocKind } from './kinds.js'; +import { PAYMENT_SELECT, allocate, createPayment, loadPayment, voidPayment } from './payments.js'; +import { cancelDocument, convertDocument, issueDocument, setStatus } from './posting.js'; + +const quantity = z.string().trim().regex(/^\d{1,14}(\.\d{1,4})?$/, 'Quantity: up to 4 decimals, sent as a string'); +const price = z.string().trim().regex(/^\d{1,14}(\.\d{1,4})?$/, 'Price: up to 4 decimals, sent as a string'); +const percent = z.string().trim().regex(/^\d{1,3}(\.\d{1,2})?$/); + +const line = z.object({ + productId: z.uuid().nullish(), + accountId: z.uuid().nullish(), + description: z.string().trim().max(500).nullish(), + quantity, + unitPrice: price.optional(), + discountAmount: amountString.optional(), + discountPercent: percent.optional(), + unitId: z.uuid().nullish(), + vatCategory: z.enum(['S', 'Z', 'E', 'O']).optional(), +}); + +const draftFields = { + companyId: z.uuid().optional(), + partyId: z.uuid(), + docDate: isoDate, + dueDate: isoDate.nullish(), + validUntil: isoDate.nullish(), + expectedDate: isoDate.nullish(), + supplierInvoiceNumber: z.string().trim().min(1).max(60).nullish(), + branchId: z.uuid().nullish(), + warehouseId: z.uuid().nullish(), + pricesIncludeVat: z.boolean().optional(), + notes: z.string().trim().max(2000).nullish(), + lines: z.array(line).min(1).max(500), +}; +const draftBody = z.object(draftFields); +const draftUpdate = z.object(draftFields).omit({ companyId: true }).partial(); + +const returnBody = z.object({ + originalInvoiceId: z.uuid(), + docDate: isoDate, + reason: z.string().trim().min(3).max(500), + notes: z.string().trim().max(2000).nullish(), + lines: z.array(z.object({ sourceItemId: z.uuid(), quantity })).min(1).max(500), +}); +const returnUpdate = returnBody.omit({ originalInvoiceId: true }).partial(); + +const listQuery = z.object({ + companyId: z.uuid().optional(), + status: z.string().regex(/^[A-Z_]+(,[A-Z_]+)*$/).optional(), + partyId: z.uuid().optional(), + dateFrom: isoDate.optional(), + dateTo: isoDate.optional(), + open: z.enum(['true', 'false']).optional(), + search: z.string().trim().max(60).optional(), + limit: z.coerce.number().int().min(1).max(200).default(50), + offset: z.coerce.number().int().min(0).default(0), +}); + +const ctxOf = (req: FastifyRequest): Ctx => ({ tenantId: req.auth!.tenantId, userId: req.auth!.userId, meta: req.auditMeta() }); + +const asCalcError = (e: unknown) => (e instanceof CalcError ? badRequest('INVALID_LINE', e.message) : e); + +function documentRoutes(kind: DocKind) { + return async (app: FastifyInstance) => { + const base = `/${kind.path}`; + const can = (p: keyof DocKind['perm']) => requirePermission(app, kind.perm[p]); + + app.get(base, { preHandler: can('view') }, async (req) => { + const q = parse(listQuery, req.query); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const statuses = q.status?.split(',') ?? null; + const search = q.search ? `%${q.search.replace(/[%_\\]/g, (m) => `\\${m}`)}%` : null; + const hasRemaining = kind.legal; + const { rows } = await db.query<{ total_count: string }>( + `${headerSelect(kind).replace('SELECT d.id', 'SELECT count(*) OVER () AS total_count, d.id')} + WHERE d.tenant_id = $1 AND d.company_id = $2 AND d.deleted_at IS NULL + AND ($3::text[] IS NULL OR d.status = ANY($3)) + AND ($4::uuid IS NULL OR d.${partyColumn(kind)} = $4) + AND ($5::date IS NULL OR d.doc_date >= $5) AND ($6::date IS NULL OR d.doc_date <= $6) + AND (NOT $7::boolean ${hasRemaining ? `OR (d.status NOT IN ('DRAFT', 'CANCELLED') AND d.remaining_amount > 0)` : ''}) + AND ($8::text IS NULL OR d.doc_number ILIKE $8 OR p.name_ar ILIKE $8) + ORDER BY d.doc_date DESC, d.doc_number DESC NULLS FIRST, d.created_at DESC + LIMIT $9 OFFSET $10`, + [req.auth!.tenantId, companyId, statuses, q.partyId ?? null, q.dateFrom ?? null, q.dateTo ?? null, + q.open === 'true', search, q.limit, q.offset]); + return { data: rows.map(({ total_count: _t, ...r }) => r), total: Number(rows[0]?.total_count ?? 0) }; + }); + }); + + app.get(`${base}/:id`, { preHandler: can('view') }, async (req) => { + const { id } = parse(uuidParam, req.params); + return req.tenantTx((db) => loadDocument(db, kind, req.auth!.tenantId, id)); + }); + + /** Server-side totals for the form, without saving anything. */ + app.post(`${base}/calculate`, { preHandler: can('view') }, async (req) => { + return req.tenantTx(async (db) => { + try { + if (kind.isReturn) { + const body = parse(returnBody.extend({ reason: z.string().optional() }), req.body); + const r = await buildReturn(db, kind, req.auth!.tenantId, { ...body, reason: body.reason ?? '' }, null); + return { lines: r.lines, totals: r.totals }; + } + const body = parse(draftBody.extend({ partyId: z.uuid().optional() }), req.body); + const companyId = await resolveCompanyId(db, req.auth!.tenantId, body.companyId); + const lines = await buildLines(db, kind, companyId, body); + return { lines, totals: totalsOf(lines) }; + } catch (e) { throw asCalcError(e); } + }); + }); + + app.post(base, { preHandler: can('create') }, async (req, reply) => { + const ctx = ctxOf(req); + const doc = await req.tenantTx(async (db) => { + let id: string; + if (kind.isReturn) { + id = await createReturnDraft(db, kind, ctx, parse(returnBody, req.body)); + } else { + const body = parse(draftBody, req.body); + const companyId = await resolveCompanyId(db, ctx.tenantId, body.companyId); + id = await createDraft(db, kind, ctx, { ...body, companyId }); + } + return loadDocument(db, kind, ctx.tenantId, id); + }); + reply.code(201); + return doc; + }); + + app.patch(`${base}/:id`, { preHandler: can('edit') }, async (req) => { + const { id } = parse(uuidParam, req.params); + const body = kind.isReturn ? parse(returnUpdate, req.body) : parse(draftUpdate, req.body); + return req.tenantTx((db) => updateDraft(db, kind, ctxOf(req), id, body)); + }); + + app.delete(`${base}/:id`, { preHandler: can('delete') }, async (req, reply) => { + const { id } = parse(uuidParam, req.params); + await req.tenantTx((db) => deleteDraft(db, kind, ctxOf(req), id)); + return reply.code(204).send(); + }); + + app.post(`${base}/:id/cancel`, { preHandler: can('cancel') }, async (req) => { + const { id } = parse(uuidParam, req.params); + const { reason } = parse(z.object({ reason: z.string().trim().min(3).max(500) }), req.body); + return req.tenantTx((db) => cancelDocument(db, kind, ctxOf(req), id, reason)); + }); + + if (kind.legal) { + app.post(`${base}/:id/post`, { preHandler: can('issue') }, async (req) => { + const { id } = parse(uuidParam, req.params); + return req.tenantTx(async (db) => { + try { return await issueDocument(db, kind, ctxOf(req), id); } catch (e) { throw asCalcError(e); } + }); + }); + } else { + app.post(`${base}/:id/status`, { preHandler: can(kind.key === 'PURCHASE_ORDER' ? 'issue' : 'edit') }, async (req) => { + const { id } = parse(uuidParam, req.params); + const { status } = parse(z.object({ status: z.enum(['SENT', 'ACCEPTED', 'REJECTED', 'APPROVED']) }), req.body); + return req.tenantTx((db) => setStatus(db, kind, ctxOf(req), id, status)); + }); + app.post(`${base}/:id/convert`, { preHandler: can('create') }, async (req, reply) => { + const { id } = parse(uuidParam, req.params); + const body = parse(z.object({ docDate: isoDate.optional() }), req.body ?? {}); + const invoice = await req.tenantTx((db) => convertDocument(db, kind, ctxOf(req), id, body.docDate ?? todayIn('Asia/Riyadh'))); + reply.code(201); + return invoice; + }); + } + }; +} + +const paymentBody = z.object({ + companyId: z.uuid().optional(), + direction: z.enum(['RECEIPT', 'DISBURSEMENT']), + customerId: z.uuid().nullish(), + supplierId: z.uuid().nullish(), + paymentDate: isoDate, + methodId: z.uuid(), + amount: amountString.refine((v) => Number(v) > 0, 'Amount must be greater than zero'), + reference: z.string().trim().max(100).nullish(), + notes: z.string().trim().max(1000).nullish(), + branchId: z.uuid().nullish(), + allocations: z.array(z.object({ + documentType: z.enum(['SALES_INVOICE', 'SALES_RETURN', 'PURCHASE_INVOICE', 'PURCHASE_RETURN', 'EXPENSE']), + documentId: z.uuid(), + amount: amountString, + })).max(200).default([]), +}); + +async function paymentRoutes(app: FastifyInstance) { + app.get('/payments', { preHandler: requirePermission(app, 'payment.view') }, async (req) => { + const q = parse(z.object({ + companyId: z.uuid().optional(), + direction: z.enum(['RECEIPT', 'DISBURSEMENT']).optional(), + customerId: z.uuid().optional(), + supplierId: z.uuid().optional(), + status: z.enum(['POSTED', 'VOIDED']).optional(), + limit: z.coerce.number().int().min(1).max(200).default(50), + offset: z.coerce.number().int().min(0).default(0), + }), req.query); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const { rows } = await db.query<{ total_count: string }>( + `${PAYMENT_SELECT.replace('SELECT p.id', 'SELECT count(*) OVER () AS total_count, p.id')} + WHERE p.tenant_id = $1 AND p.company_id = $2 AND ($3::text IS NULL OR p.direction = $3) + AND ($4::uuid IS NULL OR p.customer_id = $4) AND ($5::uuid IS NULL OR p.supplier_id = $5) + AND ($6::text IS NULL OR p.status = $6) + ORDER BY p.payment_date DESC, p.payment_number DESC LIMIT $7 OFFSET $8`, + [req.auth!.tenantId, companyId, q.direction ?? null, q.customerId ?? null, q.supplierId ?? null, q.status ?? null, q.limit, q.offset]); + return { data: rows.map(({ total_count: _t, ...r }) => r), total: Number(rows[0]?.total_count ?? 0) }; + }); + }); + + app.get('/payments/:id', { preHandler: requirePermission(app, 'payment.view') }, async (req) => { + const { id } = parse(uuidParam, req.params); + return req.tenantTx((db) => loadPayment(db, req.auth!.tenantId, id)); + }); + + app.post('/payments', { preHandler: requirePermission(app, 'payment.create') }, async (req, reply) => { + const body = parse(paymentBody, req.body); + const payment = await req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, body.companyId); + return createPayment(db, ctxOf(req), { ...body, companyId }); + }); + reply.code(201); + return payment; + }); + + app.post('/payments/:id/allocations', { preHandler: requirePermission(app, 'payment.create') }, async (req) => { + const { id } = parse(uuidParam, req.params); + const { allocations } = parse(paymentBody.pick({ allocations: true }), req.body); + if (!allocations.length) throw badRequest('NO_ALLOCATIONS', 'Provide at least one allocation'); + return req.tenantTx((db) => allocate(db, ctxOf(req), id, allocations)); + }); + + app.post('/payments/:id/void', { preHandler: requirePermission(app, 'payment.void') }, async (req) => { + const { id } = parse(uuidParam, req.params); + const { reason } = parse(z.object({ reason: z.string().trim().min(3).max(500) }), req.body); + return req.tenantTx((db) => voidPayment(db, ctxOf(req), id, reason)); + }); + + // Payment methods --------------------------------------------------------------- + const METHOD_SELECT = `SELECT m.id, m.code, m.name_ar AS "nameAr", m.method_type AS "methodType", m.account_id AS "accountId", + a.code AS "accountCode", a.name_ar AS "accountName", m.is_active AS "isActive", m.sort_order AS "sortOrder" + FROM payment_methods m JOIN accounts a ON a.id = m.account_id`; + + app.get('/payment-methods', { preHandler: requirePermission(app, 'payment.view') }, async (req) => { + const q = parse(z.object({ companyId: z.uuid().optional() }), req.query); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + return { data: (await db.query(`${METHOD_SELECT} WHERE m.tenant_id = $1 AND m.company_id = $2 ORDER BY m.sort_order, m.code`, [req.auth!.tenantId, companyId])).rows }; + }); + }); + + const methodAccountCheck = async (db: Parameters[0]>[0], companyId: string, accountId: string) => { + const { rows: [a] } = await db.query<{ account_type: string; is_postable: boolean; is_active: boolean }>( + `SELECT account_type, is_postable, is_active FROM accounts WHERE company_id = $1 AND id = $2 AND deleted_at IS NULL`, [companyId, accountId]); + if (!a || a.account_type !== 'ASSET' || !a.is_postable || !a.is_active) { + throw badRequest('INVALID_ACCOUNT', 'A payment method needs an active postable asset account (cash, bank or clearing)'); + } + }; + + app.post('/payment-methods', { preHandler: requirePermission(app, 'settings.manage') }, async (req, reply) => { + const body = parse(z.object({ + companyId: z.uuid().optional(), + code: z.string().trim().toUpperCase().regex(/^[A-Z][A-Z0-9_]{1,30}$/), + nameAr: z.string().trim().min(2).max(100), + methodType: z.enum(['CASH', 'BANK', 'CARD', 'STC_PAY', 'TAMARA', 'OTHER']), + accountId: z.uuid(), + }), req.body); + const a = req.auth!; + const method = await req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, a.tenantId, body.companyId); + await methodAccountCheck(db, companyId, body.accountId); + const { rows: [m] } = await db.query<{ id: string }>( + `INSERT INTO payment_methods (tenant_id, company_id, code, name_ar, method_type, account_id, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, 100) RETURNING id`, [a.tenantId, companyId, body.code, body.nameAr, body.methodType, body.accountId]); + const created = (await db.query(`${METHOD_SELECT} WHERE m.id = $1`, [m!.id])).rows[0]; + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'SETTINGS_CHANGE', entityType: 'payment_method', entityId: m!.id, newValues: created }, req.auditMeta()); + return created; + }); + reply.code(201); + return method; + }); + + app.patch('/payment-methods/:id', { preHandler: requirePermission(app, 'settings.manage') }, async (req) => { + const { id } = parse(uuidParam, req.params); + const body = parse(z.object({ nameAr: z.string().trim().min(2).max(100), accountId: z.uuid(), isActive: z.boolean() }).partial(), req.body); + const a = req.auth!; + return req.tenantTx(async (db) => { + const { rows: [before] } = await db.query<{ companyId: string }>(`${METHOD_SELECT.replace('SELECT m.id', 'SELECT m.company_id AS "companyId", m.id')} WHERE m.tenant_id = $1 AND m.id = $2`, [a.tenantId, id]); + if (!before) throw notFound('Payment method'); + if (body.accountId) await methodAccountCheck(db, before.companyId, body.accountId); + await db.query(`UPDATE payment_methods SET name_ar = COALESCE($2, name_ar), account_id = COALESCE($3, account_id), is_active = COALESCE($4, is_active) WHERE id = $1`, + [id, body.nameAr ?? null, body.accountId ?? null, body.isActive ?? null]); + const after = (await db.query(`${METHOD_SELECT} WHERE m.id = $1`, [id])).rows[0]; + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'SETTINGS_CHANGE', entityType: 'payment_method', entityId: id, oldValues: before, newValues: after }, req.auditMeta()); + return after; + }); + }); + + // Tax rates --------------------------------------------------------------------- + // The VAT rate is data, not code: a new rate is added with an effective date + // and the previous one is closed the day before. Phase 6 adds the VAT report. + const RATE_SELECT = `SELECT id, vat_category AS "vatCategory", name_ar AS "nameAr", rate::text, effective_from AS "effectiveFrom", + effective_to AS "effectiveTo", is_active AS "isActive" FROM tax_rates`; + + app.get('/tax-rates', { preHandler: requirePermission(app, 'invoice.view') }, async (req) => { + const q = parse(z.object({ companyId: z.uuid().optional() }), req.query); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + return { data: (await db.query(`${RATE_SELECT} WHERE tenant_id = $1 AND company_id = $2 ORDER BY effective_from DESC`, [req.auth!.tenantId, companyId])).rows }; + }); + }); + + app.post('/tax-rates', { preHandler: requirePermission(app, 'tax.manage') }, async (req, reply) => { + const body = parse(z.object({ + companyId: z.uuid().optional(), + nameAr: z.string().trim().min(2).max(100), + rate: z.string().regex(/^0\.\d{1,4}$/, 'Rate is a fraction, e.g. "0.15"'), + effectiveFrom: isoDate, + }), req.body); + const a = req.auth!; + const rate = await req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, a.tenantId, body.companyId); + // Close the rate that is open on the new start date. + await db.query( + `UPDATE tax_rates SET effective_to = $2::date - 1 + WHERE company_id = $1 AND vat_category = 'S' AND is_active AND effective_from < $2 + AND (effective_to IS NULL OR effective_to >= $2)`, [companyId, body.effectiveFrom]); + const { rows: [r] } = await db.query<{ id: string }>( + `INSERT INTO tax_rates (tenant_id, company_id, name_ar, rate, effective_from, created_by) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, + [a.tenantId, companyId, body.nameAr, body.rate, body.effectiveFrom, a.userId]); + const created = (await db.query(`${RATE_SELECT} WHERE id = $1`, [r!.id])).rows[0]; + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'SETTINGS_CHANGE', entityType: 'tax_rate', entityId: r!.id, newValues: created }, req.auditMeta()); + return created; + }); + reply.code(201); + return rate; + }); +} + +export default async function documentsModule(app: FastifyInstance) { + for (const kind of Object.values(KINDS)) await app.register(documentRoutes(kind)); + await app.register(paymentRoutes); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/expenses/routes.ts b/alshuyukh-accounting/apps/api/src/modules/expenses/routes.ts new file mode 100644 index 000000000000..c93a0bc3ceec --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/expenses/routes.ts @@ -0,0 +1,173 @@ +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { z } from 'zod'; +import { isoDate } from '../../lib/dates.js'; +import { badRequest, forbidden, notFound } from '../../lib/errors.js'; +import { amountString } from '../../lib/money.js'; +import { parse, uuidParam } from '../../lib/validation.js'; +import { requirePermission } from '../../plugins/auth.js'; +import { resolveCompanyId } from '../accounting/company-context.js'; +import { writeAudit } from '../audit/audit.service.js'; +import type { Ctx } from '../documents/drafts.js'; +import { EXPENSE_SELECT, buildExpenseLines, cancelExpense, createExpense, deleteExpense, loadExpense, postExpense, updateExpense } from './service.js'; + +const line = z.object({ + categoryId: z.uuid(), + amount: amountString, + description: z.string().trim().max(500).nullish(), + vatCategory: z.enum(['S', 'Z', 'E', 'O']).optional(), + costCenterId: z.uuid().nullish(), +}); +const fields = { + companyId: z.uuid().optional(), + expenseDate: isoDate, + paymentType: z.enum(['CASH', 'BANK', 'CREDIT']), + methodId: z.uuid().nullish(), + supplierId: z.uuid().nullish(), + payeeName: z.string().trim().max(200).nullish(), + reference: z.string().trim().max(60).nullish(), + vendorVatNumber: z.string().regex(/^3\d{13}3$/, 'Saudi VAT number must be 15 digits starting and ending with 3').nullish(), + branchId: z.uuid().nullish(), + pricesIncludeVat: z.boolean().optional(), + notes: z.string().trim().max(2000).nullish(), + lines: z.array(line).min(1).max(500), +}; +const createBody = z.object({ ...fields, post: z.boolean().default(false) }); +const updateBody = z.object(fields).omit({ companyId: true }).partial(); + +const ctxOf = (req: FastifyRequest): Ctx => ({ tenantId: req.auth!.tenantId, userId: req.auth!.userId, meta: req.auditMeta() }); + +export default async function expenseRoutes(app: FastifyInstance) { + const canView = requirePermission(app, 'expense.view'); + + // Categories ------------------------------------------------------------------ + const CAT_SELECT = `SELECT c.id, c.code, c.name_ar AS "nameAr", c.account_id AS "accountId", a.code AS "accountCode", + a.name_ar AS "accountName", c.vat_category AS "vatCategory", c.is_active AS "isActive" + FROM expense_categories c JOIN accounts a ON a.id = c.account_id`; + + const checkAccount = async (db: Parameters[0]>[0], companyId: string, accountId: string) => { + // Asset accounts are allowed for prepaid expenses and small purchases, but not + // cash, bank, payment-method or control accounts (AR, inventory…): those have + // their own sub-ledgers and must not move through an expense. + const { rows: [a] } = await db.query<{ account_type: string; is_postable: boolean; is_active: boolean; protected: boolean }>( + `SELECT account_type, is_postable, is_active, + (system_key IS NOT NULL OR id IN (SELECT account_id FROM payment_methods WHERE company_id = $1)) AS protected + FROM accounts WHERE company_id = $1 AND id = $2 AND deleted_at IS NULL`, [companyId, accountId]); + if (!a || !a.is_postable || !a.is_active || !['EXPENSE', 'COST_OF_GOODS_SOLD', 'ASSET'].includes(a.account_type) + || (a.account_type === 'ASSET' && a.protected)) { + throw badRequest('INVALID_ACCOUNT', 'An expense category needs an active postable expense, cost or asset account'); + } + }; + + app.get('/expense-categories', { preHandler: canView }, async (req) => { + const q = parse(z.object({ companyId: z.uuid().optional() }), req.query); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + return { data: (await db.query(`${CAT_SELECT} WHERE c.tenant_id = $1 AND c.company_id = $2 ORDER BY c.name_ar`, [req.auth!.tenantId, companyId])).rows }; + }); + }); + + app.post('/expense-categories', { preHandler: requirePermission(app, 'account.manage') }, async (req, reply) => { + const body = parse(z.object({ + companyId: z.uuid().optional(), code: z.string().trim().toUpperCase().regex(/^[A-Z][A-Z0-9_]{1,30}$/), + nameAr: z.string().trim().min(2).max(100), accountId: z.uuid(), vatCategory: z.enum(['S', 'Z', 'E', 'O']).default('S'), + }), req.body); + const a = req.auth!; + const cat = await req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, a.tenantId, body.companyId); + await checkAccount(db, companyId, body.accountId); + const { rows: [c] } = await db.query<{ id: string }>( + `INSERT INTO expense_categories (tenant_id, company_id, code, name_ar, account_id, vat_category) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, + [a.tenantId, companyId, body.code, body.nameAr, body.accountId, body.vatCategory]); + const created = (await db.query(`${CAT_SELECT} WHERE c.id = $1`, [c!.id])).rows[0]; + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'CREATE', entityType: 'expense_category', entityId: c!.id, newValues: created }, req.auditMeta()); + return created; + }); + reply.code(201); + return cat; + }); + + app.patch('/expense-categories/:id', { preHandler: requirePermission(app, 'account.manage') }, async (req) => { + const { id } = parse(uuidParam, req.params); + const body = parse(z.object({ nameAr: z.string().trim().min(2).max(100), accountId: z.uuid(), vatCategory: z.enum(['S', 'Z', 'E', 'O']), isActive: z.boolean() }).partial(), req.body); + const a = req.auth!; + return req.tenantTx(async (db) => { + const { rows: [before] } = await db.query<{ companyId: string }>(`${CAT_SELECT.replace('SELECT c.id', 'SELECT c.company_id AS "companyId", c.id')} WHERE c.tenant_id = $1 AND c.id = $2`, [a.tenantId, id]); + if (!before) throw notFound('Expense category'); + if (body.accountId) await checkAccount(db, before.companyId, body.accountId); + await db.query(`UPDATE expense_categories SET name_ar = COALESCE($2, name_ar), account_id = COALESCE($3, account_id), + vat_category = COALESCE($4, vat_category), is_active = COALESCE($5, is_active) WHERE id = $1`, + [id, body.nameAr ?? null, body.accountId ?? null, body.vatCategory ?? null, body.isActive ?? null]); + const after = (await db.query(`${CAT_SELECT} WHERE c.id = $1`, [id])).rows[0]; + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'UPDATE', entityType: 'expense_category', entityId: id, oldValues: before, newValues: after }, req.auditMeta()); + return after; + }); + }); + + // Expenses -------------------------------------------------------------------- + app.get('/expenses', { preHandler: canView }, async (req) => { + const q = parse(z.object({ + companyId: z.uuid().optional(), status: z.string().regex(/^[A-Z_]+(,[A-Z_]+)*$/).optional(), supplierId: z.uuid().optional(), + open: z.enum(['true', 'false']).optional(), dateFrom: isoDate.optional(), dateTo: isoDate.optional(), + limit: z.coerce.number().int().min(1).max(200).default(50), offset: z.coerce.number().int().min(0).default(0), + }), req.query); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const { rows } = await db.query<{ total_count: string }>( + `${EXPENSE_SELECT.replace('SELECT e.id', 'SELECT count(*) OVER () AS total_count, e.id')} + WHERE e.tenant_id = $1 AND e.company_id = $2 AND e.deleted_at IS NULL + AND ($3::text[] IS NULL OR e.status = ANY($3)) AND ($4::uuid IS NULL OR e.supplier_id = $4) + AND (NOT $5::boolean OR (e.status IN ('POSTED', 'PARTIALLY_PAID') AND e.remaining_amount > 0)) + AND ($6::date IS NULL OR e.expense_date >= $6) AND ($7::date IS NULL OR e.expense_date <= $7) + ORDER BY e.expense_date DESC, e.expense_number DESC NULLS FIRST, e.created_at DESC LIMIT $8 OFFSET $9`, + [req.auth!.tenantId, companyId, q.status?.split(',') ?? null, q.supplierId ?? null, q.open === 'true', + q.dateFrom ?? null, q.dateTo ?? null, q.limit, q.offset]); + return { data: rows.map(({ total_count: _t, ...r }) => r), total: Number(rows[0]?.total_count ?? 0) }; + }); + }); + + app.get('/expenses/:id', { preHandler: canView }, async (req) => { + const { id } = parse(uuidParam, req.params); + return req.tenantTx((db) => loadExpense(db, req.auth!.tenantId, id)); + }); + + app.post('/expenses/calculate', { preHandler: canView }, async (req) => { + const body = parse(z.object({ companyId: z.uuid().optional(), expenseDate: isoDate, pricesIncludeVat: z.boolean().optional(), lines: fields.lines }), req.body); + return req.tenantTx(async (db) => buildExpenseLines(db, await resolveCompanyId(db, req.auth!.tenantId, body.companyId), body)); + }); + + app.post('/expenses', { preHandler: requirePermission(app, 'expense.create') }, async (req, reply) => { + const body = parse(createBody, req.body); + if (body.post && !req.auth!.permissions.has('expense.post')) throw forbidden('Missing permission: expense.post'); + const ctx = ctxOf(req); + const expense = await req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, ctx.tenantId, body.companyId); + const id = await createExpense(db, ctx, { ...body, companyId }); + return body.post ? postExpense(db, ctx, id) : loadExpense(db, ctx.tenantId, id); + }); + reply.code(201); + return expense; + }); + + app.patch('/expenses/:id', { preHandler: requirePermission(app, 'expense.create') }, async (req) => { + const { id } = parse(uuidParam, req.params); + const body = parse(updateBody, req.body); + return req.tenantTx((db) => updateExpense(db, ctxOf(req), id, body)); + }); + + app.delete('/expenses/:id', { preHandler: requirePermission(app, 'expense.create') }, async (req, reply) => { + const { id } = parse(uuidParam, req.params); + await req.tenantTx((db) => deleteExpense(db, ctxOf(req), id)); + return reply.code(204).send(); + }); + + app.post('/expenses/:id/post', { preHandler: requirePermission(app, 'expense.post') }, async (req) => { + const { id } = parse(uuidParam, req.params); + return req.tenantTx((db) => postExpense(db, ctxOf(req), id)); + }); + + app.post('/expenses/:id/cancel', { preHandler: requirePermission(app, 'expense.cancel') }, async (req) => { + const { id } = parse(uuidParam, req.params); + const { reason } = parse(z.object({ reason: z.string().trim().min(3).max(500) }), req.body); + return req.tenantTx((db) => cancelExpense(db, ctxOf(req), id, reason)); + }); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/expenses/service.ts b/alshuyukh-accounting/apps/api/src/modules/expenses/service.ts new file mode 100644 index 000000000000..0dba87882018 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/expenses/service.ts @@ -0,0 +1,278 @@ +import type { Db } from '../../db/tx.js'; +import { badRequest, conflict, notFound } from '../../lib/errors.js'; +import { Decimal, toMoney } from '../../lib/money.js'; +import { nextDocumentNumber } from '../../lib/sequences.js'; +import { postEntry, reverse, type LineInput as JournalLine } from '../accounting/engine.js'; +import { writeAudit } from '../audit/audit.service.js'; +import { CalcError, calculateLine, totalsOf, type VatCategory } from '../documents/calc.js'; +import { vatRateFor, type Ctx } from '../documents/drafts.js'; +import { controlAccount, systemAccount } from '../documents/posting.js'; +import { recordTax, reverseTax, taxGroups } from '../tax/ledger.js'; + +/** + * Expenses. + * + * Paid now (CASH / BANK): Dr Expense accounts + Dr VAT input / Cr payment method account + * On credit (CREDIT): Dr Expense accounts + Dr VAT input / Cr Payable (supplier) + * + * A credit expense is settled later with a supplier payment allocated to it. + */ + +export interface ExpenseLineInput { + categoryId: string; + amount: string; + description?: string | null; + vatCategory?: VatCategory; + costCenterId?: string | null; +} + +export interface ExpenseInput { + companyId: string; + expenseDate: string; + paymentType: 'CASH' | 'BANK' | 'CREDIT'; + methodId?: string | null; + supplierId?: string | null; + payeeName?: string | null; + reference?: string | null; + vendorVatNumber?: string | null; + branchId?: string | null; + pricesIncludeVat?: boolean; + notes?: string | null; + lines: ExpenseLineInput[]; +} + +interface BuiltLine { + categoryId: string; accountId: string; description: string | null; costCenterId: string | null; amount: string; + netAmount: string; vatCategory: VatCategory; vatRate: string; vatAmount: string; totalAmount: string; +} + +export async function buildExpenseLines(db: Db, companyId: string, input: Pick) { + if (!input.lines.length) throw badRequest('NO_LINES', 'An expense needs at least one line'); + const ids = [...new Set(input.lines.map((l) => l.categoryId))]; + const { rows: cats } = await db.query<{ id: string; account_id: string; vat_category: VatCategory; is_active: boolean; name_ar: string }>( + `SELECT id, account_id, vat_category, is_active, name_ar FROM expense_categories WHERE company_id = $1 AND id = ANY($2::uuid[])`, [companyId, ids]); + const byId = new Map(cats.map((c) => [c.id, c])); + const ccIds = [...new Set(input.lines.map((l) => l.costCenterId).filter(Boolean))] as string[]; + if (ccIds.length) { + const { rowCount } = await db.query(`SELECT 1 FROM cost_centers WHERE company_id = $1 AND id = ANY($2::uuid[]) AND is_active AND deleted_at IS NULL`, [companyId, ccIds]); + if (rowCount !== ccIds.length) throw badRequest('INVALID_COST_CENTER', 'A cost center does not exist or is inactive'); + } + const lines: BuiltLine[] = []; + for (const [i, l] of input.lines.entries()) { + const cat = byId.get(l.categoryId); + if (!cat) throw badRequest('INVALID_CATEGORY', `Line ${i + 1}: expense category does not exist in this company`); + if (!cat.is_active) throw badRequest('CATEGORY_INACTIVE', `Line ${i + 1}: "${cat.name_ar}" is inactive`); + const category = l.vatCategory ?? cat.vat_category; + const rate = await vatRateFor(db, companyId, category, input.expenseDate); + let calc; + try { + calc = calculateLine({ quantity: '1', unitPrice: l.amount, vatCategory: category, vatRate: rate }, input.pricesIncludeVat ?? false, i + 1); + } catch (e) { + if (e instanceof CalcError) throw badRequest('INVALID_LINE', e.message); + throw e; + } + if (!new Decimal(l.amount).greaterThan(0)) throw badRequest('INVALID_LINE', `Line ${i + 1}: amount must be greater than zero`); + lines.push({ + categoryId: cat.id, accountId: cat.account_id, description: l.description ?? null, costCenterId: l.costCenterId ?? null, + amount: toMoney(l.amount), netAmount: calc.netAmount, vatCategory: category, vatRate: calc.vatRate, vatAmount: calc.vatAmount, + totalAmount: calc.totalAmount, + }); + } + const t = totalsOf(lines.map((l) => ({ grossAmount: l.netAmount, discountAmount: '0', netAmount: l.netAmount, vatCategory: l.vatCategory, vatRate: l.vatRate }))); + return { lines, totals: { subtotal: t.taxableAmount, taxAmount: t.taxAmount, total: t.total } }; +} + +async function checkHeader(db: Db, companyId: string, input: Partial) { + if (input.paymentType === 'CREDIT' && !input.supplierId) throw badRequest('SUPPLIER_REQUIRED', 'An expense on credit needs a supplier'); + if (input.paymentType && input.paymentType !== 'CREDIT' && !input.methodId) throw badRequest('METHOD_REQUIRED', 'Choose how the expense was paid'); + if (input.supplierId) { + const s = await db.query(`SELECT 1 FROM suppliers WHERE company_id = $1 AND id = $2 AND deleted_at IS NULL AND is_active`, [companyId, input.supplierId]); + if (!s.rowCount) throw badRequest('INVALID_PARTY', 'Supplier does not exist in this company or is inactive'); + } + if (input.methodId) { + const m = await db.query(`SELECT 1 FROM payment_methods WHERE company_id = $1 AND id = $2 AND is_active`, [companyId, input.methodId]); + if (!m.rowCount) throw badRequest('INVALID_METHOD', 'Payment method does not exist in this company or is inactive'); + } + if (input.branchId) { + const b = await db.query(`SELECT 1 FROM branches WHERE company_id = $1 AND id = $2 AND deleted_at IS NULL`, [companyId, input.branchId]); + if (!b.rowCount) throw badRequest('INVALID_BRANCH', 'Branch does not exist in this company'); + } +} + +async function insertLines(db: Db, ctx: Ctx, companyId: string, expenseId: string, lines: BuiltLine[]) { + for (const [i, l] of lines.entries()) { + await db.query( + `INSERT INTO expense_items (tenant_id, company_id, document_id, line_no, category_id, account_id, description, cost_center_id, + amount, net_amount, vat_category, vat_rate, vat_amount, total_amount) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, + [ctx.tenantId, companyId, expenseId, i + 1, l.categoryId, l.accountId, l.description, l.costCenterId, l.amount, + l.netAmount, l.vatCategory, l.vatRate, l.vatAmount, l.totalAmount]); + } +} + +export async function createExpense(db: Db, ctx: Ctx, input: ExpenseInput): Promise { + await checkHeader(db, input.companyId, input); + const { lines, totals } = await buildExpenseLines(db, input.companyId, input); + const { rows: [e] } = await db.query<{ id: string }>( + `INSERT INTO expenses (tenant_id, company_id, branch_id, expense_date, payment_type, method_id, supplier_id, payee_name, + reference, vendor_vat_number, prices_include_vat, subtotal, tax_amount, total, notes, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING id`, + [ctx.tenantId, input.companyId, input.branchId ?? null, input.expenseDate, input.paymentType, + input.paymentType === 'CREDIT' ? null : input.methodId, input.supplierId ?? null, input.payeeName ?? null, + input.reference ?? null, input.vendorVatNumber ?? null, input.pricesIncludeVat ?? false, + totals.subtotal, totals.taxAmount, totals.total, input.notes ?? null, ctx.userId]); + await insertLines(db, ctx, input.companyId, e!.id, lines); + await writeAudit(db, { tenantId: ctx.tenantId, userId: ctx.userId, action: 'CREATE', entityType: 'expense', entityId: e!.id, newValues: { ...input, totals } }, ctx.meta); + return e!.id; +} + +async function lockExpense(db: Db, tenantId: string, id: string) { + const { rows: [e] } = await db.query<{ + id: string; company_id: string; status: string; expense_date: string; payment_type: 'CASH' | 'BANK' | 'CREDIT'; + method_id: string | null; supplier_id: string | null; payee_name: string | null; vendor_vat_number: string | null; + prices_include_vat: boolean; branch_id: string | null; journal_entry_id: string | null; expense_number: string | null; + paid_amount: string; deleted_at: Date | null; [k: string]: unknown; + }>(`SELECT *, paid_amount::text FROM expenses WHERE tenant_id = $1 AND id = $2 FOR UPDATE`, [tenantId, id]); + if (!e || e.deleted_at) throw notFound('Expense'); + return e; +} + +export async function updateExpense(db: Db, ctx: Ctx, id: string, input: Partial>) { + const e = await lockExpense(db, ctx.tenantId, id); + if (e.status !== 'DRAFT') throw conflict('DOCUMENT_NOT_DRAFT', 'Only draft expenses can be edited'); + const before = await loadExpense(db, ctx.tenantId, id); + const merged = { + expenseDate: input.expenseDate ?? e.expense_date, paymentType: input.paymentType ?? e.payment_type, + methodId: input.methodId !== undefined ? input.methodId : e.method_id, + supplierId: input.supplierId !== undefined ? input.supplierId : e.supplier_id, + payeeName: input.payeeName !== undefined ? input.payeeName : e.payee_name, + reference: input.reference !== undefined ? input.reference : (e.reference as string | null), + vendorVatNumber: input.vendorVatNumber !== undefined ? input.vendorVatNumber : e.vendor_vat_number, + branchId: input.branchId !== undefined ? input.branchId : e.branch_id, + pricesIncludeVat: input.pricesIncludeVat ?? e.prices_include_vat, + notes: input.notes !== undefined ? input.notes : (e.notes as string | null), + lines: input.lines ?? before.lines.map((l) => ({ categoryId: l.categoryId, amount: l.amount, description: l.description, vatCategory: l.vatCategory, costCenterId: l.costCenterId })), + }; + await checkHeader(db, e.company_id, merged); + const { lines, totals } = await buildExpenseLines(db, e.company_id, merged); + await db.query( + `UPDATE expenses SET expense_date = $2, payment_type = $3, method_id = $4, supplier_id = $5, payee_name = $6, reference = $7, + vendor_vat_number = $8, branch_id = $9, prices_include_vat = $10, notes = $11, subtotal = $12, tax_amount = $13, total = $14 + WHERE id = $1`, + [id, merged.expenseDate, merged.paymentType, merged.paymentType === 'CREDIT' ? null : merged.methodId, merged.supplierId, + merged.payeeName, merged.reference, merged.vendorVatNumber, merged.branchId, merged.pricesIncludeVat, merged.notes, + totals.subtotal, totals.taxAmount, totals.total]); + await db.query(`DELETE FROM expense_items WHERE document_id = $1`, [id]); + await insertLines(db, ctx, e.company_id, id, lines); + const after = await loadExpense(db, ctx.tenantId, id); + await writeAudit(db, { tenantId: ctx.tenantId, userId: ctx.userId, action: 'UPDATE', entityType: 'expense', entityId: id, oldValues: before, newValues: after }, ctx.meta); + return after; +} + +export async function deleteExpense(db: Db, ctx: Ctx, id: string) { + const e = await lockExpense(db, ctx.tenantId, id); + if (e.status !== 'DRAFT') throw conflict('DOCUMENT_NOT_DRAFT', 'Posted expenses cannot be deleted. Cancel them instead.'); + await db.query(`UPDATE expenses SET deleted_at = now() WHERE id = $1`, [id]); + await writeAudit(db, { tenantId: ctx.tenantId, userId: ctx.userId, action: 'DELETE', entityType: 'expense', entityId: id }, ctx.meta); +} + +export async function postExpense(db: Db, ctx: Ctx, id: string) { + const e = await lockExpense(db, ctx.tenantId, id); + if (e.status !== 'DRAFT') throw conflict('DOCUMENT_NOT_DRAFT', 'This expense is already posted'); + const current = await loadExpense(db, ctx.tenantId, id); + // Recalculate with the rates in force on the expense date. + const { lines, totals } = await buildExpenseLines(db, e.company_id, { + expenseDate: e.expense_date, pricesIncludeVat: e.prices_include_vat, + lines: current.lines.map((l) => ({ categoryId: l.categoryId, amount: l.amount, description: l.description, vatCategory: l.vatCategory, costCenterId: l.costCenterId })), + }); + await checkHeader(db, e.company_id, { paymentType: e.payment_type, methodId: e.method_id, supplierId: e.supplier_id }); + + const number = await nextDocumentNumber(db, ctx.tenantId, e.company_id, 'EXPENSE', { prefix: 'EXP', padding: 6 }); + let creditAccount: string; + if (e.payment_type === 'CREDIT') { + creditAccount = await controlAccount(db, 'supplier', e.company_id, e.supplier_id!); + } else { + const { rows: [m] } = await db.query<{ account_id: string }>(`SELECT account_id FROM payment_methods WHERE id = $1`, [e.method_id]); + creditAccount = m!.account_id; + } + // Debit each expense account and cost center once. + const debits = new Map(); + for (const l of lines) { + const key = `${l.accountId}|${l.costCenterId ?? ''}`; + const d = debits.get(key) ?? { accountId: l.accountId, costCenterId: l.costCenterId, amount: new Decimal(0) }; + d.amount = d.amount.plus(l.netAmount); + debits.set(key, d); + } + const journal: JournalLine[] = [...debits.values()].filter((d) => !d.amount.isZero()) + .map((d) => ({ accountId: d.accountId, debit: toMoney(d.amount), costCenterId: d.costCenterId, branchId: e.branch_id })); + if (new Decimal(totals.taxAmount).greaterThan(0)) { + journal.push({ accountId: await systemAccount(db, e.company_id, 'VAT_INPUT'), debit: totals.taxAmount, branchId: e.branch_id }); + } + journal.push({ + accountId: creditAccount, credit: totals.total, branchId: e.branch_id, + ...(e.payment_type === 'CREDIT' ? { supplierId: e.supplier_id } : {}), + }); + const entry = await postEntry(db, ctx, { + companyId: e.company_id, entryDate: e.expense_date, description: `مصروف ${number}${e.payee_name ? ` — ${e.payee_name}` : ''}`, + referenceType: 'EXPENSE', referenceId: id, source: 'SYSTEM', lines: journal, + }); + + const paidNow = e.payment_type !== 'CREDIT'; + await db.query(`DELETE FROM expense_items WHERE document_id = $1`, [id]); + await insertLines(db, ctx, e.company_id, id, lines); + await db.query( + `UPDATE expenses SET status = $2, expense_number = $3, journal_entry_id = $4, posted_by = $5, posted_at = now(), + subtotal = $6, tax_amount = $7, total = $8, paid_amount = $9 WHERE id = $1`, + [id, paidNow ? 'PAID' : 'POSTED', number, entry.id, ctx.userId, totals.subtotal, totals.taxAmount, totals.total, + paidNow ? totals.total : '0']); + + const { rows: [party] } = await db.query<{ name: string | null; vat: string | null }>( + `SELECT COALESCE(s.name_ar, $2) AS name, COALESCE($3, s.vat_number) AS vat FROM (SELECT 1) x LEFT JOIN suppliers s ON s.id = $1`, + [e.supplier_id, e.payee_name, e.vendor_vat_number]); + await recordTax(db, { + tenantId: ctx.tenantId, companyId: e.company_id, sourceType: 'EXPENSE', sourceId: id, sourceNumber: number, + journalEntryId: entry.id, date: e.expense_date, groups: taxGroups(lines, totals.taxAmount), + partyName: party?.name ?? null, partyVatNumber: party?.vat ?? null, + }); + await writeAudit(db, { tenantId: ctx.tenantId, userId: ctx.userId, action: 'POST', entityType: 'expense', entityId: id, newValues: { number, total: totals.total, journalEntryId: entry.id } }, ctx.meta); + return loadExpense(db, ctx.tenantId, id); +} + +/** Cancels a posted expense by reversing its entry and VAT. Credit expenses must have no payments. */ +export async function cancelExpense(db: Db, ctx: Ctx, id: string, reason: string) { + const e = await lockExpense(db, ctx.tenantId, id); + if (e.status === 'DRAFT') throw conflict('DOCUMENT_NOT_ISSUED', 'Delete the draft instead'); + if (e.status === 'CANCELLED') throw conflict('ALREADY_CANCELLED', 'This expense is already cancelled'); + const allocated = await db.query(`SELECT 1 FROM payment_allocations WHERE expense_id = $1 AND reversed_at IS NULL LIMIT 1`, [id]); + if (allocated.rowCount) throw conflict('HAS_PAYMENTS', 'Void the payments allocated to this expense first'); + await reverse(db, ctx, e.journal_entry_id!, { reason: `إلغاء مصروف ${e.expense_number}: ${reason}`, date: e.expense_date, allowSystem: true }); + await reverseTax(db, 'EXPENSE', id); + await db.query(`UPDATE expenses SET status = 'CANCELLED', cancelled_by = $2, cancelled_at = now(), cancel_reason = $3 WHERE id = $1`, [id, ctx.userId, reason]); + await writeAudit(db, { tenantId: ctx.tenantId, userId: ctx.userId, action: 'CANCEL', entityType: 'expense', entityId: id, newValues: { reason } }, ctx.meta); + return loadExpense(db, ctx.tenantId, id); +} + +export const EXPENSE_SELECT = ` + SELECT e.id, e.company_id AS "companyId", e.expense_number AS number, e.expense_date AS date, e.payment_type AS "paymentType", + e.method_id AS "methodId", m.name_ar AS "methodName", e.supplier_id AS "supplierId", s.name_ar AS "supplierName", + e.payee_name AS "payeeName", e.reference, e.vendor_vat_number AS "vendorVatNumber", e.branch_id AS "branchId", + e.prices_include_vat AS "pricesIncludeVat", e.subtotal::text, e.tax_amount::text AS "taxAmount", e.total::text, + e.paid_amount::text AS "paidAmount", e.remaining_amount::text AS "remainingAmount", e.notes, e.status, + e.journal_entry_id AS "journalEntryId", e.cancel_reason AS "cancelReason", e.created_at AS "createdAt" + FROM expenses e + LEFT JOIN payment_methods m ON m.id = e.method_id + LEFT JOIN suppliers s ON s.id = e.supplier_id`; + +export async function loadExpense(db: Db, tenantId: string, id: string) { + const { rows: [e] } = await db.query>(`${EXPENSE_SELECT} WHERE e.tenant_id = $1 AND e.id = $2 AND e.deleted_at IS NULL`, [tenantId, id]); + if (!e) throw notFound('Expense'); + const { rows: lines } = await db.query<{ + id: string; categoryId: string; amount: string; description: string | null; vatCategory: VatCategory; costCenterId: string | null; + }>( + `SELECT i.id, i.line_no AS "lineNo", i.category_id AS "categoryId", c.name_ar AS "categoryName", i.account_id AS "accountId", + a.code AS "accountCode", i.description, i.cost_center_id AS "costCenterId", i.amount::text, i.net_amount::text AS "netAmount", + i.vat_category AS "vatCategory", i.vat_rate::text AS "vatRate", i.vat_amount::text AS "vatAmount", i.total_amount::text AS "totalAmount" + FROM expense_items i JOIN expense_categories c ON c.id = i.category_id JOIN accounts a ON a.id = i.account_id + WHERE i.document_id = $1 ORDER BY i.line_no`, [id]); + return { ...e, lines }; +} diff --git a/alshuyukh-accounting/apps/api/src/modules/inventory/costing.ts b/alshuyukh-accounting/apps/api/src/modules/inventory/costing.ts new file mode 100644 index 000000000000..006c7d81e57d --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/inventory/costing.ts @@ -0,0 +1,39 @@ +import { Decimal, toMoney } from '../../lib/money.js'; + +/** + * Costing strategy. The engine asks it how much an issue costs and what the + * balance becomes; it never touches the database. + * + * WEIGHTED_AVERAGE is implemented. FIFO needs cost layers (one row per + * receipt with remaining quantity) and would implement the same interface, + * consuming the oldest layers on issue. + */ +export interface Balance { quantity: string; value: string } + +export interface CostingMethod { + receive(balance: Balance, quantity: string, totalCost: string): Balance; + issue(balance: Balance, quantity: string): { cost: string; balance: Balance }; +} + +export const weightedAverage: CostingMethod = { + receive(b, quantity, totalCost) { + return { + quantity: new Decimal(b.quantity).plus(quantity).toString(), + value: toMoney(new Decimal(b.value).plus(totalCost)), + }; + }, + issue(b, quantity) { + const qty = new Decimal(b.quantity); + const out = new Decimal(quantity); + // The last units out take exactly what is left, so no value is stranded. + const cost = out.equals(qty) + ? new Decimal(b.value) + : new Decimal(b.value).times(out).dividedBy(qty).toDecimalPlaces(2, Decimal.ROUND_HALF_UP); + return { + cost: toMoney(cost), + balance: { quantity: qty.minus(out).toString(), value: toMoney(new Decimal(b.value).minus(cost)) }, + }; + }, +}; + +export const COSTING: Record = { WEIGHTED_AVERAGE: weightedAverage }; diff --git a/alshuyukh-accounting/apps/api/src/modules/inventory/engine.ts b/alshuyukh-accounting/apps/api/src/modules/inventory/engine.ts new file mode 100644 index 000000000000..5faa7b65ca9d --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/inventory/engine.ts @@ -0,0 +1,150 @@ +import type { Db } from '../../db/tx.js'; +import { badRequest, conflict } from '../../lib/errors.js'; +import { Decimal, toMoney } from '../../lib/money.js'; +import { COSTING, type Balance, type CostingMethod } from './costing.js'; + +/** + * Inventory engine. Every quantity change goes through receive() or issue(), + * which lock the balance row, write an append-only stock movement, and + * update the balance in the caller's transaction. + * + * Accounting stays with the caller: it posts the movement's cost to the + * Inventory account in the same journal entry, so the stock ledger and the + * general ledger always hold the same value. + */ + +export type MovementType = + | 'PURCHASE' | 'SALE' | 'SALE_RETURN' | 'PURCHASE_RETURN' | 'TRANSFER_IN' | 'TRANSFER_OUT' + | 'ADJUSTMENT_IN' | 'ADJUSTMENT_OUT' | 'CANCELLATION_IN' | 'CANCELLATION_OUT'; + +export interface Ref { + tenantId: string; + companyId: string; + userId: string; + date: string; + referenceType: string; + referenceId: string; +} + +export interface MovementInput { + productId: string; + warehouseId: string; + quantity: string; + type: MovementType; + lineId?: string | null; +} + +async function costingFor(db: Db, companyId: string): Promise { + const { rows: [c] } = await db.query<{ costing_method: string }>(`SELECT costing_method FROM companies WHERE id = $1`, [companyId]); + return COSTING[c!.costing_method]!; +} + +async function lockBalance(db: Db, ref: Ref, productId: string, warehouseId: string): Promise { + await db.query( + `INSERT INTO inventory_balances (tenant_id, company_id, product_id, warehouse_id) VALUES ($1, $2, $3, $4) + ON CONFLICT (product_id, warehouse_id) DO NOTHING`, + [ref.tenantId, ref.companyId, productId, warehouseId]); + const { rows: [b] } = await db.query( + `SELECT quantity::text, value::text FROM inventory_balances WHERE product_id = $1 AND warehouse_id = $2 FOR UPDATE`, + [productId, warehouseId]); + return b!; +} + +async function write(db: Db, ref: Ref, m: MovementInput, direction: 'IN' | 'OUT', cost: string, after: Balance, reversesId?: string) { + await db.query(`UPDATE inventory_balances SET quantity = $3, value = $4 WHERE product_id = $1 AND warehouse_id = $2`, + [m.productId, m.warehouseId, after.quantity, after.value]); + const unitCost = new Decimal(cost).dividedBy(m.quantity).toDecimalPlaces(6).toString(); + const { rows: [row] } = await db.query<{ id: string }>( + `INSERT INTO stock_movements (tenant_id, company_id, product_id, warehouse_id, movement_type, direction, quantity, + unit_cost, total_cost, balance_quantity, balance_value, reference_type, reference_id, reference_line_id, + reverses_id, movement_date, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING id`, + [ref.tenantId, ref.companyId, m.productId, m.warehouseId, m.type, direction, m.quantity, unitCost, cost, + after.quantity, after.value, ref.referenceType, ref.referenceId, m.lineId ?? null, reversesId ?? null, ref.date, ref.userId]); + return row!.id; +} + +/** Adds stock at a known total cost (purchase price, or the cost a return or transfer carries). */ +export async function receive(db: Db, ref: Ref, m: MovementInput, totalCost: string, reversesId?: string) { + const method = await costingFor(db, ref.companyId); + const before = await lockBalance(db, ref, m.productId, m.warehouseId); + const after = method.receive(before, m.quantity, toMoney(totalCost)); + const id = await write(db, ref, m, 'IN', toMoney(totalCost), after, reversesId); + return { id, cost: toMoney(totalCost) }; +} + +async function productName(db: Db, productId: string) { + return (await db.query<{ name_ar: string }>(`SELECT name_ar FROM products WHERE id = $1`, [productId])).rows[0]?.name_ar ?? productId; +} + +/** Removes stock at its current cost. Negative stock is not allowed. */ +export async function issue(db: Db, ref: Ref, m: MovementInput) { + const method = await costingFor(db, ref.companyId); + const before = await lockBalance(db, ref, m.productId, m.warehouseId); + if (new Decimal(m.quantity).greaterThan(before.quantity)) { + throw conflict('INSUFFICIENT_STOCK', `Not enough stock of "${await productName(db, m.productId)}": ${new Decimal(before.quantity).toString()} available, ${new Decimal(m.quantity).toString()} needed`); + } + const { cost, balance } = method.issue(before, m.quantity); + const id = await write(db, ref, m, 'OUT', cost, balance); + return { id, cost }; +} + +/** + * Removes stock at a fixed cost, to undo an earlier receipt (cancelling a + * purchase or a sales return). If that empties the warehouse while value is + * left over, the leftover is returned so the caller can expense it. + */ +export async function issueAtCost(db: Db, ref: Ref, m: MovementInput, totalCost: string, reversesId: string) { + const before = await lockBalance(db, ref, m.productId, m.warehouseId); + const name = await productName(db, m.productId); + if (new Decimal(m.quantity).greaterThan(before.quantity) || new Decimal(totalCost).greaterThan(before.value)) { + throw conflict('INSUFFICIENT_STOCK', `The stock of "${name}" received by this document has already been used. Issue a return instead.`); + } + const qty = new Decimal(before.quantity).minus(m.quantity); + let value = new Decimal(before.value).minus(totalCost); + let residual = new Decimal(0); + if (qty.isZero() && !value.isZero()) { residual = value; value = new Decimal(0); } + const id = await write(db, ref, m, 'OUT', toMoney(totalCost), { quantity: qty.toString(), value: toMoney(value) }, reversesId); + return { id, residual: toMoney(residual) }; +} + +/** + * Undoes every movement of a document with opposite movements at the same + * cost. Returns any residual value to expense (see issueAtCost). + */ +export async function reverseDocumentMovements(db: Db, ref: Ref, originalType: string, originalId: string) { + const { rows } = await db.query<{ id: string; product_id: string; warehouse_id: string; direction: 'IN' | 'OUT'; quantity: string; total_cost: string; reference_line_id: string | null }>( + `SELECT m.id, m.product_id, m.warehouse_id, m.direction, m.quantity::text, m.total_cost::text, m.reference_line_id + FROM stock_movements m + WHERE m.reference_type = $1 AND m.reference_id = $2 AND m.reverses_id IS NULL + AND NOT EXISTS (SELECT 1 FROM stock_movements r WHERE r.reverses_id = m.id) + ORDER BY m.created_at`, [originalType, originalId]); + let residual = new Decimal(0); + for (const m of rows) { + const input: MovementInput = { + productId: m.product_id, warehouseId: m.warehouse_id, quantity: m.quantity, lineId: m.reference_line_id, + type: m.direction === 'IN' ? 'CANCELLATION_OUT' : 'CANCELLATION_IN', + }; + if (m.direction === 'OUT') await receive(db, ref, input, m.total_cost, m.id); + else residual = residual.plus((await issueAtCost(db, ref, input, m.total_cost, m.id)).residual); + } + return toMoney(residual); +} + +/** The document's warehouse, or the company's main warehouse. */ +export async function resolveWarehouse(db: Db, companyId: string, warehouseId?: string | null): Promise { + if (warehouseId) return warehouseId; + const { rows: [w] } = await db.query<{ id: string }>( + `SELECT id FROM warehouses WHERE company_id = $1 AND deleted_at IS NULL AND is_active + ORDER BY (code = 'MAIN') DESC, created_at LIMIT 1`, [companyId]); + if (!w) throw badRequest('WAREHOUSE_REQUIRED', 'Choose a warehouse for stocked items'); + return w.id; +} + +/** Products on these lines that hold stock. */ +export async function trackedProducts(db: Db, productIds: (string | null)[]): Promise> { + const ids = [...new Set(productIds.filter(Boolean))] as string[]; + if (!ids.length) return new Set(); + const { rows } = await db.query<{ id: string }>(`SELECT id FROM products WHERE id = ANY($1::uuid[]) AND track_inventory`, [ids]); + return new Set(rows.map((r) => r.id)); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/inventory/routes.ts b/alshuyukh-accounting/apps/api/src/modules/inventory/routes.ts new file mode 100644 index 000000000000..c9a50898058b --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/inventory/routes.ts @@ -0,0 +1,321 @@ +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { z } from 'zod'; +import type { Db } from '../../db/tx.js'; +import { isoDate } from '../../lib/dates.js'; +import { badRequest, notFound } from '../../lib/errors.js'; +import { Decimal, toMoney } from '../../lib/money.js'; +import { nextDocumentNumber } from '../../lib/sequences.js'; +import { parse, uuidParam } from '../../lib/validation.js'; +import { requirePermission } from '../../plugins/auth.js'; +import { resolveCompanyId } from '../accounting/company-context.js'; +import { postEntry } from '../accounting/engine.js'; +import { writeAudit } from '../audit/audit.service.js'; +import { systemAccount } from '../documents/posting.js'; +import { issue, receive, type Ref } from './engine.js'; + +const quantity = z.string().trim().regex(/^\d{1,14}(\.\d{1,4})?$/, 'Quantity: up to 4 decimals, sent as a string'); +const cost = z.string().trim().regex(/^\d{1,14}(\.\d{1,6})?$/, 'Unit cost: up to 6 decimals, sent as a string'); + +async function checkWarehouse(db: Db, companyId: string, warehouseId: string) { + const { rowCount } = await db.query(`SELECT 1 FROM warehouses WHERE company_id = $1 AND id = $2 AND deleted_at IS NULL AND is_active`, [companyId, warehouseId]); + if (!rowCount) throw badRequest('INVALID_WAREHOUSE', 'Warehouse does not exist in this company or is inactive'); +} + +async function checkStocked(db: Db, companyId: string, productIds: string[]) { + const ids = [...new Set(productIds)]; + const { rows } = await db.query<{ id: string; track_inventory: boolean; name_ar: string }>( + `SELECT id, track_inventory, name_ar FROM products WHERE company_id = $1 AND id = ANY($2::uuid[]) AND deleted_at IS NULL`, [companyId, ids]); + if (rows.length !== ids.length) throw badRequest('INVALID_PRODUCT', 'A product does not exist in this company'); + const untracked = rows.find((p) => !p.track_inventory); + if (untracked) throw badRequest('NOT_STOCKED', `"${untracked.name_ar}" does not track stock`); + if (ids.length !== productIds.length) throw badRequest('INVALID_LINE', 'The same product appears twice'); +} + +const ctxRef = (req: FastifyRequest, companyId: string, date: string, referenceType: string, referenceId: string): Ref => + ({ tenantId: req.auth!.tenantId, companyId, userId: req.auth!.userId, date, referenceType, referenceId }); + +export default async function inventoryRoutes(app: FastifyInstance) { + const canView = requirePermission(app, 'inventory.view'); + + /** Quantity, value and average cost per product and warehouse. */ + app.get('/inventory/balances', { preHandler: canView }, async (req) => { + const q = parse(z.object({ + companyId: z.uuid().optional(), warehouseId: z.uuid().optional(), productId: z.uuid().optional(), + includeZero: z.enum(['true', 'false']).optional(), + }), req.query); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const { rows } = await db.query( + `SELECT b.product_id AS "productId", p.sku, p.name_ar AS "productName", u.code AS "unitCode", + b.warehouse_id AS "warehouseId", w.name AS "warehouseName", b.quantity::text, b.value::text, + CASE WHEN b.quantity > 0 THEN round(b.value / b.quantity, 4) ELSE 0 END::text AS "averageCost" + FROM inventory_balances b + JOIN products p ON p.id = b.product_id JOIN units u ON u.id = p.unit_id JOIN warehouses w ON w.id = b.warehouse_id + WHERE b.tenant_id = $1 AND b.company_id = $2 AND ($3::uuid IS NULL OR b.warehouse_id = $3) + AND ($4::uuid IS NULL OR b.product_id = $4) AND ($5::boolean OR b.quantity > 0) + ORDER BY p.name_ar, w.name`, + [req.auth!.tenantId, companyId, q.warehouseId ?? null, q.productId ?? null, q.includeZero === 'true']); + return { data: rows }; + }); + }); + + /** Stock card: movements of one product with running balances. */ + app.get('/inventory/movements', { preHandler: canView }, async (req) => { + const q = parse(z.object({ + companyId: z.uuid().optional(), productId: z.uuid().optional(), warehouseId: z.uuid().optional(), + dateFrom: isoDate.optional(), dateTo: isoDate.optional(), + limit: z.coerce.number().int().min(1).max(500).default(100), offset: z.coerce.number().int().min(0).default(0), + }), req.query); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const { rows } = await db.query<{ total_count: string }>( + `SELECT count(*) OVER () AS total_count, m.id, m.movement_date AS date, m.movement_type AS type, m.direction, + m.product_id AS "productId", p.name_ar AS "productName", p.sku, m.warehouse_id AS "warehouseId", w.name AS "warehouseName", + m.quantity::text, m.unit_cost::text AS "unitCost", m.total_cost::text AS "totalCost", + m.balance_quantity::text AS "balanceQuantity", m.balance_value::text AS "balanceValue", + m.reference_type AS "referenceType", m.reference_id AS "referenceId", m.created_at AS "createdAt" + FROM stock_movements m JOIN products p ON p.id = m.product_id JOIN warehouses w ON w.id = m.warehouse_id + WHERE m.tenant_id = $1 AND m.company_id = $2 AND ($3::uuid IS NULL OR m.product_id = $3) + AND ($4::uuid IS NULL OR m.warehouse_id = $4) AND ($5::date IS NULL OR m.movement_date >= $5) + AND ($6::date IS NULL OR m.movement_date <= $6) + ORDER BY m.created_at DESC, m.id DESC LIMIT $7 OFFSET $8`, + [req.auth!.tenantId, companyId, q.productId ?? null, q.warehouseId ?? null, q.dateFrom ?? null, q.dateTo ?? null, q.limit, q.offset]); + return { data: rows.map(({ total_count: _t, ...r }) => r), total: Number(rows[0]?.total_count ?? 0) }; + }); + }); + + /** + * Inventory valuation, reconciled with the general ledger: the stock value + * must equal the balance of the Inventory account. + */ + app.get('/inventory/valuation', { preHandler: canView }, async (req) => { + const q = parse(z.object({ companyId: z.uuid().optional() }), req.query); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const { rows } = await db.query<{ value: string }>( + `SELECT p.id AS "productId", p.sku, p.name_ar AS "productName", sum(b.quantity)::text AS quantity, sum(b.value)::text AS value + FROM inventory_balances b JOIN products p ON p.id = b.product_id + WHERE b.tenant_id = $1 AND b.company_id = $2 + GROUP BY p.id HAVING sum(b.quantity) > 0 ORDER BY p.name_ar`, [req.auth!.tenantId, companyId]); + const stockValue = rows.reduce((s, r) => s.plus(r.value), new Decimal(0)); + const { rows: [gl] } = await db.query<{ balance: string }>( + `SELECT COALESCE(sum(l.debit - l.credit), 0)::text AS balance + FROM journal_entry_lines l JOIN journal_entries e ON e.id = l.journal_entry_id + JOIN accounts a ON a.id = l.account_id + WHERE e.company_id = $1 AND e.status IN ('POSTED', 'REVERSED') AND a.system_key = 'INVENTORY'`, [companyId]); + const difference = new Decimal(gl!.balance).minus(stockValue); + return { + data: rows, stockValue: toMoney(stockValue), ledgerBalance: toMoney(gl!.balance), + difference: toMoney(difference), reconciled: difference.isZero(), + }; + }); + }); + + // Transfers -------------------------------------------------------------------- + const TRANSFER_SELECT = ` + SELECT t.id, t.transfer_number AS number, t.transfer_date AS date, t.from_warehouse_id AS "fromWarehouseId", + fw.name AS "fromWarehouseName", t.to_warehouse_id AS "toWarehouseId", tw.name AS "toWarehouseName", + t.notes, t.total_cost::text AS "totalCost", t.created_at AS "createdAt" + FROM stock_transfers t JOIN warehouses fw ON fw.id = t.from_warehouse_id JOIN warehouses tw ON tw.id = t.to_warehouse_id`; + + async function loadTransfer(db: Db, tenantId: string, id: string) { + const { rows: [t] } = await db.query(`${TRANSFER_SELECT} WHERE t.tenant_id = $1 AND t.id = $2`, [tenantId, id]); + if (!t) throw notFound('Transfer'); + const { rows: lines } = await db.query( + `SELECT i.line_no AS "lineNo", i.product_id AS "productId", p.sku, p.name_ar AS "productName", i.quantity::text, i.total_cost::text AS "totalCost" + FROM stock_transfer_items i JOIN products p ON p.id = i.product_id WHERE i.transfer_id = $1 ORDER BY i.line_no`, [id]); + return { ...t, lines }; + } + + app.get('/stock-transfers', { preHandler: canView }, async (req) => { + const q = parse(z.object({ companyId: z.uuid().optional() }), req.query); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + return { data: (await db.query(`${TRANSFER_SELECT} WHERE t.tenant_id = $1 AND t.company_id = $2 ORDER BY t.transfer_date DESC, t.transfer_number DESC LIMIT 200`, [req.auth!.tenantId, companyId])).rows }; + }); + }); + + app.get('/stock-transfers/:id', { preHandler: canView }, async (req) => { + const { id } = parse(uuidParam, req.params); + return req.tenantTx((db) => loadTransfer(db, req.auth!.tenantId, id)); + }); + + /** Moves stock between warehouses at average cost. No journal entry: the value stays in Inventory. */ + app.post('/stock-transfers', { preHandler: requirePermission(app, 'inventory.transfer') }, async (req, reply) => { + const body = parse(z.object({ + companyId: z.uuid().optional(), fromWarehouseId: z.uuid(), toWarehouseId: z.uuid(), date: isoDate, + notes: z.string().trim().max(1000).nullish(), + lines: z.array(z.object({ productId: z.uuid(), quantity })).min(1).max(500), + }), req.body); + if (body.fromWarehouseId === body.toWarehouseId) throw badRequest('SAME_WAREHOUSE', 'Choose two different warehouses'); + const a = req.auth!; + const transfer = await req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, a.tenantId, body.companyId); + await checkWarehouse(db, companyId, body.fromWarehouseId); + await checkWarehouse(db, companyId, body.toWarehouseId); + await checkStocked(db, companyId, body.lines.map((l) => l.productId)); + const number = await nextDocumentNumber(db, a.tenantId, companyId, 'STOCK_TRANSFER', { prefix: 'TRF', padding: 6 }); + const id = crypto.randomUUID(); + const ref = ctxRef(req, companyId, body.date, 'STOCK_TRANSFER', id); + const costs: string[] = []; + for (const [i, l] of body.lines.entries()) { + if (!new Decimal(l.quantity).greaterThan(0)) throw badRequest('INVALID_LINE', `Line ${i + 1}: quantity must be greater than zero`); + const out = await issue(db, ref, { productId: l.productId, warehouseId: body.fromWarehouseId, quantity: l.quantity, type: 'TRANSFER_OUT' }); + await receive(db, ref, { productId: l.productId, warehouseId: body.toWarehouseId, quantity: l.quantity, type: 'TRANSFER_IN' }, out.cost); + costs.push(out.cost); + } + const total = toMoney(costs.reduce((s2, c) => s2.plus(c), new Decimal(0))); + await db.query( + `INSERT INTO stock_transfers (id, tenant_id, company_id, transfer_number, transfer_date, from_warehouse_id, to_warehouse_id, notes, total_cost, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + [id, a.tenantId, companyId, number, body.date, body.fromWarehouseId, body.toWarehouseId, body.notes ?? null, total, a.userId]); + for (const [i, l] of body.lines.entries()) { + await db.query( + `INSERT INTO stock_transfer_items (tenant_id, company_id, transfer_id, line_no, product_id, quantity, total_cost) VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [a.tenantId, companyId, id, i + 1, l.productId, l.quantity, costs[i]]); + } + const t = { id }; + const created = await loadTransfer(db, a.tenantId, t!.id); + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'CREATE', entityType: 'stock_transfer', entityId: t!.id, newValues: created }, req.auditMeta()); + return created; + }); + reply.code(201); + return transfer; + }); + + // Adjustments ------------------------------------------------------------------ + const ADJ_SELECT = ` + SELECT s.id, s.adjustment_number AS number, s.adjustment_date AS date, s.warehouse_id AS "warehouseId", w.name AS "warehouseName", + s.reason, s.offset_account_id AS "offsetAccountId", a.code AS "offsetAccountCode", a.name_ar AS "offsetAccountName", + s.total_increase::text AS "totalIncrease", s.total_decrease::text AS "totalDecrease", + s.journal_entry_id AS "journalEntryId", s.created_at AS "createdAt" + FROM stock_adjustments s JOIN warehouses w ON w.id = s.warehouse_id JOIN accounts a ON a.id = s.offset_account_id`; + + async function loadAdjustment(db: Db, tenantId: string, id: string) { + const { rows: [s] } = await db.query(`${ADJ_SELECT} WHERE s.tenant_id = $1 AND s.id = $2`, [tenantId, id]); + if (!s) throw notFound('Adjustment'); + const { rows: lines } = await db.query( + `SELECT i.line_no AS "lineNo", i.product_id AS "productId", p.sku, p.name_ar AS "productName", i.direction, + i.quantity::text, i.counted_quantity::text AS "countedQuantity", i.system_quantity::text AS "systemQuantity", + i.unit_cost::text AS "unitCost", i.total_cost::text AS "totalCost" + FROM stock_adjustment_items i JOIN products p ON p.id = i.product_id WHERE i.adjustment_id = $1 ORDER BY i.line_no`, [id]); + return { ...s, lines }; + } + + app.get('/stock-adjustments', { preHandler: canView }, async (req) => { + const q = parse(z.object({ companyId: z.uuid().optional() }), req.query); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + return { data: (await db.query(`${ADJ_SELECT} WHERE s.tenant_id = $1 AND s.company_id = $2 ORDER BY s.adjustment_date DESC, s.adjustment_number DESC LIMIT 200`, [req.auth!.tenantId, companyId])).rows }; + }); + }); + + app.get('/stock-adjustments/:id', { preHandler: canView }, async (req) => { + const { id } = parse(uuidParam, req.params); + return req.tenantTx((db) => loadAdjustment(db, req.auth!.tenantId, id)); + }); + + /** + * Adjusts stock in one warehouse. Each line is either a direction and + * quantity, or a counted quantity (stock count) from which the difference + * is derived. Increases are valued at the given unit cost or the current + * average; decreases at average cost. The journal entry posts the + * difference against the offset account (default: inventory adjustments; + * use an equity account for opening stock). + */ + app.post('/stock-adjustments', { preHandler: requirePermission(app, 'inventory.adjust') }, async (req, reply) => { + const line = z.union([ + z.object({ productId: z.uuid(), countedQuantity: z.string().trim().regex(/^\d{1,14}(\.\d{1,4})?$/), unitCost: cost.optional() }), + z.object({ productId: z.uuid(), direction: z.enum(['IN', 'OUT']), quantity, unitCost: cost.optional() }), + ]); + const body = parse(z.object({ + companyId: z.uuid().optional(), warehouseId: z.uuid(), date: isoDate, reason: z.string().trim().min(3).max(500), + offsetAccountId: z.uuid().optional(), lines: z.array(line).min(1).max(500), + }), req.body); + const a = req.auth!; + const adjustment = await req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, a.tenantId, body.companyId); + await checkWarehouse(db, companyId, body.warehouseId); + await checkStocked(db, companyId, body.lines.map((l) => l.productId)); + const offset = body.offsetAccountId ?? await systemAccount(db, companyId, 'INVENTORY_ADJUSTMENT'); + const { rows: [acc] } = await db.query<{ account_type: string; is_postable: boolean; is_active: boolean; system_key: string | null }>( + `SELECT account_type, is_postable, is_active, system_key FROM accounts WHERE company_id = $1 AND id = $2 AND deleted_at IS NULL`, [companyId, offset]); + if (!acc || !acc.is_postable || !acc.is_active || acc.system_key === 'INVENTORY' + || !['EXPENSE', 'COST_OF_GOODS_SOLD', 'EQUITY', 'REVENUE'].includes(acc.account_type)) { + throw badRequest('INVALID_OFFSET_ACCOUNT', 'The offset account must be an active postable expense, cost, equity or revenue account'); + } + + const number = await nextDocumentNumber(db, a.tenantId, companyId, 'STOCK_ADJUSTMENT', { prefix: 'ADJ', padding: 6 }); + const id = crypto.randomUUID(); + const ref = ctxRef(req, companyId, body.date, 'STOCK_ADJUSTMENT', id); + let increase = new Decimal(0); + let decrease = new Decimal(0); + const items: unknown[][] = []; + for (const [i, l] of body.lines.entries()) { + const n = i + 1; + const { rows: [bal] } = await db.query<{ quantity: string; value: string }>( + `SELECT quantity::text, value::text FROM inventory_balances WHERE product_id = $1 AND warehouse_id = $2`, [l.productId, body.warehouseId]); + const systemQty = new Decimal(bal?.quantity ?? 0); + let direction: 'IN' | 'OUT'; + let qty: Decimal; + let counted: string | null = null; + if ('countedQuantity' in l) { + const diff = new Decimal(l.countedQuantity).minus(systemQty); + if (diff.isZero()) continue; // counted matches the books + direction = diff.greaterThan(0) ? 'IN' : 'OUT'; + qty = diff.abs(); + counted = l.countedQuantity; + } else { + direction = l.direction; + qty = new Decimal(l.quantity); + if (!qty.greaterThan(0)) throw badRequest('INVALID_LINE', `Line ${n}: quantity must be greater than zero`); + } + let lineCost: string; + if (direction === 'IN') { + let unit: Decimal; + if (l.unitCost !== undefined) unit = new Decimal(l.unitCost); + else if (systemQty.greaterThan(0)) unit = new Decimal(bal!.value).dividedBy(systemQty); + else throw badRequest('UNIT_COST_REQUIRED', `Line ${n}: enter a unit cost; there is no current cost to use`); + lineCost = toMoney(unit.times(qty).toDecimalPlaces(2, Decimal.ROUND_HALF_UP)); + await receive(db, ref, { productId: l.productId, warehouseId: body.warehouseId, quantity: qty.toString(), type: 'ADJUSTMENT_IN' }, lineCost); + increase = increase.plus(lineCost); + } else { + lineCost = (await issue(db, ref, { productId: l.productId, warehouseId: body.warehouseId, quantity: qty.toString(), type: 'ADJUSTMENT_OUT' })).cost; + decrease = decrease.plus(lineCost); + } + items.push([l.productId, direction, qty.toString(), counted, counted === null ? null : systemQty.toString(), + new Decimal(lineCost).dividedBy(qty).toDecimalPlaces(6).toString(), lineCost]); + } + if (!items.length) throw badRequest('NOTHING_TO_ADJUST', 'Every counted quantity matches the system quantity'); + + let journalEntryId: string | null = null; + const journal = []; + const inventory = await systemAccount(db, companyId, 'INVENTORY'); + if (increase.greaterThan(0)) journal.push({ accountId: inventory, debit: toMoney(increase) }, { accountId: offset, credit: toMoney(increase) }); + if (decrease.greaterThan(0)) journal.push({ accountId: offset, debit: toMoney(decrease) }, { accountId: inventory, credit: toMoney(decrease) }); + if (journal.length) { + const entry = await postEntry(db, { tenantId: a.tenantId, userId: a.userId, meta: req.auditMeta() }, { + companyId, entryDate: body.date, description: `تسوية مخزون ${number}: ${body.reason}`, + referenceType: 'STOCK_ADJUSTMENT', referenceId: id, source: 'SYSTEM', lines: journal, + }); + journalEntryId = entry.id; + } + await db.query( + `INSERT INTO stock_adjustments (id, tenant_id, company_id, adjustment_number, adjustment_date, warehouse_id, reason, + offset_account_id, total_increase, total_decrease, journal_entry_id, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, + [id, a.tenantId, companyId, number, body.date, body.warehouseId, body.reason, offset, toMoney(increase), toMoney(decrease), journalEntryId, a.userId]); + for (const [i, item] of items.entries()) { + await db.query( + `INSERT INTO stock_adjustment_items (tenant_id, company_id, adjustment_id, line_no, product_id, direction, quantity, + counted_quantity, system_quantity, unit_cost, total_cost) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, [a.tenantId, companyId, id, i + 1, ...item]); + } + const created = await loadAdjustment(db, a.tenantId, id); + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'CREATE', entityType: 'stock_adjustment', entityId: id, newValues: created }, req.auditMeta()); + return created; + }); + reply.code(201); + return adjustment; + }); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/parties/parties.routes.ts b/alshuyukh-accounting/apps/api/src/modules/parties/parties.routes.ts new file mode 100644 index 000000000000..48e319fd202b --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/parties/parties.routes.ts @@ -0,0 +1,270 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import type { Db } from '../../db/tx.js'; +import { badRequest, conflict, notFound } from '../../lib/errors.js'; +import { amountString } from '../../lib/money.js'; +import { nextFreeCode } from '../../lib/sequences.js'; +import { optionalText, parse, uuidParam } from '../../lib/validation.js'; +import { requirePermission } from '../../plugins/auth.js'; +import { resolveCompanyId } from '../accounting/company-context.js'; +import { writeAudit } from '../audit/audit.service.js'; + +/** + * Customers and suppliers share one implementation. Each kind has its own + * tables, permissions, numbering, and control account (AR / AP). Balances + * come from journal lines tagged with the party, never from a stored field. + */ +interface PartyKind { + path: string; // URL segment + table: 'customers' | 'suppliers'; + addressTable: 'customer_addresses' | 'supplier_addresses'; + fk: 'customer_id' | 'supplier_id'; + accountColumn: 'receivable_account_id' | 'payable_account_id'; + // Control account must be of this type (AR is an asset, AP a liability). + accountType: 'ASSET' | 'LIABILITY'; + defaultAccountKey: 'ACCOUNTS_RECEIVABLE' | 'ACCOUNTS_PAYABLE'; + docType: 'CUSTOMER' | 'SUPPLIER'; + prefix: string; + viewPermission: string; + managePermission: string; + entity: 'customer' | 'supplier'; + label: string; +} + +export const CUSTOMER: PartyKind = { + path: 'customers', table: 'customers', addressTable: 'customer_addresses', fk: 'customer_id', + accountColumn: 'receivable_account_id', accountType: 'ASSET', + defaultAccountKey: 'ACCOUNTS_RECEIVABLE', docType: 'CUSTOMER', prefix: 'CUS', + viewPermission: 'customer.view', managePermission: 'customer.manage', entity: 'customer', label: 'Customer', +}; + +export const SUPPLIER: PartyKind = { + path: 'suppliers', table: 'suppliers', addressTable: 'supplier_addresses', fk: 'supplier_id', + accountColumn: 'payable_account_id', accountType: 'LIABILITY', + defaultAccountKey: 'ACCOUNTS_PAYABLE', docType: 'SUPPLIER', prefix: 'SUP', + viewPermission: 'supplier.view', managePermission: 'supplier.manage', entity: 'supplier', label: 'Supplier', +}; + +const address = z.object({ + addressType: z.enum(['BILLING', 'SHIPPING']).default('BILLING'), + isDefault: z.boolean().default(false), + buildingNumber: z.string().regex(/^\d{4}$/, 'Building number must be 4 digits').nullish(), + street: optionalText(200), + district: optionalText(100), + city: optionalText(100), + postalCode: z.string().regex(/^\d{5}$/, 'Postal code must be 5 digits').nullish(), + additionalNumber: z.string().regex(/^\d{4}$/, 'Additional number must be 4 digits').nullish(), + country: z.string().regex(/^[A-Z]{2}$/).default('SA'), +}); +type AddressInput = z.infer; + +const fields = { + code: z.string().trim().regex(/^[0-9A-Za-z_-]{1,30}$/, 'Code: letters, digits, _ or -').optional(), + partyType: z.enum(['BUSINESS', 'INDIVIDUAL']), + nameAr: z.string().trim().min(2).max(200), + nameEn: optionalText(200), + vatNumber: z.string().regex(/^3\d{13}3$/, 'Saudi VAT number must be 15 digits starting and ending with 3').nullish(), + commercialRegistration: z.string().regex(/^\d{10}$/, 'Commercial registration must be 10 digits').nullish(), + nationalId: z.string().regex(/^[12]\d{9}$/, 'National ID / Iqama must be 10 digits starting with 1 or 2').nullish(), + email: z.string().trim().toLowerCase().pipe(z.email().max(254)).nullish(), + phone: z.string().regex(/^\+?[0-9 ()-]{6,20}$/).nullish(), + creditLimit: amountString.nullish(), + paymentTermsDays: z.number().int().min(0).max(365), + controlAccountId: z.uuid().nullish(), + notes: optionalText(2000), +}; + +const createBody = z.object({ + companyId: z.uuid().optional(), + ...fields, + partyType: fields.partyType.default('BUSINESS'), + paymentTermsDays: fields.paymentTermsDays.default(0), + addresses: z.array(address).max(10).default([]), +}); +const updateBody = z.object({ ...fields, isActive: z.boolean() }).partial(); + +function checkAddresses(addresses: AddressInput[]) { + for (const type of ['BILLING', 'SHIPPING'] as const) { + const ofType = addresses.filter((a) => a.addressType === type); + const defaults = ofType.filter((a) => a.isDefault).length; + if (defaults > 1) throw badRequest('MULTIPLE_DEFAULT_ADDRESSES', `Only one default ${type.toLowerCase()} address is allowed`); + // The first address of each type becomes the default when none is marked. + if (ofType.length && defaults === 0) ofType[0]!.isDefault = true; + } +} + +export function partyRoutes(kind: PartyKind) { + const SELECT = ` + SELECT p.id, p.company_id AS "companyId", p.code, p.party_type AS "partyType", p.name_ar AS "nameAr", p.name_en AS "nameEn", + p.vat_number AS "vatNumber", p.commercial_registration AS "commercialRegistration", p.national_id AS "nationalId", + p.email, p.phone, p.credit_limit::text AS "creditLimit", p.payment_terms_days AS "paymentTermsDays", + p.${kind.accountColumn} AS "controlAccountId", p.notes, p.is_active AS "isActive", + p.created_at AS "createdAt", p.updated_at AS "updatedAt", + COALESCE(b.balance, 0)::numeric(18,2)::text AS balance + FROM ${kind.table} p + LEFT JOIN LATERAL ( + SELECT sum(l.debit - l.credit) AS balance + FROM journal_entry_lines l JOIN journal_entries e ON e.id = l.journal_entry_id + WHERE l.${kind.fk} = p.id AND e.status IN ('POSTED', 'REVERSED') + ) b ON true`; + + const ADDRESS_SELECT = ` + SELECT id, address_type AS "addressType", is_default AS "isDefault", building_number AS "buildingNumber", + street, district, city, postal_code AS "postalCode", additional_number AS "additionalNumber", country + FROM ${kind.addressTable}`; + + async function load(db: Db, tenantId: string, id: string) { + const { rows: [party] } = await db.query<{ companyId: string; [key: string]: unknown }>( + `${SELECT} WHERE p.tenant_id = $1 AND p.id = $2 AND p.deleted_at IS NULL`, [tenantId, id]); + if (!party) return undefined; + const { rows: addresses } = await db.query( + `${ADDRESS_SELECT} WHERE ${kind.fk} = $1 AND deleted_at IS NULL ORDER BY address_type, is_default DESC, created_at`, [id]); + return { ...party, addresses }; + } + + async function checkControlAccount(db: Db, companyId: string, accountId: string | null | undefined) { + if (!accountId) return; + const { rows: [a] } = await db.query<{ account_type: string; is_postable: boolean; is_active: boolean }>( + `SELECT account_type, is_postable, is_active FROM accounts WHERE company_id = $1 AND id = $2 AND deleted_at IS NULL`, [companyId, accountId]); + if (!a) throw badRequest('INVALID_ACCOUNT', 'Account does not exist in this company'); + if (a.account_type !== kind.accountType || !a.is_postable || !a.is_active) { + throw badRequest('INVALID_CONTROL_ACCOUNT', `The ${kind.entity} account must be an active postable ${kind.accountType.toLowerCase()} account`); + } + } + + async function insertAddresses(db: Db, tenantId: string, companyId: string, partyId: string, addresses: AddressInput[]) { + for (const a of addresses) { + await db.query( + `INSERT INTO ${kind.addressTable} (tenant_id, company_id, ${kind.fk}, address_type, is_default, building_number, + street, district, city, postal_code, additional_number, country) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, + [tenantId, companyId, partyId, a.addressType, a.isDefault, a.buildingNumber ?? null, a.street ?? null, + a.district ?? null, a.city ?? null, a.postalCode ?? null, a.additionalNumber ?? null, a.country]); + } + } + + return async function routes(app: FastifyInstance) { + const canView = requirePermission(app, kind.viewPermission); + const canManage = requirePermission(app, kind.managePermission); + + app.get(`/${kind.path}`, { preHandler: canView }, async (req) => { + const q = parse(z.object({ + companyId: z.uuid().optional(), + search: z.string().trim().max(100).optional(), + status: z.enum(['active', 'inactive', 'all']).default('active'), + limit: z.coerce.number().int().min(1).max(200).default(50), + offset: z.coerce.number().int().min(0).default(0), + }), req.query); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const search = q.search ? `%${q.search.replace(/[%_\\]/g, (m) => `\\${m}`)}%` : null; + const { rows } = await db.query<{ total: string }>( + `${SELECT.replace('SELECT p.id', 'SELECT count(*) OVER () AS total, p.id')} + WHERE p.tenant_id = $1 AND p.company_id = $2 AND p.deleted_at IS NULL + AND ($3 = 'all' OR p.is_active = ($3 = 'active')) + AND ($4::text IS NULL OR p.name_ar ILIKE $4 OR p.name_en ILIKE $4 OR p.code ILIKE $4 + OR p.vat_number ILIKE $4 OR p.phone ILIKE $4 OR p.email::text ILIKE $4) + ORDER BY p.name_ar LIMIT $5 OFFSET $6`, + [req.auth!.tenantId, companyId, q.status, search, q.limit, q.offset]); + return { data: rows.map(({ total: _t, ...r }) => r), total: Number(rows[0]?.total ?? 0) }; + }); + }); + + app.get(`/${kind.path}/:id`, { preHandler: canView }, async (req) => { + const { id } = parse(uuidParam, req.params); + const party = await req.tenantTx((db) => load(db, req.auth!.tenantId, id)); + if (!party) throw notFound(kind.label); + return party; + }); + + app.post(`/${kind.path}`, { preHandler: canManage }, async (req, reply) => { + const body = parse(createBody, req.body); + checkAddresses(body.addresses); + const a = req.auth!; + const party = await req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, a.tenantId, body.companyId); + await checkControlAccount(db, companyId, body.controlAccountId); + const code = body.code ?? await nextFreeCode(db, a.tenantId, companyId, kind.docType, { prefix: kind.prefix }, kind.table, 'code'); + const { rows: [row] } = await db.query<{ id: string }>( + `INSERT INTO ${kind.table} (tenant_id, company_id, code, party_type, name_ar, name_en, vat_number, + commercial_registration, national_id, email, phone, credit_limit, payment_terms_days, ${kind.accountColumn}, + notes, created_by, updated_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $16) RETURNING id`, + [a.tenantId, companyId, code, body.partyType, body.nameAr, body.nameEn ?? null, body.vatNumber ?? null, + body.commercialRegistration ?? null, body.nationalId ?? null, body.email ?? null, body.phone ?? null, + body.creditLimit ?? null, body.paymentTermsDays, body.controlAccountId ?? null, body.notes ?? null, a.userId]); + await insertAddresses(db, a.tenantId, companyId, row!.id, body.addresses); + const created = await load(db, a.tenantId, row!.id); + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'CREATE', entityType: kind.entity, entityId: row!.id, newValues: created }, req.auditMeta()); + return created; + }); + reply.code(201); + return party; + }); + + app.patch(`/${kind.path}/:id`, { preHandler: canManage }, async (req) => { + const { id } = parse(uuidParam, req.params); + const body = parse(updateBody, req.body); + const a = req.auth!; + return req.tenantTx(async (db) => { + const before = await load(db, a.tenantId, id); + if (!before) throw notFound(kind.label); + if (body.controlAccountId) await checkControlAccount(db, before.companyId, body.controlAccountId); + // Whitelisted column names only; values are parameters. + const map: [keyof typeof body, string][] = [ + ['code', 'code'], ['partyType', 'party_type'], ['nameAr', 'name_ar'], ['nameEn', 'name_en'], + ['vatNumber', 'vat_number'], ['commercialRegistration', 'commercial_registration'], ['nationalId', 'national_id'], + ['email', 'email'], ['phone', 'phone'], ['creditLimit', 'credit_limit'], ['paymentTermsDays', 'payment_terms_days'], + ['controlAccountId', kind.accountColumn], ['notes', 'notes'], ['isActive', 'is_active'], + ]; + const sets: string[] = []; + const values: unknown[] = []; + for (const [key, col] of map) { + if (body[key] !== undefined) { values.push(body[key]); sets.push(`${col} = $${values.length + 3}`); } + } + if (sets.length) { + await db.query(`UPDATE ${kind.table} SET ${sets.join(', ')}, updated_by = $3 WHERE tenant_id = $1 AND id = $2`, + [a.tenantId, id, a.userId, ...values]); + } + const after = await load(db, a.tenantId, id); + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'UPDATE', entityType: kind.entity, entityId: id, oldValues: before, newValues: after }, req.auditMeta()); + return after; + }); + }); + + /** Replaces the address list. Old addresses are soft-deleted (documents keep their own copy). */ + app.put(`/${kind.path}/:id/addresses`, { preHandler: canManage }, async (req) => { + const { id } = parse(uuidParam, req.params); + const body = parse(z.object({ addresses: z.array(address).max(10) }), req.body); + checkAddresses(body.addresses); + const a = req.auth!; + return req.tenantTx(async (db) => { + const before = await load(db, a.tenantId, id); + if (!before) throw notFound(kind.label); + await db.query(`UPDATE ${kind.addressTable} SET deleted_at = now() WHERE ${kind.fk} = $1 AND deleted_at IS NULL`, [id]); + await insertAddresses(db, a.tenantId, before.companyId, id, body.addresses); + const after = await load(db, a.tenantId, id); + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'UPDATE', entityType: kind.entity, entityId: id, oldValues: { addresses: before.addresses }, newValues: { addresses: after!.addresses } }, req.auditMeta()); + return after; + }); + }); + + app.delete(`/${kind.path}/:id`, { preHandler: canManage }, async (req, reply) => { + const { id } = parse(uuidParam, req.params); + const a = req.auth!; + await req.tenantTx(async (db) => { + const before = await load(db, a.tenantId, id); + if (!before) throw notFound(kind.label); + const docTables = kind.table === 'customers' ? ['sales_quotes', 'sales_invoices', 'sales_returns'] : ['purchase_orders', 'purchase_invoices', 'purchase_returns']; + const used = await db.query( + `SELECT 1 FROM journal_entry_lines WHERE ${kind.fk} = $1 + UNION ALL SELECT 1 FROM payments WHERE ${kind.fk} = $1 + ${docTables.map((t) => `UNION ALL SELECT 1 FROM ${t} WHERE ${kind.fk} = $1`).join(' ')} LIMIT 1`, [id]); + if (used.rowCount) throw conflict('PARTY_IN_USE', `This ${kind.entity} has accounting transactions. Deactivate it instead.`); + await db.query(`UPDATE ${kind.table} SET deleted_at = now(), is_active = false, updated_by = $2 WHERE id = $1`, [id, a.userId]); + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'DELETE', entityType: kind.entity, entityId: id, oldValues: before }, req.auditMeta()); + }); + return reply.code(204).send(); + }); + }; +} diff --git a/alshuyukh-accounting/apps/api/src/modules/products/products.routes.ts b/alshuyukh-accounting/apps/api/src/modules/products/products.routes.ts new file mode 100644 index 000000000000..f9dadb776609 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/products/products.routes.ts @@ -0,0 +1,320 @@ +import { assertWithinLimit } from '../subscriptions/service.js'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import type { Db } from '../../db/tx.js'; +import { badRequest, conflict, notFound } from '../../lib/errors.js'; +import { nextFreeCode } from '../../lib/sequences.js'; +import { optionalText, parse, uuidParam } from '../../lib/validation.js'; +import { requirePermission } from '../../plugins/auth.js'; +import { resolveCompanyId } from '../accounting/company-context.js'; +import { writeAudit } from '../audit/audit.service.js'; + +/** Unit price: up to 4 decimals, as a string. */ +const price = z.string().trim().regex(/^\d{1,14}(\.\d{1,4})?$/, 'Price must be a non-negative number with at most 4 decimals, sent as a string'); + +const PRODUCT_SELECT = ` + SELECT p.id, p.company_id AS "companyId", p.sku, p.barcode, p.name_ar AS "nameAr", p.name_en AS "nameEn", + p.description, p.product_type AS "productType", p.category_id AS "categoryId", c.name_ar AS "categoryName", + p.unit_id AS "unitId", u.code AS "unitCode", u.name_ar AS "unitName", + p.sale_price::text AS "salePrice", p.sale_price_includes_vat AS "salePriceIncludesVat", + p.purchase_price::text AS "purchasePrice", p.vat_category AS "vatCategory", + p.vat_exemption_code AS "vatExemptionCode", p.vat_exemption_reason AS "vatExemptionReason", p.track_inventory AS "trackInventory", + p.sales_account_id AS "salesAccountId", p.purchase_account_id AS "purchaseAccountId", + p.is_active AS "isActive", p.created_at AS "createdAt", p.updated_at AS "updatedAt", + COALESCE((SELECT sum(b.quantity) FROM inventory_balances b WHERE b.product_id = p.id), 0)::text AS "onHand" + FROM products p + JOIN units u ON u.id = p.unit_id + LEFT JOIN product_categories c ON c.id = p.category_id`; + +const productFields = { + sku: z.string().trim().regex(/^[0-9A-Za-z._/-]{1,40}$/, 'SKU: letters, digits, . _ / -'), + barcode: z.string().trim().regex(/^[0-9A-Za-z-]{4,40}$/).nullish(), + nameAr: z.string().trim().min(2).max(200), + nameEn: optionalText(200), + description: optionalText(2000), + productType: z.enum(['GOODS', 'SERVICE']), + categoryId: z.uuid().nullish(), + unitId: z.uuid(), + salePrice: price, + salePriceIncludesVat: z.boolean(), + purchasePrice: price, + vatCategory: z.enum(['S', 'Z', 'E', 'O']), + // ZATCA exemption reason for zero-rated, exempt and out-of-scope supplies. + vatExemptionCode: z.string().regex(/^VATEX-SA-[A-Z0-9-]{2,12}$/).nullish(), + vatExemptionReason: optionalText(300), + trackInventory: z.boolean(), + salesAccountId: z.uuid().nullish(), + purchaseAccountId: z.uuid().nullish(), +}; + +const createBody = z.object({ + companyId: z.uuid().optional(), + ...productFields, + sku: productFields.sku.optional(), + unitId: productFields.unitId.optional(), + productType: productFields.productType.default('GOODS'), + salePrice: price.default('0'), + salePriceIncludesVat: z.boolean().default(false), + purchasePrice: price.default('0'), + vatCategory: productFields.vatCategory.default('S'), + trackInventory: z.boolean().optional(), +}); +const updateBody = z.object({ ...productFields, isActive: z.boolean() }).partial(); + +const COLUMNS: [keyof z.infer, string][] = [ + ['sku', 'sku'], ['barcode', 'barcode'], ['nameAr', 'name_ar'], ['nameEn', 'name_en'], ['description', 'description'], + ['productType', 'product_type'], ['categoryId', 'category_id'], ['unitId', 'unit_id'], ['salePrice', 'sale_price'], + ['salePriceIncludesVat', 'sale_price_includes_vat'], ['purchasePrice', 'purchase_price'], ['vatCategory', 'vat_category'], + ['vatExemptionCode', 'vat_exemption_code'], ['vatExemptionReason', 'vat_exemption_reason'], + ['trackInventory', 'track_inventory'], ['salesAccountId', 'sales_account_id'], ['purchaseAccountId', 'purchase_account_id'], + ['isActive', 'is_active'], +]; + +async function loadProduct(db: Db, tenantId: string, id: string) { + const { rows: [p] } = await db.query<{ companyId: string; productType: string; trackInventory: boolean; [k: string]: unknown }>( + `${PRODUCT_SELECT} WHERE p.tenant_id = $1 AND p.id = $2 AND p.deleted_at IS NULL`, [tenantId, id]); + return p; +} + +/** Checks that referenced rows belong to the same company and fit their role. */ +async function checkReferences(db: Db, companyId: string, body: Partial>) { + if (body.unitId) { + const u = await db.query(`SELECT 1 FROM units WHERE company_id = $1 AND id = $2 AND is_active`, [companyId, body.unitId]); + if (!u.rowCount) throw badRequest('INVALID_UNIT', 'Unit does not exist in this company'); + } + if (body.categoryId) { + const c = await db.query(`SELECT 1 FROM product_categories WHERE company_id = $1 AND id = $2 AND deleted_at IS NULL`, [companyId, body.categoryId]); + if (!c.rowCount) throw badRequest('INVALID_CATEGORY', 'Category does not exist in this company'); + } + const checkAccount = async (id: string | null | undefined, allowed: string[], label: string) => { + if (!id) return; + const { rows: [a] } = await db.query<{ account_type: string; is_postable: boolean; is_active: boolean }>( + `SELECT account_type, is_postable, is_active FROM accounts WHERE company_id = $1 AND id = $2 AND deleted_at IS NULL`, [companyId, id]); + if (!a || !a.is_postable || !a.is_active || !allowed.includes(a.account_type)) { + throw badRequest('INVALID_PRODUCT_ACCOUNT', `${label} must be an active postable account of type ${allowed.join(' or ')}`); + } + }; + await checkAccount(body.salesAccountId, ['REVENUE'], 'Sales account'); + await checkAccount(body.purchaseAccountId, ['ASSET', 'EXPENSE', 'COST_OF_GOODS_SOLD'], 'Purchase account'); +} + +export default async function productsRoutes(app: FastifyInstance) { + const canView = requirePermission(app, 'product.view'); + const canManage = requirePermission(app, 'product.manage'); + + // Units ------------------------------------------------------------------------ + app.get('/units', { preHandler: canView }, async (req) => { + const q = parse(z.object({ companyId: z.uuid().optional() }), req.query); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const { rows } = await db.query( + `SELECT id, code, name_ar AS "nameAr", name_en AS "nameEn", is_active AS "isActive" + FROM units WHERE tenant_id = $1 AND company_id = $2 ORDER BY code`, [req.auth!.tenantId, companyId]); + return { data: rows }; + }); + }); + + app.post('/units', { preHandler: canManage }, async (req, reply) => { + const body = parse(z.object({ + companyId: z.uuid().optional(), + code: z.string().trim().toUpperCase().regex(/^[A-Z0-9]{1,10}$/), + nameAr: z.string().trim().min(1).max(50), + nameEn: optionalText(50), + }), req.body); + const a = req.auth!; + const unit = await req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, a.tenantId, body.companyId); + const { rows: [u] } = await db.query<{ id: string }>( + `INSERT INTO units (tenant_id, company_id, code, name_ar, name_en) VALUES ($1, $2, $3, $4, $5) + RETURNING id, code, name_ar AS "nameAr", name_en AS "nameEn", is_active AS "isActive"`, + [a.tenantId, companyId, body.code, body.nameAr, body.nameEn ?? null]); + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'CREATE', entityType: 'unit', entityId: u!.id, newValues: u }, req.auditMeta()); + return u; + }); + reply.code(201); + return unit; + }); + + app.patch('/units/:id', { preHandler: canManage }, async (req) => { + const { id } = parse(uuidParam, req.params); + const body = parse(z.object({ nameAr: z.string().trim().min(1).max(50), nameEn: optionalText(50), isActive: z.boolean() }).partial(), req.body); + const a = req.auth!; + return req.tenantTx(async (db) => { + const { rows: [before] } = await db.query(`SELECT id, code, name_ar AS "nameAr", name_en AS "nameEn", is_active AS "isActive" FROM units WHERE tenant_id = $1 AND id = $2`, [a.tenantId, id]); + if (!before) throw notFound('Unit'); + if (body.isActive === false) { + const used = await db.query(`SELECT 1 FROM products WHERE unit_id = $1 AND deleted_at IS NULL AND is_active LIMIT 1`, [id]); + if (used.rowCount) throw conflict('UNIT_IN_USE', 'Active products use this unit'); + } + const { rows: [after] } = await db.query( + `UPDATE units SET name_ar = COALESCE($2, name_ar), name_en = CASE WHEN $3::boolean THEN $4 ELSE name_en END, + is_active = COALESCE($5, is_active) + WHERE id = $1 RETURNING id, code, name_ar AS "nameAr", name_en AS "nameEn", is_active AS "isActive"`, + [id, body.nameAr ?? null, body.nameEn !== undefined, body.nameEn ?? null, body.isActive ?? null]); + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'UPDATE', entityType: 'unit', entityId: id, oldValues: before, newValues: after }, req.auditMeta()); + return after; + }); + }); + + // Categories ------------------------------------------------------------------- + const CATEGORY_SELECT = `SELECT id, parent_id AS "parentId", name_ar AS "nameAr", name_en AS "nameEn", is_active AS "isActive" FROM product_categories`; + + app.get('/product-categories', { preHandler: canView }, async (req) => { + const q = parse(z.object({ companyId: z.uuid().optional() }), req.query); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const { rows } = await db.query(`${CATEGORY_SELECT} WHERE tenant_id = $1 AND company_id = $2 AND deleted_at IS NULL ORDER BY name_ar`, [req.auth!.tenantId, companyId]); + return { data: rows }; + }); + }); + + app.post('/product-categories', { preHandler: canManage }, async (req, reply) => { + const body = parse(z.object({ companyId: z.uuid().optional(), nameAr: z.string().trim().min(2).max(100), nameEn: optionalText(100), parentId: z.uuid().nullish() }), req.body); + const a = req.auth!; + const cat = await req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, a.tenantId, body.companyId); + if (body.parentId) { + const p = await db.query(`SELECT 1 FROM product_categories WHERE company_id = $1 AND id = $2 AND deleted_at IS NULL`, [companyId, body.parentId]); + if (!p.rowCount) throw badRequest('INVALID_CATEGORY', 'Parent category does not exist in this company'); + } + const { rows: [c] } = await db.query<{ id: string }>( + `INSERT INTO product_categories (tenant_id, company_id, parent_id, name_ar, name_en) VALUES ($1, $2, $3, $4, $5) RETURNING id`, + [a.tenantId, companyId, body.parentId ?? null, body.nameAr, body.nameEn ?? null]); + const created = (await db.query(`${CATEGORY_SELECT} WHERE id = $1`, [c!.id])).rows[0]; + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'CREATE', entityType: 'product_category', entityId: c!.id, newValues: created }, req.auditMeta()); + return created; + }); + reply.code(201); + return cat; + }); + + app.patch('/product-categories/:id', { preHandler: canManage }, async (req) => { + const { id } = parse(uuidParam, req.params); + const body = parse(z.object({ nameAr: z.string().trim().min(2).max(100), nameEn: optionalText(100), isActive: z.boolean() }).partial(), req.body); + const a = req.auth!; + return req.tenantTx(async (db) => { + const before = (await db.query(`${CATEGORY_SELECT} WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL`, [a.tenantId, id])).rows[0]; + if (!before) throw notFound('Category'); + await db.query( + `UPDATE product_categories SET name_ar = COALESCE($2, name_ar), name_en = CASE WHEN $3::boolean THEN $4 ELSE name_en END, + is_active = COALESCE($5, is_active) WHERE id = $1`, + [id, body.nameAr ?? null, body.nameEn !== undefined, body.nameEn ?? null, body.isActive ?? null]); + const after = (await db.query(`${CATEGORY_SELECT} WHERE id = $1`, [id])).rows[0]; + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'UPDATE', entityType: 'product_category', entityId: id, oldValues: before, newValues: after }, req.auditMeta()); + return after; + }); + }); + + // Products --------------------------------------------------------------------- + app.get('/products', { preHandler: canView }, async (req) => { + const q = parse(z.object({ + companyId: z.uuid().optional(), + search: z.string().trim().max(100).optional(), + categoryId: z.uuid().optional(), + productType: z.enum(['GOODS', 'SERVICE']).optional(), + status: z.enum(['active', 'inactive', 'all']).default('active'), + limit: z.coerce.number().int().min(1).max(200).default(50), + offset: z.coerce.number().int().min(0).default(0), + }), req.query); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const search = q.search ? `%${q.search.replace(/[%_\\]/g, (m) => `\\${m}`)}%` : null; + const { rows } = await db.query<{ total: string }>( + `${PRODUCT_SELECT.replace('SELECT p.id', 'SELECT count(*) OVER () AS total, p.id')} + WHERE p.tenant_id = $1 AND p.company_id = $2 AND p.deleted_at IS NULL + AND ($3 = 'all' OR p.is_active = ($3 = 'active')) + AND ($4::text IS NULL OR p.name_ar ILIKE $4 OR p.name_en ILIKE $4 OR p.sku ILIKE $4 OR p.barcode = $5) + AND ($6::uuid IS NULL OR p.category_id = $6) + AND ($7::text IS NULL OR p.product_type = $7) + ORDER BY p.name_ar LIMIT $8 OFFSET $9`, + [req.auth!.tenantId, companyId, q.status, search, q.search ?? null, q.categoryId ?? null, q.productType ?? null, q.limit, q.offset]); + return { data: rows.map(({ total: _t, ...r }) => r), total: Number(rows[0]?.total ?? 0) }; + }); + }); + + app.get('/products/:id', { preHandler: canView }, async (req) => { + const { id } = parse(uuidParam, req.params); + const product = await req.tenantTx((db) => loadProduct(db, req.auth!.tenantId, id)); + if (!product) throw notFound('Product'); + return product; + }); + + app.post('/products', { preHandler: canManage }, async (req, reply) => { + const body = parse(createBody, req.body); + const trackInventory = body.trackInventory ?? body.productType === 'GOODS'; + if (body.productType === 'SERVICE' && trackInventory) throw badRequest('SERVICE_NO_STOCK', 'Services cannot track inventory'); + const a = req.auth!; + const product = await req.tenantTx(async (db) => { + await assertWithinLimit(db, a.tenantId, 'max_products'); + const companyId = await resolveCompanyId(db, a.tenantId, body.companyId); + let unitId = body.unitId; + if (!unitId) { + const { rows: [u] } = await db.query<{ id: string }>(`SELECT id FROM units WHERE company_id = $1 AND code = $2`, [companyId, body.productType === 'SERVICE' ? 'HUR' : 'PCE']); + if (!u) throw badRequest('UNIT_REQUIRED', 'unitId is required'); + unitId = u.id; + } + await checkReferences(db, companyId, { ...body, unitId }); + const sku = body.sku ?? await nextFreeCode(db, a.tenantId, companyId, 'PRODUCT', { prefix: 'PRD' }, 'products', 'sku'); + const { rows: [row] } = await db.query<{ id: string }>( + `INSERT INTO products (tenant_id, company_id, sku, barcode, name_ar, name_en, description, product_type, category_id, + unit_id, sale_price, sale_price_includes_vat, purchase_price, vat_category, track_inventory, + sales_account_id, purchase_account_id, created_by, updated_by, vat_exemption_code, vat_exemption_reason) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $18, $19, $20) RETURNING id`, + [a.tenantId, companyId, sku, body.barcode ?? null, body.nameAr, body.nameEn ?? null, body.description ?? null, + body.productType, body.categoryId ?? null, unitId, body.salePrice, body.salePriceIncludesVat, body.purchasePrice, + body.vatCategory, trackInventory, body.salesAccountId ?? null, body.purchaseAccountId ?? null, a.userId, + body.vatExemptionCode ?? null, body.vatExemptionReason ?? null]); + const created = await loadProduct(db, a.tenantId, row!.id); + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'CREATE', entityType: 'product', entityId: row!.id, newValues: created }, req.auditMeta()); + return created; + }); + reply.code(201); + return product; + }); + + app.patch('/products/:id', { preHandler: canManage }, async (req) => { + const { id } = parse(uuidParam, req.params); + const body = parse(updateBody, req.body); + const a = req.auth!; + return req.tenantTx(async (db) => { + const before = await loadProduct(db, a.tenantId, id); + if (!before) throw notFound('Product'); + const type = body.productType ?? before.productType; + const track = body.trackInventory ?? (body.productType === 'SERVICE' ? false : before.trackInventory); + if (type === 'SERVICE' && track) throw badRequest('SERVICE_NO_STOCK', 'Services cannot track inventory'); + if ((body.productType !== undefined && body.productType !== before.productType) || + (body.trackInventory !== undefined && body.trackInventory !== before.trackInventory)) { + const moved = await db.query(`SELECT 1 FROM stock_movements WHERE product_id = $1 LIMIT 1`, [id]); + if (moved.rowCount) throw conflict('PRODUCT_HAS_STOCK_HISTORY', 'The type and stock tracking of a product with stock movements cannot change'); + } + if (body.productType === 'SERVICE' && body.trackInventory === undefined) body.trackInventory = false; + await checkReferences(db, before.companyId, body); + const sets: string[] = []; + const values: unknown[] = []; + for (const [key, col] of COLUMNS) { + if (body[key] !== undefined) { values.push(body[key]); sets.push(`${col} = $${values.length + 3}`); } + } + if (sets.length) { + await db.query(`UPDATE products SET ${sets.join(', ')}, updated_by = $3 WHERE tenant_id = $1 AND id = $2`, [a.tenantId, id, a.userId, ...values]); + } + const after = await loadProduct(db, a.tenantId, id); + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'UPDATE', entityType: 'product', entityId: id, oldValues: before, newValues: after }, req.auditMeta()); + return after; + }); + }); + + app.delete('/products/:id', { preHandler: canManage }, async (req, reply) => { + const { id } = parse(uuidParam, req.params); + const a = req.auth!; + await req.tenantTx(async (db) => { + const before = await loadProduct(db, a.tenantId, id); + if (!before) throw notFound('Product'); + const itemTables = ['sales_quote_items', 'sales_invoice_items', 'sales_return_items', 'purchase_order_items', 'purchase_invoice_items', 'purchase_return_items']; + const used = await db.query(`${[...itemTables, 'stock_movements'].map((t) => `SELECT 1 FROM ${t} WHERE product_id = $1`).join(' UNION ALL ')} LIMIT 1`, [id]); + if (used.rowCount) throw conflict('PRODUCT_IN_USE', 'This product is used in documents. Deactivate it instead.'); + await db.query(`UPDATE products SET deleted_at = now(), is_active = false, updated_by = $2 WHERE id = $1`, [id, a.userId]); + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'DELETE', entityType: 'product', entityId: id, oldValues: before }, req.auditMeta()); + }); + return reply.code(204).send(); + }); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/rbac/catalog.ts b/alshuyukh-accounting/apps/api/src/modules/rbac/catalog.ts new file mode 100644 index 000000000000..e07fc95ceb92 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/rbac/catalog.ts @@ -0,0 +1,167 @@ +/** + * Permission and system-role catalog. + * + * This file is the single source of truth. `npm run db:migrate` syncs it into + * the `permissions`, `roles` (system rows) and `role_permissions` tables. + * Tenants can create additional custom roles from these permissions. + * + * Permissions for modules delivered in later phases are defined now so that + * role definitions stay stable; the endpoints that check them arrive with + * their phase. + */ + +export interface PermissionDef { + code: string; + module: string; + ar: string; + en: string; +} + +export const PERMISSIONS: readonly PermissionDef[] = [ + // Phase 1 — platform foundations + { code: 'user.view', module: 'users', ar: 'عرض المستخدمين', en: 'View users' }, + { code: 'user.invite', module: 'users', ar: 'إضافة مستخدمين', en: 'Add users' }, + { code: 'user.manage', module: 'users', ar: 'إدارة المستخدمين وأدوارهم', en: 'Manage users and their roles' }, + { code: 'role.view', module: 'roles', ar: 'عرض الأدوار', en: 'View roles' }, + { code: 'role.manage', module: 'roles', ar: 'إدارة الأدوار والصلاحيات', en: 'Manage roles and permissions' }, + { code: 'company.view', module: 'companies', ar: 'عرض الشركات والفروع', en: 'View companies and branches' }, + { code: 'company.manage', module: 'companies', ar: 'إدارة الشركات والفروع والمستودعات', en: 'Manage companies, branches, and warehouses' }, + { code: 'settings.manage', module: 'settings', ar: 'إدارة الإعدادات', en: 'Manage settings' }, + { code: 'audit.view', module: 'audit', ar: 'عرض سجل التدقيق', en: 'View audit log' }, + { code: 'subscription.manage', module: 'subscriptions', ar: 'إدارة الاشتراك', en: 'Manage subscription' }, + + // Phase 2 — accounting + { code: 'account.view', module: 'accounting', ar: 'عرض دليل الحسابات', en: 'View chart of accounts' }, + { code: 'account.manage', module: 'accounting', ar: 'إدارة دليل الحسابات', en: 'Manage chart of accounts' }, + { code: 'journal.view', module: 'accounting', ar: 'عرض القيود', en: 'View journal entries' }, + { code: 'journal.create', module: 'accounting', ar: 'إنشاء القيود', en: 'Create journal entries' }, + { code: 'journal.post', module: 'accounting', ar: 'ترحيل القيود', en: 'Post journal entries' }, + { code: 'journal.reverse', module: 'accounting', ar: 'عكس القيود', en: 'Reverse journal entries' }, + { code: 'fiscal.manage', module: 'accounting', ar: 'إدارة السنوات والفترات المالية', en: 'Manage fiscal years and periods' }, + + // Phase 3–4 — sales, purchases, payments + { code: 'customer.view', module: 'sales', ar: 'عرض العملاء', en: 'View customers' }, + { code: 'customer.manage', module: 'sales', ar: 'إدارة العملاء', en: 'Manage customers' }, + { code: 'supplier.view', module: 'purchases', ar: 'عرض الموردين', en: 'View suppliers' }, + { code: 'supplier.manage', module: 'purchases', ar: 'إدارة الموردين', en: 'Manage suppliers' }, + { code: 'product.view', module: 'inventory', ar: 'عرض المنتجات', en: 'View products' }, + { code: 'product.manage', module: 'inventory', ar: 'إدارة المنتجات', en: 'Manage products' }, + { code: 'invoice.view', module: 'sales', ar: 'عرض الفواتير', en: 'View invoices' }, + { code: 'invoice.create', module: 'sales', ar: 'إنشاء الفواتير', en: 'Create invoices' }, + { code: 'invoice.edit', module: 'sales', ar: 'تعديل الفواتير (مسودة)', en: 'Edit draft invoices' }, + { code: 'invoice.delete', module: 'sales', ar: 'حذف الفواتير (مسودة)', en: 'Delete draft invoices' }, + { code: 'invoice.post', module: 'sales', ar: 'اعتماد الفواتير', en: 'Issue invoices' }, + { code: 'invoice.cancel', module: 'sales', ar: 'إلغاء الفواتير', en: 'Cancel invoices' }, + { code: 'purchase.view', module: 'purchases', ar: 'عرض المشتريات', en: 'View purchases' }, + { code: 'purchase.create', module: 'purchases', ar: 'إنشاء المشتريات', en: 'Create purchases' }, + { code: 'purchase.post', module: 'purchases', ar: 'اعتماد المشتريات', en: 'Post purchases' }, + { code: 'purchase.cancel', module: 'purchases', ar: 'إلغاء المشتريات', en: 'Cancel purchases' }, + { code: 'payment.view', module: 'payments', ar: 'عرض المدفوعات', en: 'View payments' }, + { code: 'payment.create', module: 'payments', ar: 'تسجيل المدفوعات', en: 'Record payments' }, + { code: 'payment.void', module: 'payments', ar: 'إلغاء المدفوعات', en: 'Void payments' }, + + // Phase 5–6 — inventory, expenses, tax + { code: 'inventory.view', module: 'inventory', ar: 'عرض المخزون', en: 'View inventory' }, + { code: 'inventory.adjust', module: 'inventory', ar: 'تسوية المخزون', en: 'Adjust inventory' }, + { code: 'inventory.transfer', module: 'inventory', ar: 'تحويل المخزون', en: 'Transfer inventory' }, + { code: 'expense.view', module: 'expenses', ar: 'عرض المصروفات', en: 'View expenses' }, + { code: 'expense.create', module: 'expenses', ar: 'إنشاء المصروفات', en: 'Create expenses' }, + { code: 'expense.post', module: 'expenses', ar: 'اعتماد المصروفات', en: 'Post expenses' }, + { code: 'expense.cancel', module: 'expenses', ar: 'إلغاء المصروفات', en: 'Cancel expenses' }, + { code: 'tax.manage', module: 'tax', ar: 'إدارة الضرائب', en: 'Manage tax rates' }, + + // Phase 7–8 — reports, e-invoicing + { code: 'report.view', module: 'reports', ar: 'عرض التقارير التشغيلية', en: 'View operational reports' }, + { code: 'financial_report.view', module: 'reports', ar: 'عرض القوائم والتقارير المالية', en: 'View financial statements and reports' }, + { code: 'zatca.view', module: 'zatca', ar: 'عرض الفوترة الإلكترونية', en: 'View e-invoicing' }, + { code: 'zatca.manage', module: 'zatca', ar: 'إدارة الفوترة الإلكترونية', en: 'Manage e-invoicing' }, +] as const; + +export const ALL_PERMISSION_CODES = PERMISSIONS.map((p) => p.code); + +export interface SystemRoleDef { + code: string; + ar: string; + en: string; + permissions: readonly string[] | 'ALL'; +} + +const ADMIN_EXCLUDED = new Set(['subscription.manage']); + +/** + * SUPER_ADMIN is not a tenant role. Platform administration uses + * users.is_platform_admin and a separate /admin surface (Phase 9). + */ +export const SYSTEM_ROLES: readonly SystemRoleDef[] = [ + { code: 'TENANT_OWNER', ar: 'مالك المنشأة', en: 'Tenant owner', permissions: 'ALL' }, + { + code: 'COMPANY_ADMIN', + ar: 'مدير الشركة', + en: 'Company admin', + permissions: ALL_PERMISSION_CODES.filter((c) => !ADMIN_EXCLUDED.has(c)), + }, + { + code: 'ACCOUNTANT', + ar: 'محاسب', + en: 'Accountant', + permissions: [ + 'company.view', 'account.view', 'account.manage', 'journal.view', 'journal.create', + 'journal.post', 'journal.reverse', 'fiscal.manage', 'customer.view', 'customer.manage', + 'supplier.view', 'supplier.manage', + 'product.view', 'invoice.view', 'invoice.create', 'invoice.edit', 'invoice.post', + 'invoice.cancel', 'purchase.view', 'purchase.create', 'purchase.post', 'purchase.cancel', 'payment.view', + 'payment.create', 'payment.void', 'inventory.view', 'expense.view', 'expense.create', 'expense.post', 'expense.cancel', + 'tax.manage', 'report.view', 'financial_report.view', 'zatca.view', + ], + }, + { + code: 'SALES_MANAGER', + ar: 'مدير المبيعات', + en: 'Sales manager', + permissions: [ + 'company.view', 'customer.view', 'customer.manage', 'product.view', 'invoice.view', + 'invoice.create', 'invoice.edit', 'invoice.delete', 'invoice.post', 'invoice.cancel', + 'payment.view', 'payment.create', 'inventory.view', 'report.view', 'zatca.view', + ], + }, + { + code: 'SALES_EMPLOYEE', + ar: 'موظف مبيعات', + en: 'Sales employee', + permissions: ['company.view', 'customer.view', 'product.view', 'invoice.view', 'invoice.create', 'invoice.edit'], + }, + { + code: 'PURCHASE_MANAGER', + ar: 'مدير المشتريات', + en: 'Purchase manager', + permissions: [ + 'company.view', 'supplier.view', 'supplier.manage', 'product.view', 'purchase.view', + 'purchase.create', 'purchase.post', 'purchase.cancel', 'payment.view', 'inventory.view', 'report.view', + ], + }, + { + code: 'WAREHOUSE_MANAGER', + ar: 'مدير المستودع', + en: 'Warehouse manager', + permissions: [ + 'company.view', 'product.view', 'product.manage', 'inventory.view', 'inventory.adjust', + 'inventory.transfer', 'report.view', + ], + }, + { + code: 'WAREHOUSE_EMPLOYEE', + ar: 'موظف مستودع', + en: 'Warehouse employee', + permissions: ['company.view', 'product.view', 'inventory.view', 'inventory.transfer'], + }, + { + code: 'VIEWER', + ar: 'مشاهد', + en: 'Viewer', + permissions: ALL_PERMISSION_CODES.filter((c) => c.endsWith('.view')), + }, +]; + +export function resolveRolePermissions(role: SystemRoleDef): string[] { + return role.permissions === 'ALL' ? [...ALL_PERMISSION_CODES] : [...role.permissions]; +} diff --git a/alshuyukh-accounting/apps/api/src/modules/rbac/rbac.routes.ts b/alshuyukh-accounting/apps/api/src/modules/rbac/rbac.routes.ts new file mode 100644 index 000000000000..5fbb9235e38b --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/rbac/rbac.routes.ts @@ -0,0 +1,171 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import type { Db } from '../../db/tx.js'; +import { badRequest, conflict, forbidden, notFound } from '../../lib/errors.js'; +import { parse, uuidParam } from '../../lib/validation.js'; +import { requirePermission } from '../../plugins/auth.js'; +import type { AuthContext } from '../../types.js'; +import { writeAudit } from '../audit/audit.service.js'; + +/** + * Resolves role IDs visible to the current tenant and the union of their + * permissions. Unknown, deleted, or other-tenant roles are rejected. + */ +export async function resolveRoles(db: Db, roleIds: string[]) { + const unique = [...new Set(roleIds)]; + if (unique.length === 0) return { roles: [], permissions: new Set() }; + const { rows } = await db.query<{ id: string; code: string; perms: string[] }>( + `SELECT r.id, r.code, COALESCE(array_agg(p.code) FILTER (WHERE p.code IS NOT NULL), '{}') AS perms + FROM roles r + LEFT JOIN role_permissions rp ON rp.role_id = r.id + LEFT JOIN permissions p ON p.id = rp.permission_id + WHERE r.id = ANY($1::uuid[]) AND r.deleted_at IS NULL + GROUP BY r.id, r.code`, + [unique], + ); + if (rows.length !== unique.length) throw badRequest('INVALID_ROLE', 'One or more roles do not exist'); + return { roles: rows, permissions: new Set(rows.flatMap((r) => r.perms)) }; +} + +/** Prevents privilege escalation: nobody can grant a permission they do not hold. */ +export function assertCanGrant(actor: AuthContext, permissions: Iterable) { + const missing = [...permissions].filter((p) => !actor.permissions.has(p)); + if (missing.length) throw forbidden(`You cannot grant permissions you do not have: ${missing.join(', ')}`); +} + +async function validPermissionIds(db: Db, codes: string[]) { + const { rows } = await db.query<{ id: string; code: string }>( + `SELECT id, code FROM permissions WHERE code = ANY($1::text[])`, [codes]); + const known = new Set(rows.map((r) => r.code)); + const unknown = codes.filter((c) => !known.has(c)); + if (unknown.length) throw badRequest('INVALID_PERMISSION', `Unknown permissions: ${unknown.join(', ')}`); + return rows.map((r) => r.id); +} + +const roleBody = z.object({ + code: z.string().trim().toUpperCase().regex(/^[A-Z][A-Z0-9_]{1,62}$/), + nameAr: z.string().trim().min(2).max(100), + nameEn: z.string().trim().min(2).max(100), + description: z.string().trim().max(500).nullish(), + permissions: z.array(z.string()).max(500), +}); + +type RoleRow = { id: string; code: string; name_ar: string; name_en: string; description: string | null; is_system: boolean; permissions: string[] }; + +const toRole = (r: RoleRow) => ({ + id: r.id, code: r.code, nameAr: r.name_ar, nameEn: r.name_en, + description: r.description, isSystem: r.is_system, permissions: r.permissions, +}); + +async function loadRole(db: Db, id: string): Promise { + const { rows } = await db.query( + `SELECT r.id, r.code, r.name_ar, r.name_en, r.description, r.is_system, + COALESCE(array_agg(p.code ORDER BY p.code) FILTER (WHERE p.code IS NOT NULL), '{}') AS permissions + FROM roles r + LEFT JOIN role_permissions rp ON rp.role_id = r.id + LEFT JOIN permissions p ON p.id = rp.permission_id + WHERE r.id = $1 AND r.deleted_at IS NULL + GROUP BY r.id`, + [id], + ); + return rows[0]; +} + +export default async function rbacRoutes(app: FastifyInstance) { + app.get('/permissions', { preHandler: requirePermission(app, 'role.view') }, async (req) => + req.tenantTx(async (db) => { + const { rows } = await db.query( + `SELECT code, module, description_ar AS "descriptionAr", description_en AS "descriptionEn" + FROM permissions ORDER BY module, code`); + return { data: rows }; + })); + + app.get('/roles', { preHandler: requirePermission(app, 'role.view') }, async (req) => + req.tenantTx(async (db) => { + const { rows } = await db.query( + `SELECT r.id, r.code, r.name_ar, r.name_en, r.description, r.is_system, + COALESCE(array_agg(p.code ORDER BY p.code) FILTER (WHERE p.code IS NOT NULL), '{}') AS permissions + FROM roles r + LEFT JOIN role_permissions rp ON rp.role_id = r.id + LEFT JOIN permissions p ON p.id = rp.permission_id + WHERE r.deleted_at IS NULL AND (r.tenant_id IS NULL OR r.tenant_id = $1) + GROUP BY r.id + ORDER BY r.is_system DESC, r.code`, + [req.auth!.tenantId]); + return { data: rows.map(toRole) }; + })); + + app.post('/roles', { preHandler: requirePermission(app, 'role.manage') }, async (req, reply) => { + const body = parse(roleBody, req.body); + const a = req.auth!; + const role = await req.tenantTx(async (db) => { + const sys = await db.query(`SELECT 1 FROM roles WHERE tenant_id IS NULL AND code = $1`, [body.code]); + if (sys.rowCount) throw conflict('ROLE_CODE_RESERVED', 'This code is reserved for a system role'); + const permIds = await validPermissionIds(db, body.permissions); + assertCanGrant(a, body.permissions); + const { rows: [r] } = await db.query<{ id: string }>( + `INSERT INTO roles (tenant_id, code, name_ar, name_en, description, is_system) + VALUES ($1, $2, $3, $4, $5, false) RETURNING id`, + [a.tenantId, body.code, body.nameAr, body.nameEn, body.description ?? null]); + await db.query( + `INSERT INTO role_permissions (role_id, permission_id) SELECT $1, unnest($2::uuid[])`, [r!.id, permIds]); + const created = (await loadRole(db, r!.id))!; + await writeAudit(db, { + tenantId: a.tenantId, userId: a.userId, action: 'PERMISSION_CHANGE', entityType: 'role', entityId: r!.id, + newValues: toRole(created), + }, req.auditMeta()); + return created; + }); + reply.code(201); + return toRole(role); + }); + + app.patch('/roles/:id', { preHandler: requirePermission(app, 'role.manage') }, async (req) => { + const { id } = parse(uuidParam, req.params); + const body = parse(roleBody.omit({ code: true }).partial(), req.body); + const a = req.auth!; + const role = await req.tenantTx(async (db) => { + const before = await loadRole(db, id); + if (!before) throw notFound('Role'); + if (before.is_system) throw forbidden('System roles cannot be modified'); + // Editing a role you hold could strip your own access mid-session; allowed, + // but you also cannot remove permissions you are not allowed to grant. + assertCanGrant(a, before.permissions); + await db.query( + `UPDATE roles SET name_ar = COALESCE($2, name_ar), name_en = COALESCE($3, name_en), + description = CASE WHEN $4::boolean THEN $5 ELSE description END + WHERE id = $1`, + [id, body.nameAr ?? null, body.nameEn ?? null, body.description !== undefined, body.description ?? null]); + if (body.permissions) { + const permIds = await validPermissionIds(db, body.permissions); + assertCanGrant(a, body.permissions); + await db.query(`DELETE FROM role_permissions WHERE role_id = $1`, [id]); + await db.query(`INSERT INTO role_permissions (role_id, permission_id) SELECT $1, unnest($2::uuid[])`, [id, permIds]); + } + const after = (await loadRole(db, id))!; + await writeAudit(db, { + tenantId: a.tenantId, userId: a.userId, action: 'PERMISSION_CHANGE', entityType: 'role', entityId: id, + oldValues: toRole(before), newValues: toRole(after), + }, req.auditMeta()); + return after; + }); + return toRole(role); + }); + + app.delete('/roles/:id', { preHandler: requirePermission(app, 'role.manage') }, async (req, reply) => { + const { id } = parse(uuidParam, req.params); + const a = req.auth!; + await req.tenantTx(async (db) => { + const before = await loadRole(db, id); + if (!before) throw notFound('Role'); + if (before.is_system) throw forbidden('System roles cannot be deleted'); + const used = await db.query(`SELECT 1 FROM user_roles WHERE role_id = $1 LIMIT 1`, [id]); + if (used.rowCount) throw conflict('ROLE_IN_USE', 'Remove this role from all users before deleting it'); + await db.query(`UPDATE roles SET deleted_at = now() WHERE id = $1`, [id]); + await writeAudit(db, { + tenantId: a.tenantId, userId: a.userId, action: 'DELETE', entityType: 'role', entityId: id, oldValues: toRole(before), + }, req.auditMeta()); + }); + return reply.code(204).send(); + }); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/reports/commercial.routes.ts b/alshuyukh-accounting/apps/api/src/modules/reports/commercial.routes.ts new file mode 100644 index 000000000000..91fbe9029f8e --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/reports/commercial.routes.ts @@ -0,0 +1,111 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { sum, toMoney } from '../../lib/money.js'; +import { parse } from '../../lib/validation.js'; +import { requirePermission } from '../../plugins/auth.js'; +import { resolveCompanyId } from '../accounting/company-context.js'; +import { checkRange, GL, NOT_CLOSING, rangeFields } from './common.js'; + +/** + * Operational sales and purchase reports, read from issued documents. + * Returns subtract from the period of the return. Cancelled documents and + * drafts are excluded. Financial totals (revenue, cost) belong to the P&L; + * these reports explain them by customer, supplier, product or month. + */ +const SIDES = { + sales: { + party: 'customer', doc: 'sales_invoices', items: 'sales_invoice_items', ret: 'sales_returns', retItems: 'sales_return_items', + out: 'SALE', back: 'SALE_RETURN', partyTable: 'customers', + }, + purchases: { + party: 'supplier', doc: 'purchase_invoices', items: 'purchase_invoice_items', ret: 'purchase_returns', retItems: 'purchase_return_items', + out: 'PURCHASE', back: 'PURCHASE_RETURN', partyTable: 'suppliers', + }, +} as const; + +const GROUP_BY = ['party', 'product', 'month', 'document'] as const; + +export default async function commercialReportRoutes(app: FastifyInstance) { + const canView = requirePermission(app, 'report.view'); + + for (const [name, s] of Object.entries(SIDES)) { + app.get(`/reports/${name}`, { preHandler: canView }, async (req) => { + const q = parse(z.object({ + ...rangeFields, groupBy: z.enum(GROUP_BY).default('party'), + partyId: z.uuid().optional(), productId: z.uuid().optional(), branchId: z.uuid().optional(), + }), req.query); + checkRange(q); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + // For sales the cost is what the stock movements took out (COGS); returns bring it back. + const lines = (table: string, items: string, sign: 1 | -1, movement: string) => ` + SELECT d.id AS doc_id, d.doc_number, d.doc_date, d.${s.party}_id AS party_id, i.product_id, ${sign} AS sign, + i.quantity, i.net_amount, i.vat_amount, i.total_amount, + COALESCE((SELECT sum(m.total_cost) FROM stock_movements m WHERE m.reference_line_id = i.id AND m.movement_type = '${movement}'), 0) AS cost + FROM ${table} d JOIN ${items} i ON i.document_id = d.id + WHERE d.tenant_id = $1 AND d.company_id = $2 AND d.status NOT IN ('DRAFT', 'CANCELLED') AND d.doc_date BETWEEN $3 AND $4 + AND ($5::uuid IS NULL OR d.${s.party}_id = $5) AND ($6::uuid IS NULL OR i.product_id = $6) AND ($7::uuid IS NULL OR d.branch_id = $7)`; + const key = { + party: { key: 'l.party_id::text', label: 'pt.name_ar', code: 'pt.code' }, + product: { key: `COALESCE(l.product_id::text, '')`, label: `COALESCE(p.name_ar, 'بنود بدون صنف (حسابات)')`, code: 'p.sku' }, + month: { key: `to_char(l.doc_date, 'YYYY-MM')`, label: `to_char(l.doc_date, 'YYYY-MM')`, code: 'NULL::text' }, + document: { key: 'l.doc_id::text', label: 'l.doc_number', code: 'l.doc_date::text' }, + }[q.groupBy]; + const { rows } = await db.query<{ key: string; label: string; code: string | null; quantity: string; netAmount: string; vatAmount: string; totalAmount: string; cost: string; documents: string; returns: string }>( + `WITH l AS (${lines(s.doc, s.items, 1, s.out)} UNION ALL ${lines(s.ret, s.retItems, -1, s.back)}) + SELECT ${key.key} AS key, ${key.label} AS label, ${key.code} AS code, + sum(l.sign * l.quantity)::numeric(18,4)::text AS quantity, + sum(l.sign * l.net_amount)::numeric(18,2)::text AS "netAmount", + sum(l.sign * l.vat_amount)::numeric(18,2)::text AS "vatAmount", + sum(l.sign * l.total_amount)::numeric(18,2)::text AS "totalAmount", + sum(l.sign * l.cost)::numeric(18,2)::text AS cost, + count(DISTINCT l.doc_id) FILTER (WHERE l.sign = 1)::text AS documents, + count(DISTINCT l.doc_id) FILTER (WHERE l.sign = -1)::text AS returns + FROM l LEFT JOIN ${s.partyTable} pt ON pt.id = l.party_id LEFT JOIN products p ON p.id = l.product_id + GROUP BY 1, 2, 3 ORDER BY ${q.groupBy === 'month' || q.groupBy === 'document' ? '3, 2' : `sum(l.sign * l.net_amount) DESC`}`, + [req.auth!.tenantId, companyId, q.dateFrom, q.dateTo, q.partyId ?? null, q.productId ?? null, q.branchId ?? null]); + const isSales = name === 'sales'; + const data = rows.map((r) => ({ + ...r, documents: Number(r.documents), returns: Number(r.returns), + // Gross profit only makes sense for sales; for purchases "cost" is the stock value received. + ...(isSales ? { grossProfit: toMoney(sum([r.netAmount]).minus(r.cost)) } : {}), + })); + const total = (k: 'netAmount' | 'vatAmount' | 'totalAmount' | 'cost') => toMoney(sum(data.map((r) => r[k]))); + const totals = { netAmount: total('netAmount'), vatAmount: total('vatAmount'), totalAmount: total('totalAmount'), cost: total('cost') }; + return { + companyId, dateFrom: q.dateFrom, dateTo: q.dateTo, groupBy: q.groupBy, data, + totals: isSales ? { ...totals, grossProfit: toMoney(sum([totals.netAmount]).minus(totals.cost)) } : totals, + }; + }); + }); + } + + /** Expenses from the ledger (vouchers, purchase-invoice expense lines and manual entries alike). */ + app.get('/reports/expenses', { preHandler: requirePermission(app, 'financial_report.view') }, async (req) => { + const q = parse(z.object({ + ...rangeFields, groupBy: z.enum(['account', 'costCenter', 'month']).default('account'), + branchId: z.uuid().optional(), costCenterId: z.uuid().optional(), accountId: z.uuid().optional(), + }), req.query); + checkRange(q); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const key = { + account: { key: 'a.id::text', label: 'a.name_ar', code: 'a.code' }, + costCenter: { key: `COALESCE(cc.id::text, '')`, label: `COALESCE(cc.name, 'بدون مركز تكلفة')`, code: 'cc.code' }, + month: { key: `to_char(g.entry_date, 'YYYY-MM')`, label: `to_char(g.entry_date, 'YYYY-MM')`, code: 'NULL::text' }, + }[q.groupBy]; + const { rows } = await db.query<{ key: string; label: string; code: string | null; amount: string; entries: string }>( + `WITH g AS (${GL}) + SELECT ${key.key} AS key, ${key.label} AS label, ${key.code} AS code, + sum(g.debit - g.credit)::numeric(18,2)::text AS amount, count(DISTINCT g.entry_id)::text AS entries + FROM g JOIN accounts a ON a.id = g.account_id LEFT JOIN cost_centers cc ON cc.id = g.cost_center_id + WHERE a.account_type = 'EXPENSE' AND ${NOT_CLOSING} AND g.entry_date BETWEEN $3 AND $4 + AND ($5::uuid IS NULL OR g.branch_id = $5) AND ($6::uuid IS NULL OR g.cost_center_id = $6) AND ($7::uuid IS NULL OR g.account_id = $7) + GROUP BY 1, 2, 3 HAVING sum(g.debit - g.credit) <> 0 + ORDER BY ${q.groupBy === 'month' ? '2' : 'sum(g.debit - g.credit) DESC'}`, + [req.auth!.tenantId, companyId, q.dateFrom, q.dateTo, q.branchId ?? null, q.costCenterId ?? null, q.accountId ?? null]); + const data = rows.map((r) => ({ ...r, entries: Number(r.entries) })); + return { companyId, dateFrom: q.dateFrom, dateTo: q.dateTo, groupBy: q.groupBy, data, total: toMoney(sum(data.map((r) => r.amount))) }; + }); + }); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/reports/common.ts b/alshuyukh-accounting/apps/api/src/modules/reports/common.ts new file mode 100644 index 000000000000..3f87c16a6d11 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/reports/common.ts @@ -0,0 +1,70 @@ +import { z } from 'zod'; +import type { Db } from '../../db/tx.js'; +import { isoDate, todayIn } from '../../lib/dates.js'; +import { badRequest } from '../../lib/errors.js'; + +/** + * Shared building blocks for the reporting engine. Every financial figure is + * read from posted journal lines; documents are only used for operational + * reports (sales and purchases by customer or product) and for aging. + */ + +export const rangeFields = { companyId: z.uuid().optional(), dateFrom: isoDate, dateTo: isoDate }; + +/** Longest period a report may cover. */ +export const MAX_RANGE_YEARS = 5; + +export function checkRange(q: { dateFrom: string; dateTo: string }) { + if (q.dateFrom > q.dateTo) throw badRequest('INVALID_RANGE', 'dateFrom must be on or before dateTo'); + const limit = new Date(`${q.dateFrom}T00:00:00Z`); + limit.setUTCFullYear(limit.getUTCFullYear() + MAX_RANGE_YEARS); + if (new Date(`${q.dateTo}T00:00:00Z`) > limit) throw badRequest('RANGE_TOO_LONG', `A report can cover at most ${MAX_RANGE_YEARS} years`); +} + +/** + * Posted ledger lines of one company ($1 tenant, $2 company). A reversal + * inherits the origin of the entry it reverses, so a cancelled document and + * its reversal can be recognised and netted together. Entries with status + * REVERSED stay in the ledger alongside their reversal; both count. + */ +export const GL = ` + SELECT l.id AS line_id, l.account_id, l.debit, l.credit, l.branch_id, l.cost_center_id, l.customer_id, l.supplier_id, + l.line_no, l.description AS line_description, + e.id AS entry_id, e.entry_number, e.entry_date, e.posted_at, e.description, e.reference_type, e.reference_id, + COALESCE(o.reference_type, e.reference_type) AS origin_type, COALESCE(o.reference_id, e.reference_id) AS origin_id + FROM journal_entry_lines l + JOIN journal_entries e ON e.id = l.journal_entry_id + LEFT JOIN journal_entries o ON o.id = e.reversal_of_id + WHERE e.tenant_id = $1 AND e.company_id = $2 AND e.status IN ('POSTED', 'REVERSED')`; + +/** Year-closing entries move P&L balances into retained earnings; income reports exclude them. */ +export const NOT_CLOSING = `g.origin_type <> 'YEAR_CLOSING'`; + +/** + * Document numbers of the company ($1 tenant, $2 company), keyed by id, to be + * LEFT JOINed on a ledger line's origin_id. A join rather than a correlated + * subquery per line: under RLS those subqueries cannot use the primary-key + * index and turned into a sequential scan per ledger line. + */ +export const DOC_NUMBERS = ` + SELECT id, doc_number AS number FROM sales_invoices WHERE tenant_id = $1 AND company_id = $2 + UNION ALL SELECT id, doc_number FROM sales_returns WHERE tenant_id = $1 AND company_id = $2 + UNION ALL SELECT id, doc_number FROM purchase_invoices WHERE tenant_id = $1 AND company_id = $2 + UNION ALL SELECT id, doc_number FROM purchase_returns WHERE tenant_id = $1 AND company_id = $2 + UNION ALL SELECT id, payment_number FROM payments WHERE tenant_id = $1 AND company_id = $2 + UNION ALL SELECT id, expense_number FROM expenses WHERE tenant_id = $1 AND company_id = $2 + UNION ALL SELECT id, adjustment_number FROM stock_adjustments WHERE tenant_id = $1 AND company_id = $2`; + +/** + * Cash and cash equivalents: the system cash and bank accounts plus every + * account behind a payment method (cards, wallets and other clearing accounts). + */ +export const CASH_ACCOUNTS = ` + SELECT a.id FROM accounts a + WHERE a.company_id = $2 AND (a.system_key IN ('CASH', 'BANK') OR a.id IN (SELECT account_id FROM payment_methods WHERE company_id = $2))`; + +/** Today in the company's time zone (Asia/Riyadh by default). */ +export async function companyToday(db: Db, companyId: string): Promise { + const { rows: [t] } = await db.query<{ timezone: string }>(`SELECT timezone FROM companies WHERE id = $1`, [companyId]); + return todayIn(t?.timezone ?? 'Asia/Riyadh'); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/reports/dashboard.routes.ts b/alshuyukh-accounting/apps/api/src/modules/reports/dashboard.routes.ts new file mode 100644 index 000000000000..146da0ef6a79 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/reports/dashboard.routes.ts @@ -0,0 +1,86 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { addMonths, isoDate } from '../../lib/dates.js'; +import { Decimal, toMoney } from '../../lib/money.js'; +import { parse } from '../../lib/validation.js'; +import { requirePermission } from '../../plugins/auth.js'; +import { resolveCompanyId } from '../accounting/company-context.js'; +import { CASH_ACCOUNTS, checkRange, companyToday, GL, NOT_CLOSING } from './common.js'; + +/** + * Company dashboard. Every number comes from posted journal lines: + * profit from revenue/cost/expense accounts (never from invoices), + * balances from the control accounts' sub-ledgers and cash accounts. + */ +export default async function dashboardRoutes(app: FastifyInstance) { + app.get('/dashboard', { preHandler: requirePermission(app, 'financial_report.view') }, async (req) => { + const q = parse(z.object({ companyId: z.uuid().optional(), dateFrom: isoDate.optional(), dateTo: isoDate.optional() }), req.query); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const today = await companyToday(db, companyId); + // Default period: the fiscal year that contains today (or the calendar year), up to today. + let dateFrom = q.dateFrom; + const dateTo = q.dateTo ?? today; + if (!dateFrom) { + const { rows: [fy] } = await db.query<{ start_date: string }>( + `SELECT start_date FROM fiscal_years WHERE company_id = $1 AND $2::date BETWEEN start_date AND end_date`, [companyId, dateTo]); + dateFrom = fy?.start_date ?? `${dateTo.slice(0, 4)}-01-01`; + } + checkRange({ dateFrom, dateTo }); + const params = [req.auth!.tenantId, companyId, dateFrom, dateTo]; + + const { rows: [k] } = await db.query>( + `WITH g AS (${GL}), cash AS (${CASH_ACCOUNTS}), + cash_only AS (SELECT a.id FROM accounts a WHERE a.company_id = $2 AND (a.system_key = 'CASH' + OR a.id IN (SELECT account_id FROM payment_methods WHERE company_id = $2 AND method_type = 'CASH'))) + SELECT + COALESCE(sum(g.credit - g.debit) FILTER (WHERE a.account_type = 'REVENUE' AND in_period AND ${NOT_CLOSING}), 0) AS sales, + COALESCE(sum(g.debit - g.credit) FILTER (WHERE a.account_type = 'COST_OF_GOODS_SOLD' AND in_period AND ${NOT_CLOSING}), 0) AS cogs, + COALESCE(sum(g.debit - g.credit) FILTER (WHERE a.account_type = 'EXPENSE' AND in_period AND ${NOT_CLOSING}), 0) AS expenses, + -- Purchases net of VAT: what purchase invoices and returns put into stock, expense and cost accounts. + COALESCE(sum(g.debit - g.credit) FILTER (WHERE in_period AND g.origin_type IN ('PURCHASE_INVOICE', 'PURCHASE_RETURN') + AND g.supplier_id IS NULL AND a.system_key IS DISTINCT FROM 'VAT_INPUT'), 0) AS purchases, + COALESCE(sum(g.debit - g.credit) FILTER (WHERE g.customer_id IS NOT NULL), 0) AS receivables, + COALESCE(sum(g.credit - g.debit) FILTER (WHERE g.supplier_id IS NOT NULL), 0) AS payables, + COALESCE(sum(g.debit - g.credit) FILTER (WHERE g.account_id IN (SELECT id FROM cash_only)), 0) AS cash, + COALESCE(sum(g.debit - g.credit) FILTER (WHERE g.account_id IN (SELECT id FROM cash) AND g.account_id NOT IN (SELECT id FROM cash_only)), 0) AS bank, + COALESCE(sum(g.debit - g.credit) FILTER (WHERE a.system_key = 'INVENTORY'), 0) AS inventory, + COALESCE(sum(g.credit - g.debit) FILTER (WHERE a.system_key IN ('VAT_OUTPUT', 'VAT_INPUT')), 0) AS vat + FROM (SELECT g.*, g.entry_date >= $3 AS in_period FROM g WHERE g.entry_date <= $4) g JOIN accounts a ON a.id = g.account_id`, params); + + // Twelve months ending with the month of dateTo. + const firstMonth = addMonths(`${dateTo.slice(0, 7)}-01`, -11); + const { rows: monthly } = await db.query<{ month: string; revenue: string; costs: string; cash: string }>( + `WITH g AS (${GL}) + SELECT to_char(g.entry_date, 'YYYY-MM') AS month, + COALESCE(sum(g.credit - g.debit) FILTER (WHERE a.account_type = 'REVENUE' AND ${NOT_CLOSING}), 0)::text AS revenue, + COALESCE(sum(g.debit - g.credit) FILTER (WHERE a.account_type IN ('EXPENSE', 'COST_OF_GOODS_SOLD') AND ${NOT_CLOSING}), 0)::text AS costs, + COALESCE(sum(g.debit - g.credit) FILTER (WHERE g.account_id IN (${CASH_ACCOUNTS})), 0)::text AS cash + FROM g JOIN accounts a ON a.id = g.account_id + WHERE g.entry_date BETWEEN $3 AND $4 GROUP BY 1`, [req.auth!.tenantId, companyId, firstMonth, dateTo]); + const { rows: [open] } = await db.query<{ cash: string }>( + `WITH g AS (${GL}) SELECT COALESCE(sum(g.debit - g.credit), 0)::text AS cash FROM g WHERE g.entry_date < $3 AND g.account_id IN (${CASH_ACCOUNTS})`, + [req.auth!.tenantId, companyId, firstMonth]); + let cash = new Decimal(open!.cash); + const months = Array.from({ length: 12 }, (_, i) => addMonths(firstMonth, i).slice(0, 7)).map((month) => { + const m = monthly.find((r) => r.month === month); + cash = cash.plus(m?.cash ?? 0); + const revenue = new Decimal(m?.revenue ?? 0); + const costs = new Decimal(m?.costs ?? 0); + return { month, revenue: toMoney(revenue), costs: toMoney(costs), netProfit: toMoney(revenue.minus(costs)), cashBalance: toMoney(cash) }; + }); + + const money = (key: string) => toMoney(k![key] ?? 0); + return { + companyId, dateFrom, dateTo, + kpis: { + totalSales: money('sales'), totalPurchases: money('purchases'), costOfSales: money('cogs'), expenses: money('expenses'), + netProfit: toMoney(new Decimal(k!.sales!).minus(k!.cogs!).minus(k!.expenses!)), + receivables: money('receivables'), payables: money('payables'), cash: money('cash'), bank: money('bank'), + inventoryValue: money('inventory'), vatPayable: money('vat'), + }, + monthly: months, + }; + }); + }); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/reports/financial.routes.ts b/alshuyukh-accounting/apps/api/src/modules/reports/financial.routes.ts new file mode 100644 index 000000000000..523ed7c9be2c --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/reports/financial.routes.ts @@ -0,0 +1,211 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { isoDate } from '../../lib/dates.js'; +import { badRequest, notFound } from '../../lib/errors.js'; +import { Decimal, sum, toMoney } from '../../lib/money.js'; +import { parse } from '../../lib/validation.js'; +import { requirePermission } from '../../plugins/auth.js'; +import { DEBIT_NORMAL, type AccountType } from '../accounting/chart-template.js'; +import { resolveCompanyId } from '../accounting/company-context.js'; +import { CASH_ACCOUNTS, checkRange, DOC_NUMBERS, GL, NOT_CLOSING, rangeFields } from './common.js'; + +interface AccountRow { accountId: string; code: string; nameAr: string; type: AccountType; groupCode: string | null; groupName: string | null; amount: string } + +const SECTION_AR: Record = { + REVENUE: 'الإيرادات', COST_OF_GOODS_SOLD: 'تكلفة المبيعات', EXPENSE: 'المصروفات', + ASSET: 'الأصول', LIABILITY: 'الالتزامات', EQUITY: 'حقوق الملكية', +}; + +const section = (type: AccountType, rows: AccountRow[]) => { + const accounts = rows.filter((r) => r.type === type); + return { type, title: SECTION_AR[type], accounts, total: toMoney(sum(accounts.map((a) => a.amount))) }; +}; + +export default async function financialReportRoutes(app: FastifyInstance) { + const canView = requirePermission(app, 'financial_report.view'); + + /** Income statement: revenue − cost of sales = gross profit; − expenses = net profit. */ + app.get('/reports/profit-loss', { preHandler: canView }, async (req) => { + const q = parse(z.object({ ...rangeFields, branchId: z.uuid().optional(), costCenterId: z.uuid().optional() }), req.query); + checkRange(q); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const { rows } = await db.query( + `WITH g AS (${GL}) + SELECT a.id AS "accountId", a.code, a.name_ar AS "nameAr", a.account_type AS type, ag.code AS "groupCode", ag.name_ar AS "groupName", + (CASE WHEN a.account_type = 'REVENUE' THEN sum(g.credit - g.debit) ELSE sum(g.debit - g.credit) END)::numeric(18,2)::text AS amount + FROM g JOIN accounts a ON a.id = g.account_id LEFT JOIN account_groups ag ON ag.id = a.group_id + WHERE a.account_type IN ('REVENUE', 'COST_OF_GOODS_SOLD', 'EXPENSE') AND ${NOT_CLOSING} + AND g.entry_date BETWEEN $3 AND $4 AND ($5::uuid IS NULL OR g.branch_id = $5) AND ($6::uuid IS NULL OR g.cost_center_id = $6) + GROUP BY a.id, ag.code, ag.name_ar HAVING sum(g.debit) <> 0 OR sum(g.credit) <> 0 + ORDER BY a.code`, + [req.auth!.tenantId, companyId, q.dateFrom, q.dateTo, q.branchId ?? null, q.costCenterId ?? null]); + const revenue = section('REVENUE', rows); + const cogs = section('COST_OF_GOODS_SOLD', rows); + const expenses = section('EXPENSE', rows); + const grossProfit = new Decimal(revenue.total).minus(cogs.total); + return { + companyId, dateFrom: q.dateFrom, dateTo: q.dateTo, + sections: [revenue, cogs, expenses], + revenue: revenue.total, costOfSales: cogs.total, grossProfit: toMoney(grossProfit), + expenses: expenses.total, netProfit: toMoney(grossProfit.minus(expenses.total)), + }; + }); + }); + + /** + * Balance sheet at a date. Profit not yet closed into retained earnings is + * shown as current-period earnings, so assets = liabilities + equity. + */ + app.get('/reports/balance-sheet', { preHandler: canView }, async (req) => { + const q = parse(z.object({ companyId: z.uuid().optional(), asOf: isoDate }), req.query); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const { rows } = await db.query( + `WITH g AS (${GL}) + SELECT a.id AS "accountId", a.code, a.name_ar AS "nameAr", a.account_type AS type, ag.code AS "groupCode", ag.name_ar AS "groupName", + (CASE WHEN a.account_type IN ('ASSET', 'EXPENSE', 'COST_OF_GOODS_SOLD') THEN sum(g.debit - g.credit) ELSE sum(g.credit - g.debit) END)::numeric(18,2)::text AS amount + FROM g JOIN accounts a ON a.id = g.account_id LEFT JOIN account_groups ag ON ag.id = a.group_id + WHERE g.entry_date <= $3 + GROUP BY a.id, ag.code, ag.name_ar HAVING sum(g.debit - g.credit) <> 0 + ORDER BY a.code`, + [req.auth!.tenantId, companyId, q.asOf]); + const pl = rows.filter((r) => !['ASSET', 'LIABILITY', 'EQUITY'].includes(r.type)); + const earnings = pl.reduce((s, r) => (r.type === 'REVENUE' ? s.plus(r.amount) : s.minus(r.amount)), new Decimal(0)); + const assets = section('ASSET', rows); + const liabilities = section('LIABILITY', rows); + const equity = section('EQUITY', rows); + const equityTotal = new Decimal(equity.total).plus(earnings); + const liabilitiesAndEquity = equityTotal.plus(liabilities.total); + return { + companyId, asOf: q.asOf, + assets, liabilities, equity: { ...equity, currentEarnings: toMoney(earnings), total: toMoney(equityTotal) }, + totalAssets: assets.total, totalLiabilitiesAndEquity: toMoney(liabilitiesAndEquity), + balanced: liabilitiesAndEquity.equals(assets.total), + }; + }); + }); + + /** + * Cash flow statement, direct method. For every entry that moves cash, the + * other lines say where the cash came from or went: their credit − debit + * equals the cash movement exactly, so opening + net flow = closing. + * Classification: equity and non-current liabilities are financing, + * non-current assets are investing, everything else is operating. + */ + app.get('/reports/cash-flow', { preHandler: canView }, async (req) => { + const q = parse(z.object(rangeFields), req.query); + checkRange(q); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const params = [req.auth!.tenantId, companyId, q.dateFrom, q.dateTo]; + const { rows: [bal] } = await db.query<{ opening: string; closing: string }>( + `WITH g AS (${GL}) + SELECT COALESCE(sum(CASE WHEN g.entry_date < $3 THEN g.debit - g.credit END), 0)::numeric(18,2)::text AS opening, + COALESCE(sum(g.debit - g.credit), 0)::numeric(18,2)::text AS closing + FROM g WHERE g.entry_date <= $4 AND g.account_id IN (${CASH_ACCOUNTS})`, params); + const { rows } = await db.query<{ category: string; accountId: string; code: string; nameAr: string; inflow: string; outflow: string }>( + `WITH g AS (${GL}), + cash_entries AS (SELECT DISTINCT entry_id FROM g WHERE g.entry_date BETWEEN $3 AND $4 AND g.account_id IN (${CASH_ACCOUNTS})) + SELECT CASE WHEN a.account_type = 'EQUITY' OR ag.code = 'NON_CURRENT_LIABILITIES' THEN 'FINANCING' + WHEN ag.code = 'NON_CURRENT_ASSETS' THEN 'INVESTING' ELSE 'OPERATING' END AS category, + a.id AS "accountId", a.code, a.name_ar AS "nameAr", + sum(g.credit)::numeric(18,2)::text AS inflow, sum(g.debit)::numeric(18,2)::text AS outflow + FROM g JOIN cash_entries c ON c.entry_id = g.entry_id + JOIN accounts a ON a.id = g.account_id LEFT JOIN account_groups ag ON ag.id = a.group_id + WHERE g.account_id NOT IN (${CASH_ACCOUNTS}) + GROUP BY 1, a.id, a.code, a.name_ar ORDER BY a.code`, params); + const sections = (['OPERATING', 'INVESTING', 'FINANCING'] as const).map((category) => { + const lines = rows.filter((r) => r.category === category).map((r) => ({ ...r, net: toMoney(new Decimal(r.inflow).minus(r.outflow)) })); + return { category, lines, total: toMoney(sum(lines.map((l) => l.net))) }; + }); + const net = sum(sections.map((s) => s.total)); + return { + companyId, dateFrom: q.dateFrom, dateTo: q.dateTo, sections, + openingCash: bal!.opening, netChange: toMoney(net), closingCash: bal!.closing, + reconciled: new Decimal(bal!.opening).plus(net).equals(bal!.closing), + }; + }); + }); + + /** General ledger for one account with opening balance and running balance. */ + app.get('/reports/general-ledger', { preHandler: canView }, async (req) => { + const q = parse(z.object({ + ...rangeFields, accountId: z.uuid(), branchId: z.uuid().optional(), costCenterId: z.uuid().optional(), + customerId: z.uuid().optional(), supplierId: z.uuid().optional(), + }), req.query); + checkRange(q); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const { rows: [account] } = await db.query<{ id: string; code: string; nameAr: string; type: AccountType; isPostable: boolean }>( + `SELECT id, code, name_ar AS "nameAr", account_type AS type, is_postable AS "isPostable" FROM accounts + WHERE tenant_id = $1 AND company_id = $2 AND id = $3 AND deleted_at IS NULL`, [req.auth!.tenantId, companyId, q.accountId]); + if (!account) throw notFound('Account'); + if (!account.isPostable) throw badRequest('HEADER_ACCOUNT', 'Choose a postable account; header accounts have no lines'); + const filters = `g.account_id = $3 AND ($6::uuid IS NULL OR g.branch_id = $6) AND ($7::uuid IS NULL OR g.cost_center_id = $7) + AND ($8::uuid IS NULL OR g.customer_id = $8) AND ($9::uuid IS NULL OR g.supplier_id = $9)`; + const params = [req.auth!.tenantId, companyId, q.accountId, q.dateFrom, q.dateTo, q.branchId ?? null, q.costCenterId ?? null, q.customerId ?? null, q.supplierId ?? null]; + // Same parameter list as the lines query; $5 (dateTo) is referenced only so PostgreSQL can type it. + const { rows: [o] } = await db.query<{ opening: string }>( + `WITH g AS (${GL}) SELECT COALESCE(sum(g.debit - g.credit), 0)::text AS opening FROM g WHERE ${filters} AND g.entry_date < $4 AND $5::date IS NOT NULL`, params); + const LIMIT = 5000; + const { rows } = await db.query<{ entryId: string; entryNumber: string; date: string; description: string; referenceType: string; documentNumber: string | null; debit: string; credit: string }>( + `WITH g AS (${GL}), dn AS (${DOC_NUMBERS}) + SELECT g.entry_id AS "entryId", g.entry_number AS "entryNumber", g.entry_date AS date, + COALESCE(g.line_description, g.description) AS description, g.origin_type AS "referenceType", g.origin_id AS "referenceId", + dn.number AS "documentNumber", g.debit::text, g.credit::text + FROM g LEFT JOIN dn ON dn.id = g.origin_id WHERE ${filters} AND g.entry_date BETWEEN $4 AND $5 + ORDER BY g.entry_date, g.posted_at, g.entry_number, g.line_no LIMIT ${LIMIT + 1}`, params); + const truncated = rows.length > LIMIT; + return { companyId, dateFrom: q.dateFrom, dateTo: q.dateTo, account, ...runningLines(o!.opening, rows.slice(0, LIMIT), DEBIT_NORMAL.has(account.type)), truncated }; + }); + }); + + /** Journal report: posted entries in a period with their lines. */ + app.get('/reports/journal', { preHandler: canView }, async (req) => { + const q = parse(z.object({ + ...rangeFields, referenceType: z.string().regex(/^[A-Z_]+$/).optional(), + limit: z.coerce.number().int().min(1).max(500).default(100), offset: z.coerce.number().int().min(0).default(0), + }), req.query); + checkRange(q); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const params = [req.auth!.tenantId, companyId, q.dateFrom, q.dateTo, q.referenceType ?? null]; + const where = `e.tenant_id = $1 AND e.company_id = $2 AND e.status IN ('POSTED', 'REVERSED') AND e.entry_date BETWEEN $3 AND $4 + AND ($5::text IS NULL OR e.reference_type = $5)`; + const { rows: [t] } = await db.query<{ count: string; debit: string; credit: string }>( + `SELECT count(*)::text, COALESCE(sum(total_debit), 0)::text AS debit, COALESCE(sum(total_credit), 0)::text AS credit FROM journal_entries e WHERE ${where}`, params); + const { rows: entries } = await db.query<{ id: string }>( + `SELECT e.id, e.entry_number AS "entryNumber", e.entry_date AS date, e.description, e.reference_type AS "referenceType", + e.status, e.total_debit::text AS "totalDebit", e.total_credit::text AS "totalCredit" + FROM journal_entries e WHERE ${where} + ORDER BY e.entry_date, e.posted_at, e.entry_number LIMIT $6 OFFSET $7`, [...params, q.limit, q.offset]); + const { rows: lines } = await db.query<{ entryId: string }>( + `SELECT l.journal_entry_id AS "entryId", l.line_no AS "lineNo", a.code AS "accountCode", a.name_ar AS "accountName", + l.description, l.debit::text, l.credit::text + FROM journal_entry_lines l JOIN accounts a ON a.id = l.account_id + WHERE l.journal_entry_id = ANY($1) ORDER BY l.journal_entry_id, l.line_no`, [entries.map((e) => e.id)]); + return { + companyId, dateFrom: q.dateFrom, dateTo: q.dateTo, + data: entries.map((e) => ({ ...e, lines: lines.filter((l) => l.entryId === e.id) })), + total: Number(t!.count), totals: { debit: toMoney(t!.debit), credit: toMoney(t!.credit) }, + }; + }); + }); +} + +/** Adds a running balance. Debit-normal accounts show debit − credit; others credit − debit. */ +export function runningLines(opening: string, rows: T[], debitNormal: boolean) { + const sign = debitNormal ? 1 : -1; + let balance = new Decimal(opening).times(sign); + const lines = rows.map((r) => { + balance = balance.plus(new Decimal(r.debit).minus(r.credit).times(sign)); + return { ...r, balance: toMoney(balance) }; + }); + return { + openingBalance: toMoney(new Decimal(opening).times(sign)), + lines, + totals: { debit: toMoney(sum(rows.map((r) => r.debit))), credit: toMoney(sum(rows.map((r) => r.credit))) }, + closingBalance: toMoney(balance), + }; +} diff --git a/alshuyukh-accounting/apps/api/src/modules/reports/parties.routes.ts b/alshuyukh-accounting/apps/api/src/modules/reports/parties.routes.ts new file mode 100644 index 000000000000..7c166e9c30fa --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/reports/parties.routes.ts @@ -0,0 +1,109 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { notFound } from '../../lib/errors.js'; +import { Decimal, sum, toMoney } from '../../lib/money.js'; +import { parse } from '../../lib/validation.js'; +import { requirePermission } from '../../plugins/auth.js'; +import { resolveCompanyId } from '../accounting/company-context.js'; +import { checkRange, companyToday, DOC_NUMBERS, GL, rangeFields } from './common.js'; +import { runningLines } from './financial.routes.js'; + +type Party = 'customer' | 'supplier'; +const BUCKETS = ['current', 'days1to30', 'days31to60', 'days61to90', 'over90'] as const; +type Bucket = (typeof BUCKETS)[number]; + +const bucketOf = (daysOverdue: number): Bucket => + daysOverdue <= 0 ? 'current' : daysOverdue <= 30 ? 'days1to30' : daysOverdue <= 60 ? 'days31to60' : daysOverdue <= 90 ? 'days61to90' : 'over90'; + +/** Open documents that make up a party's balance, by party type. */ +const OPEN_DOCS: Record = { + customer: ` + SELECT 'SALES_INVOICE' AS type, id, doc_number AS number, doc_date AS date, COALESCE(due_date, doc_date) AS due, customer_id AS party_id, total, remaining_amount AS remaining + FROM sales_invoices WHERE tenant_id = $1 AND company_id = $2 AND status IN ('ISSUED', 'PARTIALLY_PAID') AND remaining_amount > 0`, + supplier: ` + SELECT 'PURCHASE_INVOICE' AS type, id, doc_number AS number, doc_date AS date, COALESCE(due_date, doc_date) AS due, supplier_id AS party_id, total, remaining_amount AS remaining + FROM purchase_invoices WHERE tenant_id = $1 AND company_id = $2 AND status IN ('POSTED', 'PARTIALLY_PAID') AND remaining_amount > 0 + UNION ALL + SELECT 'EXPENSE', id, expense_number, expense_date, expense_date, supplier_id, total, remaining_amount + FROM expenses WHERE tenant_id = $1 AND company_id = $2 AND status IN ('POSTED', 'PARTIALLY_PAID') AND remaining_amount > 0 + AND supplier_id IS NOT NULL AND deleted_at IS NULL`, +}; + +export default async function partyReportRoutes(app: FastifyInstance) { + const canView = requirePermission(app, 'report.view'); + + /** + * Receivables / payables aging at today's date. Open documents are bucketed + * by days past due. The party balance comes from the ledger; whatever the + * open documents do not explain (advance payments, unapplied credit notes, + * manual entries) appears as "unapplied", so every row reconciles to the + * ledger and the grand total equals the control accounts' sub-ledger. + */ + for (const party of ['customer', 'supplier'] as const) { + const path = party === 'customer' ? '/reports/receivables-aging' : '/reports/payables-aging'; + app.get(path, { preHandler: canView }, async (req) => { + const q = parse(z.object({ companyId: z.uuid().optional(), partyId: z.uuid().optional() }), req.query); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const asOf = await companyToday(db, companyId); + const params = [req.auth!.tenantId, companyId, q.partyId ?? null]; + const { rows: docs } = await db.query<{ type: string; id: string; number: string; date: string; due: string; party_id: string; total: string; remaining: string; overdue: number }>( + `SELECT d.*, ($4::date - d.due) AS overdue FROM (${OPEN_DOCS[party]}) d + WHERE ($3::uuid IS NULL OR d.party_id = $3) ORDER BY d.due, d.number`, [...params, asOf]); + const col = party === 'customer' ? 'customer_id' : 'supplier_id'; + const { rows: balances } = await db.query<{ party_id: string; balance: string }>( + `WITH g AS (${GL}) + SELECT g.${col} AS party_id, ${party === 'customer' ? 'sum(g.debit - g.credit)' : 'sum(g.credit - g.debit)'}::text AS balance + FROM g WHERE g.${col} IS NOT NULL AND ($3::uuid IS NULL OR g.${col} = $3) GROUP BY g.${col}`, params); + const { rows: parties } = await db.query<{ id: string; code: string; nameAr: string }>( + `SELECT id, code, name_ar AS "nameAr" FROM ${party}s WHERE tenant_id = $1 AND company_id = $2 AND ($3::uuid IS NULL OR id = $3)`, params); + + const rows = parties.map((p) => { + const own = docs.filter((d) => d.party_id === p.id); + const buckets = Object.fromEntries(BUCKETS.map((b) => [b, new Decimal(0)])) as Record; + for (const d of own) buckets[bucketOf(d.overdue)] = buckets[bucketOf(d.overdue)].plus(d.remaining); + const openTotal = sum(own.map((d) => d.remaining)); + const balance = new Decimal(balances.find((b) => b.party_id === p.id)?.balance ?? 0); + return { + partyId: p.id, code: p.code, nameAr: p.nameAr, + ...Object.fromEntries(BUCKETS.map((b) => [b, toMoney(buckets[b])])) as Record, + unapplied: toMoney(balance.minus(openTotal)), + balance: toMoney(balance), + documents: own.map((d) => ({ type: d.type, id: d.id, number: d.number, date: d.date, dueDate: d.due, total: d.total, remaining: d.remaining, daysOverdue: Math.max(d.overdue, 0) })), + }; + }).filter((r) => r.documents.length || r.balance !== '0.00').sort((a, b) => a.code.localeCompare(b.code)); + + const totals = Object.fromEntries([...BUCKETS, 'unapplied', 'balance'].map((k) => + [k, toMoney(sum(rows.map((r) => r[k as Bucket | 'unapplied' | 'balance'])))])) as Record; + return { companyId, asOf, data: rows, totals }; + }); + }); + } + + /** Customer or supplier statement: opening balance, every ledger movement, running balance. */ + for (const party of ['customer', 'supplier'] as const) { + app.get(`/reports/${party}-statement`, { preHandler: canView }, async (req) => { + const q = parse(z.object({ ...rangeFields, partyId: z.uuid() }), req.query); + checkRange(q); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const { rows: [p] } = await db.query<{ id: string; code: string; nameAr: string; vatNumber: string | null }>( + `SELECT id, code, name_ar AS "nameAr", vat_number AS "vatNumber" FROM ${party}s WHERE tenant_id = $1 AND company_id = $2 AND id = $3`, + [req.auth!.tenantId, companyId, q.partyId]); + if (!p) throw notFound(party === 'customer' ? 'Customer' : 'Supplier'); + const col = party === 'customer' ? 'customer_id' : 'supplier_id'; + const params = [req.auth!.tenantId, companyId, q.partyId, q.dateFrom, q.dateTo]; + const { rows: [o] } = await db.query<{ opening: string }>( + `WITH g AS (${GL}) SELECT COALESCE(sum(g.debit - g.credit), 0)::text AS opening FROM g WHERE g.${col} = $3 AND g.entry_date < $4`, params.slice(0, 4)); + const { rows } = await db.query<{ debit: string; credit: string }>( + `WITH g AS (${GL}), dn AS (${DOC_NUMBERS}) + SELECT g.entry_id AS "entryId", g.entry_number AS "entryNumber", g.entry_date AS date, g.description, + g.origin_type AS "referenceType", g.origin_id AS "referenceId", (g.reference_type = 'REVERSAL') AS "isReversal", + dn.number AS "documentNumber", g.debit::text, g.credit::text + FROM g LEFT JOIN dn ON dn.id = g.origin_id WHERE g.${col} = $3 AND g.entry_date BETWEEN $4 AND $5 + ORDER BY g.entry_date, g.posted_at, g.entry_number, g.line_no`, params); + return { companyId, dateFrom: q.dateFrom, dateTo: q.dateTo, party: p, ...runningLines(o!.opening, rows, party === 'customer') }; + }); + }); + } +} diff --git a/alshuyukh-accounting/apps/api/src/modules/settings/settings.routes.ts b/alshuyukh-accounting/apps/api/src/modules/settings/settings.routes.ts new file mode 100644 index 000000000000..2dc6b63a7d2d --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/settings/settings.routes.ts @@ -0,0 +1,48 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { conflict } from '../../lib/errors.js'; +import { parse, timezone } from '../../lib/validation.js'; +import { requireAuth, requirePermission } from '../../plugins/auth.js'; +import { writeAudit } from '../audit/audit.service.js'; + +const SELECT = `SELECT t.name AS "tenantName", s.default_currency AS "defaultCurrency", s.timezone, s.locale, + s.fiscal_year_start_month AS "fiscalYearStartMonth", s.date_format AS "dateFormat", s.updated_at AS "updatedAt" + FROM tenant_settings s JOIN tenants t ON t.id = s.tenant_id WHERE s.tenant_id = $1`; + +const updateBody = z.object({ + tenantName: z.string().trim().min(2).max(200), + defaultCurrency: z.string().regex(/^[A-Z]{3}$/), + timezone, + locale: z.enum(['ar', 'en']), + fiscalYearStartMonth: z.number().int().min(1).max(12), + dateFormat: z.enum(['YYYY-MM-DD', 'DD/MM/YYYY', 'MM/DD/YYYY']), +}).partial(); + +export default async function settingsRoutes(app: FastifyInstance) { + app.get('/settings/tenant', { preHandler: requireAuth(app) }, async (req) => + req.tenantTx(async (db) => (await db.query(SELECT, [req.auth!.tenantId])).rows[0])); + + app.patch('/settings/tenant', { preHandler: requirePermission(app, 'settings.manage') }, async (req) => { + const body = parse(updateBody, req.body); + const a = req.auth!; + return req.tenantTx(async (db) => { + const before = (await db.query(SELECT, [a.tenantId])).rows[0]; + if (body.tenantName) await db.query(`UPDATE tenants SET name = $2 WHERE id = $1`, [a.tenantId, body.tenantName]); + if (body.fiscalYearStartMonth !== undefined && body.fiscalYearStartMonth !== before.fiscalYearStartMonth) { + const years = await db.query(`SELECT 1 FROM fiscal_years WHERE tenant_id = $1 LIMIT 1`, [a.tenantId]); + if (years.rowCount) throw conflict('FISCAL_YEARS_EXIST', 'The fiscal year start month cannot change after fiscal years have been created'); + } + await db.query( + `UPDATE tenant_settings SET + default_currency = COALESCE($2, default_currency), timezone = COALESCE($3, timezone), + locale = COALESCE($4, locale), fiscal_year_start_month = COALESCE($5, fiscal_year_start_month), + date_format = COALESCE($6, date_format) + WHERE tenant_id = $1`, + [a.tenantId, body.defaultCurrency ?? null, body.timezone ?? null, body.locale ?? null, + body.fiscalYearStartMonth ?? null, body.dateFormat ?? null]); + const after = (await db.query(SELECT, [a.tenantId])).rows[0]; + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'SETTINGS_CHANGE', entityType: 'tenant_settings', entityId: a.tenantId, oldValues: before, newValues: after }, req.auditMeta()); + return after; + }); + }); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/subscriptions/routes.ts b/alshuyukh-accounting/apps/api/src/modules/subscriptions/routes.ts new file mode 100644 index 000000000000..5c9d358d0dd5 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/subscriptions/routes.ts @@ -0,0 +1,50 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { notFound } from '../../lib/errors.js'; +import { parse } from '../../lib/validation.js'; +import { requireAuth, requirePermission } from '../../plugins/auth.js'; +import { writeAudit } from '../audit/audit.service.js'; +import { billingEvent, currentSubscription, featuresOf, LIMIT_KEYS, usageOf } from './service.js'; + +export const PLAN_PUBLIC_SELECT = `SELECT id, code, name_ar AS "nameAr", name_en AS "nameEn", description, currency, + price_monthly::text AS "priceMonthly", price_yearly::text AS "priceYearly", trial_days AS "trialDays", grace_days AS "graceDays", + ${LIMIT_KEYS.map((k) => `${k} AS "${k}"`).join(', ')}, is_public AS "isPublic", is_default AS "isDefault", is_active AS "isActive", sort_order AS "sortOrder" + FROM plans`; + +/** The organization's own subscription: state, limits and usage, and plan change requests. */ +export default async function subscriptionRoutes(app: FastifyInstance) { + app.get('/subscription', { preHandler: requireAuth(app) }, async (req) => + req.tenantTx(async (db) => { + const sub = await currentSubscription(db, req.auth!.tenantId); + const { rows: items } = await db.query( + `SELECT code, description, quantity, unit_price::text AS "unitPrice", currency FROM subscription_items WHERE subscription_id = $1`, [sub.id]); + return { ...sub, items, usage: await usageOf(db, req.auth!.tenantId), features: await featuresOf(db, req.auth!.tenantId) }; + })); + + app.get('/subscription/plans', { preHandler: requireAuth(app) }, async (req) => + req.tenantTx(async (db) => ({ data: (await db.query(`${PLAN_PUBLIC_SELECT} WHERE is_active AND is_public ORDER BY sort_order, price_monthly`)).rows }))); + + app.get('/subscription/billing-events', { preHandler: requirePermission(app, 'subscription.manage') }, async (req) => + req.tenantTx(async (db) => ({ + data: (await db.query( + `SELECT id, event_type AS "eventType", amount::text, currency, reference, details, created_at AS "createdAt" + FROM billing_events WHERE tenant_id = $1 ORDER BY created_at DESC LIMIT 100`, [req.auth!.tenantId])).rows, + }))); + + /** + * Online payment is not integrated yet: the owner asks for a plan change and + * the platform administrator applies it once payment is received. + */ + app.post('/subscription/request-change', { preHandler: requirePermission(app, 'subscription.manage') }, async (req) => { + const body = parse(z.object({ planId: z.uuid(), billingCycle: z.enum(['MONTHLY', 'YEARLY']), note: z.string().trim().max(500).nullish() }), req.body); + const a = req.auth!; + return req.tenantTx(async (db) => { + const { rows: [plan] } = await db.query<{ code: string }>(`SELECT code FROM plans WHERE id = $1 AND is_active AND is_public`, [body.planId]); + if (!plan) throw notFound('Plan'); + const sub = await currentSubscription(db, a.tenantId); + await billingEvent(db, { tenantId: a.tenantId, subscriptionId: sub.id, type: 'PLAN_CHANGE_REQUESTED', userId: a.userId, details: { plan: plan.code, billingCycle: body.billingCycle, note: body.note ?? null } }); + await writeAudit(db, { tenantId: a.tenantId, userId: a.userId, action: 'UPDATE', entityType: 'subscription', entityId: sub.id, newValues: { requestedPlan: plan.code, billingCycle: body.billingCycle } }, req.auditMeta()); + return { requested: true }; + }); + }); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/subscriptions/service.ts b/alshuyukh-accounting/apps/api/src/modules/subscriptions/service.ts new file mode 100644 index 000000000000..eaffe31a74ac --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/subscriptions/service.ts @@ -0,0 +1,190 @@ +import type pg from 'pg'; +import type { Db } from '../../db/tx.js'; +import { AppError, badRequest, notFound } from '../../lib/errors.js'; +import { riyadhNow } from '../zatca/service.js'; + +/** + * Subscription state and plan limits. Prices and limits live in the plans + * table (edited by the platform administrator); NULL means unlimited. + */ + +export const LIMIT_KEYS = ['max_users', 'max_companies', 'max_branches', 'max_warehouses', 'max_products', + 'max_invoices_per_month', 'max_storage_mb', 'max_api_calls_per_month'] as const; +export type LimitKey = (typeof LIMIT_KEYS)[number]; +export type Limits = Record; + +/** TRIALING / ACTIVE: within the period. GRACE: period ended, still within grace days. */ +export type SubscriptionState = 'TRIALING' | 'ACTIVE' | 'GRACE' | 'EXPIRED' | 'CANCELLED' | 'NONE'; + +export interface SubscriptionView { + id: string | null; planId: string | null; planCode: string | null; planName: string | null; status: string | null; + state: SubscriptionState; writable: boolean; billingCycle: string | null; + periodStart: Date | null; periodEnd: Date | null; graceEnd: Date | null; limits: Limits; limitOverrides: Partial; +} + +const SUB_SQL = ` + SELECT s.id, s.plan_id, s.status, s.billing_cycle, s.current_period_start, s.current_period_end, s.limit_overrides, + p.code AS plan_code, p.name_ar AS plan_name, p.grace_days, ${LIMIT_KEYS.map((k) => `p.${k}`).join(', ')} + FROM subscriptions s JOIN plans p ON p.id = s.plan_id + WHERE s.tenant_id = $1 AND s.status <> 'CANCELLED'`; + +type SubRow = Record; + +export function viewOf(row: SubRow | undefined, now = new Date()): SubscriptionView { + const emptyLimits = Object.fromEntries(LIMIT_KEYS.map((k) => [k, null])) as Limits; + if (!row) { + return { id: null, planId: null, planCode: null, planName: null, status: null, state: 'NONE', writable: false, billingCycle: null, + periodStart: null, periodEnd: null, graceEnd: null, limits: emptyLimits, limitOverrides: {} }; + } + const overrides = (row.limit_overrides ?? {}) as Partial; + const limits = Object.fromEntries(LIMIT_KEYS.map((k) => [k, k in overrides ? overrides[k] ?? null : row[k] ?? null])) as Limits; + const end = new Date(row.current_period_end); + const graceEnd = new Date(end.getTime() + Number(row.grace_days) * 86_400_000); + const state: SubscriptionState = now <= end ? row.status : now <= graceEnd ? 'GRACE' : 'EXPIRED'; + return { + id: row.id, planId: row.plan_id, planCode: row.plan_code, planName: row.plan_name, status: row.status, state, + writable: state !== 'EXPIRED', billingCycle: row.billing_cycle, periodStart: new Date(row.current_period_start), + periodEnd: end, graceEnd, limits, limitOverrides: overrides, + }; +} + +export async function currentSubscription(db: Db | pg.Pool, tenantId: string, lock = false): Promise { + const { rows: [row] } = await db.query(`${SUB_SQL}${lock ? ' FOR UPDATE OF s' : ''}`, [tenantId]); + return viewOf(row); +} + +/** First day of the current month in Riyadh, as YYYY-MM-01. */ +export const monthStart = () => `${riyadhNow().date.slice(0, 7)}-01`; + +/** What each limit counts, for one tenant. */ +export const USAGE_SQL: Record, string> = { + max_users: `SELECT count(*) FROM user_tenants WHERE tenant_id = $1 AND status = 'ACTIVE'`, + max_companies: `SELECT count(*) FROM companies WHERE tenant_id = $1 AND deleted_at IS NULL`, + max_branches: `SELECT count(*) FROM branches WHERE tenant_id = $1 AND deleted_at IS NULL`, + max_warehouses: `SELECT count(*) FROM warehouses WHERE tenant_id = $1 AND deleted_at IS NULL`, + max_products: `SELECT count(*) FROM products WHERE tenant_id = $1 AND deleted_at IS NULL`, + max_invoices_per_month: `SELECT count(*) FROM sales_invoices WHERE tenant_id = $1 AND status <> 'DRAFT' + AND issued_at >= ($2::date::timestamp AT TIME ZONE 'Asia/Riyadh')`, + max_api_calls_per_month: `SELECT COALESCE(sum(quantity), 0) FROM usage_records WHERE tenant_id = $1 AND metric = 'API_CALLS' AND period = $2::date`, +}; + +export async function usageOf(db: Db, tenantId: string): Promise> { + const month = monthStart(); + const out = { max_storage_mb: 0 } as Record; + for (const [k, sql] of Object.entries(USAGE_SQL)) { + const { rows: [r] } = await db.query<{ count?: string; coalesce?: string }>(sql, sql.includes('$2') ? [tenantId, month] : [tenantId]); + out[k as LimitKey] = Number(Object.values(r!)[0]); + } + out.max_api_calls_per_month += pendingApiCalls(tenantId); + return out; +} + +const LABEL_AR: Record = { + max_users: 'المستخدمين', max_companies: 'الشركات', max_branches: 'الفروع', max_warehouses: 'المستودعات', max_products: 'المنتجات', + max_invoices_per_month: 'الفواتير الشهرية', max_storage_mb: 'التخزين', max_api_calls_per_month: 'طلبات API الشهرية', +}; + +/** + * Throws 402 PLAN_LIMIT_REACHED when creating one more item would exceed the + * plan. A per-tenant advisory lock serialises concurrent creations, so they + * cannot overshoot the limit together. + */ +export async function assertWithinLimit(db: Db, tenantId: string, key: Exclude) { + // A per-tenant transaction lock (tenants cannot lock their subscription row: it is read-only to them). + await db.query(`SELECT pg_advisory_xact_lock(hashtext('plan-limits:' || $1))`, [tenantId]); + const sub = await currentSubscription(db, tenantId); + const limit = sub.limits[key]; + if (limit === null) return; + const sql = USAGE_SQL[key]; + const { rows: [r] } = await db.query(sql, sql.includes('$2') ? [tenantId, monthStart()] : [tenantId]); + const used = Number(Object.values(r!)[0]); + if (used >= limit) { + throw new AppError(402, 'PLAN_LIMIT_REACHED', `بلغت الحد الأقصى لـ${LABEL_AR[key]} في باقتك (${limit}). رقِّ الباقة لإضافة المزيد.`, { limit: key, max: limit, used }); + } +} + +// --- Starting and changing subscriptions ----------------------------------------------- + +export function addPeriod(from: Date, cycle: 'MONTHLY' | 'YEARLY', count = 1): Date { + const d = new Date(from); + d.setUTCMonth(d.getUTCMonth() + (cycle === 'YEARLY' ? 12 : 1) * count); + return d; +} + +async function planRow(db: Db, planId: string | null) { + const { rows: [p] } = await db.query<{ id: string; code: string; name_ar: string; price_monthly: string; price_yearly: string; currency: string; trial_days: number; is_active: boolean }>( + `SELECT id, code, name_ar, price_monthly::text, price_yearly::text, currency, trial_days, is_active FROM plans WHERE ${planId ? 'id = $1' : 'is_default'}`, + planId ? [planId] : []); + if (!p) throw planId ? notFound('Plan') : new AppError(500, 'NO_DEFAULT_PLAN', 'No default plan is configured'); + if (!p.is_active) throw badRequest('PLAN_INACTIVE', 'This plan is no longer offered'); + return p; +} + +export async function replaceItems(db: Db, tenantId: string, subscriptionId: string, plan: Awaited>, cycle: 'MONTHLY' | 'YEARLY') { + await db.query(`DELETE FROM subscription_items WHERE subscription_id = $1`, [subscriptionId]); + await db.query( + `INSERT INTO subscription_items (tenant_id, subscription_id, item_type, code, description, unit_price, currency) VALUES ($1, $2, 'PLAN', $3, $4, $5, $6)`, + [tenantId, subscriptionId, plan.code, `${plan.name_ar} — ${cycle === 'YEARLY' ? 'سنوي' : 'شهري'}`, cycle === 'YEARLY' ? plan.price_yearly : plan.price_monthly, plan.currency]); +} + +/** New tenant: the given plan (or the default one), as a trial when the plan has trial days. */ +export async function startSubscription(db: Db, tenantId: string, userId: string | null, planId: string | null = null) { + const plan = await planRow(db, planId); + const now = new Date(); + const trial = plan.trial_days > 0; + const end = trial ? new Date(now.getTime() + plan.trial_days * 86_400_000) : addPeriod(now, 'MONTHLY'); + const { rows: [s] } = await db.query<{ id: string }>( + `INSERT INTO subscriptions (tenant_id, plan_id, status, current_period_start, current_period_end) VALUES ($1, $2, $3, $4, $5) RETURNING id`, + [tenantId, plan.id, trial ? 'TRIALING' : 'ACTIVE', now, end]); + await replaceItems(db, tenantId, s!.id, plan, 'MONTHLY'); + await billingEvent(db, { tenantId, subscriptionId: s!.id, type: trial ? 'TRIAL_STARTED' : 'PLAN_CHANGED', userId, details: { plan: plan.code, periodEnd: end } }); + return s!.id; +} + +export async function changePlan(db: Db, tenantId: string, userId: string, planId: string, cycle: 'MONTHLY' | 'YEARLY') { + const sub = await currentSubscription(db, tenantId, true); + if (!sub.id) throw notFound('Subscription'); + const plan = await planRow(db, planId); + await db.query(`UPDATE subscriptions SET plan_id = $2, billing_cycle = $3 WHERE id = $1`, [sub.id, plan.id, cycle]); + await replaceItems(db, tenantId, sub.id, plan, cycle); + await billingEvent(db, { tenantId, subscriptionId: sub.id, type: 'PLAN_CHANGED', userId, details: { from: sub.planCode, to: plan.code, billingCycle: cycle } }); + return currentSubscription(db, tenantId); +} + +export type BillingEventType = 'TRIAL_STARTED' | 'PLAN_CHANGED' | 'PERIOD_EXTENDED' | 'PAYMENT_RECORDED' | 'LIMITS_CHANGED' + | 'PLAN_CHANGE_REQUESTED' | 'SUBSCRIPTION_CANCELLED' | 'TENANT_SUSPENDED' | 'TENANT_ACTIVATED'; + +export async function billingEvent(db: Db, e: { tenantId: string; subscriptionId?: string | null; type: BillingEventType; userId: string | null; amount?: string | null; currency?: string | null; reference?: string | null; details?: unknown }) { + await db.query( + `INSERT INTO billing_events (tenant_id, subscription_id, event_type, amount, currency, reference, details, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [e.tenantId, e.subscriptionId ?? null, e.type, e.amount ?? null, e.currency ?? null, e.reference ?? null, JSON.stringify(e.details ?? {}), e.userId]); +} + +// --- API call metering ------------------------------------------------------------------- + +const pending = new Map(); +export const pendingApiCalls = (tenantId: string) => pending.get(tenantId) ?? 0; +export const countApiCall = (tenantId: string) => pending.set(tenantId, pendingApiCalls(tenantId) + 1); + +/** Writes the in-memory API call counters (one statement per tenant). */ +export async function flushUsage(pool: pg.Pool) { + if (!pending.size) return; + const batch = [...pending.entries()]; + pending.clear(); + const month = monthStart(); + for (const [tenantId, n] of batch) { + try { + await pool.query(`SELECT usage_increment($1, 'API_CALLS', $2, $3)`, [tenantId, month, n]); + } catch { + pending.set(tenantId, pendingApiCalls(tenantId) + n); // try again on the next flush + } + } +} + +/** Effective feature flags for a tenant: the per-tenant override, else the global default. */ +export async function featuresOf(db: Db, tenantId: string): Promise> { + const { rows } = await db.query<{ key: string; enabled: boolean }>( + `SELECT f.key, COALESCE(t.enabled, f.enabled) AS enabled FROM feature_flags f + LEFT JOIN tenant_feature_flags t ON t.flag_key = f.key AND t.tenant_id = $1`, [tenantId]); + return Object.fromEntries(rows.map((r) => [r.key, r.enabled])); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/tax/ledger.ts b/alshuyukh-accounting/apps/api/src/modules/tax/ledger.ts new file mode 100644 index 000000000000..883525810bf8 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/tax/ledger.ts @@ -0,0 +1,86 @@ +import type { Db } from '../../db/tx.js'; +import { Decimal, toMoney } from '../../lib/money.js'; +import type { VatCategory } from '../documents/calc.js'; + +/** + * VAT sub-ledger. Every taxable document writes one row per (VAT category, + * rate) when it posts; cancelling writes the negated rows. The VAT return is + * built only from these rows. + */ + +export type TaxSource = 'SALES_INVOICE' | 'SALES_RETURN' | 'PURCHASE_INVOICE' | 'PURCHASE_RETURN' | 'EXPENSE'; + +export interface TaxGroup { vatCategory: VatCategory; vatRate: string; taxableAmount: string; taxAmount: string } + +/** + * Groups document lines by (category, rate). The VAT of each group is + * round(taxable × rate); if the document's VAT differs (a capped final + * return), the difference goes to the largest standard-rated group so the + * groups always add up to the posted VAT. + */ +export function taxGroups(lines: { netAmount: string; vatCategory: VatCategory; vatRate: string }[], documentTax: string): TaxGroup[] { + const map = new Map(); + for (const l of lines) { + const rate = new Decimal(l.vatRate); + const key = `${l.vatCategory}|${rate.toString()}`; + const g = map.get(key) ?? { vatCategory: l.vatCategory, vatRate: rate, taxable: new Decimal(0) }; + g.taxable = g.taxable.plus(l.netAmount); + map.set(key, g); + } + const groups = [...map.values()].map((g) => ({ + vatCategory: g.vatCategory, vatRate: g.vatRate.toString(), taxable: g.taxable, + tax: g.taxable.times(g.vatRate).toDecimalPlaces(2, Decimal.ROUND_HALF_UP), + })); + const diff = new Decimal(documentTax).minus(groups.reduce((s, g) => s.plus(g.tax), new Decimal(0))); + if (!diff.isZero()) { + const target = groups.filter((g) => g.vatCategory === 'S').sort((a, b) => b.taxable.comparedTo(a.taxable))[0]; + if (target) target.tax = target.tax.plus(diff); + } + return groups.map((g) => ({ vatCategory: g.vatCategory, vatRate: g.vatRate, taxableAmount: toMoney(g.taxable), taxAmount: toMoney(g.tax) })); +} + +export interface RecordInput { + tenantId: string; + companyId: string; + sourceType: TaxSource; + sourceId: string; + sourceNumber: string | null; + journalEntryId: string; + date: string; + groups: TaxGroup[]; + partyName?: string | null; + partyVatNumber?: string | null; +} + +const DIRECTION: Record = { + SALES_INVOICE: 'OUTPUT', SALES_RETURN: 'OUTPUT', PURCHASE_INVOICE: 'INPUT', PURCHASE_RETURN: 'INPUT', EXPENSE: 'INPUT', +}; +const IS_RETURN = (s: TaxSource) => s === 'SALES_RETURN' || s === 'PURCHASE_RETURN'; + +export async function recordTax(db: Db, input: RecordInput): Promise { + const sign = IS_RETURN(input.sourceType) ? -1 : 1; + for (const g of input.groups) { + await db.query( + `INSERT INTO tax_transactions (tenant_id, company_id, direction, source_type, source_id, source_number, journal_entry_id, + transaction_date, vat_category, vat_rate, taxable_amount, tax_amount, is_adjustment, party_name, party_vat_number) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`, + [input.tenantId, input.companyId, DIRECTION[input.sourceType], input.sourceType, input.sourceId, input.sourceNumber, + input.journalEntryId, input.date, g.vatCategory, g.vatRate, + toMoney(new Decimal(g.taxableAmount).times(sign)), toMoney(new Decimal(g.taxAmount).times(sign)), + IS_RETURN(input.sourceType), input.partyName ?? null, input.partyVatNumber ?? null]); + } +} + +/** Writes the negated rows of a cancelled document, dated like the originals. */ +export async function reverseTax(db: Db, sourceType: TaxSource, sourceId: string): Promise { + await db.query( + `INSERT INTO tax_transactions (tenant_id, company_id, direction, source_type, source_id, source_number, journal_entry_id, + transaction_date, vat_category, vat_rate, taxable_amount, tax_amount, is_adjustment, reverses_id, party_name, party_vat_number) + SELECT t.tenant_id, t.company_id, t.direction, t.source_type, t.source_id, t.source_number, t.journal_entry_id, + t.transaction_date, t.vat_category, t.vat_rate, -t.taxable_amount, -t.tax_amount, t.is_adjustment, t.id, + t.party_name, t.party_vat_number + FROM tax_transactions t + WHERE t.source_type = $1 AND t.source_id = $2 AND t.reverses_id IS NULL + AND NOT EXISTS (SELECT 1 FROM tax_transactions r WHERE r.reverses_id = t.id)`, + [sourceType, sourceId]); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/tax/routes.ts b/alshuyukh-accounting/apps/api/src/modules/tax/routes.ts new file mode 100644 index 000000000000..fa3f65ff285f --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/tax/routes.ts @@ -0,0 +1,108 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { isoDate } from '../../lib/dates.js'; +import { badRequest } from '../../lib/errors.js'; +import { Decimal, toMoney } from '../../lib/money.js'; +import { parse } from '../../lib/validation.js'; +import { requirePermission } from '../../plugins/auth.js'; +import { resolveCompanyId } from '../accounting/company-context.js'; + +interface Box { amount: string; adjustment: string; vat: string } + +/** + * VAT return in the layout of the ZATCA form. Built from tax_transactions + * and reconciled with the VAT accounts in the general ledger: any manual + * journal entry to a VAT account shows up as a difference. + */ +export default async function taxRoutes(app: FastifyInstance) { + const range = z.object({ companyId: z.uuid().optional(), dateFrom: isoDate, dateTo: isoDate }); + + app.get('/reports/vat-return', { preHandler: requirePermission(app, 'financial_report.view') }, async (req) => { + const q = parse(range, req.query); + if (q.dateFrom > q.dateTo) throw badRequest('INVALID_RANGE', 'dateFrom must be on or before dateTo'); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const { rows } = await db.query<{ direction: string; vat_category: string; is_adjustment: boolean; taxable: string; tax: string }>( + `SELECT direction, vat_category, is_adjustment, sum(taxable_amount)::text AS taxable, sum(tax_amount)::text AS tax + FROM tax_transactions + WHERE tenant_id = $1 AND company_id = $2 AND transaction_date BETWEEN $3 AND $4 + GROUP BY direction, vat_category, is_adjustment`, + [req.auth!.tenantId, companyId, q.dateFrom, q.dateTo]); + const box = (direction: string, category: string): Box => { + const pick = (adj: boolean) => rows.find((r) => r.direction === direction && r.vat_category === category && r.is_adjustment === adj); + const sale = pick(false); + const adj = pick(true); + return { + amount: toMoney(sale?.taxable ?? 0), + adjustment: toMoney(adj?.taxable ?? 0), + vat: toMoney(new Decimal(sale?.tax ?? 0).plus(adj?.tax ?? 0)), + }; + }; + const sum = (boxes: Box[]): Box => ({ + amount: toMoney(boxes.reduce((s, b) => s.plus(b.amount), new Decimal(0))), + adjustment: toMoney(boxes.reduce((s, b) => s.plus(b.adjustment), new Decimal(0))), + vat: toMoney(boxes.reduce((s, b) => s.plus(b.vat), new Decimal(0))), + }); + const zero: Box = { amount: '0.00', adjustment: '0.00', vat: '0.00' }; + + const sales = { standardRated: box('OUTPUT', 'S'), zeroRated: box('OUTPUT', 'Z'), exempt: box('OUTPUT', 'E') }; + const purchases = { standardRated: box('INPUT', 'S'), zeroRated: box('INPUT', 'Z'), exempt: box('INPUT', 'E') }; + const salesTotal = sum(Object.values(sales)); + const purchasesTotal = sum(Object.values(purchases)); + const netVat = new Decimal(salesTotal.vat).minus(purchasesTotal.vat); + + const { rows: [gl] } = await db.query<{ output: string; input: string }>( + `SELECT COALESCE(sum(CASE WHEN a.system_key = 'VAT_OUTPUT' THEN l.credit - l.debit END), 0)::text AS output, + COALESCE(sum(CASE WHEN a.system_key = 'VAT_INPUT' THEN l.debit - l.credit END), 0)::text AS input + FROM journal_entry_lines l JOIN journal_entries e ON e.id = l.journal_entry_id JOIN accounts a ON a.id = l.account_id + WHERE e.company_id = $1 AND e.status IN ('POSTED', 'REVERSED') AND e.entry_date BETWEEN $2 AND $3 + AND a.system_key IN ('VAT_OUTPUT', 'VAT_INPUT')`, [companyId, q.dateFrom, q.dateTo]); + const outputDiff = new Decimal(gl!.output).minus(salesTotal.vat); + const inputDiff = new Decimal(gl!.input).minus(purchasesTotal.vat); + + return { + companyId, dateFrom: q.dateFrom, dateTo: q.dateTo, + // Box numbers follow the ZATCA VAT return. Boxes this system cannot + // yet separate (private health/education, exports, imports, reverse + // charge) are reported as zero and listed in `notSupported`. + sales: { + '1_standardRated': sales.standardRated, '2_citizenHealthEducation': zero, '3_zeroRatedDomestic': sales.zeroRated, + '4_exports': zero, '5_exempt': sales.exempt, '6_total': salesTotal, + }, + purchases: { + '7_standardRatedDomestic': purchases.standardRated, '8_importsPaidAtCustoms': zero, '9_importsReverseCharge': zero, + '10_zeroRated': purchases.zeroRated, '11_exempt': purchases.exempt, '12_total': purchasesTotal, + }, + '13_totalVatDue': toMoney(netVat), + '14_previousPeriodCorrections': '0.00', + '15_creditCarriedForward': '0.00', + '16_netVatDue': toMoney(netVat), + notSupported: ['2_citizenHealthEducation', '4_exports', '8_importsPaidAtCustoms', '9_importsReverseCharge', '14_previousPeriodCorrections', '15_creditCarriedForward'], + ledger: { + vatOutput: toMoney(gl!.output), vatInput: toMoney(gl!.input), + outputDifference: toMoney(outputDiff), inputDifference: toMoney(inputDiff), + reconciled: outputDiff.isZero() && inputDiff.isZero(), + }, + }; + }); + }); + + /** The tax transactions behind the return, for review and audit. */ + app.get('/reports/vat-transactions', { preHandler: requirePermission(app, 'financial_report.view') }, async (req) => { + const q = parse(range.extend({ direction: z.enum(['OUTPUT', 'INPUT']).optional(), vatCategory: z.enum(['S', 'Z', 'E', 'O']).optional() }), req.query); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const { rows } = await db.query( + `SELECT id, direction, source_type AS "sourceType", source_id AS "sourceId", source_number AS "sourceNumber", + transaction_date AS date, vat_category AS "vatCategory", vat_rate::text AS "vatRate", + taxable_amount::text AS "taxableAmount", tax_amount::text AS "taxAmount", is_adjustment AS "isAdjustment", + reverses_id AS "reversesId", party_name AS "partyName", party_vat_number AS "partyVatNumber" + FROM tax_transactions + WHERE tenant_id = $1 AND company_id = $2 AND transaction_date BETWEEN $3 AND $4 + AND ($5::text IS NULL OR direction = $5) AND ($6::text IS NULL OR vat_category = $6) + ORDER BY transaction_date, created_at`, + [req.auth!.tenantId, companyId, q.dateFrom, q.dateTo, q.direction ?? null, q.vatCategory ?? null]); + return { data: rows }; + }); + }); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/users/users.routes.ts b/alshuyukh-accounting/apps/api/src/modules/users/users.routes.ts new file mode 100644 index 000000000000..4980095a1690 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/users/users.routes.ts @@ -0,0 +1,164 @@ +import { assertWithinLimit } from '../subscriptions/service.js'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import type { Db } from '../../db/tx.js'; +import { badRequest, conflict, forbidden, notFound } from '../../lib/errors.js'; +import { hashPassword } from '../../lib/password.js'; +import { parse, password, uuidParam } from '../../lib/validation.js'; +import { requirePermission } from '../../plugins/auth.js'; +import { writeAudit } from '../audit/audit.service.js'; +import { assertCanGrant, resolveRoles } from '../rbac/rbac.routes.js'; + +type MemberRow = { + id: string; email: string; full_name: string; user_status: string; member_status: string; + is_owner: boolean; last_login_at: Date | null; joined_at: Date; + roles: { id: string; code: string; nameAr: string; nameEn: string }[]; +}; + +const MEMBER_SELECT = ` + SELECT u.id, u.email, u.full_name, u.status AS user_status, ut.status AS member_status, + ut.is_owner, u.last_login_at, ut.joined_at, + COALESCE(json_agg(json_build_object('id', r.id, 'code', r.code, 'nameAr', r.name_ar, 'nameEn', r.name_en) + ORDER BY r.code) FILTER (WHERE r.id IS NOT NULL), '[]') AS roles + FROM user_tenants ut + JOIN users u ON u.id = ut.user_id + LEFT JOIN user_roles ur ON ur.tenant_id = ut.tenant_id AND ur.user_id = ut.user_id + LEFT JOIN roles r ON r.id = ur.role_id AND r.deleted_at IS NULL + WHERE ut.tenant_id = $1 AND u.deleted_at IS NULL`; + +/** An invited person's name and activity stay private until they accept. */ +const toMember = (m: MemberRow) => { + const invited = m.member_status === 'INVITED'; + return { + id: m.id, email: m.email, fullName: invited ? null : m.full_name, + status: invited ? 'INVITED' : m.member_status === 'ACTIVE' && m.user_status === 'ACTIVE' ? 'ACTIVE' : 'DISABLED', + isOwner: m.is_owner, lastLoginAt: invited ? null : m.last_login_at, joinedAt: m.joined_at, roles: m.roles, + }; +}; + +async function loadMember(db: Db, tenantId: string, userId: string) { + const { rows } = await db.query(`${MEMBER_SELECT} AND u.id = $2 GROUP BY u.id, ut.id`, [tenantId, userId]); + return rows[0]; +} + +const createBody = z.object({ + email: z.string().trim().toLowerCase().pipe(z.email().max(254)), + fullName: z.string().trim().min(2).max(200), + // Initial password for a new account. The user must change it at first login. + // TODO(Notification System): replace with an e-mailed invitation link. + initialPassword: password.optional(), + roleIds: z.array(z.uuid()).min(1).max(20), +}); + +export default async function usersRoutes(app: FastifyInstance) { + app.get('/users', { preHandler: requirePermission(app, 'user.view') }, async (req) => + req.tenantTx(async (db) => { + const { rows } = await db.query(`${MEMBER_SELECT} GROUP BY u.id, ut.id ORDER BY ut.joined_at`, [req.auth!.tenantId]); + return { data: rows.map(toMember) }; + })); + + app.get('/users/:id', { preHandler: requirePermission(app, 'user.view') }, async (req) => { + const { id } = parse(uuidParam, req.params); + const member = await req.tenantTx((db) => loadMember(db, req.auth!.tenantId, id)); + if (!member) throw notFound('User'); + return toMember(member); + }); + + /** + * Adds a user to the tenant. A new e-mail gets an account (with the initial + * password); an existing account gets an invitation it must accept, so no one + * can be added to an organization without consent. + */ + app.post('/users', { preHandler: requirePermission(app, 'user.invite', 'user.manage') }, async (req, reply) => { + const body = parse(createBody, req.body); + const a = req.auth!; + const passwordHash = body.initialPassword ? await hashPassword(body.initialPassword) : null; + const member = await req.tenantTx(async (db) => { + await assertWithinLimit(db, a.tenantId, 'max_users'); + const { permissions } = await resolveRoles(db, body.roleIds); + assertCanGrant(a, permissions); + + let { rows: [user] } = await db.query<{ id: string }>(`SELECT id FROM users WHERE email = $1 AND deleted_at IS NULL`, [body.email]); + let created = false; + if (!user) { + if (!passwordHash) throw badRequest('INITIAL_PASSWORD_REQUIRED', 'initialPassword is required for a new account'); + ({ rows: [user] } = await db.query<{ id: string }>( + `INSERT INTO users (email, password_hash, full_name, must_change_password) VALUES ($1, $2, $3, true) RETURNING id`, + [body.email, passwordHash, body.fullName])); + created = true; + } + const exists = await db.query(`SELECT 1 FROM user_tenants WHERE tenant_id = $1 AND user_id = $2`, [a.tenantId, user!.id]); + if (exists.rowCount) throw conflict('ALREADY_MEMBER', 'This user is already a member of the organization'); + + await db.query(`INSERT INTO user_tenants (tenant_id, user_id, status) VALUES ($1, $2, $3)`, [a.tenantId, user!.id, created ? 'ACTIVE' : 'INVITED']); + await db.query( + `INSERT INTO user_roles (tenant_id, user_id, role_id, created_by) SELECT $1, $2, unnest($3::uuid[]), $4`, + [a.tenantId, user!.id, [...new Set(body.roleIds)], a.userId]); + const m = (await loadMember(db, a.tenantId, user!.id))!; + await writeAudit(db, { + tenantId: a.tenantId, userId: a.userId, action: 'CREATE', entityType: 'user', entityId: user!.id, + newValues: { email: body.email, accountCreated: created, roles: m.roles.map((r) => r.code) }, + }, req.auditMeta()); + return m; + }); + reply.code(201); + return toMember(member); + }); + + app.patch('/users/:id', { preHandler: requirePermission(app, 'user.manage') }, async (req) => { + const { id } = parse(uuidParam, req.params); + const body = parse(z.object({ status: z.enum(['ACTIVE', 'DISABLED']) }), req.body); + const a = req.auth!; + if (id === a.userId) throw forbidden('You cannot change your own status'); + const member = await req.tenantTx(async (db) => { + const before = await loadMember(db, a.tenantId, id); + if (!before) throw notFound('User'); + if (before.is_owner) throw forbidden('The organization owner cannot be disabled'); + // Only the invited person can accept an invitation; it can be withdrawn by disabling it. + if (before.member_status === 'INVITED' && body.status === 'ACTIVE') throw conflict('INVITATION_PENDING', 'The invitation has not been accepted yet'); + // You cannot act on someone who holds permissions you do not have. + if (before.roles.length) assertCanGrant(a, (await resolveRoles(db, before.roles.map((r) => r.id))).permissions); + if (body.status === 'ACTIVE' && before.member_status !== 'ACTIVE') await assertWithinLimit(db, a.tenantId, 'max_users'); + await db.query(`UPDATE user_tenants SET status = $3 WHERE tenant_id = $1 AND user_id = $2`, [a.tenantId, id, body.status]); + if (body.status === 'DISABLED') { + await db.query(`UPDATE user_sessions SET revoked_at = now() WHERE user_id = $1 AND tenant_id = $2 AND revoked_at IS NULL`, [id, a.tenantId]); + } + await writeAudit(db, { + tenantId: a.tenantId, userId: a.userId, action: 'UPDATE', entityType: 'user', entityId: id, + oldValues: { status: before.member_status }, newValues: { status: body.status }, + }, req.auditMeta()); + return (await loadMember(db, a.tenantId, id))!; + }); + return toMember(member); + }); + + app.put('/users/:id/roles', { preHandler: requirePermission(app, 'user.manage') }, async (req) => { + const { id } = parse(uuidParam, req.params); + const body = parse(z.object({ roleIds: z.array(z.uuid()).min(1).max(20) }), req.body); + const a = req.auth!; + if (id === a.userId) throw forbidden('You cannot change your own roles'); + const member = await req.tenantTx(async (db) => { + const before = await loadMember(db, a.tenantId, id); + if (!before) throw notFound('User'); + const { roles, permissions } = await resolveRoles(db, body.roleIds); + assertCanGrant(a, permissions); + // Removing a role is also a privilege change: you must hold everything it grants. + const removed = before.roles.filter((r) => !body.roleIds.includes(r.id)).map((r) => r.id); + if (removed.length) assertCanGrant(a, (await resolveRoles(db, removed)).permissions); + if (before.is_owner && !roles.some((r) => r.code === 'TENANT_OWNER')) { + throw forbidden('The organization owner must keep the TENANT_OWNER role'); + } + await db.query(`DELETE FROM user_roles WHERE tenant_id = $1 AND user_id = $2`, [a.tenantId, id]); + await db.query( + `INSERT INTO user_roles (tenant_id, user_id, role_id, created_by) SELECT $1, $2, unnest($3::uuid[]), $4`, + [a.tenantId, id, [...new Set(body.roleIds)], a.userId]); + const after = (await loadMember(db, a.tenantId, id))!; + await writeAudit(db, { + tenantId: a.tenantId, userId: a.userId, action: 'PERMISSION_CHANGE', entityType: 'user', entityId: id, + oldValues: { roles: before.roles.map((r) => r.code) }, newValues: { roles: after.roles.map((r) => r.code) }, + }, req.auditMeta()); + return after; + }); + return toMember(member); + }); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/zatca/client.ts b/alshuyukh-accounting/apps/api/src/modules/zatca/client.ts new file mode 100644 index 000000000000..6f880b42b410 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/zatca/client.ts @@ -0,0 +1,96 @@ +/** + * HTTP client for the ZATCA e-invoicing (Fatoora) API. + * NOTE: written against ZATCA's published API description; it has not yet been + * run against the live developer portal, simulation or production gateways. + */ +export type Environment = 'DEVELOPER' | 'SIMULATION' | 'PRODUCTION'; + +export const BASE_URLS: Record = { + DEVELOPER: 'https://gw-fatoora.zatca.gov.sa/e-invoicing/developer-portal', + SIMULATION: 'https://gw-fatoora.zatca.gov.sa/e-invoicing/simulation', + PRODUCTION: 'https://gw-fatoora.zatca.gov.sa/e-invoicing/core', +}; + +export interface Transport { (url: string, init: { method: string; headers: Record; body: string }): Promise<{ status: number; text(): Promise }> } + +export interface ZatcaResponse { + status: number; + body: Record | null; + durationMs: number; + /** Network failure or timeout: nothing reached ZATCA (or we cannot tell). */ + transportError?: string; +} + +export interface Credentials { username: string; password: string } + +export class ZatcaClient { + constructor(private readonly env: Environment, private readonly transport: Transport = defaultTransport, private readonly baseUrl = BASE_URLS[env]) {} + + private async call(path: string, body: unknown, extra: Record, auth?: Credentials): Promise { + const headers: Record = { 'Content-Type': 'application/json', Accept: 'application/json', 'Accept-Language': 'en', 'Accept-Version': 'V2', ...extra }; + if (auth) headers.Authorization = `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString('base64')}`; + const started = Date.now(); + try { + const res = await this.transport(`${this.baseUrl}${path}`, { method: 'POST', headers, body: JSON.stringify(body) }); + const text = await res.text(); + let parsed: Record | null = null; + try { parsed = text ? JSON.parse(text) : null; } catch { parsed = { raw: text.slice(0, 2000) }; } + return { status: res.status, body: parsed, durationMs: Date.now() - started }; + } catch (e) { + return { status: 0, body: null, durationMs: Date.now() - started, transportError: (e as Error).message }; + } + } + + /** Step 1 of onboarding: CSR + one-time password from the Fatoora portal → compliance CSID. */ + complianceCsid(csrPem: string, otp: string) { + return this.call('/compliance', { csr: Buffer.from(csrPem).toString('base64') }, { OTP: otp }); + } + /** Step 2: sample documents signed with the compliance CSID. */ + complianceCheck(auth: Credentials, doc: { invoiceHash: string; uuid: string; invoice: string }) { + return this.call('/compliance/invoices', doc, {}, auth); + } + /** Step 3: production CSID, once the compliance checks pass. */ + productionCsid(auth: Credentials, complianceRequestId: string) { + return this.call('/production/csids', { compliance_request_id: complianceRequestId }, {}, auth); + } + /** Simplified invoices and their notes: report within 24 hours. */ + report(auth: Credentials, doc: { invoiceHash: string; uuid: string; invoice: string }) { + return this.call('/invoices/reporting/single', doc, { 'Clearance-Status': '0' }, auth); + } + /** Standard invoices and their notes: must be cleared before they are given to the buyer. */ + clear(auth: Credentials, doc: { invoiceHash: string; uuid: string; invoice: string }) { + return this.call('/invoices/clearance/single', doc, { 'Clearance-Status': '1' }, auth); + } +} + +const defaultTransport: Transport = async (url, init) => { + const res = await fetch(url, { ...init, signal: AbortSignal.timeout(30_000) }); + return { status: res.status, text: () => res.text() }; +}; + +/** The transport used by the app; tests replace it. */ +let currentTransport: Transport = defaultTransport; +let gatewayOverride: string | null = null; +export const setTransport = (t: Transport | null) => { currentTransport = t ?? defaultTransport; }; +/** Points every environment at another gateway, e.g. a local stand-in. */ +export const setGatewayUrl = (url: string | null) => { gatewayOverride = url; }; +export const clientFor = (env: Environment) => + new ZatcaClient(env, (url, init) => currentTransport(url, init), gatewayOverride ? `${gatewayOverride.replace(/\/$/, '')}/${env.toLowerCase()}` : BASE_URLS[env]); + +export interface ValidationMessage { type?: string; code?: string; category?: string; message?: string; status?: string } + +/** Validation messages from a reporting/clearance/compliance response. */ +export function validationMessages(body: Record | null): { level: 'ERROR' | 'WARNING' | 'INFO'; code: string | null; category: string | null; message: string }[] { + const v = body?.validationResults; + if (!v) { + // Errors outside validation (authentication, malformed request). + if (body?.message || body?.errors) { + const list = Array.isArray(body.errors) ? body.errors : [{ message: body.message }]; + return list.map((e: ValidationMessage | string) => ({ level: 'ERROR' as const, code: typeof e === 'string' ? null : e.code ?? null, category: null, message: typeof e === 'string' ? e : String(e.message ?? JSON.stringify(e)) })); + } + return []; + } + const map = (list: ValidationMessage[] | undefined, level: 'ERROR' | 'WARNING' | 'INFO') => + (list ?? []).map((m) => ({ level, code: m.code ?? null, category: m.category ?? null, message: String(m.message ?? '') })); + return [...map(v.errorMessages, 'ERROR'), ...map(v.warningMessages, 'WARNING'), ...map(v.infoMessages, 'INFO')]; +} diff --git a/alshuyukh-accounting/apps/api/src/modules/zatca/crypto.ts b/alshuyukh-accounting/apps/api/src/modules/zatca/crypto.ts new file mode 100644 index 000000000000..5cbdd5fa6bf2 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/zatca/crypto.ts @@ -0,0 +1,129 @@ +import { createCipheriv, createDecipheriv, createHash, createPrivateKey, createPublicKey, generateKeyPairSync, hkdfSync, randomBytes, sign, X509Certificate } from 'node:crypto'; +import { bits, children, ctx, int, octets, oid, printable, read, seq, set, utf8 } from './der.js'; + +/** + * Key material for the cryptographic stamp. ZATCA requires ECDSA on the + * secp256k1 curve with SHA-256. + */ +export function generateKeyPair() { + const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'secp256k1' }); + return { + privateKeyPem: privateKey.export({ type: 'sec1', format: 'pem' }) as string, + publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }) as string, + }; +} + +// --- Secrets at rest ------------------------------------------------------------------- + +/** + * Private keys and CSID secrets are stored encrypted with AES-256-GCM. The key + * comes from ZATCA_ENCRYPTION_KEY (base64, 32 bytes) or, in development, is + * derived from JWT_SECRET with HKDF under a dedicated label. + */ +export function encryptionKey(env: { ZATCA_ENCRYPTION_KEY?: string; JWT_SECRET: string }): Buffer { + if (env.ZATCA_ENCRYPTION_KEY) { + const k = Buffer.from(env.ZATCA_ENCRYPTION_KEY, 'base64'); + if (k.length !== 32) throw new Error('ZATCA_ENCRYPTION_KEY must be 32 bytes, base64 encoded'); + return k; + } + return Buffer.from(hkdfSync('sha256', env.JWT_SECRET, 'alshuyukh', 'zatca-secrets-v1', 32)); +} + +export function encrypt(key: Buffer, plain: string): string { + const iv = randomBytes(12); + const c = createCipheriv('aes-256-gcm', key, iv); + const body = Buffer.concat([c.update(plain, 'utf8'), c.final()]); + return ['v1', iv.toString('base64'), c.getAuthTag().toString('base64'), body.toString('base64')].join('.'); +} + +export function decrypt(key: Buffer, sealed: string): string { + const [v, iv, tag, body] = sealed.split('.'); + if (v !== 'v1' || !iv || !tag || !body) throw new Error('Unknown secret format'); + const d = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64')); + d.setAuthTag(Buffer.from(tag, 'base64')); + return Buffer.concat([d.update(Buffer.from(body, 'base64')), d.final()]).toString('utf8'); +} + +// --- CSR ------------------------------------------------------------------------------- + +export interface CsrInput { + privateKeyPem: string; + environment: 'DEVELOPER' | 'SIMULATION' | 'PRODUCTION'; + commonName: string; // CN + organizationUnit: string; // OU: branch name, or the 10-digit TIN for VAT groups + organization: string; // O: taxpayer name + country: string; // C + egsSerial: string; // SAN SN: 1-|2-|3- + vatNumber: string; // SAN UID + invoiceTypes: string; // SAN title: 4 digits, e.g. 1100 = standard + simplified + registeredAddress: string; // SAN registeredAddress + businessCategory: string; // SAN businessCategory +} + +/** Certificate template name ZATCA expects in the CSR, per environment. */ +export const TEMPLATE_NAME = { DEVELOPER: 'TSTZATCA-Code-Signing', SIMULATION: 'PREZATCA-Code-Signing', PRODUCTION: 'ZATCA-Code-Signing' } as const; + +const rdn = (type: string, value: string) => set(seq(oid(type), utf8(value))); + +/** Builds a PKCS#10 CSR (PEM) with the ZATCA certificate template and SAN fields. */ +export function buildCsr(i: CsrInput): string { + const key = createPrivateKey(i.privateKeyPem); + const spki = createPublicKey(key).export({ type: 'spki', format: 'der' }); + const subject = seq( + rdn('2.5.4.6', i.country), rdn('2.5.4.11', i.organizationUnit), rdn('2.5.4.10', i.organization), rdn('2.5.4.3', i.commonName), + ); + const directoryName = seq( + rdn('2.5.4.4', i.egsSerial), // SN (surname) + rdn('0.9.2342.19200300.100.1.1', i.vatNumber), // UID + rdn('2.5.4.12', i.invoiceTypes), // title + rdn('2.5.4.26', i.registeredAddress), // registeredAddress + rdn('2.5.4.15', i.businessCategory), // businessCategory + ); + const extensions = seq( + seq(oid('1.3.6.1.4.1.311.20.2'), octets(printable(TEMPLATE_NAME[i.environment]))), + seq(oid('2.5.29.17'), octets(seq(ctx(4, directoryName)))), + ); + const attributes = ctx(0, seq(oid('1.2.840.113549.1.9.14'), set(extensions))); + const info = seq(int(0), subject, Buffer.from(spki), attributes); + const signature = sign('sha256', info, key); // DER-encoded ECDSA signature + const csr = seq(info, seq(oid('1.2.840.10045.4.3.2')), bits(signature)); + return `-----BEGIN CERTIFICATE REQUEST-----\n${csr.toString('base64').replace(/(.{64})/g, '$1\n').trim()}\n-----END CERTIFICATE REQUEST-----\n`; +} + +// --- Certificates ---------------------------------------------------------------------- + +/** + * ZATCA returns the CSID as a base64 "binarySecurityToken" whose decoded text + * is the base64 DER certificate (without PEM armour). + */ +export function certificateFromCsid(binarySecurityToken: string): string { + return Buffer.from(binarySecurityToken, 'base64').toString('utf8').trim(); +} + +export interface CertInfo { + /** Base64 DER, as embedded in the signature's X509Certificate. */ + base64: string; + issuer: string; // RFC 4514-style, most specific first (as ZATCA's SDK prints it) + serialNumber: string; // decimal + validTo: Date; + publicKeyDer: Buffer; // SubjectPublicKeyInfo + signature: Buffer; // the CA's signature over the certificate (QR tag 9) +} + +export function parseCertificate(base64Der: string): CertInfo { + const der = Buffer.from(base64Der, 'base64'); + const cert = new X509Certificate(der); + // Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm, signatureValue BIT STRING } + const [, , sigBits] = children(read(der)); + return { + base64: base64Der, + issuer: cert.issuer.split('\n').reverse().join(', '), + serialNumber: BigInt(`0x${cert.serialNumber}`).toString(10), + validTo: new Date(cert.validTo), + publicKeyDer: cert.publicKey.export({ type: 'spki', format: 'der' }) as Buffer, + signature: sigBits!.value.subarray(1), + }; +} + +/** ZATCA's certificate digest: SHA-256 as lowercase hex, then base64 of that text. */ +export const certificateHash = (base64Der: string) => Buffer.from(createHash('sha256').update(base64Der).digest('hex')).toString('base64'); diff --git a/alshuyukh-accounting/apps/api/src/modules/zatca/der.ts b/alshuyukh-accounting/apps/api/src/modules/zatca/der.ts new file mode 100644 index 000000000000..e03c4429daee --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/zatca/der.ts @@ -0,0 +1,60 @@ +/** + * Minimal ASN.1 DER encoder and reader, enough to build a PKCS#10 CSR with + * ZATCA's extensions and to read fields out of an X.509 certificate. + */ + +function length(n: number): Buffer { + if (n < 0x80) return Buffer.from([n]); + const bytes: number[] = []; + for (let v = n; v > 0; v = Math.floor(v / 256)) bytes.unshift(v & 0xff); + return Buffer.from([0x80 | bytes.length, ...bytes]); +} + +export const tlv = (tag: number, value: Buffer) => Buffer.concat([Buffer.from([tag]), length(value.length), value]); +export const seq = (...items: Buffer[]) => tlv(0x30, Buffer.concat(items)); +export const set = (...items: Buffer[]) => tlv(0x31, Buffer.concat(items)); +export const int = (n: number) => tlv(0x02, Buffer.from([n])); +export const utf8 = (s: string) => tlv(0x0c, Buffer.from(s, 'utf8')); +export const printable = (s: string) => tlv(0x13, Buffer.from(s, 'ascii')); +export const octets = (b: Buffer) => tlv(0x04, b); +export const bits = (b: Buffer) => tlv(0x03, Buffer.concat([Buffer.from([0]), b])); +/** Context-specific constructed tag [n]. */ +export const ctx = (n: number, ...items: Buffer[]) => tlv(0xa0 | n, Buffer.concat(items)); + +export function oid(dotted: string): Buffer { + const parts = dotted.split('.').map(Number); + const out = [40 * parts[0]! + parts[1]!]; + for (const p of parts.slice(2)) { + const stack: number[] = [p & 0x7f]; + for (let v = Math.floor(p / 128); v > 0; v = Math.floor(v / 128)) stack.unshift((v & 0x7f) | 0x80); + out.push(...stack); + } + return tlv(0x06, Buffer.from(out)); +} + +export interface Node { tag: number; start: number; header: number; length: number; value: Buffer; raw: Buffer } + +/** Reads one TLV node at `offset`. */ +export function read(buf: Buffer, offset = 0): Node { + const tag = buf[offset]!; + let len = buf[offset + 1]!; + let header = 2; + if (len & 0x80) { + const n = len & 0x7f; + len = 0; + for (let i = 0; i < n; i++) len = len * 256 + buf[offset + 2 + i]!; + header += n; + } + return { tag, start: offset, header, length: len, value: buf.subarray(offset + header, offset + header + len), raw: buf.subarray(offset, offset + header + len) }; +} + +/** Children of a constructed node. */ +export function children(node: Node): Node[] { + const out: Node[] = []; + for (let off = 0; off < node.value.length;) { + const c = read(node.value, off); + out.push(c); + off += c.header + c.length; + } + return out; +} diff --git a/alshuyukh-accounting/apps/api/src/modules/zatca/einvoice.ts b/alshuyukh-accounting/apps/api/src/modules/zatca/einvoice.ts new file mode 100644 index 000000000000..95d92484a314 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/zatca/einvoice.ts @@ -0,0 +1,142 @@ +import { Decimal, toMoney } from '../../lib/money.js'; +import type { Db } from '../../db/tx.js'; +import { badRequest } from '../../lib/errors.js'; +import { taxGroups } from '../tax/ledger.js'; +import type { Address, EInvoice, Line, TypeCode } from './xml.js'; + +/** + * Turns an issued sales invoice or sales return into the data of a ZATCA + * e-invoice, and checks everything ZATCA requires before anything is signed. + */ +export type ZatcaDocType = 'SALES_INVOICE' | 'SALES_RETURN'; +interface Problem { path: string; message: string } + +interface Seller { name: string; vatNumber: string | null; crn: string | null; street: string | null; buildingNumber: string | null; additionalNumber: string | null; district: string | null; city: string | null; postalCode: string | null; country: string } + +export async function loadSeller(db: Db, companyId: string): Promise { + const { rows: [c] } = await db.query( + `SELECT COALESCE(legal_name, name) AS name, vat_number AS "vatNumber", commercial_registration AS crn, street, + building_number AS "buildingNumber", additional_number AS "additionalNumber", district, city, postal_code AS "postalCode", country + FROM companies WHERE id = $1`, [companyId]); + return c!; +} + +/** Seller requirements: VAT number, CRN and a complete national address. */ +export function sellerProblems(s: Seller): Problem[] { + const p: Problem[] = []; + if (!s.vatNumber) p.push({ path: 'company.vatNumber', message: 'الرقم الضريبي للشركة مطلوب' }); + if (!s.crn) p.push({ path: 'company.commercialRegistration', message: 'رقم السجل التجاري للشركة مطلوب' }); + for (const [k, label] of [['street', 'الشارع'], ['buildingNumber', 'رقم المبنى'], ['district', 'الحي'], ['city', 'المدينة'], ['postalCode', 'الرمز البريدي']] as const) { + if (!s[k]) p.push({ path: `company.${k}`, message: `${label} في العنوان الوطني للشركة مطلوب` }); + } + return p; +} + +export const sellerAddress = (s: Seller): Address => ({ + street: s.street!, buildingNumber: s.buildingNumber!, additionalNumber: s.additionalNumber, district: s.district!, city: s.city!, postalCode: s.postalCode!, country: s.country, +}); + +const OUT_OF_SCOPE = { code: 'VATEX-SA-OOS', reason: 'خارج نطاق ضريبة القيمة المضافة' }; + +export interface BuildContext { uuid: string; icv: number; previousHash: string; issueTime: string } + +/** Builds the e-invoice data for an issued document, or throws 400 ZATCA_INVALID listing every problem. */ +export async function buildEInvoice(db: Db, type: ZatcaDocType, documentId: string, c: BuildContext): Promise<{ invoice: EInvoice; kind: 'STANDARD' | 'SIMPLIFIED'; companyId: string; number: string }> { + const table = type === 'SALES_INVOICE' ? 'sales_invoices' : 'sales_returns'; + const items = type === 'SALES_INVOICE' ? 'sales_invoice_items' : 'sales_return_items'; + const { rows: [d] } = await db.query<{ + company_id: string; doc_number: string; doc_date: string; due_date: string | null; invoice_kind: string | null; party_snapshot: any; + taxable_amount: string; tax_amount: string; total: string; reason: string | null; original_number: string | null; original_date: string | null; + }>( + `SELECT d.company_id, d.doc_number, d.doc_date, ${type === 'SALES_INVOICE' ? 'd.due_date' : 'NULL::date AS due_date'}, + d.invoice_kind, d.party_snapshot, d.taxable_amount::text, d.tax_amount::text, d.total::text, + ${type === 'SALES_RETURN' + ? `d.reason, o.doc_number AS original_number, o.doc_date AS original_date FROM ${table} d JOIN sales_invoices o ON o.id = d.original_invoice_id` + : `NULL AS reason, NULL AS original_number, NULL AS original_date FROM ${table} d`} + WHERE d.id = $1`, [documentId]); + if (!d) throw badRequest('DOCUMENT_NOT_FOUND', 'Document not found'); + const kind = (d.invoice_kind ?? 'SIMPLIFIED') as 'STANDARD' | 'SIMPLIFIED'; + const seller = await loadSeller(db, d.company_id); + const problems = sellerProblems(seller); + + const snap = d.party_snapshot ?? {}; + const addr = snap.address ?? null; + if (kind === 'STANDARD') { + if (!snap.vatNumber) problems.push({ path: 'customer.vatNumber', message: 'الرقم الضريبي للعميل مطلوب في الفاتورة الضريبية' }); + for (const [k, label] of [['street', 'الشارع'], ['buildingNumber', 'رقم المبنى'], ['district', 'الحي'], ['city', 'المدينة'], ['postalCode', 'الرمز البريدي']] as const) { + if (!addr?.[k]) problems.push({ path: `customer.address.${k}`, message: `${label} في عنوان العميل مطلوب في الفاتورة الضريبية` }); + } + } + + const { rows: lines } = await db.query<{ line_no: number; name: string; quantity: string; unit_code: string | null; net_amount: string; vat_category: Line['vatCategory']; vat_rate: string; vat_amount: string; exemption_code: string | null; exemption_reason: string | null }>( + `SELECT i.line_no, COALESCE(NULLIF(btrim(i.description), ''), p.name_ar, 'بند') AS name, i.quantity::text, u.code AS unit_code, + i.net_amount::text, i.vat_category, i.vat_rate::text, i.vat_amount::text, + p.vat_exemption_code AS exemption_code, p.vat_exemption_reason AS exemption_reason + FROM ${items} i LEFT JOIN products p ON p.id = i.product_id LEFT JOIN units u ON u.id = i.unit_id + WHERE i.document_id = $1 ORDER BY i.line_no`, [documentId]); + + const eLines: Line[] = lines.map((l) => { + let exemptionCode = l.exemption_code; + let exemptionReason = l.exemption_reason; + if (l.vat_category === 'O' && !exemptionCode) ({ code: exemptionCode, reason: exemptionReason } = OUT_OF_SCOPE); + if ((l.vat_category === 'Z' || l.vat_category === 'E') && !exemptionCode) { + problems.push({ path: `lines.${l.line_no}`, message: `السطر ${l.line_no} (${l.name}): رمز سبب الإعفاء أو النسبة الصفرية مطلوب في بطاقة الصنف` }); + } + return { + id: l.line_no, name: l.name, quantity: l.quantity, unitCode: l.unit_code ?? 'PCE', netAmount: l.net_amount, + vatCategory: l.vat_category, vatRate: l.vat_rate, vatAmount: l.vat_amount, exemptionCode, exemptionReason, + }; + }); + + // Tax breakdown equals what was posted; one exemption reason per category. + const groups = taxGroups(eLines, d.tax_amount).map((g) => { + const codes = [...new Set(eLines.filter((l) => l.vatCategory === g.vatCategory && new Decimal(l.vatRate).equals(g.vatRate)).map((l) => l.exemptionCode ?? null))]; + if (g.vatCategory !== 'S' && codes.length > 1) problems.push({ path: 'lines', message: `أسباب إعفاء مختلفة للفئة ${g.vatCategory} في مستند واحد غير مدعومة؛ افصلها في مستندات مستقلة` }); + const line = eLines.find((l) => l.vatCategory === g.vatCategory && l.exemptionCode === codes[0]); + return { ...g, exemptionCode: line?.exemptionCode ?? null, exemptionReason: line?.exemptionReason ?? null }; + }); + + if (problems.length) throw badRequest('ZATCA_INVALID', 'لا يمكن إنشاء الفاتورة الإلكترونية: بيانات ناقصة', problems); + + const typeCode: TypeCode = type === 'SALES_INVOICE' ? '388' : '381'; + const invoice: EInvoice = { + number: d.doc_number, uuid: c.uuid, issueDate: d.doc_date, issueTime: c.issueTime, + typeCode, subtype: kind === 'STANDARD' ? '0100000' : '0200000', icv: c.icv, previousHash: c.previousHash, currency: 'SAR', + seller: { name: seller.name, vatNumber: seller.vatNumber!, crn: seller.crn, address: sellerAddress(seller) }, + buyer: snap.nameAr ? { + name: snap.nameAr, vatNumber: snap.vatNumber ?? null, crn: null, + address: addr?.street && addr?.buildingNumber ? { street: addr.street, buildingNumber: addr.buildingNumber, additionalNumber: addr.additionalNumber, district: addr.district ?? '', city: addr.city ?? '', postalCode: addr.postalCode ?? '', country: addr.country ?? 'SA' } : null, + } : null, + supplyDate: d.doc_date, + billingReference: type === 'SALES_RETURN' ? { number: d.original_number!, date: d.original_date! } : null, + reason: d.reason, + // 30 = credit transfer (sold on credit), 10 = cash. + paymentMeansCode: d.due_date && d.due_date > d.doc_date ? '30' : '10', + lines: eLines, + taxSubtotals: groups.map((g) => ({ vatCategory: g.vatCategory, vatRate: g.vatRate, taxableAmount: g.taxableAmount, taxAmount: g.taxAmount, exemptionCode: g.exemptionCode, exemptionReason: g.exemptionReason })), + totals: { lineExtension: toMoney(d.taxable_amount), taxExclusive: toMoney(d.taxable_amount), taxAmount: toMoney(d.tax_amount), taxInclusive: toMoney(d.total), payable: toMoney(d.total) }, + }; + return { invoice, kind, companyId: d.company_id, number: d.doc_number }; +} + +/** Sample documents for the onboarding compliance checks (not part of any real chain). */ +export function sampleInvoices(seller: Seller, types: string, at: { date: string; time: string }): EInvoice[] { + const base = (n: number, typeCode: TypeCode, standard: boolean): Omit => ({ + number: `COMPLIANCE-${n}`, issueDate: at.date, issueTime: at.time, typeCode, subtype: standard ? '0100000' : '0200000', currency: 'SAR', + seller: { name: seller.name, vatNumber: seller.vatNumber!, crn: seller.crn, address: sellerAddress(seller) }, + buyer: standard + ? { name: 'عميل فحص الامتثال', vatNumber: '399999999800003', address: { street: 'شارع الملك فهد', buildingNumber: '1234', district: 'العليا', city: 'الرياض', postalCode: '12211', country: 'SA' } } + : null, + supplyDate: at.date, + billingReference: typeCode === '388' ? null : { number: 'COMPLIANCE-1', date: at.date }, + reason: typeCode === '381' ? 'إرجاع بضاعة' : typeCode === '383' ? 'تعديل السعر' : null, + paymentMeansCode: '10', + lines: [{ id: 1, name: 'بند فحص الامتثال', quantity: '1', unitCode: 'PCE', netAmount: '100.00', vatCategory: 'S', vatRate: '0.15', vatAmount: '15.00' }], + taxSubtotals: [{ vatCategory: 'S', vatRate: '0.15', taxableAmount: '100.00', taxAmount: '15.00' }], + totals: { lineExtension: '100.00', taxExclusive: '100.00', taxAmount: '15.00', taxInclusive: '115.00', payable: '115.00' }, + }); + const out: Omit[] = []; + if (types[0] === '1') out.push(base(1, '388', true), base(2, '381', true), base(3, '383', true)); + if (types[1] === '1') out.push(base(4, '388', false), base(5, '381', false), base(6, '383', false)); + return out as EInvoice[]; +} diff --git a/alshuyukh-accounting/apps/api/src/modules/zatca/qr.ts b/alshuyukh-accounting/apps/api/src/modules/zatca/qr.ts new file mode 100644 index 000000000000..35f716a62434 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/zatca/qr.ts @@ -0,0 +1,35 @@ +/** + * ZATCA QR code: a base64 TLV (tag, length, value) list. + * 1 seller name · 2 VAT number · 3 timestamp · 4 total with VAT · 5 VAT total + * (phase 2 adds) 6 invoice hash · 7 ECDSA signature · 8 ECDSA public key + * · 9 the CA's signature of the stamp certificate (simplified invoices only) + */ +export interface QrFields { + sellerName: string; vatNumber: string; timestamp: string; total: string; vatTotal: string; + invoiceHash?: string; signature?: string; publicKey?: Buffer; certificateSignature?: Buffer; +} + +export function encodeQr(f: QrFields): string { + const parts: [number, Buffer][] = [ + [1, Buffer.from(f.sellerName, 'utf8')], [2, Buffer.from(f.vatNumber)], [3, Buffer.from(f.timestamp)], + [4, Buffer.from(f.total)], [5, Buffer.from(f.vatTotal)], + ]; + if (f.invoiceHash) parts.push([6, Buffer.from(f.invoiceHash)]); + if (f.signature) parts.push([7, Buffer.from(f.signature)]); + if (f.publicKey) parts.push([8, f.publicKey]); + if (f.certificateSignature) parts.push([9, f.certificateSignature]); + for (const [tag, v] of parts) if (v.length > 255) throw new Error(`QR field ${tag} is longer than 255 bytes`); + return Buffer.concat(parts.map(([tag, v]) => Buffer.concat([Buffer.from([tag, v.length]), v]))).toString('base64'); +} + +export function decodeQr(base64: string): Map { + const buf = Buffer.from(base64, 'base64'); + const out = new Map(); + for (let i = 0; i < buf.length;) { + const tag = buf[i]!; + const len = buf[i + 1]!; + out.set(tag, buf.subarray(i + 2, i + 2 + len)); + i += 2 + len; + } + return out; +} diff --git a/alshuyukh-accounting/apps/api/src/modules/zatca/routes.ts b/alshuyukh-accounting/apps/api/src/modules/zatca/routes.ts new file mode 100644 index 000000000000..883ac445cef9 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/zatca/routes.ts @@ -0,0 +1,146 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import QRCode from 'qrcode'; +import { z } from 'zod'; +import type { Db } from '../../db/tx.js'; +import { forbidden, notFound } from '../../lib/errors.js'; +import { featuresOf } from '../subscriptions/service.js'; +import { parse, uuidParam } from '../../lib/validation.js'; +import { requirePermission } from '../../plugins/auth.js'; +import { resolveCompanyId } from '../accounting/company-context.js'; +import { loadSeller } from './einvoice.js'; +import { encodeQr } from './qr.js'; +import { + activateDevice, createDevice, DEVICE_SELECT, loadDevice, loadZatcaInvoice, requestComplianceCsid, revokeDevice, riyadhNow, + runComplianceChecks, submitInvoice, ZATCA_INVOICE_SELECT, type Ctx, type Run, +} from './service.js'; + +const ctxOf = (req: FastifyRequest): Ctx => ({ tenantId: req.auth!.tenantId, userId: req.auth!.userId, meta: req.auditMeta() }); +const runOf = (req: FastifyRequest): Run => (fn) => req.tenantTx(fn); +const qrSvg = (qr: string) => QRCode.toString(qr, { type: 'svg', errorCorrectionLevel: 'M', margin: 1 }); + +export default async function zatcaRoutes(app: FastifyInstance) { + const canView = requirePermission(app, 'zatca.view'); + const canManage = requirePermission(app, 'zatca.manage'); + + // Units (EGS) and onboarding ------------------------------------------------------------ + app.get('/zatca/devices', { preHandler: canView }, async (req) => { + const q = parse(z.object({ companyId: z.uuid().optional() }), req.query); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + return { data: (await db.query(`${DEVICE_SELECT} WHERE d.tenant_id = $1 AND d.company_id = $2 ORDER BY d.created_at DESC`, [req.auth!.tenantId, companyId])).rows }; + }); + }); + + app.get('/zatca/devices/:id', { preHandler: canView }, async (req) => { + const { id } = parse(uuidParam, req.params); + return req.tenantTx((db) => loadDevice(db, req.auth!.tenantId, id)); + }); + + app.post('/zatca/devices', { preHandler: canManage }, async (req, reply) => { + const body = parse(z.object({ + companyId: z.uuid().optional(), branchId: z.uuid().nullish(), + name: z.string().trim().min(2).max(100), + environment: z.enum(['DEVELOPER', 'SIMULATION', 'PRODUCTION']), + businessCategory: z.string().trim().min(2).max(100), + registeredAddress: z.string().trim().min(3).max(200).nullish(), + invoiceTypes: z.enum(['1100', '1000', '0100']).default('1100'), + }), req.body); + const device = await req.tenantTx(async (db) => { + if (!(await featuresOf(db, req.auth!.tenantId)).zatca_einvoicing) throw forbidden('الفوترة الإلكترونية غير مفعلة لهذه المنشأة'); + const companyId = await resolveCompanyId(db, req.auth!.tenantId, body.companyId); + return createDevice(db, ctxOf(req), { ...body, companyId }); + }); + reply.code(201); + return device; + }); + + app.post('/zatca/devices/:id/compliance-csid', { preHandler: canManage }, async (req) => { + const { id } = parse(uuidParam, req.params); + const { otp } = parse(z.object({ otp: z.string().regex(/^\d{6}$/, 'The OTP from the Fatoora portal is 6 digits') }), req.body); + return requestComplianceCsid(runOf(req), ctxOf(req), id, otp); + }); + + app.post('/zatca/devices/:id/compliance-checks', { preHandler: canManage }, async (req) => { + const { id } = parse(uuidParam, req.params); + return runComplianceChecks(runOf(req), ctxOf(req), id); + }); + + app.post('/zatca/devices/:id/activate', { preHandler: canManage }, async (req) => { + const { id } = parse(uuidParam, req.params); + return activateDevice(runOf(req), ctxOf(req), id); + }); + + app.post('/zatca/devices/:id/revoke', { preHandler: canManage }, async (req) => { + const { id } = parse(uuidParam, req.params); + const { reason } = parse(z.object({ reason: z.string().trim().min(3).max(500) }), req.body); + return req.tenantTx((db) => revokeDevice(db, ctxOf(req), id, reason)); + }); + + // E-invoices ---------------------------------------------------------------------------- + app.get('/zatca/invoices', { preHandler: canView }, async (req) => { + const q = parse(z.object({ + companyId: z.uuid().optional(), status: z.enum(['PENDING', 'REPORTED', 'CLEARED', 'REJECTED']).optional(), + documentType: z.enum(['SALES_INVOICE', 'SALES_RETURN']).optional(), + limit: z.coerce.number().int().min(1).max(200).default(50), offset: z.coerce.number().int().min(0).default(0), + }), req.query); + return req.tenantTx(async (db) => { + const companyId = await resolveCompanyId(db, req.auth!.tenantId, q.companyId); + const where = `z.tenant_id = $1 AND z.company_id = $2 AND ($3::text IS NULL OR z.status = $3) AND ($4::text IS NULL OR z.document_type = $4)`; + const params = [req.auth!.tenantId, companyId, q.status ?? null, q.documentType ?? null]; + const { rows } = await db.query(`${ZATCA_INVOICE_SELECT} WHERE ${where} ORDER BY z.created_at DESC LIMIT $5 OFFSET $6`, [...params, q.limit, q.offset]); + const { rows: [c] } = await db.query>( + `SELECT count(*) AS total, count(*) FILTER (WHERE status = 'PENDING') AS pending, count(*) FILTER (WHERE status = 'REJECTED') AS rejected, + count(*) FILTER (WHERE status = 'PENDING' AND invoice_kind = 'SIMPLIFIED' AND created_at < now() - interval '24 hours') AS overdue + FROM zatca_invoices z WHERE ${where}`, params); + return { data: rows.map(({ qr: _q, ...r }) => r), total: Number(c!.total), summary: { pending: Number(c!.pending), rejected: Number(c!.rejected), overdue: Number(c!.overdue) } }; + }); + }); + + app.get('/zatca/invoices/:id', { preHandler: canView }, async (req) => { + const { id } = parse(uuidParam, req.params); + return req.tenantTx((db) => loadZatcaInvoice(db, req.auth!.tenantId, id)); + }); + + app.get('/zatca/invoices/:id/xml', { preHandler: canView }, async (req, reply: FastifyReply) => { + const { id } = parse(uuidParam, req.params); + const { kind } = parse(z.object({ kind: z.enum(['SIGNED', 'CLEARED']).default('SIGNED') }), req.query); + const doc = await req.tenantTx(async (db) => { + const { rows: [d] } = await db.query<{ content: string; number: string }>( + `SELECT d.content, z.document_number AS number FROM zatca_documents d JOIN zatca_invoices z ON z.id = d.zatca_invoice_id + WHERE z.tenant_id = $1 AND z.id = $2 AND d.kind = $3`, [req.auth!.tenantId, id, kind]); + if (!d) throw notFound('XML document'); + return d; + }); + reply.header('Content-Type', 'application/xml; charset=utf-8'); + reply.header('Content-Disposition', `attachment; filename="${doc.number}-${kind.toLowerCase()}.xml"`); + return doc.content; + }); + + // Whoever may issue invoices may push them to ZATCA (the obligation comes with issuing). + app.post('/zatca/invoices/:id/submit', { preHandler: requirePermission(app, 'invoice.post') }, async (req) => { + const { id } = parse(uuidParam, req.params); + return submitInvoice(runOf(req), ctxOf(req), id); + }); + + /** + * E-invoicing data for printing a sales document: the signed e-invoice's QR + * when it exists, otherwise a phase-1 QR (seller, VAT number, time, totals). + */ + app.get('/zatca/document', { preHandler: requirePermission(app, 'invoice.view') }, async (req) => { + const q = parse(z.object({ type: z.enum(['SALES_INVOICE', 'SALES_RETURN']), id: z.uuid() }), req.query); + return req.tenantTx(async (db: Db) => { + const { rows: [z_] } = await db.query>(`${ZATCA_INVOICE_SELECT} WHERE z.tenant_id = $1 AND z.document_type = $2 AND z.document_id = $3`, + [req.auth!.tenantId, q.type, q.id]); + if (z_) return { einvoice: z_, qr: z_.qr, qrSvg: await qrSvg(z_.qr) }; + const table = q.type === 'SALES_INVOICE' ? 'sales_invoices' : 'sales_returns'; + const { rows: [d] } = await db.query<{ company_id: string; doc_date: string; issued_at: Date | null; total: string; tax_amount: string; status: string }>( + `SELECT company_id, doc_date, issued_at, total::text, tax_amount::text, status FROM ${table} WHERE tenant_id = $1 AND id = $2`, [req.auth!.tenantId, q.id]); + if (!d) throw notFound('Document'); + if (d.status === 'DRAFT' || !d.issued_at) return { einvoice: null, qr: null, qrSvg: null }; + const seller = await loadSeller(db, d.company_id); + if (!seller.vatNumber) return { einvoice: null, qr: null, qrSvg: null, missing: 'company.vatNumber' }; + const qr = encodeQr({ sellerName: seller.name, vatNumber: seller.vatNumber, timestamp: `${d.doc_date}T${riyadhNow(d.issued_at).time}`, total: d.total, vatTotal: d.tax_amount }); + return { einvoice: null, qr, qrSvg: await qrSvg(qr) }; + }); + }); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/zatca/service.ts b/alshuyukh-accounting/apps/api/src/modules/zatca/service.ts new file mode 100644 index 000000000000..36dab1058d48 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/zatca/service.ts @@ -0,0 +1,318 @@ +import { createHash, randomUUID } from 'node:crypto'; +import type { Db } from '../../db/tx.js'; +import { AppError, badRequest, conflict, notFound } from '../../lib/errors.js'; +import { writeAudit, type AuditMeta } from '../audit/audit.service.js'; +import { clientFor, validationMessages, type Credentials, type Environment, type ZatcaResponse } from './client.js'; +import { buildCsr, certificateFromCsid, decrypt, encrypt, generateKeyPair, parseCertificate } from './crypto.js'; +import { buildEInvoice, loadSeller, sampleInvoices, sellerProblems, type ZatcaDocType } from './einvoice.js'; +import { INITIAL_PIH, signInvoice } from './sign.js'; + +export interface Ctx { tenantId: string; userId: string | null; meta?: AuditMeta } +/** Runs a function in its own short tenant transaction; network calls happen between them. */ +export type Run = (fn: (db: Db) => Promise) => Promise; + +let secretKey: Buffer | null = null; +export const configureZatca = (key: Buffer) => { secretKey = key; }; +const key = () => { + if (!secretKey) throw new AppError(500, 'ZATCA_NOT_CONFIGURED', 'ZATCA encryption key is not configured'); + return secretKey; +}; + +/** Riyadh wall-clock date and time, as used on Saudi invoices. */ +export function riyadhNow(d = new Date()) { + const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Riyadh', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }) + .formatToParts(d).reduce>((a, p) => ({ ...a, [p.type]: p.value }), {}); + return { date: `${parts.year}-${parts.month}-${parts.day}`, time: `${parts.hour}:${parts.minute}:${parts.second}` }; +} + +export const DEVICE_SELECT = `SELECT d.id, d.company_id AS "companyId", d.branch_id AS "branchId", b.name AS "branchName", d.name, d.environment, + d.egs_serial AS "egsSerial", d.common_name AS "commonName", d.organization_unit AS "organizationUnit", d.invoice_types AS "invoiceTypes", + d.registered_address AS "registeredAddress", d.business_category AS "businessCategory", d.status, d.csr, + d.compliance_checks AS "complianceChecks", d.certificate_serial AS "certificateSerial", d.certificate_issuer AS "certificateIssuer", + d.certificate_expires_at AS "certificateExpiresAt", d.icv, d.last_hash AS "lastHash", d.activated_at AS "activatedAt", + d.revoked_at AS "revokedAt", d.created_at AS "createdAt" + FROM zatca_devices d LEFT JOIN branches b ON b.id = d.branch_id`; + +interface DeviceRow { + id: string; company_id: string; environment: Environment; status: string; csr: string; invoice_types: string; + private_key_enc: string; compliance_csid: string | null; compliance_secret_enc: string | null; compliance_request_id: string | null; + compliance_checks: Record; production_csid: string | null; production_secret_enc: string | null; icv: string; last_hash: string; +} + +async function deviceRow(db: Db, tenantId: string, id: string, lock = false): Promise { + const { rows: [d] } = await db.query(`SELECT * FROM zatca_devices WHERE tenant_id = $1 AND id = $2${lock ? ' FOR UPDATE' : ''}`, [tenantId, id]); + if (!d) throw notFound('Device'); + return d; +} + +export async function loadDevice(db: Db, tenantId: string, id: string) { + const { rows: [d] } = await db.query(`${DEVICE_SELECT} WHERE d.tenant_id = $1 AND d.id = $2`, [tenantId, id]); + if (!d) throw notFound('Device'); + return d; +} + +// --- Onboarding ------------------------------------------------------------------------ + +export interface DeviceInput { + companyId: string; branchId?: string | null; name: string; environment: Environment; + businessCategory: string; registeredAddress?: string | null; invoiceTypes?: string; +} + +/** Creates an EGS unit: key pair (encrypted at rest) and the CSR to submit with the portal OTP. */ +export async function createDevice(db: Db, ctx: Ctx, input: DeviceInput) { + const seller = await loadSeller(db, input.companyId); + const problems = sellerProblems(seller); + if (problems.length) throw badRequest('ZATCA_INVALID', 'أكمل بيانات الشركة قبل ربطها بالفوترة الإلكترونية', problems); + let unit = 'الفرع الرئيسي'; + if (input.branchId) { + const { rows: [b] } = await db.query<{ name: string }>(`SELECT name FROM branches WHERE company_id = $1 AND id = $2`, [input.companyId, input.branchId]); + if (!b) throw badRequest('INVALID_BRANCH', 'Branch not found in this company'); + unit = b.name; + } + const serial = randomUUID(); + const keys = generateKeyPair(); + const csrInput = { + privateKeyPem: keys.privateKeyPem, environment: input.environment, + commonName: `EGS-${serial.slice(0, 8)}`, organizationUnit: unit, organization: seller.name, country: seller.country, + egsSerial: `1-ALSHUYUKH|2-1.0|3-${serial}`, vatNumber: seller.vatNumber!, invoiceTypes: input.invoiceTypes ?? '1100', + registeredAddress: input.registeredAddress ?? `${seller.buildingNumber} ${seller.street}, ${seller.district}, ${seller.city} ${seller.postalCode}`, + businessCategory: input.businessCategory, + }; + const csr = buildCsr(csrInput); + const { rows: [row] } = await db.query<{ id: string }>( + `INSERT INTO zatca_devices (tenant_id, company_id, branch_id, name, environment, egs_serial, common_name, organization_unit, invoice_types, + registered_address, business_category, private_key_enc, public_key, csr, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id`, + [ctx.tenantId, input.companyId, input.branchId ?? null, input.name, input.environment, csrInput.egsSerial, csrInput.commonName, + unit, csrInput.invoiceTypes, csrInput.registeredAddress, input.businessCategory, encrypt(key(), keys.privateKeyPem), keys.publicKeyPem, csr, ctx.userId]); + const created = await loadDevice(db, ctx.tenantId, row!.id); + await writeAudit(db, { tenantId: ctx.tenantId, userId: ctx.userId, action: 'CREATE', entityType: 'zatca_device', entityId: row!.id, + newValues: { name: input.name, environment: input.environment, egsSerial: csrInput.egsSerial } }, ctx.meta); + return created; +} + +type Operation = 'COMPLIANCE_CSID' | 'COMPLIANCE_CHECK' | 'PRODUCTION_CSID' | 'REPORTING' | 'CLEARANCE'; + +/** Never store certificates, secrets or the (large) cleared invoice in the submissions log. */ +function sanitize(body: Record | null) { + if (!body) return null; + const { binarySecurityToken: _t, secret: _s, clearedInvoice, ...rest } = body; + return { ...rest, ...(clearedInvoice ? { clearedInvoice: '[stored as document]' } : {}) }; +} + +async function recordSubmission(db: Db, ctx: Ctx, deviceId: string, zatcaInvoiceId: string | null, operation: Operation, res: ZatcaResponse, outcome: 'SUCCESS' | 'WARNING' | 'REJECTED' | 'TRANSPORT_ERROR') { + const { rows: [s] } = await db.query<{ id: string }>( + `INSERT INTO zatca_submissions (tenant_id, device_id, zatca_invoice_id, operation, http_status, outcome, response, duration_ms, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`, + [ctx.tenantId, deviceId, zatcaInvoiceId, operation, res.status || null, outcome, + JSON.stringify(res.transportError ? { transportError: res.transportError } : sanitize(res.body)), res.durationMs, ctx.userId]); + const messages = validationMessages(res.body); + for (const m of messages) { + await db.query(`INSERT INTO zatca_errors (tenant_id, submission_id, zatca_invoice_id, level, code, category, message) VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ctx.tenantId, s!.id, zatcaInvoiceId, m.level, m.code, m.category, m.message.slice(0, 2000)]); + } + return messages; +} + +const errorSummary = (res: ZatcaResponse) => res.transportError + ? `تعذر الاتصال بمنصة فاتورة: ${res.transportError}` + : validationMessages(res.body).filter((m) => m.level === 'ERROR').map((m) => `${m.code ?? ''} ${m.message}`.trim()).join(' | ') || `HTTP ${res.status}`; + +/** Step 1: exchange the CSR and the portal OTP for a compliance CSID. */ +export async function requestComplianceCsid(run: Run, ctx: Ctx, deviceId: string, otp: string) { + const d = await run((db) => deviceRow(db, ctx.tenantId, deviceId)); + if (d.status !== 'NEW' && d.status !== 'COMPLIANCE') throw conflict('DEVICE_STATE', 'This unit is already active or revoked'); + const res = await clientFor(d.environment).complianceCsid(d.csr, otp); + const ok = res.status === 200 && typeof res.body?.binarySecurityToken === 'string' && typeof res.body?.secret === 'string'; + await run(async (db) => { + await recordSubmission(db, ctx, deviceId, null, 'COMPLIANCE_CSID', res, ok ? 'SUCCESS' : res.transportError ? 'TRANSPORT_ERROR' : 'REJECTED'); + if (!ok) return; + await db.query( + `UPDATE zatca_devices SET status = 'COMPLIANCE', compliance_csid = $2, compliance_secret_enc = $3, compliance_request_id = $4, compliance_checks = '{}' + WHERE id = $1`, [deviceId, res.body!.binarySecurityToken, encrypt(key(), res.body!.secret), String(res.body!.requestID ?? '')]); + await writeAudit(db, { tenantId: ctx.tenantId, userId: ctx.userId, action: 'UPDATE', entityType: 'zatca_device', entityId: deviceId, newValues: { status: 'COMPLIANCE' } }, ctx.meta); + }); + if (!ok) throw new AppError(502, 'ZATCA_REJECTED', errorSummary(res)); + return run((db) => loadDevice(db, ctx.tenantId, deviceId)); +} + +const credentials = (csid: string, secretEnc: string): Credentials => ({ username: csid, password: decrypt(key(), secretEnc) }); +const isAccepted = (res: ZatcaResponse) => res.status === 200 || res.status === 202; + +/** Step 2: sign and submit one sample of every document type the unit will issue. */ +export async function runComplianceChecks(run: Run, ctx: Ctx, deviceId: string) { + const d = await run((db) => deviceRow(db, ctx.tenantId, deviceId)); + if (d.status !== 'COMPLIANCE' || !d.compliance_csid || !d.compliance_secret_enc) throw conflict('DEVICE_STATE', 'Request the compliance CSID first'); + const seller = await run((db) => loadSeller(db, d.company_id)); + const cert = parseCertificate(certificateFromCsid(d.compliance_csid)); + const privateKeyPem = decrypt(key(), d.private_key_enc); + const auth = credentials(d.compliance_csid, d.compliance_secret_enc); + const now = riyadhNow(); + const results: Record = {}; + let previousHash = INITIAL_PIH; + for (const [i, sample] of sampleInvoices(seller, d.invoice_types, now).entries()) { + const label = `${sample.subtype === '0100000' ? 'STANDARD' : 'SIMPLIFIED'}_${{ 388: 'INVOICE', 381: 'CREDIT_NOTE', 383: 'DEBIT_NOTE' }[sample.typeCode]}`; + const uuid = randomUUID(); + const signed = signInvoice({ ...sample, uuid, icv: i + 1, previousHash }, { privateKeyPem, cert }, `${now.date}T${now.time}`); + previousHash = signed.hash; + const res = await clientFor(d.environment).complianceCheck(auth, { invoiceHash: signed.hash, uuid, invoice: Buffer.from(signed.xml).toString('base64') }); + const passed = isAccepted(res); + results[label] = passed ? (res.status === 202 ? 'PASSED_WITH_WARNINGS' : 'PASSED') : 'FAILED'; + await run((db) => recordSubmission(db, ctx, deviceId, null, 'COMPLIANCE_CHECK', res, passed ? (res.status === 202 ? 'WARNING' : 'SUCCESS') : res.transportError ? 'TRANSPORT_ERROR' : 'REJECTED')); + } + await run((db) => db.query(`UPDATE zatca_devices SET compliance_checks = $2 WHERE id = $1`, [deviceId, JSON.stringify(results)])); + return run((db) => loadDevice(db, ctx.tenantId, deviceId)); +} + +/** Step 3: once every sample passed, obtain the production CSID; the unit starts signing invoices. */ +export async function activateDevice(run: Run, ctx: Ctx, deviceId: string) { + const d = await run((db) => deviceRow(db, ctx.tenantId, deviceId)); + if (d.status !== 'COMPLIANCE' || !d.compliance_csid || !d.compliance_secret_enc) throw conflict('DEVICE_STATE', 'Request the compliance CSID first'); + const checks = Object.values(d.compliance_checks ?? {}); + if (!checks.length || checks.some((c) => c === 'FAILED')) throw conflict('COMPLIANCE_INCOMPLETE', 'Run the compliance checks until every sample passes'); + const res = await clientFor(d.environment).productionCsid(credentials(d.compliance_csid, d.compliance_secret_enc), d.compliance_request_id ?? ''); + const ok = res.status === 200 && typeof res.body?.binarySecurityToken === 'string' && typeof res.body?.secret === 'string'; + await run(async (db) => { + await recordSubmission(db, ctx, deviceId, null, 'PRODUCTION_CSID', res, ok ? 'SUCCESS' : res.transportError ? 'TRANSPORT_ERROR' : 'REJECTED'); + if (!ok) return; + const cert = parseCertificate(certificateFromCsid(res.body!.binarySecurityToken)); + await db.query( + `UPDATE zatca_devices SET status = 'ACTIVE', production_csid = $2, production_secret_enc = $3, certificate_serial = $4, + certificate_issuer = $5, certificate_expires_at = $6, activated_at = now() WHERE id = $1`, + [deviceId, res.body!.binarySecurityToken, encrypt(key(), res.body!.secret), cert.serialNumber, cert.issuer, cert.validTo]); + await writeAudit(db, { tenantId: ctx.tenantId, userId: ctx.userId, action: 'UPDATE', entityType: 'zatca_device', entityId: deviceId, + newValues: { status: 'ACTIVE', certificateSerial: cert.serialNumber, expiresAt: cert.validTo } }, ctx.meta); + }); + if (!ok) throw new AppError(502, 'ZATCA_REJECTED', errorSummary(res)); + return run((db) => loadDevice(db, ctx.tenantId, deviceId)); +} + +export async function revokeDevice(db: Db, ctx: Ctx, deviceId: string, reason: string) { + const d = await deviceRow(db, ctx.tenantId, deviceId, true); + if (d.status === 'REVOKED') throw conflict('DEVICE_STATE', 'Already revoked'); + await db.query(`UPDATE zatca_devices SET status = 'REVOKED', revoked_at = now() WHERE id = $1`, [deviceId]); + await writeAudit(db, { tenantId: ctx.tenantId, userId: ctx.userId, action: 'UPDATE', entityType: 'zatca_device', entityId: deviceId, newValues: { status: 'REVOKED', reason } }, ctx.meta); + return loadDevice(db, ctx.tenantId, deviceId); +} + +// --- Generation ------------------------------------------------------------------------ + +/** + * Called inside the issuing transaction of a sales invoice or return. When + * the company (or branch) has an active unit, the document gets the next ICV, + * the previous hash, a signed XML and a QR code. The unit row is locked, so + * concurrent issues are serialised and the chain cannot fork. Any missing + * data throws, which rolls back the issue itself. + */ +export async function generateForDocument(db: Db, ctx: Ctx, type: ZatcaDocType, documentId: string, scope: { companyId: string; branchId: string | null }) { + const { rows: [device] } = await db.query( + `SELECT * FROM zatca_devices WHERE tenant_id = $1 AND company_id = $2 AND status = 'ACTIVE' AND (branch_id = $3 OR branch_id IS NULL) + ORDER BY branch_id NULLS LAST LIMIT 1 FOR UPDATE`, [ctx.tenantId, scope.companyId, scope.branchId]); + if (!device) return null; + const icv = Number(device.icv) + 1; + const now = riyadhNow(); + const uuid = randomUUID(); + const { invoice, kind, number } = await buildEInvoice(db, type, documentId, { uuid, icv, previousHash: device.last_hash, issueTime: now.time }); + const signed = signInvoice(invoice, { privateKeyPem: decrypt(key(), device.private_key_enc), cert: parseCertificate(certificateFromCsid(device.production_csid!)) }, `${now.date}T${now.time}`); + + const { rows: [z] } = await db.query<{ id: string }>( + `INSERT INTO zatca_invoices (tenant_id, company_id, device_id, document_type, document_id, document_number, invoice_kind, type_code, subtype, + uuid, icv, invoice_hash, previous_hash, issue_date, issue_time, qr) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING id`, + [ctx.tenantId, scope.companyId, device.id, type, documentId, number, kind, invoice.typeCode, invoice.subtype, + uuid, icv, signed.hash, device.last_hash, invoice.issueDate, invoice.issueTime, signed.qr]); + await db.query(`INSERT INTO zatca_documents (tenant_id, zatca_invoice_id, kind, content, sha256) VALUES ($1, $2, 'SIGNED', $3, $4)`, + [ctx.tenantId, z!.id, signed.xml, createHash('sha256').update(signed.xml).digest('hex')]); + await db.query(`INSERT INTO invoice_hashes (tenant_id, company_id, device_id, icv, invoice_hash, previous_hash, zatca_invoice_id) VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ctx.tenantId, scope.companyId, device.id, icv, signed.hash, device.last_hash, z!.id]); + await db.query(`UPDATE zatca_devices SET icv = $2, last_hash = $3 WHERE id = $1`, [device.id, icv, signed.hash]); + return z!.id; +} + +/** An e-invoice in the chain can only be corrected by a credit note, unless ZATCA rejected it. */ +export async function assertCancellable(db: Db, type: string, documentId: string) { + const { rows: [z] } = await db.query<{ status: string }>(`SELECT status FROM zatca_invoices WHERE document_type = $1 AND document_id = $2`, [type, documentId]); + if (z && z.status !== 'REJECTED') { + throw conflict('ZATCA_ISSUED', 'هذا المستند صدر فاتورة إلكترونية؛ لا يمكن إلغاؤه، أصدر إشعارًا دائنًا (مرتجعًا) بدلًا من ذلك'); + } +} + +// --- Submission ------------------------------------------------------------------------ + +const backoffMinutes = (attempts: number) => Math.min(2 ** attempts, 360); + +/** + * Reports (simplified) or clears (standard) one pending e-invoice. A short + * lease on next_attempt_at keeps two submitters from sending it twice. + * `due` = true is the background worker: it only takes rows whose time has come. + */ +export async function submitInvoice(run: Run, ctx: Ctx, zatcaInvoiceId: string, opts: { due?: boolean } = {}) { + const lease = await run(async (db) => { + const { rows: [z] } = await db.query<{ id: string; device_id: string; invoice_kind: string; uuid: string; invoice_hash: string; attempts: number }>( + `UPDATE zatca_invoices SET next_attempt_at = now() + interval '5 minutes' + WHERE tenant_id = $1 AND id = $2 AND status = 'PENDING' ${opts.due ? 'AND next_attempt_at <= now()' : ''} + RETURNING id, device_id, invoice_kind, uuid, invoice_hash, attempts`, [ctx.tenantId, zatcaInvoiceId]); + if (!z) return null; + const { rows: [doc] } = await db.query<{ content: string }>(`SELECT content FROM zatca_documents WHERE zatca_invoice_id = $1 AND kind = 'SIGNED'`, [z.id]); + const device = await deviceRow(db, ctx.tenantId, z.device_id); + return { z, xml: doc!.content, device }; + }); + if (!lease) { + const exists = await run((db) => db.query(`SELECT status FROM zatca_invoices WHERE tenant_id = $1 AND id = $2`, [ctx.tenantId, zatcaInvoiceId])); + if (!exists.rowCount) throw notFound('E-invoice'); + if (opts.due) return null; + throw conflict('NOT_PENDING', 'This e-invoice is not waiting for submission (or is being submitted right now)'); + } + const { z, xml, device } = lease; + if (!device.production_csid || !device.production_secret_enc) throw conflict('DEVICE_STATE', 'The unit has no production CSID'); + const clearance = z.invoice_kind === 'STANDARD'; + const client = clientFor(device.environment); + const payload = { invoiceHash: z.invoice_hash, uuid: z.uuid, invoice: Buffer.from(xml).toString('base64') }; + const res = clearance ? await client.clear(credentials(device.production_csid, device.production_secret_enc), payload) + : await client.report(credentials(device.production_csid, device.production_secret_enc), payload); + + await run(async (db) => { + const accepted = isAccepted(res); + const rejected = res.status === 400 || res.status === 409 || res.status === 422; + const outcome = accepted ? (res.status === 202 ? 'WARNING' : 'SUCCESS') : rejected ? 'REJECTED' : 'TRANSPORT_ERROR'; + const messages = await recordSubmission(db, ctx, device.id, z.id, clearance ? 'CLEARANCE' : 'REPORTING', res, outcome); + if (accepted) { + await db.query( + `UPDATE zatca_invoices SET status = $2, has_warnings = $3, attempts = attempts + 1, submitted_at = now(), last_error = NULL WHERE id = $1`, + [z.id, clearance ? 'CLEARED' : 'REPORTED', res.status === 202 || messages.some((m) => m.level === 'WARNING')]); + if (clearance && typeof res.body?.clearedInvoice === 'string') { + const cleared = Buffer.from(res.body.clearedInvoice, 'base64').toString('utf8'); + await db.query(`INSERT INTO zatca_documents (tenant_id, zatca_invoice_id, kind, content, sha256) VALUES ($1, $2, 'CLEARED', $3, $4) ON CONFLICT DO NOTHING`, + [ctx.tenantId, z.id, cleared, createHash('sha256').update(cleared).digest('hex')]); + } + } else if (rejected) { + await db.query(`UPDATE zatca_invoices SET status = 'REJECTED', attempts = attempts + 1, submitted_at = now(), last_error = $2 WHERE id = $1`, [z.id, errorSummary(res)]); + } else { + // Network failure, 401/403 or 5xx: try again later with exponential backoff. + await db.query( + `UPDATE zatca_invoices SET attempts = attempts + 1, last_error = $2, next_attempt_at = now() + make_interval(mins => $3) WHERE id = $1`, + [z.id, errorSummary(res), backoffMinutes(z.attempts + 1)]); + } + }); + return run((db) => loadZatcaInvoice(db, ctx.tenantId, z.id)); +} + +export const ZATCA_INVOICE_SELECT = `SELECT z.id, z.company_id AS "companyId", z.device_id AS "deviceId", z.document_type AS "documentType", + z.document_id AS "documentId", z.document_number AS "documentNumber", z.invoice_kind AS "invoiceKind", z.type_code AS "typeCode", + z.subtype, z.uuid, z.icv, z.invoice_hash AS "invoiceHash", z.previous_hash AS "previousHash", z.issue_date AS "issueDate", + z.issue_time AS "issueTime", z.qr, z.status, z.has_warnings AS "hasWarnings", z.attempts, z.next_attempt_at AS "nextAttemptAt", + z.last_error AS "lastError", z.submitted_at AS "submittedAt", z.created_at AS "createdAt", + (z.status = 'PENDING' AND z.invoice_kind = 'SIMPLIFIED' AND z.created_at < now() - interval '24 hours') AS "reportingOverdue" + FROM zatca_invoices z`; + +export async function loadZatcaInvoice(db: Db, tenantId: string, id: string) { + const { rows: [z] } = await db.query(`${ZATCA_INVOICE_SELECT} WHERE z.tenant_id = $1 AND z.id = $2`, [tenantId, id]); + if (!z) throw notFound('E-invoice'); + const { rows: submissions } = await db.query( + `SELECT s.id, s.operation, s.http_status AS "httpStatus", s.outcome, s.duration_ms AS "durationMs", s.created_at AS "createdAt", + COALESCE((SELECT json_agg(json_build_object('level', e.level, 'code', e.code, 'category', e.category, 'message', e.message) ORDER BY e.level) + FROM zatca_errors e WHERE e.submission_id = s.id), '[]') AS messages + FROM zatca_submissions s WHERE s.zatca_invoice_id = $1 ORDER BY s.created_at`, [id]); + const { rows: docs } = await db.query(`SELECT kind, sha256, created_at AS "createdAt" FROM zatca_documents WHERE zatca_invoice_id = $1 ORDER BY created_at`, [id]); + return { ...z, submissions, documents: docs }; +} diff --git a/alshuyukh-accounting/apps/api/src/modules/zatca/sign.ts b/alshuyukh-accounting/apps/api/src/modules/zatca/sign.ts new file mode 100644 index 000000000000..f57a367a4d12 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/zatca/sign.ts @@ -0,0 +1,106 @@ +import { createHash, createPrivateKey, sign as ecSign } from 'node:crypto'; +import { DOMParser, type Element, type Node as XmlNode } from '@xmldom/xmldom'; +import { C14nCanonicalization } from 'xml-crypto'; +import { certificateHash, type CertInfo } from './crypto.js'; +import { encodeQr } from './qr.js'; +import { buildInvoiceXml, esc, type EInvoice } from './xml.js'; + +/** PIH of the first document in a chain: base64 of the hex SHA-256 of "0", as defined by ZATCA. */ +export const INITIAL_PIH = 'NWZlY2ViNjZmZmM4NmYzOGQ5NTI3ODZjNmQ2OTZjNzljMmRiYzIzOWRkNGU5MWI0NjcyOWQ3M2EyN2ZiNTdlOQ=='; + +const UBL_NS = { + ext: 'urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2', + cac: 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2', + cbc: 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2', +}; + +/** + * Invoice hash as ZATCA computes it: drop ext:UBLExtensions, cac:Signature and + * the QR AdditionalDocumentReference, canonicalise (C14N 1.1; identical to 1.0 + * for this document), SHA-256, base64. + */ +export function invoiceHash(xml: string): string { + const doc = new DOMParser().parseFromString(xml, 'text/xml'); + const root = doc.documentElement!; + for (const child of Array.from(root.childNodes) as XmlNode[]) { + if (child.nodeType !== 1) continue; + const e = child as Element; + const isQr = e.localName === 'AdditionalDocumentReference' && e.namespaceURI === UBL_NS.cac + && Array.from(e.getElementsByTagNameNS(UBL_NS.cbc, 'ID')).some((id) => id.parentNode === e && id.textContent === 'QR'); + if ((e.localName === 'UBLExtensions' && e.namespaceURI === UBL_NS.ext) || (e.localName === 'Signature' && e.namespaceURI === UBL_NS.cac) || isQr) { + root.removeChild(e); + } + } + const canonical = new C14nCanonicalization().process(root as unknown as globalThis.Node, {}) as string; + return createHash('sha256').update(canonical, 'utf8').digest('base64'); +} + +/** ECDSA (secp256k1, SHA-256) signature over the invoice hash bytes, base64. */ +export function signHash(hashBase64: string, privateKeyPem: string): string { + return ecSign('sha256', Buffer.from(hashBase64, 'base64'), createPrivateKey(privateKeyPem)).toString('base64'); +} + +/** + * XAdES signed properties. ZATCA's SDK digests this element as text + * (SHA-256 → hex → base64); the same text is embedded in the document. + */ +export function signedProperties(signingTime: string, cert: CertInfo): string { + return ` + + ${signingTime} + + + + + ${certificateHash(cert.base64)} + + + ${esc(cert.issuer)} + ${cert.serialNumber} + + + + +`; +} + +function ublExtensions(hash: string, signature: string, cert: CertInfo, props: string): string { + const propsHash = Buffer.from(createHash('sha256').update(props, 'utf8').digest('hex')).toString('base64'); + return `urn:oasis:names:specification:ubl:dsig:enveloped:xades` + + `` + + `urn:oasis:names:specification:ubl:signature:1urn:oasis:names:specification:ubl:signature:Invoice` + + `` + + `` + + `` + + `not(//ancestor-or-self::ext:UBLExtensions)` + + `not(//ancestor-or-self::cac:Signature)` + + `not(//ancestor-or-self::cac:AdditionalDocumentReference[cbc:ID='QR'])` + + `` + + `${hash}` + + `` + + `${propsHash}` + + `${signature}` + + `${cert.base64}` + + `${props}` + + ``; +} + +export interface SignedInvoice { xml: string; hash: string; signature: string; qr: string } + +/** + * Produces the final e-invoice: hash of the unsigned document, ECDSA signature, + * XAdES block, and the phase-2 QR code (tags 1–9). + */ +export function signInvoice(inv: EInvoice, key: { privateKeyPem: string; cert: CertInfo }, signingTime: string): SignedInvoice { + const hash = invoiceHash(buildInvoiceXml(inv)); + const signature = signHash(hash, key.privateKeyPem); + const qr = encodeQr({ + sellerName: inv.seller.name, vatNumber: inv.seller.vatNumber, timestamp: `${inv.issueDate}T${inv.issueTime}`, + total: inv.totals.taxInclusive, vatTotal: inv.totals.taxAmount, invoiceHash: hash, signature, + publicKey: key.cert.publicKeyDer, + // Tag 9 is for simplified invoices only (standard ones get their QR from ZATCA at clearance). + certificateSignature: inv.subtype.startsWith('02') ? key.cert.signature : undefined, + }); + const xml = buildInvoiceXml(inv, { ublExtensions: ublExtensions(hash, signature, key.cert, signedProperties(signingTime, key.cert)), qr }); + return { xml, hash, signature, qr }; +} diff --git a/alshuyukh-accounting/apps/api/src/modules/zatca/worker.ts b/alshuyukh-accounting/apps/api/src/modules/zatca/worker.ts new file mode 100644 index 000000000000..18f47970e84b --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/zatca/worker.ts @@ -0,0 +1,41 @@ +import type pg from 'pg'; +import type { FastifyBaseLogger } from 'fastify'; +import { withTx } from '../../db/tx.js'; +import { submitInvoice } from './service.js'; + +/** + * Background submitter: reports simplified invoices (deadline: 24 hours) and + * clears standard ones that could not be cleared when issued. Failures stay + * PENDING with exponential backoff; rejections are final and shown in the UI. + */ +/** One pass: submits up to 20 due e-invoices, each in its own tenant's context. Returns how many were attempted. */ +export async function runDueSubmissions(pool: pg.Pool, log: Pick): Promise { + const { rows } = await pool.query<{ tenant_id: string; id: string }>(`SELECT tenant_id, id FROM zatca_due_invoices(20)`); + for (const r of rows) { + const ctx = { tenantId: r.tenant_id, userId: null }; + try { + await submitInvoice((fn) => withTx(pool, ctx, fn), ctx, r.id, { due: true }); + } catch (e) { + log.warn({ err: e, zatcaInvoiceId: r.id }, 'zatca submission failed'); + } + } + return rows.length; +} + +export function startZatcaWorker(pool: pg.Pool, log: FastifyBaseLogger, intervalSeconds: number) { + let running = false; + const tick = async () => { + if (running) return; + running = true; + try { + await runDueSubmissions(pool, log); + } catch (e) { + log.error({ err: e }, 'zatca worker tick failed'); + } finally { + running = false; + } + }; + const timer = setInterval(tick, intervalSeconds * 1000); + timer.unref(); + return () => clearInterval(timer); +} diff --git a/alshuyukh-accounting/apps/api/src/modules/zatca/xml.ts b/alshuyukh-accounting/apps/api/src/modules/zatca/xml.ts new file mode 100644 index 000000000000..8e79b579c3e9 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/modules/zatca/xml.ts @@ -0,0 +1,118 @@ +import { Decimal } from '../../lib/money.js'; + +/** + * UBL 2.1 e-invoice in the ZATCA (KSA) profile. The XML is generated without + * whitespace between elements, so the canonical form used for the invoice + * hash is unambiguous: removing the signature parts leaves no stray text. + */ + +export type TypeCode = '388' | '381' | '383'; // invoice, credit note, debit note +export interface Address { street: string; buildingNumber: string; additionalNumber?: string | null; district: string; city: string; postalCode: string; country: string } +export interface Party { name: string; vatNumber?: string | null; crn?: string | null; address?: Address | null } +export interface Line { + id: number; name: string; quantity: string; unitCode: string; netAmount: string; + vatCategory: 'S' | 'Z' | 'E' | 'O'; vatRate: string; vatAmount: string; exemptionCode?: string | null; exemptionReason?: string | null; +} +export interface TaxSubtotal { vatCategory: Line['vatCategory']; vatRate: string; taxableAmount: string; taxAmount: string; exemptionCode?: string | null; exemptionReason?: string | null } +export interface EInvoice { + number: string; uuid: string; issueDate: string; issueTime: string; + typeCode: TypeCode; subtype: string; icv: number; previousHash: string; currency: string; + seller: Party & { address: Address; vatNumber: string }; + buyer: Party | null; + supplyDate?: string | null; + /** Credit/debit notes: the original invoice and the reason. */ + billingReference?: { number: string; date: string } | null; + reason?: string | null; + paymentMeansCode: string; + lines: Line[]; + taxSubtotals: TaxSubtotal[]; + totals: { lineExtension: string; taxExclusive: string; taxAmount: string; taxInclusive: string; payable: string }; +} + +const NS = [ + 'xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"', + 'xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"', + 'xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"', + 'xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2"', +].join(' '); + +export const esc = (s: string) => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +const el = (name: string, value: string, attrs = '') => `<${name}${attrs ? ` ${attrs}` : ''}>${esc(value)}`; +const amt = (name: string, value: string, currency: string) => el(name, value, `currencyID="${currency}"`); +const percent = (rate: string) => new Decimal(rate).times(100).toFixed(2); + +function address(a: Address) { + return '' + + el('cbc:StreetName', a.street) + el('cbc:BuildingNumber', a.buildingNumber) + + (a.additionalNumber ? el('cbc:PlotIdentification', a.additionalNumber) : '') + + el('cbc:CitySubdivisionName', a.district) + el('cbc:CityName', a.city) + el('cbc:PostalZone', a.postalCode) + + `${el('cbc:IdentificationCode', a.country)}`; +} + +function party(tag: string, p: Party) { + return `` + + (p.crn ? `${el('cbc:ID', p.crn, 'schemeID="CRN"')}` : '') + + (p.address ? address(p.address) : '') + + (p.vatNumber ? `${el('cbc:CompanyID', p.vatNumber)}${el('cbc:ID', 'VAT')}` : '') + + (p.name ? `${el('cbc:RegistrationName', p.name)}` : '') + + ``; +} + +function taxCategory(tag: string, c: { vatCategory: string; vatRate: string; exemptionCode?: string | null; exemptionReason?: string | null }) { + return `${el('cbc:ID', c.vatCategory)}${el('cbc:Percent', percent(c.vatRate))}` + + (c.vatCategory !== 'S' && c.exemptionCode ? el('cbc:TaxExemptionReasonCode', c.exemptionCode) + el('cbc:TaxExemptionReason', c.exemptionReason ?? c.exemptionCode) : '') + + `${el('cbc:ID', 'VAT')}`; +} + +/** + * Unit price such that quantity × price = net exactly. When the division is not + * exact the net is expressed per BaseQuantity = quantity, which keeps the + * ZATCA rule "line net = quantity × price / base quantity" free of rounding. + */ +function price(l: Line, currency: string) { + const q = new Decimal(l.quantity); + const unit = new Decimal(l.netAmount).dividedBy(q); + if (unit.decimalPlaces() <= 4 && unit.times(q).equals(l.netAmount)) return `${amt('cbc:PriceAmount', unit.toFixed(Math.max(2, unit.decimalPlaces())), currency)}`; + return `${amt('cbc:PriceAmount', l.netAmount, currency)}${el('cbc:BaseQuantity', q.toFixed(), `unitCode="${l.unitCode}"`)}`; +} + +/** + * The invoice without signature or QR. `signatureBlock` and `qr` are inserted + * later; they are excluded from the hash, so the hash can be computed first. + */ +export function buildInvoiceXml(inv: EInvoice, parts: { ublExtensions?: string; qr?: string } = {}): string { + const c = inv.currency; + const body = [ + parts.ublExtensions ?? '', + el('cbc:ProfileID', 'reporting:1.0'), + el('cbc:ID', inv.number), + el('cbc:UUID', inv.uuid), + el('cbc:IssueDate', inv.issueDate), + el('cbc:IssueTime', inv.issueTime), + el('cbc:InvoiceTypeCode', inv.typeCode, `name="${inv.subtype}"`), + el('cbc:DocumentCurrencyCode', c), + el('cbc:TaxCurrencyCode', c), + inv.billingReference ? `${el('cbc:ID', inv.billingReference.number)}${el('cbc:IssueDate', inv.billingReference.date)}` : '', + `${el('cbc:ID', 'ICV')}${el('cbc:UUID', String(inv.icv))}`, + `${el('cbc:ID', 'PIH')}${el('cbc:EmbeddedDocumentBinaryObject', inv.previousHash, 'mimeCode="text/plain"')}`, + parts.qr ? `${el('cbc:ID', 'QR')}${el('cbc:EmbeddedDocumentBinaryObject', parts.qr, 'mimeCode="text/plain"')}` : '', + parts.ublExtensions ? `${el('cbc:ID', 'urn:oasis:names:specification:ubl:signature:Invoice')}${el('cbc:SignatureMethod', 'urn:oasis:names:specification:ubl:dsig:enveloped:xades')}` : '', + party('AccountingSupplierParty', inv.seller), + // A simplified invoice may have no identified buyer; the element itself is mandatory. + party('AccountingCustomerParty', inv.buyer ?? { name: '' }), + inv.supplyDate ? `${el('cbc:ActualDeliveryDate', inv.supplyDate)}` : '', + `${el('cbc:PaymentMeansCode', inv.paymentMeansCode)}${inv.reason ? el('cbc:InstructionNote', inv.reason) : ''}`, + // Two TaxTotals: the first in the tax currency (SAR), the second with the breakdown. + `${amt('cbc:TaxAmount', inv.totals.taxAmount, c)}`, + `${amt('cbc:TaxAmount', inv.totals.taxAmount, c)}${inv.taxSubtotals.map((s) => + `${amt('cbc:TaxableAmount', s.taxableAmount, c)}${amt('cbc:TaxAmount', s.taxAmount, c)}${taxCategory('TaxCategory', s)}`).join('')}`, + `${amt('cbc:LineExtensionAmount', inv.totals.lineExtension, c)}${amt('cbc:TaxExclusiveAmount', inv.totals.taxExclusive, c)}` + + `${amt('cbc:TaxInclusiveAmount', inv.totals.taxInclusive, c)}${amt('cbc:AllowanceTotalAmount', '0.00', c)}${amt('cbc:PrepaidAmount', '0.00', c)}` + + `${amt('cbc:PayableAmount', inv.totals.payable, c)}`, + ...inv.lines.map((l) => `${el('cbc:ID', String(l.id))}${el('cbc:InvoicedQuantity', new Decimal(l.quantity).toFixed(), `unitCode="${l.unitCode}"`)}` + + `${amt('cbc:LineExtensionAmount', l.netAmount, c)}` + + `${amt('cbc:TaxAmount', l.vatAmount, c)}${amt('cbc:RoundingAmount', new Decimal(l.netAmount).plus(l.vatAmount).toFixed(2), c)}` + + `${el('cbc:Name', l.name)}${taxCategory('ClassifiedTaxCategory', l)}${price(l, c)}`), + ].join(''); + return `\n${body}`; +} diff --git a/alshuyukh-accounting/apps/api/src/plugins/auth.ts b/alshuyukh-accounting/apps/api/src/plugins/auth.ts new file mode 100644 index 000000000000..dc8e6c9279d3 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/plugins/auth.ts @@ -0,0 +1,119 @@ +import type { FastifyInstance, FastifyRequest, preHandlerAsyncHookHandler } from 'fastify'; +import fp from 'fastify-plugin'; +import { withTx, type Db } from '../db/tx.js'; +import { AppError, forbidden, unauthorized } from '../lib/errors.js'; +import type { AuthContext } from '../types.js'; +import { countApiCall, currentSubscription, monthStart, pendingApiCalls } from '../modules/subscriptions/service.js'; + +const READS = new Set(['GET', 'HEAD', 'OPTIONS']); +/** Paths that stay usable when the subscription has expired (sign in/out, renewing). */ +const ALWAYS_ALLOWED = /^\/api\/(auth|subscription)(\/|$|\?)/; +// Not counted or limited: signing in, the subscription page itself, and platform administration. +const UNMETERED = /^\/api\/(auth|admin|subscription)(\/|$|\?)/; + +export async function loadPermissions(db: Db, tenantId: string, userId: string): Promise> { + const { rows } = await db.query<{ code: string }>( + `SELECT DISTINCT p.code + FROM user_roles ur + JOIN roles r ON r.id = ur.role_id AND r.deleted_at IS NULL + JOIN role_permissions rp ON rp.role_id = r.id + JOIN permissions p ON p.id = rp.permission_id + WHERE ur.tenant_id = $1 AND ur.user_id = $2`, + [tenantId, userId], + ); + return new Set(rows.map((r) => r.code)); +} + +/** + * Verifies the access token, then re-checks the database on every request: + * the session is not revoked, the user is active, the membership in the + * token's tenant is active, and the tenant is not suspended. Permissions are + * loaded fresh, so role changes apply immediately. + */ +async function authenticate(this: FastifyInstance, req: FastifyRequest): Promise { + if (req.auth) return; + const header = req.headers.authorization; + if (!header?.startsWith('Bearer ')) throw unauthorized(); + const claims = await this.deps.tokens.verifyAccess(header.slice(7)); + + req.auth = await withTx(this.deps.pool, { tenantId: claims.tid, userId: claims.sub }, async (db) => { + const { rows } = await db.query<{ + user_status: string; user_deleted: Date | null; is_platform_admin: boolean; + member_status: string; is_owner: boolean; tenant_status: string; + session_revoked: Date | null; session_expires: Date; session_tenant: string; + }>( + `SELECT u.status AS user_status, u.deleted_at AS user_deleted, u.is_platform_admin, + ut.status AS member_status, ut.is_owner, t.status AS tenant_status, + s.revoked_at AS session_revoked, s.expires_at AS session_expires, s.tenant_id AS session_tenant + FROM users u + JOIN user_tenants ut ON ut.user_id = u.id AND ut.tenant_id = $2 + JOIN tenants t ON t.id = ut.tenant_id + JOIN user_sessions s ON s.id = $3 AND s.user_id = u.id + WHERE u.id = $1`, + [claims.sub, claims.tid, claims.sid], + ); + const r = rows[0]; + if (!r || r.session_revoked || r.session_expires < new Date() || r.session_tenant !== claims.tid) { + throw unauthorized('Session is no longer valid'); + } + if (r.user_status !== 'ACTIVE' || r.user_deleted) throw unauthorized('Account is disabled'); + if (r.member_status !== 'ACTIVE') throw forbidden('Your access to this organization is disabled'); + if (r.tenant_status !== 'ACTIVE') throw new AppError(403, 'TENANT_SUSPENDED', 'This organization is suspended'); + + // Subscription: an expired one leaves the data readable but refuses changes. + const sub = await currentSubscription(db, claims.tid); + const url = req.url; + const adminRequest = r.is_platform_admin && url.startsWith('/api/admin'); + if (!sub.writable && !READS.has(req.method) && !ALWAYS_ALLOWED.test(url) && !adminRequest) { + throw new AppError(402, 'SUBSCRIPTION_INACTIVE', 'انتهى الاشتراك؛ البيانات متاحة للاطلاع فقط حتى التجديد'); + } + // Monthly API call allowance (counted in memory, flushed periodically). + if (!UNMETERED.test(url)) { + const limit = sub.limits.max_api_calls_per_month; + if (limit !== null) { + const { rows: [u] } = await db.query<{ quantity: string }>( + `SELECT quantity::text FROM usage_records WHERE tenant_id = $1 AND metric = 'API_CALLS' AND period = $2::date`, [claims.tid, monthStart()]); + if (Number(u?.quantity ?? 0) + pendingApiCalls(claims.tid) >= limit) { + throw new AppError(429, 'PLAN_LIMIT_REACHED', 'بلغت المنشأة الحد الشهري لطلبات API في باقتها'); + } + } + countApiCall(claims.tid); + } + + const ctx: AuthContext = { + userId: claims.sub, + tenantId: claims.tid, + sessionId: claims.sid, + isOwner: r.is_owner, + isPlatformAdmin: r.is_platform_admin, + permissions: await loadPermissions(db, claims.tid, claims.sub), + subscription: { state: sub.state, writable: sub.writable }, + }; + return ctx; + }); +} + +export default fp(async (app) => { + app.decorate('authenticate', authenticate); + app.decorateRequest('auth', null); + app.decorateRequest('tenantTx', function (this: FastifyRequest, fn: (db: Db) => Promise) { + if (!this.auth) throw unauthorized(); + return withTx(app.deps.pool, { tenantId: this.auth.tenantId, userId: this.auth.userId }, fn); + }); + app.decorateRequest('auditMeta', function (this: FastifyRequest) { + return { ip: this.ip, userAgent: this.headers['user-agent'] ?? null, requestId: String(this.id) }; + }); +}); + +/** Route guard: authenticated and holding every listed permission. */ +export function requirePermission(app: FastifyInstance, ...codes: string[]): preHandlerAsyncHookHandler { + return async (req) => { + await app.authenticate(req); + const missing = codes.filter((c) => !req.auth!.permissions.has(c)); + if (missing.length) throw forbidden(`Missing permission: ${missing.join(', ')}`); + }; +} + +export function requireAuth(app: FastifyInstance): preHandlerAsyncHookHandler { + return async (req) => app.authenticate(req); +} diff --git a/alshuyukh-accounting/apps/api/src/server.ts b/alshuyukh-accounting/apps/api/src/server.ts new file mode 100644 index 000000000000..18862ff5711b --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/server.ts @@ -0,0 +1,21 @@ +import { buildApp } from './app.js'; +import { loadEnv } from './config/env.js'; +import { createPool } from './db/pool.js'; +import { startZatcaWorker } from './modules/zatca/worker.js'; + +const env = loadEnv(); +const pool = createPool(env.DATABASE_URL, { max: env.DB_POOL_MAX, statementTimeoutMs: env.DB_STATEMENT_TIMEOUT_MS }); +const app = await buildApp({ env, pool }); + +const stopWorker = env.ZATCA_WORKER === 'on' ? startZatcaWorker(pool, app.log, env.ZATCA_WORKER_INTERVAL_SECONDS) : () => undefined; + +const shutdown = async () => { + stopWorker(); + await app.close(); + await pool.end(); + process.exit(0); +}; +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); + +await app.listen({ port: env.PORT, host: env.HOST }); diff --git a/alshuyukh-accounting/apps/api/src/types.ts b/alshuyukh-accounting/apps/api/src/types.ts new file mode 100644 index 000000000000..6a883db29473 --- /dev/null +++ b/alshuyukh-accounting/apps/api/src/types.ts @@ -0,0 +1,35 @@ +import type pg from 'pg'; +import type { Env } from './config/env.js'; +import type { Db } from './db/tx.js'; +import type { TokenService } from './lib/tokens.js'; +import type { AuditMeta } from './modules/audit/audit.service.js'; + +export interface Deps { + env: Env; + pool: pg.Pool; + tokens: TokenService; +} + +export interface AuthContext { + userId: string; + tenantId: string; + sessionId: string; + isOwner: boolean; + isPlatformAdmin: boolean; + permissions: ReadonlySet; + /** Subscription state; writes are refused when it is not writable. */ + subscription: { state: string; writable: boolean }; +} + +declare module 'fastify' { + interface FastifyInstance { + deps: Deps; + authenticate: (req: FastifyRequest) => Promise; + } + interface FastifyRequest { + auth: AuthContext | null; + /** Runs fn in a transaction scoped to the authenticated user's tenant. */ + tenantTx(fn: (db: Db) => Promise): Promise; + auditMeta(): AuditMeta; + } +} diff --git a/alshuyukh-accounting/apps/api/test/admin.test.ts b/alshuyukh-accounting/apps/api/test/admin.test.ts new file mode 100644 index 000000000000..17b3de5f4b13 --- /dev/null +++ b/alshuyukh-accounting/apps/api/test/admin.test.ts @@ -0,0 +1,215 @@ +import { readdirSync } from 'node:fs'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { withTx } from '../src/db/tx.js'; +import { addMember, client, login, register, setupApp, type Session, type TestContext } from './helpers.js'; + +const latestMigration = readdirSync(new URL('../src/db/migrations/', import.meta.url)).filter((f) => f.endsWith('.sql')).sort().at(-1); +let t: TestContext; +let adminSession: Session; +let admin: ReturnType; +let tenant: Session; + +beforeAll(async () => { + t = await setupApp(); + adminSession = await register(t.app, { tenantName: 'مشغل المنصة' }); + await t.ownerPool.query(`UPDATE users SET is_platform_admin = true WHERE id = $1`, [adminSession.userId]); + admin = client(t.app, adminSession.token); + tenant = await register(t.app, { tenantName: 'شركة العميل الأولى' }); +}); +afterAll(async () => { await t.close(); }); + +const planBody = (over: Record = {}) => ({ + code: `PRO_${Math.floor(Math.random() * 1e6)}`, nameAr: 'الاحترافية', priceMonthly: '299.00', priceYearly: '2990.00', trialDays: 7, graceDays: 5, + max_users: 10, max_companies: 3, max_branches: 10, max_warehouses: 10, max_products: null, max_invoices_per_month: null, max_storage_mb: 5120, + max_api_calls_per_month: null, isPublic: true, isActive: true, isDefault: false, sortOrder: 20, ...over, +}); + +describe('access', () => { + it('is for platform administrators only', async () => { + const owner = client(t.app, tenant.token); + for (const path of ['/api/admin/overview', '/api/admin/tenants', '/api/admin/users', '/api/admin/plans', '/api/admin/errors', '/api/admin/health']) { + expect((await owner.get(path)).statusCode).toBe(403); + } + expect((await owner.post('/api/admin/plans', planBody())).statusCode).toBe(403); + }); + + it('keeps the database closed to tenants: plans and platform tables', async () => { + const asTenant = (fn: Parameters>[2]) => withTx(t.pool, { tenantId: tenant.tenantId, userId: tenant.userId }, fn); + await expect(asTenant((db) => db.query(`INSERT INTO plans (code, name_ar) VALUES ('HACK', 'اختراق')`))).rejects.toThrow(/row-level security/); + expect((await asTenant((db) => db.query(`UPDATE plans SET price_monthly = 0`))).rowCount).toBe(0); + await asTenant((db) => db.query(`INSERT INTO system_errors (message) VALUES ('visible only to admins')`)); + expect((await asTenant((db) => db.query(`SELECT * FROM system_errors`))).rowCount).toBe(0); + // Without the platform flag a tenant still sees only itself. + expect((await asTenant((db) => db.query(`SELECT * FROM subscriptions`))).rows.every((r) => r.tenant_id === tenant.tenantId)).toBe(true); + }); + + it('stops a tenant from changing its own subscription, status or platform access in SQL', async () => { + const asTenant = (fn: Parameters>[2]) => withTx(t.pool, { tenantId: tenant.tenantId, userId: tenant.userId }, fn); + expect((await asTenant((db) => db.query(`UPDATE subscriptions SET current_period_end = now() + interval '10 years' WHERE tenant_id = $1`, [tenant.tenantId]))).rowCount).toBe(0); + await expect(asTenant((db) => db.query(`INSERT INTO subscriptions (tenant_id, plan_id, status, current_period_start, current_period_end) + SELECT $1, id, 'ACTIVE', now(), now() + interval '10 years' FROM plans LIMIT 1`, [tenant.tenantId]))).rejects.toThrow(/row-level security/); + await expect(asTenant((db) => db.query(`INSERT INTO billing_events (tenant_id, event_type) VALUES ($1, 'PAYMENT_RECORDED')`, [tenant.tenantId]))).rejects.toThrow(/row-level security/); + await expect(asTenant((db) => db.query(`INSERT INTO tenant_feature_flags (tenant_id, flag_key, enabled) VALUES ($1, 'zatca_einvoicing', true)`, [tenant.tenantId]))).rejects.toThrow(/row-level security/); + await expect(asTenant((db) => db.query(`UPDATE tenants SET status = 'SUSPENDED' WHERE id = $1`, [tenant.tenantId]))).rejects.toThrow(/platform administrator/); + await expect(asTenant((db) => db.query(`UPDATE users SET is_platform_admin = true WHERE id = $1`, [tenant.userId]))).rejects.toThrow(/platform administrator/); + // Ordinary profile edits still work. + expect((await asTenant((db) => db.query(`UPDATE tenants SET name = name WHERE id = $1`, [tenant.tenantId]))).rowCount).toBe(1); + }); +}); + +describe('organizations', () => { + it('lists and searches every organization with its subscription', async () => { + const list = (await admin.get('/api/admin/tenants?search=العميل الأولى')).json(); + expect(list.data).toEqual([expect.objectContaining({ id: tenant.tenantId, status: 'ACTIVE', subscriptionState: 'TRIALING', ownerEmail: tenant.email, users: 1, companies: 1 })]); + const byEmail = (await admin.get(`/api/admin/tenants?search=${encodeURIComponent(tenant.email)}`)).json(); + expect(byEmail.total).toBe(1); + const detail = (await admin.get(`/api/admin/tenants/${tenant.tenantId}`)).json(); + expect(detail).toMatchObject({ name: 'شركة العميل الأولى', subscription: { planCode: 'TRIAL', state: 'TRIALING' }, usage: { max_users: 1 } }); + expect(detail.members).toEqual([expect.objectContaining({ email: tenant.email, isOwner: true })]); + expect(detail.billingEvents.map((e: { eventType: string }) => e.eventType)).toEqual(['TRIAL_STARTED']); + }); + + it('creates an organization whose owner must change the temporary password', async () => { + const plan = (await admin.post('/api/admin/plans', planBody())).json(); + const email = `new-owner-${Date.now()}@example.test`; + const res = await admin.post('/api/admin/tenants', { tenantName: 'مؤسسة جديدة', companyName: 'مؤسسة جديدة للتجارة', ownerName: 'سعد', ownerEmail: email, planId: plan.id }); + expect(res.statusCode).toBe(201); + const { tenantId, temporaryPassword } = res.json(); + const owner = await login(t.app, email, temporaryPassword); + const me = (await client(t.app, owner.token).get('/api/auth/me')).json(); + expect(me).toMatchObject({ user: { mustChangePassword: true }, tenant: { id: tenantId, isOwner: true }, subscription: { state: 'TRIALING', planName: 'الاحترافية' } }); + expect((await admin.post('/api/admin/tenants', { tenantName: 'مكرر', companyName: 'مكرر', ownerName: 'سعد', ownerEmail: email })).json().error.code).toBe('EMAIL_TAKEN'); + }); + + it('suspends and reactivates an organization', async () => { + const s = await register(t.app); + const member = await addMember(t.app, s, 'ACCOUNTANT'); + expect((await admin.post(`/api/admin/tenants/${s.tenantId}/suspend`, { reason: 'عدم السداد' })).json()).toMatchObject({ status: 'SUSPENDED' }); + const blocked = await client(t.app, member.token).get('/api/companies'); + expect(blocked.statusCode).toBe(403); + expect(blocked.json().error.code).toBe('TENANT_SUSPENDED'); + await admin.post(`/api/admin/tenants/${s.tenantId}/activate`, { reason: 'تم السداد' }); + expect((await client(t.app, member.token).get('/api/companies')).statusCode).toBe(200); + // The organization sees what happened in its own audit log. + const audit = (await client(t.app, s.token).get('/api/audit-logs?limit=50')).json().data; + expect(audit.filter((a: { action: string; entityType: string }) => a.entityType === 'tenant' && a.action === 'SETTINGS_CHANGE')).toHaveLength(2); + expect((await admin.post(`/api/admin/tenants/${adminSession.tenantId}/suspend`, { reason: 'خطأ' })).json().error.code).toBe('OWN_TENANT'); + }); + + it('changes plan, limits and period, and records every step', async () => { + const s = await register(t.app); + const plan = (await admin.post('/api/admin/plans', planBody({ priceMonthly: '150.00', priceYearly: '1500.00' }))).json(); + const yearly = (await admin.post(`/api/admin/tenants/${s.tenantId}/subscription/plan`, { planId: plan.id, billingCycle: 'YEARLY' })).json(); + expect(yearly).toMatchObject({ planCode: plan.code, billingCycle: 'YEARLY', limits: { max_users: 10, max_products: null } }); + const limited = (await admin.post(`/api/admin/tenants/${s.tenantId}/subscription/limits`, { overrides: { max_users: 25, max_products: 50 } })).json(); + expect(limited.limits).toMatchObject({ max_users: 25, max_products: 50, max_companies: 3 }); + expect((await admin.post(`/api/admin/tenants/${s.tenantId}/subscription/limits`, { overrides: { max_unknown: 1 } })).statusCode).toBe(400); + const before = new Date(limited.periodEnd).getTime(); + const extended = (await admin.post(`/api/admin/tenants/${s.tenantId}/subscription/extend`, { days: 10, reason: 'تمديد التجربة' })).json(); + expect(new Date(extended.periodEnd).getTime() - before).toBe(10 * 86_400_000); + const paid = (await admin.post(`/api/admin/tenants/${s.tenantId}/subscription/payment`, { amount: '1500.00', reference: 'INV-2026-001' })).json(); + expect(paid).toMatchObject({ status: 'ACTIVE', state: 'ACTIVE' }); + expect(new Date(paid.periodEnd).getTime()).toBeGreaterThan(Date.now() + 360 * 86_400_000); // one yearly cycle + const detail = (await admin.get(`/api/admin/tenants/${s.tenantId}`)).json(); + expect(detail.subscription.items).toEqual([expect.objectContaining({ unitPrice: '1500.00', code: plan.code })]); + expect(detail.billingEvents.map((e: { eventType: string }) => e.eventType)).toEqual(['PAYMENT_RECORDED', 'PERIOD_EXTENDED', 'LIMITS_CHANGED', 'PLAN_CHANGED', 'TRIAL_STARTED']); + + const cancelled = (await admin.post(`/api/admin/tenants/${s.tenantId}/subscription/cancel`, { reason: 'طلب العميل' })).json(); + expect(cancelled).toMatchObject({ state: 'NONE', writable: false }); + expect((await client(t.app, s.token).post('/api/products', { nameAr: 'بعد الإلغاء' })).json().error.code).toBe('SUBSCRIPTION_INACTIVE'); + const restarted = (await admin.post(`/api/admin/tenants/${s.tenantId}/subscription/plan`, { planId: plan.id })).json(); + expect(restarted).toMatchObject({ state: 'TRIALING', writable: true }); + }); +}); + +describe('plans', () => { + it('keeps exactly one active default plan', async () => { + const plans = (await admin.get('/api/admin/plans')).json().data; + const trial = plans.find((p: { code: string }) => p.code === 'TRIAL'); + expect(trial).toMatchObject({ isDefault: true, priceMonthly: '0.00' }); + expect((await admin.patch(`/api/admin/plans/${trial.id}`, { isActive: false })).json().error.code).toBe('DEFAULT_PLAN'); + expect((await admin.patch(`/api/admin/plans/${trial.id}`, { isDefault: false })).json().error.code).toBe('DEFAULT_PLAN'); + const next = (await admin.post('/api/admin/plans', planBody({ isDefault: true }))).json(); + const after = (await admin.get('/api/admin/plans')).json().data; + expect(after.filter((p: { isDefault: boolean }) => p.isDefault).map((p: { id: string }) => p.id)).toEqual([next.id]); + await admin.patch(`/api/admin/plans/${trial.id}`, { isDefault: true }); + expect((await admin.post('/api/admin/plans', planBody({ priceMonthly: '-1' }))).statusCode).toBe(400); + expect((await admin.post('/api/admin/plans', planBody({ code: next.code }))).statusCode).toBe(409); + // Only public, active plans are offered to organizations. + await admin.patch(`/api/admin/plans/${next.id}`, { isPublic: false }); + const offered = (await client(t.app, tenant.token).get('/api/subscription/plans')).json().data.map((p: { id: string }) => p.id); + expect(offered).not.toContain(next.id); + }); +}); + +describe('users', () => { + it('disables, unlocks and grants platform access', async () => { + const s = await register(t.app); + const u = (await admin.get(`/api/admin/users?search=${encodeURIComponent(s.email)}`)).json(); + expect(u.data[0]).toMatchObject({ email: s.email, status: 'ACTIVE', isPlatformAdmin: false, tenants: [expect.objectContaining({ isOwner: true })] }); + await admin.post(`/api/admin/users/${s.userId}/status`, { status: 'DISABLED' }); + expect((await client(t.app, s.token).get('/api/companies')).statusCode).toBe(401); + await admin.post(`/api/admin/users/${s.userId}/status`, { status: 'ACTIVE' }); + expect((await client(t.app, s.token).get('/api/companies')).statusCode).toBe(200); + await t.ownerPool.query(`INSERT INTO login_failures (user_id, ip, failures, locked_until) VALUES ($1, '127.0.0.1', 9, now() + interval '1 hour')`, [s.userId]); + await expect(login(t.app, s.email)).rejects.toThrow(); + expect((await admin.get(`/api/admin/users?search=${encodeURIComponent(s.email)}`)).json().data[0]).toMatchObject({ failedLogins: 9, lockedUntil: expect.any(String) }); + await admin.post(`/api/admin/users/${s.userId}/unlock`); + expect((await login(t.app, s.email)).token).toBeTruthy(); + await admin.post(`/api/admin/users/${s.userId}/platform-admin`, { grant: true }); + expect((await client(t.app, s.token).get('/api/admin/overview')).statusCode).toBe(200); + await admin.post(`/api/admin/users/${s.userId}/platform-admin`, { grant: false }); + expect((await client(t.app, s.token).get('/api/admin/overview')).statusCode).toBe(403); + expect((await admin.post(`/api/admin/users/${adminSession.userId}/status`, { status: 'DISABLED' })).json().error.code).toBe('SELF'); + expect((await admin.post(`/api/admin/users/${adminSession.userId}/platform-admin`, { grant: false })).json().error.code).toBe('SELF'); + }); +}); + +describe('feature flags', () => { + it('combines the global default with per-organization overrides', async () => { + const s = await register(t.app); + const api = client(t.app, s.token); + expect((await admin.post('/api/admin/feature-flags', { key: 'beta_dashboard', nameAr: 'لوحة تجريبية', enabled: false })).statusCode).toBe(201); + expect((await api.get('/api/auth/me')).json().features).toMatchObject({ beta_dashboard: false, zatca_einvoicing: true }); + await admin.put(`/api/admin/tenants/${s.tenantId}/features/beta_dashboard`, { enabled: true }); + await admin.put(`/api/admin/tenants/${s.tenantId}/features/zatca_einvoicing`, { enabled: false }); + expect((await api.get('/api/auth/me')).json().features).toMatchObject({ beta_dashboard: true, zatca_einvoicing: false }); + const blocked = await api.post('/api/zatca/devices', { name: 'وحدة', environment: 'DEVELOPER', businessCategory: 'تجارة' }); + expect(blocked.statusCode).toBe(403); + await admin.put(`/api/admin/tenants/${s.tenantId}/features/zatca_einvoicing`, { enabled: null }); + expect((await api.get('/api/auth/me')).json().features.zatca_einvoicing).toBe(true); + const flags = (await admin.get('/api/admin/feature-flags')).json().data; + expect(flags.find((f: { key: string }) => f.key === 'beta_dashboard')).toMatchObject({ overrides: 1 }); + }); +}); + +describe('monitoring', () => { + it('reports revenue, usage, logs, errors and health', async () => { + const plan = (await admin.post('/api/admin/plans', planBody({ priceMonthly: '299.00' }))).json(); + await admin.post(`/api/admin/tenants/${tenant.tenantId}/subscription/plan`, { planId: plan.id, billingCycle: 'MONTHLY' }); + await admin.post(`/api/admin/tenants/${tenant.tenantId}/subscription/payment`, { amount: '299.00', reference: 'TRF-7781' }); + const overview = (await admin.get('/api/admin/overview')).json(); + expect(overview.tenants).toBeGreaterThanOrEqual(5); + expect(Number(overview.revenueThisMonth)).toBeGreaterThanOrEqual(1799); + expect(Number(overview.mrr)).toBeGreaterThanOrEqual(299); + expect(overview.subscriptions.ACTIVE).toBeGreaterThanOrEqual(1); + const revenue = (await admin.get('/api/admin/revenue')).json(); + expect(revenue.payments[0]).toMatchObject({ amount: '299.00', reference: 'TRF-7781' }); + expect(revenue.payments.map((x: { reference: string }) => x.reference)).toContain('INV-2026-001'); + expect(revenue.monthly.at(-1).payments).toBeGreaterThanOrEqual(2); + expect(revenue.byPlan.length).toBeGreaterThan(0); + const usage = (await admin.get('/api/admin/usage')).json().data; + expect(usage.find((u: { tenantId: string }) => u.tenantId === tenant.tenantId)).toMatchObject({ users: 1, companies: 1 }); + const subs = (await admin.get('/api/admin/subscriptions?state=ACTIVE')).json().data; + expect(subs.every((x: { state: string }) => x.state === 'ACTIVE')).toBe(true); + const plog = (await admin.get('/api/admin/platform-logs')).json().data; + expect(plog.map((l: { action: string }) => l.action)).toEqual(expect.arrayContaining(['SUSPEND', 'ACTIVATE', 'PAYMENT', 'PLAN_CHANGE', 'LIMITS_CHANGE', 'USER_STATUS'])); + const audit = (await admin.get(`/api/admin/audit-logs?tenantId=${tenant.tenantId}`)).json().data; + expect(audit.length).toBeGreaterThan(0); + expect(audit.every((a: { tenantId: string }) => a.tenantId === tenant.tenantId)).toBe(true); + const errors = (await admin.get('/api/admin/errors')).json().data; + expect(errors.some((e: { message: string }) => e.message === 'visible only to admins')).toBe(true); + const health = (await admin.get('/api/admin/health')).json(); + expect(health).toMatchObject({ status: 'ok', database: { migrations: expect.any(Number), lastMigration: latestMigration }, zatca: { pending: expect.any(Number) } }); + }); +}); diff --git a/alshuyukh-accounting/apps/api/test/audit.test.ts b/alshuyukh-accounting/apps/api/test/audit.test.ts new file mode 100644 index 000000000000..13941d9c6586 --- /dev/null +++ b/alshuyukh-accounting/apps/api/test/audit.test.ts @@ -0,0 +1,75 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { client, login, register, setupApp, type Session, type TestContext } from './helpers.js'; + +let t: TestContext; +let owner: Session; + +beforeAll(async () => { + t = await setupApp(); + owner = await register(t.app); +}); +afterAll(async () => { await t.close(); }); + +const logs = async (query = '') => (await client(t.app, owner.token).get(`/api/audit-logs?limit=200${query}`)).json().data as Array>; + +describe('audit log', () => { + it('records registration and login', async () => { + await login(t.app, owner.email); + const actions = (await logs()).map((l) => l.action); + expect(actions).toContain('REGISTER'); + expect(actions).toContain('LOGIN'); + }); + + it('records failed logins with the reason and request metadata', async () => { + await t.app.inject({ method: 'POST', url: '/api/auth/login', payload: { email: owner.email, password: 'Wrong-passw0rd' }, headers: { 'user-agent': 'audit-test' } }); + const { rows } = await t.ownerPool.query( + `SELECT new_values, user_agent, host(ip_address) AS ip FROM audit_logs WHERE action = 'LOGIN_FAILED' AND user_id = $1`, [owner.userId]); + expect(rows[0].new_values).toMatchObject({ reason: 'bad_password' }); + expect(rows[0].user_agent).toBe('audit-test'); + expect(rows[0].ip).toBe('127.0.0.1'); + }); + + it('records updates with old and new values', async () => { + const api = client(t.app, owner.token); + const company = (await api.get('/api/companies')).json().data[0]; + await api.patch(`/api/companies/${company.id}`, { city: 'الرياض' }); + const [entry] = await logs(`&entityId=${company.id}&action=UPDATE`); + expect(entry!.oldValues.city).toBeNull(); + expect(entry!.newValues.city).toBe('الرياض'); + expect(entry!.userId).toBe(owner.userId); + }); + + it('records settings and permission changes', async () => { + const api = client(t.app, owner.token); + await api.patch('/api/settings/tenant', { dateFormat: 'DD/MM/YYYY' }); + await api.post('/api/roles', { code: 'AUDIT_TEST', nameAr: 'اختبار', nameEn: 'Test', permissions: ['company.view'] }); + const actions = (await logs()).map((l) => l.action); + expect(actions).toContain('SETTINGS_CHANGE'); + expect(actions).toContain('PERMISSION_CHANGE'); + }); + + it('never stores password hashes or secrets', async () => { + await client(t.app, owner.token).post('/api/users', { + email: `aud-${Date.now()}@example.test`, fullName: 'Audit Member', initialPassword: 'Str0ng-Passw0rd!', + roleIds: [(await client(t.app, owner.token).get('/api/roles')).json().data.find((r: { code: string }) => r.code === 'VIEWER').id], + }); + const { rows } = await t.ownerPool.query(`SELECT old_values::text || new_values::text AS v FROM audit_logs WHERE tenant_id = $1`, [owner.tenantId]); + for (const r of rows) { + expect(r.v ?? '').not.toContain('argon2'); + expect(r.v ?? '').not.toContain('Str0ng-Passw0rd!'); + } + }); + + it('is append-only, even for the schema owner', async () => { + await expect(t.ownerPool.query(`UPDATE audit_logs SET action = 'TAMPERED' WHERE tenant_id = $1`, [owner.tenantId])).rejects.toThrow(/append-only/); + await expect(t.ownerPool.query(`DELETE FROM audit_logs WHERE tenant_id = $1`, [owner.tenantId])).rejects.toThrow(/append-only/); + }); + + it('paginates with a cursor', async () => { + const first = (await client(t.app, owner.token).get('/api/audit-logs?limit=2')).json(); + expect(first.data).toHaveLength(2); + expect(first.nextCursor).toBeTruthy(); + const second = (await client(t.app, owner.token).get(`/api/audit-logs?limit=2&cursor=${encodeURIComponent(first.nextCursor)}`)).json(); + expect(second.data[0].id).not.toBe(first.data[1].id); + }); +}); diff --git a/alshuyukh-accounting/apps/api/test/auth.test.ts b/alshuyukh-accounting/apps/api/test/auth.test.ts new file mode 100644 index 000000000000..afaa0b2b7e4d --- /dev/null +++ b/alshuyukh-accounting/apps/api/test/auth.test.ts @@ -0,0 +1,162 @@ +import { randomUUID } from 'node:crypto'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { PASSWORD, client, login, refreshCookieOf, register, setupApp, type TestContext } from './helpers.js'; + +let t: TestContext; +beforeAll(async () => { t = await setupApp(); }); +afterAll(async () => { await t.close(); }); + +const refresh = (cookie: string, csrf = true) => + t.app.inject({ + method: 'POST', url: '/api/auth/refresh', + cookies: { ash_rt: cookie }, + headers: csrf ? { 'x-csrf-protection': '1' } : {}, + }); + +describe('registration', () => { + it('creates the user, tenant, settings, default company, main branch and warehouse', async () => { + const s = await register(t.app, { companyName: 'شركة الشيوخ التجارية', vatNumber: '300000000000003' }); + const api = client(t.app, s.token); + + const me = (await api.get('/api/auth/me')).json(); + expect(me.tenant.id).toBe(s.tenantId); + expect(me.tenant.isOwner).toBe(true); + expect(me.roles.map((r: { code: string }) => r.code)).toEqual(['TENANT_OWNER']); + expect(me.permissions).toContain('subscription.manage'); + + const companies = (await api.get('/api/companies')).json().data; + expect(companies).toHaveLength(1); + expect(companies[0]).toMatchObject({ name: 'شركة الشيوخ التجارية', currency: 'SAR', timezone: 'Asia/Riyadh', country: 'SA', vatNumber: '300000000000003' }); + + const branches = (await api.get(`/api/companies/${companies[0].id}/branches`)).json().data; + expect(branches).toEqual([expect.objectContaining({ code: 'MAIN', isMain: true })]); + const warehouses = (await api.get(`/api/companies/${companies[0].id}/warehouses`)).json().data; + expect(warehouses).toEqual([expect.objectContaining({ code: 'MAIN', branchId: branches[0].id })]); + + const settings = (await api.get('/api/settings/tenant')).json(); + expect(settings).toMatchObject({ defaultCurrency: 'SAR', timezone: 'Asia/Riyadh', locale: 'ar' }); + }); + + it('stores an argon2id hash, never the plain password', async () => { + const s = await register(t.app); + const { rows: [u] } = await t.ownerPool.query('SELECT password_hash FROM users WHERE id = $1', [s.userId]); + expect(u.password_hash).toMatch(/^\$argon2id\$/); + expect(u.password_hash).not.toContain(PASSWORD); + }); + + it('rejects a duplicate e-mail, weak password and malformed VAT number', async () => { + const s = await register(t.app); + const base = { fullName: 'X Y', tenantName: 'Org', companyName: 'Co' }; + const dup = await t.app.inject({ method: 'POST', url: '/api/auth/register', payload: { ...base, email: s.email, password: PASSWORD } }); + expect(dup.statusCode).toBe(409); + const weak = await t.app.inject({ method: 'POST', url: '/api/auth/register', payload: { ...base, email: `w-${randomUUID()}@example.test`, password: 'short' } }); + expect(weak.statusCode).toBe(400); + expect(weak.json().error.code).toBe('VALIDATION_ERROR'); + const vat = await t.app.inject({ method: 'POST', url: '/api/auth/register', payload: { ...base, email: `v-${randomUUID()}@example.test`, password: PASSWORD, vatNumber: '123' } }); + expect(vat.statusCode).toBe(400); + }); +}); + +describe('login', () => { + it('logs in with valid credentials (e-mail is case-insensitive)', async () => { + const s = await register(t.app); + const l = await login(t.app, s.email.toUpperCase()); + expect(l.tenantId).toBe(s.tenantId); + expect((await client(t.app, l.token).get('/api/auth/me')).statusCode).toBe(200); + }); + + it('returns the same 401 for a wrong password and an unknown e-mail', async () => { + const s = await register(t.app); + const wrong = await t.app.inject({ method: 'POST', url: '/api/auth/login', payload: { email: s.email, password: 'Wrong-passw0rd' } }); + const unknown = await t.app.inject({ method: 'POST', url: '/api/auth/login', payload: { email: `nobody-${randomUUID()}@example.test`, password: 'Wrong-passw0rd' } }); + expect(wrong.statusCode).toBe(401); + expect(unknown.statusCode).toBe(401); + expect(wrong.json().error.message).toBe(unknown.json().error.message); + }); + + it('locks sign-in from an address after repeated failures, without revealing it', async () => { + const s = await register(t.app); + const login = (password: string, remoteAddress: string) => + t.app.inject({ method: 'POST', url: '/api/auth/login', remoteAddress, payload: { email: s.email, password } }); + for (let i = 0; i < 5; i++) await login('Wrong-passw0rd', '203.0.113.7'); + // Same answer as a wrong password: an attacker cannot tell the account exists or is locked. + const locked = await login(PASSWORD, '203.0.113.7'); + expect(locked.statusCode).toBe(401); + expect(locked.json().error.code).toBe('UNAUTHORIZED'); + // Failures from one address do not lock the owner out everywhere. + expect((await login(PASSWORD, '198.51.100.20')).statusCode).toBe(200); + }); + + it('ignores X-Forwarded-For unless a proxy is trusted', async () => { + const s = await register(t.app); + for (let i = 0; i < 5; i++) { + await t.app.inject({ method: 'POST', url: '/api/auth/login', remoteAddress: '203.0.113.8', + headers: { 'x-forwarded-for': `10.0.0.${i}` }, payload: { email: s.email, password: 'Wrong-passw0rd' } }); + } + const res = await t.app.inject({ method: 'POST', url: '/api/auth/login', remoteAddress: '203.0.113.8', + headers: { 'x-forwarded-for': '10.0.0.99' }, payload: { email: s.email, password: PASSWORD } }); + expect(res.statusCode).toBe(401); + }); + + it('rejects requests without a token or with a tampered token', async () => { + expect((await t.app.inject({ method: 'GET', url: '/api/companies' })).statusCode).toBe(401); + const s = await register(t.app); + const tampered = s.token.slice(0, -2) + (s.token.endsWith('AA') ? 'BB' : 'AA'); + expect((await client(t.app, tampered).get('/api/companies')).statusCode).toBe(401); + }); +}); + +describe('sessions', () => { + it('rotates the refresh token', async () => { + const s = await register(t.app); + const r1 = await refresh(s.refreshCookie); + expect(r1.statusCode).toBe(200); + const next = refreshCookieOf(r1); + expect(next).not.toBe(s.refreshCookie); + expect((await client(t.app, r1.json().accessToken).get('/api/auth/me')).statusCode).toBe(200); + }); + + it('requires the CSRF header on refresh', async () => { + const s = await register(t.app); + expect((await refresh(s.refreshCookie, false)).statusCode).toBe(400); + }); + + it('revokes all sessions when a rotated refresh token is reused', async () => { + const s = await register(t.app); + const r1 = await refresh(s.refreshCookie); + const newAccess = r1.json().accessToken; + const replay = await refresh(s.refreshCookie); + expect(replay.statusCode).toBe(401); + expect((await refresh(refreshCookieOf(r1))).statusCode).toBe(401); + expect((await client(t.app, newAccess).get('/api/auth/me')).statusCode).toBe(401); + expect((await client(t.app, s.token).get('/api/auth/me')).statusCode).toBe(401); + }); + + it('logout invalidates the access token immediately', async () => { + const s = await register(t.app); + const out = await t.app.inject({ method: 'POST', url: '/api/auth/logout', headers: { authorization: `Bearer ${s.token}`, 'x-csrf-protection': '1' } }); + expect(out.statusCode).toBe(204); + expect((await client(t.app, s.token).get('/api/auth/me')).statusCode).toBe(401); + expect((await refresh(s.refreshCookie)).statusCode).toBe(401); + }); + + it('changing the password signs out other sessions', async () => { + const s = await register(t.app); + const other = await login(t.app, s.email); + const res = await client(t.app, s.token).post('/api/auth/change-password', { currentPassword: PASSWORD, newPassword: 'An0ther-Strong-Pass' }); + expect(res.statusCode).toBe(204); + expect((await client(t.app, other.token).get('/api/auth/me')).statusCode).toBe(401); + expect((await client(t.app, s.token).get('/api/auth/me')).statusCode).toBe(200); + await login(t.app, s.email, 'An0ther-Strong-Pass'); + }); +}); + +describe('security headers', () => { + it('sets secure headers', async () => { + const res = await t.app.inject({ method: 'GET', url: '/api/health' }); + expect(res.statusCode).toBe(200); + expect(res.headers['x-content-type-options']).toBe('nosniff'); + expect(res.headers['content-security-policy']).toContain("default-src 'none'"); + expect(res.headers['strict-transport-security']).toBeDefined(); + }); +}); diff --git a/alshuyukh-accounting/apps/api/test/calc.test.ts b/alshuyukh-accounting/apps/api/test/calc.test.ts new file mode 100644 index 000000000000..137be9179632 --- /dev/null +++ b/alshuyukh-accounting/apps/api/test/calc.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { CalcError, calculateDocument, calculateLine, totalsOf } from '../src/modules/documents/calc.js'; + +const S = (quantity: string, unitPrice: string, extra = {}) => ({ quantity, unitPrice, vatCategory: 'S' as const, vatRate: '0.15', ...extra }); + +describe('VAT calculation', () => { + it('computes the reference example: 1000 SAR + 15% VAT = 1150', () => { + const { totals } = calculateDocument([S('1', '1000')], false); + expect(totals).toMatchObject({ subtotal: '1000.00', discountTotal: '0.00', taxableAmount: '1000.00', taxAmount: '150.00', total: '1150.00' }); + }); + + it('applies line discounts before VAT', () => { + const { lines, totals } = calculateDocument([S('4', '250', { discountPercent: '10' }), S('2', '50', { discountAmount: '5' })], false); + expect(lines[0]).toMatchObject({ grossAmount: '1000.00', discountAmount: '100.00', netAmount: '900.00', vatAmount: '135.00' }); + expect(totals).toMatchObject({ subtotal: '1100.00', discountTotal: '105.00', taxableAmount: '995.00', taxAmount: '149.25', total: '1144.25' }); + }); + + it('rounds VAT once per category, not per line', () => { + // Three lines of 0.10: per-line VAT rounds 0.015 → 0.02 each (0.06), document VAT is round(0.30 × 0.15) = 0.05. + const { lines, totals } = calculateDocument([S('1', '0.10'), S('1', '0.10'), S('1', '0.10')], false); + expect(lines.map((l) => l.vatAmount)).toEqual(['0.02', '0.02', '0.02']); + expect(totals.taxAmount).toBe('0.05'); + expect(totals.total).toBe('0.35'); + }); + + it('extracts VAT from VAT-inclusive prices', () => { + const { lines, totals } = calculateDocument([S('1', '115'), S('3', '10')], true); + expect(lines[0]).toMatchObject({ netAmount: '100.00', vatAmount: '15.00' }); + expect(lines[1]).toMatchObject({ netAmount: '26.09' }); + expect(totals).toMatchObject({ taxableAmount: '126.09', taxAmount: '18.91', total: '145.00' }); + }); + + it('handles quantities and prices with 4 decimals exactly', () => { + const line = calculateLine(S('2.5', '4.1250'), false, 1); + expect(line).toMatchObject({ grossAmount: '10.31', netAmount: '10.31', vatAmount: '1.55' }); + }); + + it('separates zero-rated, exempt and standard lines', () => { + const { totals } = calculateDocument([ + S('1', '100'), + { quantity: '1', unitPrice: '200', vatCategory: 'Z', vatRate: '0' }, + { quantity: '1', unitPrice: '50', vatCategory: 'E', vatRate: '0' }, + ], false); + expect(totals.taxBreakdown).toEqual([ + { vatCategory: 'S', vatRate: '0.15', taxableAmount: '100.00', taxAmount: '15.00' }, + { vatCategory: 'Z', vatRate: '0', taxableAmount: '200.00', taxAmount: '0.00' }, + { vatCategory: 'E', vatRate: '0', taxableAmount: '50.00', taxAmount: '0.00' }, + ]); + expect(totals.total).toBe('365.00'); + }); + + it('rejects invalid lines', () => { + const bad = [ + S('0', '10'), S('-1', '10'), S('1', '-5'), S('1', '10', { discountAmount: '11' }), + S('1', '10', { discountPercent: '101' }), S('1', '10', { discountAmount: '1', discountPercent: '5' }), + { quantity: '1', unitPrice: '10', vatCategory: 'Z' as const, vatRate: '0.15' }, + { quantity: '1', unitPrice: '10', vatCategory: 'S' as const, vatRate: '0' }, + ]; + for (const l of bad) expect(() => calculateLine(l, false, 1), JSON.stringify(l)).toThrow(CalcError); + }); + + it('totals arbitrary stored lines (used for returns)', () => { + const t = totalsOf([{ grossAmount: '500.00', discountAmount: '0.00', netAmount: '500.00', vatCategory: 'S', vatRate: '0.15' }]); + expect(t.total).toBe('575.00'); + }); +}); diff --git a/alshuyukh-accounting/apps/api/test/chart-of-accounts.test.ts b/alshuyukh-accounting/apps/api/test/chart-of-accounts.test.ts new file mode 100644 index 000000000000..9d8920a8a5b4 --- /dev/null +++ b/alshuyukh-accounting/apps/api/test/chart-of-accounts.test.ts @@ -0,0 +1,133 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { DEFAULT_ACCOUNTS } from '../src/modules/accounting/chart-template.js'; +import { chart, client, cr, dateInYear, dr, fiscalYears, register, setupApp, type Session, type TestContext } from './helpers.js'; + +let t: TestContext; +let owner: Session; + +beforeAll(async () => { + t = await setupApp(); + owner = await register(t.app); +}); +afterAll(async () => { await t.close(); }); + +describe('default setup for a new company', () => { + it('creates the default Saudi chart of accounts with system keys', async () => { + const accounts = (await client(t.app, owner.token).get('/api/accounts')).json().data; + expect(accounts.map((a: { code: string }) => a.code)).toEqual(DEFAULT_ACCOUNTS.map((a) => a.code).sort()); + const byCode = Object.fromEntries(accounts.map((a: { code: string }) => [a.code, a])); + expect(byCode['1000']).toMatchObject({ nameAr: 'الأصول', type: 'ASSET', isPostable: false, level: 1 }); + expect(byCode['1300']).toMatchObject({ nameAr: 'العملاء', systemKey: 'ACCOUNTS_RECEIVABLE', isPostable: true, level: 2, balance: '0.00' }); + expect(byCode['2210']).toMatchObject({ systemKey: 'VAT_OUTPUT', level: 3, type: 'LIABILITY' }); + expect(byCode['3200']).toMatchObject({ systemKey: 'RETAINED_EARNINGS', type: 'EQUITY' }); + expect(byCode['4100']).toMatchObject({ systemKey: 'SALES', type: 'REVENUE' }); + expect(byCode['5100']).toMatchObject({ systemKey: 'COGS', type: 'COST_OF_GOODS_SOLD' }); + }); + + it('creates the current fiscal year with 12 open monthly periods', async () => { + const [year] = await fiscalYears(t.app, owner.token); + expect(year!.status).toBe('OPEN'); + expect(year!.periods).toHaveLength(12); + expect(year!.startDate).toMatch(/-01-01$/); + expect(year!.endDate).toMatch(/-12-31$/); + expect(year!.periods[1]).toMatchObject({ startDate: `${year!.startDate.slice(0, 4)}-02-01`, status: 'OPEN' }); + expect(year!.periods.every((p) => p.status === 'OPEN')).toBe(true); + }); + + it('sets up accounting for companies created later, and the setup call is idempotent', async () => { + const api = client(t.app, owner.token); + const company = (await api.post('/api/companies', { name: `Second ${Date.now()}` })).json(); + const accounts = (await api.get(`/api/accounts?companyId=${company.id}`)).json().data; + expect(accounts.length).toBe(DEFAULT_ACCOUNTS.length); + const again = await api.post('/api/accounting/setup', { companyId: company.id }); + expect(again.json()).toMatchObject({ chartCreated: false, fiscalYearCreated: false }); + // With two companies, list endpoints require companyId. + expect((await api.get('/api/accounts')).statusCode).toBe(400); + }); +}); + +describe('managing accounts', () => { + let session: Session; + beforeAll(async () => { session = await register(t.app); }); + + it('creates a sub-account that inherits the parent type', async () => { + const api = client(t.app, session.token); + const c = await chart(t.app, session.token); + const res = await api.post('/api/accounts', { code: '6500', nameAr: 'مصروفات الصيانة', parentId: c.byCode.get('6000') }); + expect(res.statusCode).toBe(201); + expect(res.json()).toMatchObject({ type: 'EXPENSE', level: 2, isPostable: true, isSystem: false }); + }); + + it('turns an unused leaf into a header when a child is added under it', async () => { + const api = client(t.app, session.token); + const c = await chart(t.app, session.token); + const res = await api.post('/api/accounts', { code: '1210', nameAr: 'بنك الراجحي', parentId: c.byCode.get('1200') }); + expect(res.statusCode).toBe(201); + expect(res.json().level).toBe(3); + expect((await api.get(`/api/accounts/${c.byCode.get('1200')}`)).json().isPostable).toBe(false); + }); + + it('rejects invalid structures', async () => { + const api = client(t.app, session.token); + const c = await chart(t.app, session.token); + expect((await api.post('/api/accounts', { code: '6100', nameAr: 'مكرر', parentId: c.byCode.get('6000') })).statusCode).toBe(409); + expect((await api.post('/api/accounts', { code: '6600', nameAr: 'نوع خاطئ', type: 'ASSET', parentId: c.byCode.get('6000') })).statusCode).toBe(400); + expect((await api.post('/api/accounts', { code: '9000', nameAr: 'بلا نوع' })).statusCode).toBe(400); + expect((await api.post('/api/accounts', { code: 'bad code!', nameAr: 'رمز', type: 'ASSET' })).statusCode).toBe(400); + + const h1 = (await api.post('/api/accounts', { code: '7000', nameAr: 'رئيسي', type: 'EXPENSE', isPostable: false })).json(); + const h2 = (await api.post('/api/accounts', { code: '7100', nameAr: 'فرعي', parentId: h1.id, isPostable: false })).json(); + const cycle = await api.patch(`/api/accounts/${h1.id}`, { parentId: h2.id }); + expect(cycle.statusCode).toBe(400); + expect(cycle.json().error.message).toMatch(/descendant/); + }); + + it('protects system accounts and accounts with postings', async () => { + const api = client(t.app, session.token); + const c = await chart(t.app, session.token); + expect((await api.patch(`/api/accounts/${c.byKey.get('CASH')}`, { isActive: false })).statusCode).toBe(409); + expect((await api.del(`/api/accounts/${c.byKey.get('CASH')}`)).statusCode).toBe(409); + // Renaming a system account is allowed. + expect((await api.patch(`/api/accounts/${c.byKey.get('CASH')}`, { nameAr: 'الصندوق' })).statusCode).toBe(200); + + const rent = c.byCode.get('6100')!; + const date = await dateInYear(t.app, session.token); + const posted = await api.post('/api/journal-entries', { entryDate: date, description: 'إيجار', post: true, lines: [dr(rent, '500.00'), cr(c.byKey.get('CASH')!, '500.00')] }); + expect(posted.statusCode).toBe(201); + expect((await api.get(`/api/accounts/${rent}`)).json().balance).toBe('500.00'); + expect((await api.patch(`/api/accounts/${rent}`, { isActive: false })).statusCode).toBe(409); + expect((await api.del(`/api/accounts/${rent}`)).statusCode).toBe(409); + const toHeader = await api.patch(`/api/accounts/${rent}`, { isPostable: false }); + expect(toHeader.statusCode).toBe(400); + }); + + it('deactivates and soft-deletes unused accounts', async () => { + const api = client(t.app, session.token); + const c = await chart(t.app, session.token); + const acc = (await api.post('/api/accounts', { code: '6900', nameAr: 'مؤقت', parentId: c.byCode.get('6000') })).json(); + expect((await api.patch(`/api/accounts/${acc.id}`, { isActive: false })).json().isActive).toBe(false); + const listed = (await api.get('/api/accounts')).json().data.map((a: { id: string }) => a.id); + expect(listed).not.toContain(acc.id); + expect((await api.del(`/api/accounts/${acc.id}`)).statusCode).toBe(204); + expect((await api.get(`/api/accounts/${acc.id}`)).statusCode).toBe(404); + // The code can be reused after deletion. + expect((await api.post('/api/accounts', { code: '6900', nameAr: 'جديد', parentId: c.byCode.get('6000') })).statusCode).toBe(201); + }); + + it('manages cost centers', async () => { + const api = client(t.app, session.token); + const cc = await api.post('/api/cost-centers', { code: 'RUH', name: 'مركز الرياض' }); + expect(cc.statusCode).toBe(201); + expect((await api.post('/api/cost-centers', { code: 'RUH', name: 'مكرر' })).statusCode).toBe(409); + expect((await api.patch(`/api/cost-centers/${cc.json().id}`, { isActive: false })).json().isActive).toBe(false); + }); + + it('isolates charts between tenants', async () => { + const other = await register(t.app); + const c = await chart(t.app, session.token); + const api = client(t.app, other.token); + expect((await api.get(`/api/accounts/${c.byKey.get('CASH')}`)).statusCode).toBe(404); + expect((await api.patch(`/api/accounts/${c.byKey.get('CASH')}`, { nameAr: 'اختراق' })).statusCode).toBe(404); + expect((await api.post('/api/accounts', { code: '6700', nameAr: 'حقن', parentId: c.byCode.get('6000') })).statusCode).toBe(400); + }); +}); diff --git a/alshuyukh-accounting/apps/api/test/commerce-helpers.ts b/alshuyukh-accounting/apps/api/test/commerce-helpers.ts new file mode 100644 index 000000000000..52e9496d7392 --- /dev/null +++ b/alshuyukh-accounting/apps/api/test/commerce-helpers.ts @@ -0,0 +1,55 @@ +import type { FastifyInstance } from 'fastify'; +import { client, dateInYear, type Session } from './helpers.js'; + +export interface Commerce { + api: ReturnType; + date: string; + customer(extra?: Record): Promise<{ id: string }>; + supplier(extra?: Record): Promise<{ id: string }>; + /** A service by default (no stock); use goods() for stocked items. */ + product(extra?: Record): Promise<{ id: string }>; + goods(extra?: Record): Promise<{ id: string }>; + invoice(customerId: string, lines: unknown[], extra?: Record): Promise>; + postInvoice(customerId: string, lines: unknown[], extra?: Record): Promise>; + purchase(supplierId: string, lines: unknown[], extra?: Record): Promise>; + method(code: string): Promise; + journal(id: string): Promise<{ accountCode: string; debit: string; credit: string; customerId: string | null; supplierId: string | null }[]>; +} + +let seq = 0; +export async function commerce(app: FastifyInstance, s: Session): Promise { + const api = client(app, s.token); + const date = await dateInYear(app, s.token, 3, 10); + const ok = async (p: Promise<{ statusCode: number; json(): any; body: string }>, status = 201) => { + const r = await p; + if (r.statusCode !== status) throw new Error(`expected ${status}, got ${r.statusCode}: ${r.body}`); + return r.json(); + }; + return { + api, + date, + customer: (extra = {}) => ok(api.post('/api/customers', { nameAr: `عميل ${++seq}`, ...extra })), + supplier: (extra = {}) => ok(api.post('/api/suppliers', { nameAr: `مورد ${++seq}`, ...extra })), + product: (extra = {}) => ok(api.post('/api/products', { nameAr: `خدمة ${++seq}`, productType: 'SERVICE', salePrice: '100', purchasePrice: '60', ...extra })), + goods: (extra = {}) => ok(api.post('/api/products', { nameAr: `صنف ${++seq}`, productType: 'GOODS', salePrice: '100', purchasePrice: '60', ...extra })), + invoice: (customerId, lines, extra = {}) => ok(api.post('/api/invoices', { partyId: customerId, docDate: date, lines, ...extra })), + async postInvoice(customerId, lines, extra = {}) { + const inv = await ok(api.post('/api/invoices', { partyId: customerId, docDate: date, lines, ...extra })); + return ok(api.post(`/api/invoices/${inv.id}/post`), 200); + }, + async purchase(supplierId, lines, extra = {}) { + const inv = await ok(api.post('/api/purchase-invoices', { partyId: supplierId, docDate: date, lines, ...extra })); + return ok(api.post(`/api/purchase-invoices/${inv.id}/post`), 200); + }, + async method(code) { + const m = (await api.get('/api/payment-methods')).json().data.find((x: { code: string }) => x.code === code); + return m.id; + }, + async journal(id) { + return (await api.get(`/api/journal-entries/${id}`)).json().lines; + }, + }; +} + +export const balanceOf = async (api: Commerce['api'], path: 'customers' | 'suppliers', id: string) => + (await api.get(`/api/${path}/${id}`)).json().balance as string; diff --git a/alshuyukh-accounting/apps/api/test/companies.test.ts b/alshuyukh-accounting/apps/api/test/companies.test.ts new file mode 100644 index 000000000000..1af647f0b926 --- /dev/null +++ b/alshuyukh-accounting/apps/api/test/companies.test.ts @@ -0,0 +1,108 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { client, register, setupApp, type Session, type TestContext } from './helpers.js'; + +let t: TestContext; +let owner: Session; + +beforeAll(async () => { + t = await setupApp(); + owner = await register(t.app); +}); +afterAll(async () => { await t.close(); }); + +describe('companies, branches, warehouses', () => { + it('creates a company with Saudi defaults and validates input', async () => { + const api = client(t.app, owner.token); + const res = await api.post('/api/companies', { name: 'فرع جدة للتجارة', commercialRegistration: '4030000000', vatNumber: '311111111111113' }); + expect(res.statusCode).toBe(201); + expect(res.json()).toMatchObject({ currency: 'SAR', timezone: 'Asia/Riyadh', country: 'SA' }); + + expect((await api.post('/api/companies', { name: 'Bad TZ', timezone: 'Mars/Base' })).statusCode).toBe(400); + expect((await api.post('/api/companies', { name: 'Bad CR', commercialRegistration: '12' })).statusCode).toBe(400); + expect((await api.post('/api/companies', { name: 'فرع جدة للتجارة' })).statusCode).toBe(409); + }); + + it('enforces unique branch codes and a single main branch', async () => { + const api = client(t.app, owner.token); + const company = (await api.post('/api/companies', { name: `Co ${Date.now()}` })).json(); + expect((await api.post(`/api/companies/${company.id}/branches`, { code: 'RUH', name: 'الرياض' })).statusCode).toBe(201); + expect((await api.post(`/api/companies/${company.id}/branches`, { code: 'RUH', name: 'مكرر' })).statusCode).toBe(409); + }); + + it('soft-deletes a company but keeps at least one', async () => { + const api = client(t.app, owner.token); + const extra = (await api.post('/api/companies', { name: `Temp ${Date.now()}` })).json(); + expect((await api.del(`/api/companies/${extra.id}`)).statusCode).toBe(204); + expect((await api.get(`/api/companies/${extra.id}`)).statusCode).toBe(404); + const { rows } = await t.ownerPool.query('SELECT deleted_at FROM companies WHERE id = $1', [extra.id]); + expect(rows[0].deleted_at).not.toBeNull(); + + const fresh = await register(t.app); + const only = (await client(t.app, fresh.token).get('/api/companies')).json().data[0]; + expect((await client(t.app, fresh.token).del(`/api/companies/${only.id}`)).statusCode).toBe(409); + }); + + it('rejects a warehouse linked to a branch of another company', async () => { + const api = client(t.app, owner.token); + const c1 = (await api.post('/api/companies', { name: `C1 ${Date.now()}` })).json(); + const c2 = (await api.post('/api/companies', { name: `C2 ${Date.now()}` })).json(); + const b1 = (await api.post(`/api/companies/${c1.id}/branches`, { code: 'B1', name: 'Branch one' })).json(); + expect((await api.post(`/api/companies/${c2.id}/warehouses`, { code: 'W', name: 'Wrong', branchId: b1.id })).statusCode).toBe(404); + expect((await api.post(`/api/companies/${c1.id}/warehouses`, { code: 'W', name: 'Right', branchId: b1.id })).statusCode).toBe(201); + }); + + it('rejects unknown fields from reaching SQL and ignores tenant_id in the body', async () => { + const other = await register(t.app); + const api = client(t.app, owner.token); + const res = await api.post('/api/companies', { name: `Spoof ${Date.now()}`, tenantId: other.tenantId, tenant_id: other.tenantId }); + expect(res.statusCode).toBe(201); + const { rows } = await t.ownerPool.query('SELECT tenant_id FROM companies WHERE id = $1', [res.json().id]); + expect(rows[0].tenant_id).toBe(owner.tenantId); + }); +}); + +describe('multi-tenant membership', () => { + it('lets one user belong to two tenants and switch between them', async () => { + const t1 = await register(t.app, { tenantName: 'Org One' }); + const t2 = await register(t.app, { tenantName: 'Org Two' }); + const viewerRole = (await client(t.app, t2.token).get('/api/roles')).json().data.find((r: { code: string }) => r.code === 'VIEWER').id; + const add = await client(t.app, t2.token).post('/api/users', { email: t1.email, fullName: 'Shared', roleIds: [viewerRole] }); + expect(add.statusCode).toBe(201); + // An existing account joins only after it accepts; until then the inviter sees no personal details. + expect(add.json()).toMatchObject({ status: 'INVITED', fullName: null, lastLoginAt: null }); + expect((await client(t.app, t1.token).get('/api/auth/me')).json().memberships).toHaveLength(1); + expect((await client(t.app, t1.token).post('/api/auth/switch-tenant', { tenantId: t2.tenantId })).statusCode).not.toBe(200); + expect((await client(t.app, t2.token).patch(`/api/users/${add.json().id}`, { status: 'ACTIVE' })).statusCode).toBe(409); + const inv = (await client(t.app, t1.token).get('/api/auth/invitations')).json().data; + expect(inv).toEqual([{ tenantId: t2.tenantId, tenantName: 'Org Two', invitedAt: expect.any(String) }]); + expect((await client(t.app, t1.token).post(`/api/auth/invitations/${t2.tenantId}/accept`)).statusCode).toBe(204); + expect((await client(t.app, t1.token).get('/api/auth/invitations')).json().data).toEqual([]); + + const me = (await client(t.app, t1.token).get('/api/auth/me')).json(); + expect(me.memberships.map((m: { tenantName: string }) => m.tenantName).sort()).toEqual(['Org One', 'Org Two']); + + const sw = await client(t.app, t1.token).post('/api/auth/switch-tenant', { tenantId: t2.tenantId }); + expect(sw.statusCode).toBe(200); + const api2 = client(t.app, sw.json().accessToken); + const me2 = (await api2.get('/api/auth/me')).json(); + expect(me2.tenant.id).toBe(t2.tenantId); + expect(me2.roles.map((r: { code: string }) => r.code)).toEqual(['VIEWER']); + expect((await api2.post('/api/companies', { name: 'Not allowed' })).statusCode).toBe(403); + // The pre-switch token is bound to the old tenant and is no longer valid. + expect((await client(t.app, t1.token).get('/api/companies')).statusCode).toBe(401); + }); + + it('lets an invited user decline, and refuses answering for another user', async () => { + const t1 = await register(t.app); + const t2 = await register(t.app); + const t3 = await register(t.app); + const viewerRole = (await client(t.app, t2.token).get('/api/roles')).json().data.find((r: { code: string }) => r.code === 'VIEWER').id; + expect((await client(t.app, t2.token).post('/api/users', { email: t1.email, fullName: 'Someone', roleIds: [viewerRole] })).statusCode).toBe(201); + // t3 was not invited: answering is not possible. + expect((await client(t.app, t3.token).post(`/api/auth/invitations/${t2.tenantId}/accept`)).statusCode).toBe(404); + expect((await client(t.app, t1.token).post(`/api/auth/invitations/${t2.tenantId}/decline`)).statusCode).toBe(204); + expect((await client(t.app, t1.token).post(`/api/auth/invitations/${t2.tenantId}/accept`)).statusCode).toBe(404); + const member = (await client(t.app, t2.token).get('/api/users')).json().data.find((u: { email: string }) => u.email === t1.email); + expect(member.status).toBe('DISABLED'); + }); +}); diff --git a/alshuyukh-accounting/apps/api/test/env.ts b/alshuyukh-accounting/apps/api/test/env.ts new file mode 100644 index 000000000000..65ee97211c21 --- /dev/null +++ b/alshuyukh-accounting/apps/api/test/env.ts @@ -0,0 +1,13 @@ +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const envFile = resolve(import.meta.dirname, '../../../.env'); +if (existsSync(envFile)) process.loadEnvFile(envFile); + +if (!process.env.TEST_DATABASE_URL || !process.env.TEST_DATABASE_URL_MIGRATE) { + throw new Error('TEST_DATABASE_URL and TEST_DATABASE_URL_MIGRATE must be set (see .env.example)'); +} +process.env.NODE_ENV = 'test'; +process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; +process.env.DATABASE_URL_MIGRATE = process.env.TEST_DATABASE_URL_MIGRATE; +process.env.JWT_SECRET ??= 'test-secret-that-is-long-enough-for-hs256-signing'; diff --git a/alshuyukh-accounting/apps/api/test/expenses.test.ts b/alshuyukh-accounting/apps/api/test/expenses.test.ts new file mode 100644 index 000000000000..6297f4e7dbed --- /dev/null +++ b/alshuyukh-accounting/apps/api/test/expenses.test.ts @@ -0,0 +1,149 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { withTx } from '../src/db/tx.js'; +import { balanceOf, commerce, type Commerce } from './commerce-helpers.js'; +import { addMember, client, register, setupApp, type Session, type TestContext } from './helpers.js'; + +let t: TestContext; +let owner: Session; +let c: Commerce; +let cat: Record; + +beforeAll(async () => { + t = await setupApp(); + owner = await register(t.app); + c = await commerce(t.app, owner); + const cats = (await c.api.get('/api/expense-categories')).json().data as { id: string; code: string }[]; + cat = Object.fromEntries(cats.map((x) => [x.code, x.id])); +}); +afterAll(async () => { await t.close(); }); + +const expense = async (body: Record, post = true) => + c.api.post('/api/expenses', { expenseDate: c.date, paymentType: 'CASH', methodId: await c.method('CASH'), post, ...body }); +const lines = async (id: string) => (await c.journal(id)).map((l) => [l.accountCode, l.debit, l.credit]); + +describe('expense categories', () => { + it('seeds categories with their accounts and default VAT treatment', async () => { + const cats = (await c.api.get('/api/expense-categories')).json().data; + expect(cats.map((x: { code: string; accountCode: string; vatCategory: string }) => [x.code, x.accountCode, x.vatCategory]).sort()).toEqual([ + ['MARKETING', '6300', 'S'], ['RENT', '6100', 'S'], ['SALARIES', '6200', 'O'], ['UTILITIES', '6400', 'S'], + ]); + }); +}); + +describe('posting expenses', () => { + it('posts a cash expense: Dr Expense + Dr VAT input / Cr Cash', async () => { + const res = await expense({ payeeName: 'وكالة إعلانات', reference: 'A-77', vendorVatNumber: '311111111111113', lines: [{ categoryId: cat.MARKETING, amount: '1000' }] }); + expect(res.statusCode).toBe(201); + const e = res.json(); + expect(e).toMatchObject({ status: 'PAID', subtotal: '1000.00', taxAmount: '150.00', total: '1150.00', remainingAmount: '0.00' }); + expect(e.number).toMatch(/^EXP-\d{6}$/); + expect(await lines(e.journalEntryId)).toEqual([['6300', '1000.00', '0.00'], ['2220', '150.00', '0.00'], ['1100', '0.00', '1150.00']]); + const tx = (await c.api.get(`/api/reports/vat-transactions?dateFrom=${c.date}&dateTo=${c.date}&direction=INPUT`)).json().data; + expect(tx.find((x: { sourceId: string }) => x.sourceId === e.id)).toMatchObject({ sourceType: 'EXPENSE', vatCategory: 'S', taxableAmount: '1000.00', taxAmount: '150.00', partyVatNumber: '311111111111113' }); + }); + + it('extracts VAT from inclusive amounts and honours a per-line exempt override', async () => { + const incl = (await expense({ pricesIncludeVat: true, lines: [{ categoryId: cat.UTILITIES, amount: '115' }] })).json(); + expect(incl).toMatchObject({ subtotal: '100.00', taxAmount: '15.00', total: '115.00' }); + const rent = (await expense({ paymentType: 'BANK', methodId: await c.method('BANK'), lines: [{ categoryId: cat.RENT, amount: '5000', vatCategory: 'E', description: 'سكن موظفين' }] })).json(); + expect(rent).toMatchObject({ taxAmount: '0.00', total: '5000.00' }); + expect(await lines(rent.journalEntryId)).toEqual([['6100', '5000.00', '0.00'], ['1200', '0.00', '5000.00']]); + }); + + it('groups lines per account and cost center', async () => { + const cc = (await c.api.post('/api/cost-centers', { code: 'MKT', name: 'التسويق' })).json(); + const e = (await expense({ lines: [ + { categoryId: cat.MARKETING, amount: '100', costCenterId: cc.id }, + { categoryId: cat.MARKETING, amount: '50', costCenterId: cc.id }, + { categoryId: cat.MARKETING, amount: '30' }, + ] })).json(); + const entry = (await c.api.get(`/api/journal-entries/${e.journalEntryId}`)).json(); + expect(entry.lines.map((l: { accountCode: string; debit: string; costCenterId: string | null }) => [l.accountCode, l.debit, l.costCenterId])).toEqual([ + ['6300', '150.00', cc.id], ['6300', '30.00', null], ['2220', '27.00', null], ['1100', '0.00', null], + ]); + }); + + it('records a credit expense against the supplier and settles it with a supplier payment', async () => { + expect((await expense({ paymentType: 'CREDIT', methodId: null, lines: [{ categoryId: cat.UTILITIES, amount: '200' }] })).json().error.code).toBe('SUPPLIER_REQUIRED'); + const sup = await c.supplier({ vatNumber: '322222222222223' }); + const e = (await expense({ paymentType: 'CREDIT', methodId: null, supplierId: sup.id, lines: [{ categoryId: cat.UTILITIES, amount: '200' }] })).json(); + expect(e).toMatchObject({ status: 'POSTED', total: '230.00', remainingAmount: '230.00', supplierName: expect.any(String) }); + expect(await lines(e.journalEntryId)).toEqual([['6400', '200.00', '0.00'], ['2220', '30.00', '0.00'], ['2100', '0.00', '230.00']]); + expect(await balanceOf(c.api, 'suppliers', sup.id)).toBe('-230.00'); + + const open = (await c.api.get(`/api/expenses?supplierId=${sup.id}&open=true`)).json(); + expect(open.total).toBe(1); + const pay = await c.api.post('/api/payments', { + direction: 'DISBURSEMENT', supplierId: sup.id, paymentDate: c.date, methodId: await c.method('BANK'), amount: '230.00', + allocations: [{ documentType: 'EXPENSE', documentId: e.id, amount: '230.00' }], + }); + expect(pay.statusCode).toBe(201); + expect((await c.api.get(`/api/expenses/${e.id}`)).json()).toMatchObject({ status: 'PAID', paidAmount: '230.00' }); + expect(await balanceOf(c.api, 'suppliers', sup.id)).toBe('0.00'); + expect((await c.api.post(`/api/expenses/${e.id}/cancel`, { reason: 'خطأ' })).json().error.code).toBe('HAS_PAYMENTS'); + + await c.api.post(`/api/payments/${pay.json().id}/void`, { reason: 'تحويل مرتجع' }); + expect((await c.api.get(`/api/expenses/${e.id}`)).json()).toMatchObject({ status: 'POSTED', paidAmount: '0.00' }); + }); + + it('cancels a posted expense by reversing its entry and VAT', async () => { + const e = (await expense({ lines: [{ categoryId: cat.MARKETING, amount: '400' }] })).json(); + const res = await c.api.post(`/api/expenses/${e.id}/cancel`, { reason: 'أُدخل مرتين' }); + expect(res.json().status).toBe('CANCELLED'); + expect((await c.api.get(`/api/journal-entries/${e.journalEntryId}`)).json().status).toBe('REVERSED'); + const tx = (await c.api.get(`/api/reports/vat-transactions?dateFrom=${c.date}&dateTo=${c.date}`)).json().data + .filter((x: { sourceId: string }) => x.sourceId === e.id); + expect(tx.map((x: { taxAmount: string }) => x.taxAmount).sort()).toEqual(['-60.00', '60.00']); + }); +}); + +describe('drafts, validation and protection', () => { + it('edits and deletes drafts; refuses changes after posting', async () => { + const d = (await expense({ lines: [{ categoryId: cat.MARKETING, amount: '10' }] }, false)).json(); + expect(d).toMatchObject({ status: 'DRAFT', number: null }); + const edited = (await c.api.patch(`/api/expenses/${d.id}`, { lines: [{ categoryId: cat.UTILITIES, amount: '20' }] })).json(); + expect(edited).toMatchObject({ total: '23.00' }); + const posted = (await c.api.post(`/api/expenses/${d.id}/post`)).json(); + expect(posted.status).toBe('PAID'); + expect((await c.api.patch(`/api/expenses/${d.id}`, { notes: 'x' })).statusCode).toBe(409); + expect((await c.api.del(`/api/expenses/${d.id}`)).statusCode).toBe(409); + await expect(withTx(t.pool, { tenantId: owner.tenantId, userId: owner.userId }, (db) => + db.query(`UPDATE expenses SET total = 1 WHERE id = $1`, [d.id]))).rejects.toThrow(/cannot be modified/); + const other = (await expense({ lines: [{ categoryId: cat.MARKETING, amount: '10' }] }, false)).json(); + expect((await c.api.del(`/api/expenses/${other.id}`)).statusCode).toBe(204); + }); + + it('validates lines and references', async () => { + expect((await expense({ lines: [{ categoryId: cat.MARKETING, amount: '0' }] })).statusCode).toBe(400); + expect((await expense({ lines: [{ categoryId: cat.MARKETING, amount: '-5' }] })).statusCode).toBe(400); + expect((await expense({ lines: [{ categoryId: cat.MARKETING, amount: 5 }] })).statusCode).toBe(400); + expect((await expense({ methodId: null, lines: [{ categoryId: cat.MARKETING, amount: '5' }] })).json().error.code).toBe('METHOD_REQUIRED'); + const other = await register(t.app); + const foreignCat = (await client(t.app, other.token).get('/api/expense-categories')).json().data[0].id; + expect((await expense({ lines: [{ categoryId: foreignCat, amount: '5' }] })).json().error.code).toBe('INVALID_CATEGORY'); + }); +}); + +describe('permissions and isolation', () => { + it('applies expense permissions', async () => { + const sales = await addMember(t.app, owner, 'SALES_EMPLOYEE'); + expect((await client(t.app, sales.token).get('/api/expenses')).statusCode).toBe(403); + const viewer = await addMember(t.app, owner, 'VIEWER'); + expect((await client(t.app, viewer.token).get('/api/expenses')).statusCode).toBe(200); + expect((await client(t.app, viewer.token).post('/api/expenses', { expenseDate: c.date, paymentType: 'CASH', methodId: await c.method('CASH'), lines: [{ categoryId: cat.MARKETING, amount: '1' }] })).statusCode).toBe(403); + const accountant = await addMember(t.app, owner, 'ACCOUNTANT'); + const aApi = client(t.app, accountant.token); + const e = await aApi.post('/api/expenses', { expenseDate: c.date, paymentType: 'CASH', methodId: await c.method('CASH'), post: true, lines: [{ categoryId: cat.MARKETING, amount: '1' }] }); + expect(e.statusCode).toBe(201); + expect((await aApi.post(`/api/expenses/${e.json().id}/cancel`, { reason: 'تجربة' })).statusCode).toBe(200); + }); + + it('hides expenses from other tenants', async () => { + const e = (await expense({ lines: [{ categoryId: cat.MARKETING, amount: '1' }] })).json(); + const other = await register(t.app); + const oApi = client(t.app, other.token); + expect((await oApi.get(`/api/expenses/${e.id}`)).statusCode).toBe(404); + expect((await oApi.post(`/api/expenses/${e.id}/cancel`, { reason: 'اختراق' })).statusCode).toBe(404); + expect((await oApi.get('/api/expenses')).json().total).toBe(0); + }); +}); diff --git a/alshuyukh-accounting/apps/api/test/fiscal-years.test.ts b/alshuyukh-accounting/apps/api/test/fiscal-years.test.ts new file mode 100644 index 000000000000..d20afccb71ed --- /dev/null +++ b/alshuyukh-accounting/apps/api/test/fiscal-years.test.ts @@ -0,0 +1,121 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { chart, client, cr, dr, fiscalYears, register, setupApp, type TestContext } from './helpers.js'; + +let t: TestContext; +beforeAll(async () => { t = await setupApp(); }); +afterAll(async () => { await t.close(); }); + +describe('fiscal years', () => { + it('creates the next year and rejects overlaps and invalid starts', async () => { + const s = await register(t.app); + const api = client(t.app, s.token); + const [current] = await fiscalYears(t.app, s.token); + const nextStart = `${Number(current!.startDate.slice(0, 4)) + 1}-01-01`; + + const next = await api.post('/api/fiscal-years', { startDate: nextStart }); + expect(next.statusCode).toBe(201); + const years = await fiscalYears(t.app, s.token); + expect(years).toHaveLength(2); + expect(years[0]).toMatchObject({ startDate: nextStart, endDate: `${nextStart.slice(0, 4)}-12-31` }); + expect(years[0]!.periods).toHaveLength(12); + + expect((await api.post('/api/fiscal-years', { startDate: nextStart })).statusCode).toBe(409); + expect((await api.post('/api/fiscal-years', { startDate: `${nextStart.slice(0, 4)}-06-01` })).statusCode).toBe(409); + expect((await api.post('/api/fiscal-years', { startDate: '2040-01-15' })).statusCode).toBe(400); + expect((await api.post('/api/fiscal-years', { startDate: '2040-02-30' })).statusCode).toBe(400); + }); + + it('supports a short first year with an explicit end date', async () => { + const s = await register(t.app); + const res = await client(t.app, s.token).post('/api/fiscal-years', { startDate: '2040-07-01', endDate: '2040-12-31' }); + expect(res.statusCode).toBe(201); + const year = (await fiscalYears(t.app, s.token)).find((y) => y.startDate === '2040-07-01'); + expect(year!.periods).toHaveLength(6); + }); + + it('locks the fiscal start month once a fiscal year exists', async () => { + const s = await register(t.app); + const res = await client(t.app, s.token).patch('/api/settings/tenant', { fiscalYearStartMonth: 7 }); + expect(res.statusCode).toBe(409); + expect(res.json().error.code).toBe('FISCAL_YEARS_EXIST'); + }); +}); + +describe('closing a fiscal year', () => { + it('moves the net profit to retained earnings and closes every period', async () => { + const s = await register(t.app); + const api = client(t.app, s.token); + const c = await chart(t.app, s.token); + const [year] = await fiscalYears(t.app, s.token); + const y = year!.startDate.slice(0, 4); + const post = (entryDate: string, lines: unknown[]) => + api.post('/api/journal-entries', { entryDate, description: 'حركة', lines, post: true }).then((r) => { expect(r.statusCode).toBe(201); return r.json(); }); + + await post(`${y}-01-10`, [dr(c.byKey.get('CASH')!, '50000.00'), cr(c.byKey.get('CAPITAL')!, '50000.00')]); + await post(`${y}-02-10`, [dr(c.byKey.get('CASH')!, '11500.00'), cr(c.byKey.get('SALES')!, '10000.00'), cr(c.byKey.get('VAT_OUTPUT')!, '1500.00')]); + await post(`${y}-03-10`, [dr(c.byKey.get('COGS')!, '4000.00'), cr(c.byKey.get('INVENTORY')!, '4000.00')]); + await post(`${y}-04-10`, [dr(c.byCode.get('6100')!, '2500.00'), cr(c.byKey.get('BANK')!, '2500.00')]); + // Net profit = 10000 - 4000 - 2500 = 3500 + + const draft = (await api.post('/api/journal-entries', { entryDate: `${y}-05-01`, description: 'مسودة', lines: [dr(c.byCode.get('6200')!, '1.00'), cr(c.byKey.get('CASH')!, '1.00')] })).json(); + const blocked = await api.post(`/api/fiscal-years/${year!.id}/close`); + expect(blocked.json().error.code).toBe('DRAFTS_EXIST'); + await api.del(`/api/journal-entries/${draft.id}`); + + const res = await api.post(`/api/fiscal-years/${year!.id}/close`); + expect(res.statusCode).toBe(200); + const closing = (await api.get(`/api/journal-entries/${res.json().closingEntryId}`)).json(); + expect(closing).toMatchObject({ status: 'POSTED', referenceType: 'YEAR_CLOSING', source: 'SYSTEM', entryDate: year!.endDate }); + const re = closing.lines.find((l: { accountCode: string }) => l.accountCode === '3200'); + expect(re).toMatchObject({ debit: '0.00', credit: '3500.00' }); + + for (const k of ['SALES', 'COGS']) { + expect((await api.get(`/api/accounts/${c.byKey.get(k)}`)).json().balance).toBe('0.00'); + } + expect((await api.get(`/api/accounts/${c.byCode.get('6100')}`)).json().balance).toBe('0.00'); + expect((await api.get(`/api/accounts/${c.byKey.get('RETAINED_EARNINGS')}`)).json().balance).toBe('-3500.00'); + // Balance-sheet accounts carry forward untouched. + expect((await api.get(`/api/accounts/${c.byKey.get('CASH')}`)).json().balance).toBe('61500.00'); + + const [closed] = await fiscalYears(t.app, s.token); + expect(closed!.status).toBe('CLOSED'); + expect(closed!.periods.every((p) => p.status === 'CLOSED')).toBe(true); + + const late = await api.post('/api/journal-entries', { entryDate: `${y}-06-01`, description: 'متأخر', post: true, lines: [dr(c.byCode.get('6200')!, '1.00'), cr(c.byKey.get('CASH')!, '1.00')] }); + expect(late.json().error.code).toBe('PERIOD_CLOSED'); + expect((await api.post(`/api/fiscal-periods/${closed!.periods[0]!.id}/reopen`)).statusCode).toBe(409); + expect((await api.post(`/api/fiscal-years/${year!.id}/close`)).statusCode).toBe(409); + const reverseClosing = await api.post(`/api/journal-entries/${closing.id}/reverse`, { reason: 'test' }); + expect(reverseClosing.json().error.code).toBe('SYSTEM_ENTRY'); + + const tb = (await api.get(`/api/reports/trial-balance?dateFrom=${year!.startDate}&dateTo=${year!.endDate}`)).json(); + expect(tb.balanced).toBe(true); + }); + + it('debits retained earnings for a loss', async () => { + const s = await register(t.app); + const api = client(t.app, s.token); + const c = await chart(t.app, s.token); + const [year] = await fiscalYears(t.app, s.token); + await api.post('/api/journal-entries', { entryDate: year!.periods[0]!.startDate, description: 'إيجار', post: true, lines: [dr(c.byCode.get('6100')!, '1200.00'), cr(c.byKey.get('CASH')!, '1200.00')] }); + const res = await api.post(`/api/fiscal-years/${year!.id}/close`); + const closing = (await api.get(`/api/journal-entries/${res.json().closingEntryId}`)).json(); + expect(closing.lines.find((l: { accountCode: string }) => l.accountCode === '3200')).toMatchObject({ debit: '1200.00', credit: '0.00' }); + }); + + it('closes a year with no activity without a closing entry', async () => { + const s = await register(t.app); + const [year] = await fiscalYears(t.app, s.token); + const res = await client(t.app, s.token).post(`/api/fiscal-years/${year!.id}/close`); + expect(res.json()).toEqual({ closingEntryId: null }); + }); + + it('requires earlier years to be closed first', async () => { + const s = await register(t.app); + const api = client(t.app, s.token); + const [current] = await fiscalYears(t.app, s.token); + await api.post('/api/fiscal-years', { startDate: `${Number(current!.startDate.slice(0, 4)) - 1}-01-01` }); + const res = await api.post(`/api/fiscal-years/${current!.id}/close`); + expect(res.json().error.code).toBe('EARLIER_YEAR_OPEN'); + }); +}); diff --git a/alshuyukh-accounting/apps/api/test/global-setup.ts b/alshuyukh-accounting/apps/api/test/global-setup.ts new file mode 100644 index 000000000000..e8278df5e713 --- /dev/null +++ b/alshuyukh-accounting/apps/api/test/global-setup.ts @@ -0,0 +1,20 @@ +import pg from 'pg'; +import { migrate } from '../src/db/migrate.js'; +import './env.js'; + +/** Rebuilds the test database schema from scratch, then applies all migrations. */ +export default async function setup() { + const url = process.env.TEST_DATABASE_URL_MIGRATE!; + if (!/_test\b/.test(new URL(url).pathname)) throw new Error('Refusing to reset a database whose name does not end in _test'); + const client = new pg.Client({ connectionString: url }); + await client.connect(); + await client.query('DROP SCHEMA public CASCADE; CREATE SCHEMA public;'); + await client.end(); + await migrate(url); + // Module tests should not depend on the trial plan's limits; subscription tests set their own. + const c2 = new pg.Client({ connectionString: url }); + await c2.connect(); + await c2.query(`UPDATE plans SET max_users = NULL, max_companies = NULL, max_branches = NULL, max_warehouses = NULL, max_products = NULL, + max_invoices_per_month = NULL, max_storage_mb = NULL, max_api_calls_per_month = NULL WHERE code = 'TRIAL'`); + await c2.end(); +} diff --git a/alshuyukh-accounting/apps/api/test/hardening.test.ts b/alshuyukh-accounting/apps/api/test/hardening.test.ts new file mode 100644 index 000000000000..4b60a96d7199 --- /dev/null +++ b/alshuyukh-accounting/apps/api/test/hardening.test.ts @@ -0,0 +1,141 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildApp } from '../src/app.js'; +import { loadEnv } from '../src/config/env.js'; +import { createPool } from '../src/db/pool.js'; +import { migrate } from '../src/db/migrate.js'; +import { addMember, client, register, setupApp, type TestContext } from './helpers.js'; + +let t: TestContext; +beforeAll(async () => { t = await setupApp(); }); +afterAll(async () => { await t.close(); }); + +describe('configuration', () => { + const base = { DATABASE_URL: 'postgres://x', JWT_SECRET: 'a'.repeat(40) }; + it('refuses unsafe production settings', () => { + expect(() => loadEnv({ ...base, NODE_ENV: 'production', JWT_SECRET: 'change-me-' + 'a'.repeat(40), ZATCA_ENCRYPTION_KEY: 'x' })).toThrow(/JWT_SECRET/); + expect(() => loadEnv({ ...base, NODE_ENV: 'production' })).toThrow(/ZATCA_ENCRYPTION_KEY/); + expect(() => loadEnv({ ...base, JWT_SECRET: 'short' })).toThrow(/at least 32/); + expect(() => loadEnv({ ...base, NODE_ENV: 'production', ZATCA_ENCRYPTION_KEY: Buffer.alloc(32).toString('base64') })).not.toThrow(); + }); +}); + +describe('probes', () => { + it('reports liveness and readiness', async () => { + expect((await t.app.inject({ method: 'GET', url: '/api/health' })).json()).toEqual({ status: 'ok' }); + expect((await t.app.inject({ method: 'GET', url: '/api/ready' })).json()).toEqual({ status: 'ready' }); + }); +}); + +describe('migrations', () => { + it('are idempotent and refuse a migration edited after it ran', async () => { + const url = process.env.TEST_DATABASE_URL_MIGRATE!; + await expect(migrate(url)).resolves.toBeUndefined(); + const { rows: [m] } = await t.ownerPool.query<{ version: string; checksum: string }>(`SELECT version, checksum FROM schema_migrations ORDER BY version LIMIT 1`); + await t.ownerPool.query(`UPDATE schema_migrations SET checksum = 'tampered' WHERE version = $1`, [m!.version]); + try { + await expect(migrate(url)).rejects.toThrow(/was modified after it was applied/); + } finally { + await t.ownerPool.query(`UPDATE schema_migrations SET checksum = $2 WHERE version = $1`, [m!.version, m!.checksum]); + } + }); +}); + +describe('errors', () => { + it('hides internal errors from clients and records them for the platform', async () => { + const env = loadEnv(); + const pool = createPool(env.DATABASE_URL); + const app = await buildApp({ env, pool, logger: false }); + app.get('/api/__boom', async () => { throw new Error('secret internal detail'); }); + await app.ready(); + try { + const res = await app.inject({ method: 'GET', url: '/api/__boom?x=1' }); + expect(res.statusCode).toBe(500); + expect(res.json()).toEqual({ error: { code: 'INTERNAL_ERROR', message: 'Internal server error' } }); + expect(res.body).not.toContain('secret'); + // Logged asynchronously; the response never waits for it. + let rows: { path: string; message: string }[] = []; + for (let i = 0; i < 20 && !rows.length; i++) { + rows = (await t.ownerPool.query(`SELECT path, message FROM system_errors WHERE message = 'secret internal detail'`)).rows; + if (!rows.length) await new Promise((r) => setTimeout(r, 50)); + } + expect(rows).toEqual([{ path: '/api/__boom', message: 'secret internal detail' }]); + } finally { + await app.close(); + await pool.end(); + } + }); + + it('maps database constraint errors to safe client errors', async () => { + const s = await register(t.app); + const api = client(t.app, s.token); + const a = await api.post('/api/cost-centers', { code: 'DUP', name: 'مركز' }); + expect(a.statusCode).toBe(201); + const dup = await api.post('/api/cost-centers', { code: 'DUP', name: 'مركز آخر' }); + expect(dup.statusCode).toBe(409); + expect(dup.json().error.code).toBe('DUPLICATE'); + expect(dup.body).not.toMatch(/cost_centers|constraint|duplicate key/i); + }); + + it('rejects malformed and oversized input', async () => { + const s = await register(t.app); + const api = client(t.app, s.token); + expect((await api.get('/api/journal-entries?limit=100000')).statusCode).toBe(400); + expect((await api.get('/api/reports/profit-loss?dateFrom=2026-02-30&dateTo=2026-03-01')).statusCode).toBe(400); + expect((await api.get('/api/customers/not-a-uuid')).statusCode).toBe(400); + const big = await t.app.inject({ method: 'POST', url: '/api/customers', headers: { authorization: `Bearer ${s.token}`, 'content-type': 'application/json' }, + payload: JSON.stringify({ nameAr: 'x'.repeat(2 * 1024 * 1024) }) }); + expect(big.statusCode).toBe(413); + }); +}); + +describe('expense categories', () => { + it('creates, renames, deactivates and validates categories', async () => { + const s = await register(t.app); + const api = client(t.app, s.token); + const accounts = (await api.get('/api/accounts?postableOnly=true')).json().data as { id: string; code: string; type: string }[]; + const travel = accounts.find((a) => a.code === '6200')!; + const cat = await api.post('/api/expense-categories', { code: 'travel', nameAr: 'سفر', accountId: travel.id, vatCategory: 'S' }); + expect(cat.statusCode).toBe(201); + expect(cat.json()).toMatchObject({ code: 'TRAVEL', accountCode: '6200', isActive: true }); + expect((await api.post('/api/expense-categories', { code: 'TRAVEL', nameAr: 'سفر', accountId: travel.id })).statusCode).toBe(409); + const cash = accounts.find((a) => a.code === '1100')!; + expect((await api.post('/api/expense-categories', { code: 'BAD', nameAr: 'حساب أصل', accountId: cash.id })).json().error.code).toBe('INVALID_ACCOUNT'); + const calc = (await api.post('/api/expenses/calculate', { expenseDate: '2026-03-10', lines: [{ categoryId: cat.json().id, amount: '200' }] })).json(); + expect(calc.totals).toEqual({ subtotal: '200.00', taxAmount: '30.00', total: '230.00' }); + const upd = await api.patch(`/api/expense-categories/${cat.json().id}`, { nameAr: 'سفر وتنقلات', isActive: false }); + expect(upd.json()).toMatchObject({ nameAr: 'سفر وتنقلات', isActive: false }); + const other = client(t.app, (await register(t.app)).token); + expect((await other.patch(`/api/expense-categories/${cat.json().id}`, { nameAr: 'اختراق' })).statusCode).toBe(404); + // A deactivated category cannot be used on new expenses. + expect((await api.post('/api/expenses/calculate', { expenseDate: '2026-03-10', lines: [{ categoryId: cat.json().id, amount: '200' }] })).statusCode).toBe(400); + }); +}); + +describe('member management', () => { + it('does not let a user manager disable or edit someone with more access', async () => { + const owner = await register(t.app); + const api = client(t.app, owner.token); + const role = await api.post('/api/roles', { code: 'USER_DESK', nameAr: 'مكتب المستخدمين', nameEn: 'User desk', permissions: ['user.view', 'user.manage', 'role.view'] }); + expect(role.statusCode).toBe(201); + const desk = await addMember(t.app, owner, 'VIEWER'); + const admin = await addMember(t.app, owner, 'COMPANY_ADMIN'); + expect((await api.put(`/api/users/${desk.userId}/roles`, { roleIds: [role.json().id] })).statusCode).toBe(200); + const deskApi = client(t.app, desk.token); + expect((await deskApi.patch(`/api/users/${admin.userId}`, { status: 'DISABLED' })).statusCode).toBe(403); + expect((await deskApi.put(`/api/users/${admin.userId}/roles`, { roleIds: [role.json().id] })).statusCode).toBe(403); + // A member with no more access than the manager can still be managed. + const peer = await addMember(t.app, owner, 'VIEWER'); + expect((await deskApi.patch(`/api/users/${peer.userId}`, { status: 'DISABLED' })).statusCode).toBe(403); + expect((await api.put(`/api/users/${peer.userId}/roles`, { roleIds: [role.json().id] })).statusCode).toBe(200); + expect((await deskApi.patch(`/api/users/${peer.userId}`, { status: 'DISABLED' })).statusCode).toBe(200); + }); +}); + +describe('reports', () => { + it('refuses report ranges longer than five years', async () => { + const s = await register(t.app); + const res = await client(t.app, s.token).get('/api/reports/profit-loss?dateFrom=2015-01-01&dateTo=2026-12-31'); + expect(res.statusCode).toBe(400); + expect(res.json().error.code).toBe('RANGE_TOO_LONG'); + }); +}); diff --git a/alshuyukh-accounting/apps/api/test/helpers.ts b/alshuyukh-accounting/apps/api/test/helpers.ts new file mode 100644 index 000000000000..85ad456bad71 --- /dev/null +++ b/alshuyukh-accounting/apps/api/test/helpers.ts @@ -0,0 +1,112 @@ +import { randomUUID } from 'node:crypto'; +import type { FastifyInstance, LightMyRequestResponse } from 'fastify'; +import pg from 'pg'; +import { buildApp } from '../src/app.js'; +import { loadEnv } from '../src/config/env.js'; +import { createPool } from '../src/db/pool.js'; + +export interface TestContext { + app: FastifyInstance; + pool: pg.Pool; // restricted app role (RLS applies) + ownerPool: pg.Pool; // schema owner (bypasses RLS) — only for assertions + close(): Promise; +} + +export async function setupApp(): Promise { + const env = loadEnv(); + const pool = createPool(env.DATABASE_URL); + const ownerPool = createPool(process.env.DATABASE_URL_MIGRATE!); + const app = await buildApp({ env, pool, logger: false }); + await app.ready(); + return { app, pool, ownerPool, close: async () => { await app.close(); await pool.end(); await ownerPool.end(); } }; +} + +export const PASSWORD = 'Str0ng-Passw0rd!'; + +export interface Session { + token: string; + refreshCookie: string; + tenantId: string; + userId: string; + email: string; +} + +export function refreshCookieOf(res: LightMyRequestResponse): string { + const c = res.cookies.find((x) => x.name === 'ash_rt'); + if (!c) throw new Error('refresh cookie not set'); + return c.value; +} + +export async function register(app: FastifyInstance, overrides: Record = {}): Promise { + const email = `owner-${randomUUID()}@example.test`; + const res = await app.inject({ + method: 'POST', url: '/api/auth/register', + payload: { fullName: 'Test Owner', email, password: PASSWORD, tenantName: 'Test Org', companyName: `Company ${randomUUID().slice(0, 8)}`, ...overrides }, + }); + if (res.statusCode !== 201) throw new Error(`register failed: ${res.statusCode} ${res.body}`); + const body = res.json(); + return { token: body.accessToken, refreshCookie: refreshCookieOf(res), tenantId: body.tenantId, userId: body.userId, email }; +} + +export async function login(app: FastifyInstance, email: string, password = PASSWORD, tenantId?: string): Promise { + const res = await app.inject({ method: 'POST', url: '/api/auth/login', payload: { email, password, tenantId } }); + if (res.statusCode !== 200) throw new Error(`login failed: ${res.statusCode} ${res.body}`); + const body = res.json(); + return { token: body.accessToken, refreshCookie: refreshCookieOf(res), tenantId: body.tenantId, userId: body.userId, email }; +} + +/** Small request helper with a bearer token. */ +export function client(app: FastifyInstance, token: string) { + const call = (method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE') => (url: string, payload?: unknown) => + app.inject({ method, url, payload: payload as never, headers: { authorization: `Bearer ${token}` } }); + return { get: call('GET'), post: call('POST'), patch: call('PATCH'), put: call('PUT'), del: call('DELETE') }; +} + +export async function roleId(app: FastifyInstance, token: string, code: string): Promise { + const res = await client(app, token).get('/api/roles'); + const role = res.json().data.find((r: { code: string }) => r.code === code); + if (!role) throw new Error(`role ${code} not found`); + return role.id; +} + +/** Adds a member with the given system role and logs them in. */ +export async function addMember(app: FastifyInstance, owner: Session, roleCode: string): Promise { + const email = `${roleCode.toLowerCase()}-${randomUUID()}@example.test`; + const res = await client(app, owner.token).post('/api/users', { + email, fullName: `Member ${roleCode}`, initialPassword: PASSWORD, roleIds: [await roleId(app, owner.token, roleCode)], + }); + if (res.statusCode !== 201) throw new Error(`addMember failed: ${res.statusCode} ${res.body}`); + return login(app, email, PASSWORD, owner.tenantId); +} + +// Accounting helpers ------------------------------------------------------------ + +export interface Chart { byCode: Map; byKey: Map } + +export async function chart(app: FastifyInstance, token: string): Promise { + const res = await client(app, token).get('/api/accounts?includeInactive=true'); + if (res.statusCode !== 200) throw new Error(`accounts failed: ${res.body}`); + const rows = res.json().data as { id: string; code: string; systemKey: string | null }[]; + return { + byCode: new Map(rows.map((r) => [r.code, r.id])), + byKey: new Map(rows.filter((r) => r.systemKey).map((r) => [r.systemKey!, r.id])), + }; +} + +export interface FiscalYear { + id: string; name: string; startDate: string; endDate: string; status: string; + periods: { id: string; periodNumber: number; startDate: string; endDate: string; status: string }[]; +} + +export async function fiscalYears(app: FastifyInstance, token: string): Promise { + return (await client(app, token).get('/api/fiscal-years')).json().data; +} + +/** A date inside the first fiscal year, `month` 1-12 relative to its start. */ +export async function dateInYear(app: FastifyInstance, token: string, month = 3, day = 15): Promise { + const [year] = await fiscalYears(app, token); + return year!.periods[month - 1]!.startDate.slice(0, 8) + String(day).padStart(2, '0'); +} + +export const dr = (accountId: string, amount: string) => ({ accountId, debit: amount }); +export const cr = (accountId: string, amount: string) => ({ accountId, credit: amount }); diff --git a/alshuyukh-accounting/apps/api/test/inventory.test.ts b/alshuyukh-accounting/apps/api/test/inventory.test.ts new file mode 100644 index 000000000000..0b9e54db23a1 --- /dev/null +++ b/alshuyukh-accounting/apps/api/test/inventory.test.ts @@ -0,0 +1,268 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { withTx } from '../src/db/tx.js'; +import { commerce, type Commerce } from './commerce-helpers.js'; +import { addMember, chart, client, fiscalYears, register, setupApp, type Chart, type Session, type TestContext } from './helpers.js'; + +let t: TestContext; +let owner: Session; +let c: Commerce; +let ch: Chart; +let main: string; +let second: string; + +beforeAll(async () => { + t = await setupApp(); + owner = await register(t.app); + c = await commerce(t.app, owner); + ch = await chart(t.app, owner.token); + const company = (await c.api.get('/api/companies')).json().data[0]; + main = (await c.api.get(`/api/companies/${company.id}/warehouses`)).json().data[0].id; + second = (await c.api.post(`/api/companies/${company.id}/warehouses`, { code: 'JED', name: 'مستودع جدة' })).json().id; +}); +afterAll(async () => { await t.close(); }); + +const balance = async (productId: string, warehouseId = main) => { + const rows = (await c.api.get(`/api/inventory/balances?productId=${productId}&warehouseId=${warehouseId}&includeZero=true`)).json().data; + return rows[0] ? { quantity: rows[0].quantity, value: rows[0].value } : { quantity: '0', value: '0' }; +}; +const reconciled = async () => { + const v = (await c.api.get('/api/inventory/valuation')).json(); + expect(v.difference, `stock ${v.stockValue} vs ledger ${v.ledgerBalance}`).toBe('0.00'); + return v; +}; +const adjustIn = (productId: string, quantity: string, unitCost: string, warehouseId = main, offsetAccountId?: string) => + c.api.post('/api/stock-adjustments', { warehouseId, date: c.date, reason: 'رصيد افتتاحي', offsetAccountId, lines: [{ productId, direction: 'IN', quantity, unitCost }] }); +const sell = (customerId: string, productId: string, quantity: string, extra = {}) => + c.postInvoice(customerId, [{ productId, quantity, unitPrice: '100' }], extra); +const entryLines = async (journalEntryId: string) => (await c.journal(journalEntryId)).map((l) => [l.accountCode, l.debit, l.credit]); + +describe('receipts, sales and weighted average cost', () => { + let product: string; + let customer: string; + + beforeAll(async () => { + product = (await c.goods()).id; + customer = (await c.customer()).id; + }); + + it('records opening stock with an adjustment against capital', async () => { + const res = await adjustIn(product, '10', '50', main, ch.byKey.get('CAPITAL')); + expect(res.statusCode).toBe(201); + expect(res.json()).toMatchObject({ totalIncrease: '500.00', offsetAccountCode: '3100' }); + expect(await entryLines(res.json().journalEntryId)).toEqual([['1400', '500.00', '0.00'], ['3100', '0.00', '500.00']]); + expect(await balance(product)).toEqual({ quantity: '10.0000', value: '500.00' }); + await reconciled(); + }); + + it('adds purchases at the net purchase price and moves the average', async () => { + const sup = await c.supplier(); + await c.purchase(sup.id, [{ productId: product, quantity: '10', unitPrice: '60' }]); + expect(await balance(product)).toEqual({ quantity: '20.0000', value: '1100.00' }); + await reconciled(); + }); + + it('posts cost of goods sold with the sale: Dr COGS / Cr Inventory at average cost', async () => { + const inv = await sell(customer, product, '4'); + expect(await entryLines(inv.journalEntryId)).toEqual([ + ['1300', '460.00', '0.00'], ['4100', '0.00', '400.00'], ['2210', '0.00', '60.00'], + ['5100', '220.00', '0.00'], ['1400', '0.00', '220.00'], + ]); + expect(await balance(product)).toEqual({ quantity: '16.0000', value: '880.00' }); + const card = (await c.api.get(`/api/inventory/movements?productId=${product}`)).json().data; + expect(card[0]).toMatchObject({ type: 'SALE', direction: 'OUT', quantity: '4.0000', totalCost: '220.00', unitCost: '55.000000', balanceQuantity: '16.0000', balanceValue: '880.00' }); + await reconciled(); + }); + + it('refuses to sell more than is in stock and changes nothing', async () => { + const draft = await c.invoice(customer, [{ productId: product, quantity: '100', unitPrice: '100' }]); + const res = await c.api.post(`/api/invoices/${draft.id}/post`); + expect(res.statusCode).toBe(409); + expect(res.json().error.code).toBe('INSUFFICIENT_STOCK'); + expect((await c.api.get(`/api/invoices/${draft.id}`)).json().status).toBe('DRAFT'); + expect(await balance(product)).toEqual({ quantity: '16.0000', value: '880.00' }); + }); + + it('brings returned goods back at the cost they left at, exactly', async () => { + const inv = await sell(customer, product, '3'); // 3 × 55 = 165 + const ret = async (q: string) => { + const r = (await c.api.post('/api/sales-returns', { originalInvoiceId: inv.id, docDate: c.date, reason: 'إرجاع', lines: [{ sourceItemId: inv.lines[0].id, quantity: q }] })).json(); + return (await c.api.post(`/api/sales-returns/${r.id}/post`)).json(); + }; + const r1 = await ret('1'); + expect((await entryLines(r1.journalEntryId)).slice(-2)).toEqual([['5100', '0.00', '55.00'], ['1400', '55.00', '0.00']]); + const r2 = await ret('2'); + expect((await entryLines(r2.journalEntryId)).slice(-2)).toEqual([['5100', '0.00', '110.00'], ['1400', '110.00', '0.00']]); + expect(await balance(product)).toEqual({ quantity: '16.0000', value: '880.00' }); + await reconciled(); + }); + + it('restores stock when a sale is cancelled', async () => { + const inv = await sell(customer, product, '2'); + expect(await balance(product)).toEqual({ quantity: '14.0000', value: '770.00' }); + await c.api.post(`/api/invoices/${inv.id}/cancel`, { reason: 'خطأ' }); + expect(await balance(product)).toEqual({ quantity: '16.0000', value: '880.00' }); + const card = (await c.api.get(`/api/inventory/movements?productId=${product}`)).json().data; + expect(card[0]).toMatchObject({ type: 'CANCELLATION_IN', totalCost: '110.00' }); + await reconciled(); + }); +}); + +describe('rounding', () => { + it('issues 100.00 over 3 units at the running average and leaves nothing behind', async () => { + const product = (await c.goods()).id; + const cust = (await c.customer()).id; + await adjustIn(product, '3', '33.333333'); + expect(await balance(product)).toEqual({ quantity: '3.0000', value: '100.00' }); + const costs = []; + for (let i = 0; i < 3; i++) { + const inv = await sell(cust, product, '1'); + costs.push((await c.journal(inv.journalEntryId)).find((l) => l.accountCode === '5100')!.debit); + } + // 100.00 / 3 = 33.33; then 66.67 / 2 = 33.335 → 33.34; the last unit takes the remaining 33.33. + expect(costs).toEqual(['33.33', '33.34', '33.33']); + expect(await balance(product)).toEqual({ quantity: '0.0000', value: '0.00' }); + await reconciled(); + }); +}); + +describe('purchase returns and cancellations', () => { + it('returns goods at average cost and books the price difference to COGS', async () => { + const product = (await c.goods()).id; + const sup = (await c.supplier()).id; + await c.purchase(sup, [{ productId: product, quantity: '10', unitPrice: '10' }]); + const expensive = await c.purchase(sup, [{ productId: product, quantity: '10', unitPrice: '20' }]); // average 15 + const r = (await c.api.post('/api/purchase-returns', { originalInvoiceId: expensive.id, docDate: c.date, reason: 'معيب', lines: [{ sourceItemId: expensive.lines[0].id, quantity: '5' }] })).json(); + const ret = (await c.api.post(`/api/purchase-returns/${r.id}/post`)).json(); + expect(await entryLines(ret.journalEntryId)).toEqual([ + ['2100', '115.00', '0.00'], ['1400', '0.00', '75.00'], ['5100', '0.00', '25.00'], ['2220', '0.00', '15.00'], + ]); + expect(await balance(product)).toEqual({ quantity: '15.0000', value: '225.00' }); + await reconciled(); + }); + + it('refuses to cancel a purchase whose goods were already used', async () => { + const product = (await c.goods()).id; + const bill = await c.purchase((await c.supplier()).id, [{ productId: product, quantity: '5', unitPrice: '10' }]); + await sell((await c.customer()).id, product, '3'); + const res = await c.api.post(`/api/purchase-invoices/${bill.id}/cancel`, { reason: 'إلغاء' }); + expect(res.json().error.code).toBe('INSUFFICIENT_STOCK'); + }); + + it('expenses leftover value when cancelling a purchase empties the warehouse', async () => { + const product = (await c.goods()).id; + const sup = (await c.supplier()).id; + await c.purchase(sup, [{ productId: product, quantity: '10', unitPrice: '20' }]); + const cheap = await c.purchase(sup, [{ productId: product, quantity: '10', unitPrice: '10' }]); // 20 units, 300.00 + await sell((await c.customer()).id, product, '10'); // 150.00 out; 10 left worth 150.00 + const res = await c.api.post(`/api/purchase-invoices/${cheap.id}/cancel`, { reason: 'فاتورة مكررة' }); + expect(res.json().status).toBe('CANCELLED'); + expect(await balance(product)).toEqual({ quantity: '0.0000', value: '0.00' }); + const residual = (await c.api.get('/api/journal-entries?referenceType=INVENTORY_RESIDUAL')).json().data; + expect(residual[0].totalDebit).toBe('50.00'); + await reconciled(); + }); +}); + +describe('transfers', () => { + it('moves stock between warehouses at average cost without a journal entry', async () => { + const product = (await c.goods()).id; + await adjustIn(product, '10', '12'); + const entriesBefore = (await c.api.get('/api/journal-entries')).json().total; + const res = await c.api.post('/api/stock-transfers', { fromWarehouseId: main, toWarehouseId: second, date: c.date, lines: [{ productId: product, quantity: '4' }] }); + expect(res.statusCode).toBe(201); + expect(res.json()).toMatchObject({ totalCost: '48.00', fromWarehouseId: main, toWarehouseId: second }); + expect(res.json().number).toMatch(/^TRF-\d{6}$/); + expect(await balance(product, main)).toEqual({ quantity: '6.0000', value: '72.00' }); + expect(await balance(product, second)).toEqual({ quantity: '4.0000', value: '48.00' }); + expect((await c.api.get('/api/journal-entries')).json().total).toBe(entriesBefore); + await reconciled(); + + expect((await c.api.post('/api/stock-transfers', { fromWarehouseId: second, toWarehouseId: main, date: c.date, lines: [{ productId: product, quantity: '5' }] })).json().error.code).toBe('INSUFFICIENT_STOCK'); + expect((await c.api.post('/api/stock-transfers', { fromWarehouseId: main, toWarehouseId: main, date: c.date, lines: [{ productId: product, quantity: '1' }] })).statusCode).toBe(400); + }); + + it('sells from the warehouse chosen on the invoice', async () => { + const product = (await c.goods()).id; + await adjustIn(product, '5', '10', second); + const res = await c.api.post('/api/invoices', { partyId: (await c.customer()).id, docDate: c.date, warehouseId: second, lines: [{ productId: product, quantity: '2', unitPrice: '50' }] }); + const inv = (await c.api.post(`/api/invoices/${res.json().id}/post`)).json(); + expect(inv.status).toBe('ISSUED'); + expect(await balance(product, second)).toEqual({ quantity: '3.0000', value: '30.00' }); + }); +}); + +describe('stock counts and adjustments', () => { + it('adjusts to the counted quantity at average cost', async () => { + const product = (await c.goods()).id; + await adjustIn(product, '10', '8'); + const res = await c.api.post('/api/stock-adjustments', { warehouseId: main, date: c.date, reason: 'جرد نهاية الشهر', lines: [{ productId: product, countedQuantity: '7' }] }); + expect(res.statusCode).toBe(201); + expect(res.json().lines[0]).toMatchObject({ direction: 'OUT', quantity: '3.0000', countedQuantity: '7.0000', systemQuantity: '10.0000', totalCost: '24.00' }); + expect(await entryLines(res.json().journalEntryId)).toEqual([['5200', '24.00', '0.00'], ['1400', '0.00', '24.00']]); + const same = await c.api.post('/api/stock-adjustments', { warehouseId: main, date: c.date, reason: 'جرد ثان', lines: [{ productId: product, countedQuantity: '7' }] }); + expect(same.json().error.code).toBe('NOTHING_TO_ADJUST'); + await reconciled(); + }); + + it('validates adjustments', async () => { + const product = (await c.goods()).id; + expect((await c.api.post('/api/stock-adjustments', { warehouseId: main, date: c.date, reason: 'بلا تكلفة', lines: [{ productId: product, direction: 'IN', quantity: '1' }] })).json().error.code).toBe('UNIT_COST_REQUIRED'); + expect((await c.api.post('/api/stock-adjustments', { warehouseId: main, date: c.date, reason: 'نقص', lines: [{ productId: product, direction: 'OUT', quantity: '1' }] })).json().error.code).toBe('INSUFFICIENT_STOCK'); + expect((await adjustIn(product, '1', '5', main, ch.byKey.get('INVENTORY'))).json().error.code).toBe('INVALID_OFFSET_ACCOUNT'); + const service = (await c.product()).id; + expect((await adjustIn(service, '1', '5')).json().error.code).toBe('NOT_STOCKED'); + }); +}); + +describe('product rules', () => { + it('locks type and tracking, and deletion, once a product has stock history', async () => { + const product = (await c.goods()).id; + await adjustIn(product, '1', '5'); + expect((await c.api.patch(`/api/products/${product}`, { productType: 'SERVICE' })).json().error.code).toBe('PRODUCT_HAS_STOCK_HISTORY'); + expect((await c.api.patch(`/api/products/${product}`, { trackInventory: false })).json().error.code).toBe('PRODUCT_HAS_STOCK_HISTORY'); + expect((await c.api.del(`/api/products/${product}`)).json().error.code).toBe('PRODUCT_IN_USE'); + const listed = (await c.api.get(`/api/products?search=${encodeURIComponent((await c.api.get(`/api/products/${product}`)).json().sku)}`)).json().data[0]; + expect(listed.onHand).toBe('1.0000'); + }); +}); + +describe('integrity, permissions and isolation', () => { + it('keeps movements append-only and balances non-negative in the database', async () => { + const product = (await c.goods()).id; + await adjustIn(product, '2', '5'); + const ctx = { tenantId: owner.tenantId, userId: owner.userId }; + await expect(withTx(t.pool, ctx, (db) => db.query(`UPDATE stock_movements SET quantity = 99 WHERE product_id = $1`, [product]))).rejects.toThrow(/permission denied|append-only/); + await expect(withTx(t.pool, ctx, (db) => db.query(`DELETE FROM stock_movements WHERE product_id = $1`, [product]))).rejects.toThrow(/permission denied|append-only/); + await expect(withTx(t.pool, ctx, (db) => db.query(`UPDATE inventory_balances SET quantity = -1 WHERE product_id = $1`, [product]))).rejects.toThrow(/check constraint/); + await expect(withTx(t.pool, ctx, (db) => db.query(`UPDATE inventory_balances SET quantity = 0 WHERE product_id = $1`, [product]))).rejects.toThrow(/empty_ck/); + }); + + it('applies inventory permissions', async () => { + const product = (await c.goods()).id; + await adjustIn(product, '5', '5'); + const emp = await addMember(t.app, owner, 'WAREHOUSE_EMPLOYEE'); + const eApi = client(t.app, emp.token); + expect((await eApi.post('/api/stock-transfers', { fromWarehouseId: main, toWarehouseId: second, date: c.date, lines: [{ productId: product, quantity: '1' }] })).statusCode).toBe(201); + expect((await eApi.post('/api/stock-adjustments', { warehouseId: main, date: c.date, reason: 'تجربة', lines: [{ productId: product, countedQuantity: '1' }] })).statusCode).toBe(403); + const sales = await addMember(t.app, owner, 'SALES_EMPLOYEE'); + expect((await client(t.app, sales.token).get('/api/inventory/balances')).statusCode).toBe(403); + }); + + it('isolates stock between tenants', async () => { + const product = (await c.goods()).id; + await adjustIn(product, '5', '5'); + const other = await register(t.app); + const oc = await commerce(t.app, other); + expect((await oc.api.get('/api/inventory/balances')).json().data).toEqual([]); + expect((await oc.api.get(`/api/inventory/movements?productId=${product}`)).json().total).toBe(0); + const res = await oc.api.post('/api/stock-transfers', { fromWarehouseId: main, toWarehouseId: second, date: oc.date, lines: [{ productId: product, quantity: '1' }] }); + expect(res.statusCode).toBe(400); + }); + + it('ends with stock value equal to the ledger and a balanced trial balance', async () => { + const v = await reconciled(); + expect(v.reconciled).toBe(true); + const [year] = await fiscalYears(t.app, owner.token); + expect((await c.api.get(`/api/reports/trial-balance?dateFrom=${year!.startDate}&dateTo=${year!.endDate}`)).json().balanced).toBe(true); + }); +}); diff --git a/alshuyukh-accounting/apps/api/test/journal-entries.test.ts b/alshuyukh-accounting/apps/api/test/journal-entries.test.ts new file mode 100644 index 000000000000..e447b0e1ff7d --- /dev/null +++ b/alshuyukh-accounting/apps/api/test/journal-entries.test.ts @@ -0,0 +1,317 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { withTx } from '../src/db/tx.js'; +import { postEntry } from '../src/modules/accounting/engine.js'; +import { + addMember, chart, client, cr, dateInYear, dr, fiscalYears, register, setupApp, + type Chart, type Session, type TestContext, +} from './helpers.js'; + +let t: TestContext; +let owner: Session; +let c: Chart; +let date: string; + +beforeAll(async () => { + t = await setupApp(); + owner = await register(t.app); + c = await chart(t.app, owner.token); + date = await dateInYear(t.app, owner.token, 3, 15); +}); +afterAll(async () => { await t.close(); }); + +const api = () => client(t.app, owner.token); +const key = (k: string) => c.byKey.get(k)!; +const code = (k: string) => c.byCode.get(k)!; +const create = (lines: unknown[], extra: Record = {}) => + api().post('/api/journal-entries', { entryDate: date, description: 'قيد اختبار', lines, ...extra }); + +describe('the required example: invoice 1000 SAR + VAT 150 SAR', () => { + it('posts AR 1150 debit, Sales 1000 credit, VAT 150 credit through the engine', async () => { + const entry = await withTx(t.pool, { tenantId: owner.tenantId, userId: owner.userId }, async (db) => { + const { rows: [company] } = await db.query<{ id: string }>('SELECT id FROM companies WHERE tenant_id = $1', [owner.tenantId]); + return postEntry(db, { tenantId: owner.tenantId, userId: owner.userId }, { + companyId: company!.id, entryDate: date, description: 'فاتورة مبيعات INV-0001', + referenceType: 'SALES_INVOICE', source: 'SYSTEM', + lines: [ + dr(key('ACCOUNTS_RECEIVABLE'), '1150.00'), + cr(key('SALES'), '1000.00'), + cr(key('VAT_OUTPUT'), '150.00'), + ], + }); + }); + + expect(entry.status).toBe('POSTED'); + expect(entry.totalDebit).toBe('1150.00'); + expect(entry.totalCredit).toBe('1150.00'); + expect(entry.lines.map((l) => [l.accountCode, l.debit, l.credit])).toEqual([ + ['1300', '1150.00', '0.00'], + ['4100', '0.00', '1000.00'], + ['2210', '0.00', '150.00'], + ]); + + // Account balances come only from posted journal lines. + const ar = (await api().get(`/api/accounts/${key('ACCOUNTS_RECEIVABLE')}`)).json(); + const sales = (await api().get(`/api/accounts/${key('SALES')}`)).json(); + const vat = (await api().get(`/api/accounts/${key('VAT_OUTPUT')}`)).json(); + expect(ar.balance).toBe('1150.00'); + expect(sales.balance).toBe('-1000.00'); + expect(vat.balance).toBe('-150.00'); + }); + + it('refuses to reverse a document-generated entry manually', async () => { + const list = (await api().get('/api/journal-entries?referenceType=SALES_INVOICE')).json().data; + const res = await api().post(`/api/journal-entries/${list[0].id}/reverse`, { reason: 'خطأ' }); + expect(res.statusCode).toBe(409); + expect(res.json().error.code).toBe('SYSTEM_ENTRY'); + }); +}); + +describe('posting rules', () => { + it('posts a balanced entry and numbers it sequentially', async () => { + const [year] = await fiscalYears(t.app, owner.token); + const a = (await create([dr(code('6100'), '2500.00'), cr(key('BANK'), '2500.00')], { post: true })).json(); + const b = (await create([dr(code('6200'), '8000.00'), cr(key('BANK'), '8000.00')], { post: true })).json(); + const prefix = `JV-${year!.startDate.slice(0, 4)}-`; + expect(a.entryNumber).toMatch(new RegExp(`^${prefix}\\d{6}$`)); + expect(Number(b.entryNumber.slice(-6))).toBe(Number(a.entryNumber.slice(-6)) + 1); + expect(a).toMatchObject({ status: 'POSTED', totalDebit: '2500.00', totalCredit: '2500.00' }); + }); + + it('rejects an unbalanced entry at posting and keeps it as a draft', async () => { + const draft = (await create([dr(code('6300'), '100.00'), cr(key('CASH'), '90.00')])).json(); + expect(draft).toMatchObject({ status: 'DRAFT', entryNumber: null, totalDebit: '100.00', totalCredit: '90.00' }); + const res = await api().post(`/api/journal-entries/${draft.id}/post`); + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatchObject({ code: 'UNBALANCED_ENTRY', details: { totalDebit: '100.00', totalCredit: '90.00', difference: '10.00' } }); + expect((await api().get(`/api/journal-entries/${draft.id}`)).json().status).toBe('DRAFT'); + }); + + it('rolls back completely when create-and-post fails', async () => { + const before = (await api().get('/api/journal-entries?limit=200')).json().total; + const res = await create([dr(code('6300'), '100.00'), cr(key('CASH'), '99.99')], { post: true, description: 'should not exist' }); + expect(res.statusCode).toBe(400); + expect((await api().get('/api/journal-entries?limit=200')).json().total).toBe(before); + }); + + it('uses exact decimal arithmetic (0.10 + 0.20 = 0.30)', async () => { + const res = await create([dr(code('6300'), '0.10'), dr(code('6400'), '0.20'), cr(key('CASH'), '0.30')], { post: true }); + expect(res.statusCode).toBe(201); + expect(res.json().totalDebit).toBe('0.30'); + }); + + it('validates every line', async () => { + const cases = [ + [{ accountId: code('6300'), debit: '10.00', credit: '10.00' }, cr(key('CASH'), '10.00')], // both sides + [{ accountId: code('6300'), debit: '0' }, cr(key('CASH'), '0')], // zero + [dr(code('6300'), '-5.00'), cr(key('CASH'), '-5.00')], // negative + [dr(code('6300'), '1.005'), cr(key('CASH'), '1.005')], // 3 decimals + [{ accountId: code('6300'), debit: 100 }, { accountId: key('CASH'), credit: 100 }], // number, not string + [dr(code('6300'), '10.00')], // single line + ]; + for (const lines of cases) { + const res = await create(lines); + expect(res.statusCode, JSON.stringify(lines)).toBe(400); + } + }); + + it('rejects header, inactive and foreign accounts', async () => { + const header = await create([dr(code('6000'), '10.00'), cr(key('CASH'), '10.00')]); + expect(header.json().error.code).toBe('ACCOUNT_NOT_POSTABLE'); + + const temp = (await api().post('/api/accounts', { code: '6950', nameAr: 'غير نشط', parentId: code('6000') })).json(); + await api().patch(`/api/accounts/${temp.id}`, { isActive: false }); + expect((await create([dr(temp.id, '10.00'), cr(key('CASH'), '10.00')])).json().error.code).toBe('ACCOUNT_INACTIVE'); + + const other = await register(t.app); + const otherChart = await chart(t.app, other.token); + const foreign = await create([dr(otherChart.byKey.get('CASH')!, '10.00'), cr(key('CASH'), '10.00')]); + expect(foreign.json().error.code).toBe('INVALID_ACCOUNT'); + }); + + it('requires an open fiscal period that covers the date', async () => { + const res = await create([dr(code('6300'), '10.00'), cr(key('CASH'), '10.00')], { entryDate: '2010-01-01', post: true }); + expect(res.statusCode).toBe(400); + expect(res.json().error.code).toBe('NO_FISCAL_PERIOD'); + }); + + it('assigns unique gap-free numbers under concurrent posting', async () => { + const drafts = await Promise.all(Array.from({ length: 8 }, () => + create([dr(code('6400'), '1.00'), cr(key('CASH'), '1.00')]).then((r) => r.json().id as string))); + const posted = await Promise.all(drafts.map((id) => api().post(`/api/journal-entries/${id}/post`).then((r) => r.json()))); + const numbers = posted.map((e) => Number(e.entryNumber.slice(-6))).sort((x, y) => x - y); + expect(new Set(numbers).size).toBe(8); + expect(numbers[7]! - numbers[0]!).toBe(7); + }); +}); + +describe('drafts', () => { + it('edits and soft-deletes a draft', async () => { + const draft = (await create([dr(code('6300'), '10.00'), cr(key('CASH'), '10.00')])).json(); + const edited = await api().patch(`/api/journal-entries/${draft.id}`, { + description: 'معدل', lines: [dr(code('6300'), '25.00'), cr(key('BANK'), '25.00')], + }); + expect(edited.json()).toMatchObject({ description: 'معدل', totalDebit: '25.00' }); + expect(edited.json().lines[1].accountCode).toBe('1200'); + expect((await api().del(`/api/journal-entries/${draft.id}`)).statusCode).toBe(204); + expect((await api().get(`/api/journal-entries/${draft.id}`)).statusCode).toBe(404); + }); +}); + +describe('immutability of posted entries', () => { + let entry: { id: string; lines: { id: string }[] }; + + beforeAll(async () => { + entry = (await create([dr(code('6100'), '300.00'), cr(key('CASH'), '300.00')], { post: true })).json(); + }); + + it('refuses edits and deletion through the API', async () => { + expect((await api().patch(`/api/journal-entries/${entry.id}`, { description: 'تعديل' })).statusCode).toBe(409); + expect((await api().del(`/api/journal-entries/${entry.id}`)).statusCode).toBe(409); + expect((await api().post(`/api/journal-entries/${entry.id}/post`)).statusCode).toBe(409); + }); + + it('refuses direct SQL changes from the application role', async () => { + const ctx = { tenantId: owner.tenantId, userId: owner.userId }; + const attempts = [ + `UPDATE journal_entries SET description = 'tampered' WHERE id = '${entry.id}'`, + `UPDATE journal_entries SET status = 'DRAFT' WHERE id = '${entry.id}'`, + `UPDATE journal_entry_lines SET debit = 1 WHERE id = '${entry.lines[0]!.id}'`, + `DELETE FROM journal_entry_lines WHERE id = '${entry.lines[0]!.id}'`, + `INSERT INTO journal_entry_lines (tenant_id, company_id, journal_entry_id, line_no, account_id, debit) + SELECT tenant_id, company_id, id, 99, '${code('6100')}', 5 FROM journal_entries WHERE id = '${entry.id}'`, + `DELETE FROM journal_entries WHERE id = '${entry.id}'`, + ]; + for (const sql of attempts) { + await expect(withTx(t.pool, ctx, (db) => db.query(sql)), sql).rejects.toThrow(); + } + const after = (await api().get(`/api/journal-entries/${entry.id}`)).json(); + expect(after).toMatchObject({ status: 'POSTED', totalDebit: '300.00' }); + }); + + it('enforces the balance rule in the database even if the API is bypassed', async () => { + const ctx = { tenantId: owner.tenantId, userId: owner.userId }; + const draft = (await create([dr(code('6100'), '50.00'), cr(key('CASH'), '40.00')])).json(); + const [year] = await fiscalYears(t.app, owner.token); + const period = year!.periods[2]!; + await expect(withTx(t.pool, ctx, (db) => db.query( + `UPDATE journal_entries SET status = 'POSTED', entry_number = 'FAKE-1', fiscal_year_id = $2, fiscal_period_id = $3, posted_at = now() WHERE id = $1`, + [draft.id, year!.id, period.id]))).rejects.toThrow(/not balanced/); + }); +}); + +describe('reversal and correction', () => { + it('reverses a posted entry with a mirror entry and links both', async () => { + const original = (await create([dr(code('6300'), '700.00'), cr(key('BANK'), '700.00')], { post: true, description: 'حملة إعلانية' })).json(); + const res = await api().post(`/api/journal-entries/${original.id}/reverse`, { reason: 'تسجيل في حساب خاطئ' }); + expect(res.statusCode).toBe(200); + const { original: o, reversal } = res.json(); + expect(o).toMatchObject({ status: 'REVERSED', reversedByEntryId: reversal.id }); + expect(reversal).toMatchObject({ status: 'POSTED', referenceType: 'REVERSAL', referenceId: original.id, reversalOfId: original.id, totalDebit: '700.00' }); + expect(reversal.lines.map((l: { accountCode: string; debit: string; credit: string }) => [l.accountCode, l.debit, l.credit])) + .toEqual([['6300', '0.00', '700.00'], ['1200', '700.00', '0.00']]); + + const again = await api().post(`/api/journal-entries/${original.id}/reverse`, { reason: 'مرة ثانية' }); + expect(again.json().error.code).toBe('ALREADY_REVERSED'); + const ofReversal = await api().post(`/api/journal-entries/${reversal.id}/reverse`, { reason: 'عكس العكس' }); + expect(ofReversal.json().error.code).toBe('CANNOT_REVERSE_REVERSAL'); + }); + + it('creates the correcting entry as a linked draft in the same step', async () => { + const original = (await create([dr(code('6300'), '400.00'), cr(key('CASH'), '400.00')], { post: true })).json(); + const res = await api().post(`/api/journal-entries/${original.id}/reverse`, { + reason: 'الحساب الصحيح هو الكهرباء', + correction: { entryDate: date, description: 'قيد تصحيح', lines: [dr(code('6400'), '400.00'), cr(key('CASH'), '400.00')] }, + }); + expect(res.statusCode).toBe(200); + const correction = res.json().correction; + expect(correction).toMatchObject({ status: 'DRAFT', correctionOfId: original.id }); + expect((await api().post(`/api/journal-entries/${correction.id}/post`)).json().status).toBe('POSTED'); + }); + + it('cannot reverse a draft', async () => { + const draft = (await create([dr(code('6300'), '10.00'), cr(key('CASH'), '10.00')])).json(); + expect((await api().post(`/api/journal-entries/${draft.id}/reverse`, { reason: 'test' })).json().error.code).toBe('ENTRY_NOT_POSTED'); + }); +}); + +describe('fiscal periods', () => { + it('blocks posting into a closed period and allows it after reopening', async () => { + const session = await register(t.app); + const sc = await chart(t.app, session.token); + const sApi = client(t.app, session.token); + const [year] = await fiscalYears(t.app, session.token); + const period = year!.periods[4]!; + const d = period.startDate; + const draft = (await sApi.post('/api/journal-entries', { entryDate: d, description: 'x', lines: [dr(sc.byCode.get('6100')!, '10.00'), cr(sc.byKey.get('CASH')!, '10.00')] })).json(); + + expect((await sApi.post(`/api/fiscal-periods/${period.id}/close`)).statusCode).toBe(200); + const blocked = await sApi.post(`/api/journal-entries/${draft.id}/post`); + expect(blocked.statusCode).toBe(409); + expect(blocked.json().error.code).toBe('PERIOD_CLOSED'); + + expect((await sApi.post(`/api/fiscal-periods/${period.id}/reopen`)).statusCode).toBe(200); + expect((await sApi.post(`/api/journal-entries/${draft.id}/post`)).json().status).toBe('POSTED'); + }); +}); + +describe('permissions and tenant isolation', () => { + it('applies journal permissions per role', async () => { + const lines = [dr(code('6300'), '10.00'), cr(key('CASH'), '10.00')]; + const sales = await addMember(t.app, owner, 'SALES_EMPLOYEE'); + expect((await client(t.app, sales.token).post('/api/journal-entries', { entryDate: date, description: 'x', lines })).statusCode).toBe(403); + expect((await client(t.app, sales.token).get('/api/journal-entries')).statusCode).toBe(403); + + const viewer = await addMember(t.app, owner, 'VIEWER'); + expect((await client(t.app, viewer.token).get('/api/journal-entries')).statusCode).toBe(200); + expect((await client(t.app, viewer.token).post('/api/journal-entries', { entryDate: date, description: 'x', lines })).statusCode).toBe(403); + + const accountant = await addMember(t.app, owner, 'ACCOUNTANT'); + const res = await client(t.app, accountant.token).post('/api/journal-entries', { entryDate: date, description: 'x', lines, post: true }); + expect(res.statusCode).toBe(201); + + const role = await api().post('/api/roles', { code: 'JOURNAL_CLERK', nameAr: 'مدخل قيود', nameEn: 'Clerk', permissions: ['journal.view', 'journal.create', 'account.view'] }); + const clerk = await addMember(t.app, owner, 'VIEWER'); + await api().put(`/api/users/${clerk.userId}/roles`, { roleIds: [role.json().id] }); + const cApi = client(t.app, clerk.token); + expect((await cApi.post('/api/journal-entries', { entryDate: date, description: 'x', lines, post: true })).statusCode).toBe(403); + const draft = (await cApi.post('/api/journal-entries', { entryDate: date, description: 'x', lines })).json(); + expect((await cApi.post(`/api/journal-entries/${draft.id}/post`)).statusCode).toBe(403); + }); + + it('hides entries from other tenants', async () => { + const entry = (await create([dr(code('6300'), '10.00'), cr(key('CASH'), '10.00')], { post: true })).json(); + const other = await register(t.app); + const oApi = client(t.app, other.token); + expect((await oApi.get(`/api/journal-entries/${entry.id}`)).statusCode).toBe(404); + expect((await oApi.post(`/api/journal-entries/${entry.id}/reverse`, { reason: 'hack' })).statusCode).toBe(404); + expect((await oApi.get('/api/journal-entries')).json().total).toBe(0); + }); + + it('records posting and reversal in the audit log', async () => { + const actions = (await api().get('/api/audit-logs?entityType=journal_entry&limit=200')).json().data.map((l: { action: string }) => l.action); + expect(actions).toEqual(expect.arrayContaining(['CREATE', 'POST', 'REVERSE', 'UPDATE', 'DELETE'])); + }); +}); + +describe('trial balance', () => { + it('balances and nets reversed entries to zero', async () => { + const [year] = await fiscalYears(t.app, owner.token); + const res = await api().get(`/api/reports/trial-balance?dateFrom=${year!.startDate}&dateTo=${year!.endDate}`); + expect(res.statusCode).toBe(200); + const tb = res.json(); + expect(tb.balanced).toBe(true); + expect(tb.totals.periodDebit).toBe(tb.totals.periodCredit); + expect(tb.totals.closingDebit).toBe(tb.totals.closingCredit); + const marketing = tb.data.find((r: { code: string }) => r.code === '6300'); + // 700 and 400 were reversed, 100 was never posted; remaining: 0.10 + 10 (accountant) + 10 (isolation test). + expect(marketing.closingDebit).toBe('20.10'); + }); + + it('splits opening and period movements by date', async () => { + const [year] = await fiscalYears(t.app, owner.token); + const tb = (await api().get(`/api/reports/trial-balance?dateFrom=${date.slice(0, 8)}16&dateTo=${year!.endDate}`)).json(); + const ar = tb.data.find((r: { code: string }) => r.code === '1300'); + expect(ar).toMatchObject({ openingDebit: '1150.00', periodDebit: '0.00', closingDebit: '1150.00' }); + expect(tb.balanced).toBe(true); + }); +}); diff --git a/alshuyukh-accounting/apps/api/test/parties.test.ts b/alshuyukh-accounting/apps/api/test/parties.test.ts new file mode 100644 index 000000000000..47b6480678e0 --- /dev/null +++ b/alshuyukh-accounting/apps/api/test/parties.test.ts @@ -0,0 +1,171 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { addMember, chart, client, cr, dateInYear, dr, register, setupApp, type Session, type TestContext } from './helpers.js'; + +let t: TestContext; +let owner: Session; + +beforeAll(async () => { + t = await setupApp(); + owner = await register(t.app); +}); +afterAll(async () => { await t.close(); }); + +const api = () => client(t.app, owner.token); + +const address = { + addressType: 'BILLING', buildingNumber: '1234', street: 'طريق الملك فهد', district: 'العليا', + city: 'الرياض', postalCode: '12345', additionalNumber: '6789', +}; + +describe.each([ + ['customers', 'CUS', 'ACCOUNTS_RECEIVABLE', 'customer'], + ['suppliers', 'SUP', 'ACCOUNTS_PAYABLE', 'supplier'], +] as const)('%s', (path, prefix, controlKey, entity) => { + it('creates a party with an automatic code and a national address', async () => { + const res = await api().post(`/api/${path}`, { + nameAr: 'شركة النخبة للتجارة', vatNumber: '300000000000003', commercialRegistration: '1010101010', + phone: '+966 50 123 4567', email: 'Info@Nukhba.SA', paymentTermsDays: 30, creditLimit: '50000.00', + addresses: [address], + }); + expect(res.statusCode).toBe(201); + const p = res.json(); + expect(p.code).toMatch(new RegExp(`^${prefix}-\\d{5}$`)); + expect(p).toMatchObject({ partyType: 'BUSINESS', email: 'info@nukhba.sa', creditLimit: '50000.00', balance: '0.00', isActive: true }); + expect(p.addresses).toEqual([expect.objectContaining({ ...address, isDefault: true, country: 'SA' })]); + + const next = (await api().post(`/api/${path}`, { nameAr: 'عميل ثان' })).json(); + expect(Number(next.code.slice(-5))).toBe(Number(p.code.slice(-5)) + 1); + }); + + it('skips generated codes that were already taken manually', async () => { + const taken = (await api().post(`/api/${path}`, { nameAr: 'يدوي', code: `${prefix}-99999` })).json(); + expect(taken.code).toBe(`${prefix}-99999`); + expect((await api().post(`/api/${path}`, { nameAr: 'مكرر', code: `${prefix}-99999` })).statusCode).toBe(409); + }); + + it('validates Saudi identifiers and address formats', async () => { + const bad = [ + { nameAr: 'خطأ', vatNumber: '123456789012345' }, + { nameAr: 'خطأ', commercialRegistration: '123' }, + { nameAr: 'خطأ', nationalId: '3123456789' }, + { nameAr: 'خطأ', creditLimit: 100 }, + { nameAr: 'خطأ', addresses: [{ ...address, postalCode: '123' }] }, + { nameAr: 'خطأ', addresses: [{ ...address, buildingNumber: '12' }] }, + { nameAr: 'خطأ', addresses: [{ ...address, isDefault: true }, { ...address, isDefault: true }] }, + { nameAr: 'خ' }, + ]; + for (const body of bad) expect((await api().post(`/api/${path}`, body)).statusCode, JSON.stringify(body)).toBe(400); + expect((await api().post(`/api/${path}`, { nameAr: 'تكرار ضريبي', vatNumber: '300000000000003' })).statusCode).toBe(409); + }); + + it('searches by name, code, VAT number and phone', async () => { + await api().post(`/api/${path}`, { nameAr: 'مؤسسة البحث الفريدة', phone: '0555000111', vatNumber: '311111111111113' }); + for (const term of ['البحث الفريدة', '0555000111', '311111111111113']) { + const res = (await api().get(`/api/${path}?search=${encodeURIComponent(term)}`)).json(); + expect(res.data.map((p: { nameAr: string }) => p.nameAr), term).toEqual(['مؤسسة البحث الفريدة']); + } + }); + + it('updates, replaces addresses, and deactivates', async () => { + const p = (await api().post(`/api/${path}`, { nameAr: 'للتعديل', addresses: [address] })).json(); + const updated = (await api().patch(`/api/${path}/${p.id}`, { nameAr: 'بعد التعديل', paymentTermsDays: 60, phone: null })).json(); + expect(updated).toMatchObject({ nameAr: 'بعد التعديل', paymentTermsDays: 60, phone: null }); + + const addrs = (await api().put(`/api/${path}/${p.id}/addresses`, { + addresses: [{ ...address, city: 'جدة' }, { ...address, addressType: 'SHIPPING', city: 'الدمام' }], + })).json().addresses; + expect(addrs.map((a: { city: string; isDefault: boolean }) => [a.city, a.isDefault])).toEqual([['جدة', true], ['الدمام', true]]); + + expect((await api().patch(`/api/${path}/${p.id}`, { isActive: false })).json().isActive).toBe(false); + const active = (await api().get(`/api/${path}?search=${encodeURIComponent('بعد التعديل')}`)).json().data; + expect(active).toEqual([]); + const all = (await api().get(`/api/${path}?status=all&search=${encodeURIComponent('بعد التعديل')}`)).json().data; + expect(all).toHaveLength(1); + }); + + it('accepts only a matching control account', async () => { + const c = await chart(t.app, owner.token); + expect((await api().post(`/api/${path}`, { nameAr: 'حساب خاطئ', controlAccountId: c.byKey.get('SALES') })).statusCode).toBe(400); + const ok = await api().post(`/api/${path}`, { nameAr: 'حساب صحيح', controlAccountId: c.byKey.get(controlKey) }); + expect(ok.statusCode).toBe(201); + expect(ok.json().controlAccountId).toBe(c.byKey.get(controlKey)); + }); + + it('takes its balance from tagged journal lines and cannot be deleted afterwards', async () => { + const c = await chart(t.app, owner.token); + const p = (await api().post(`/api/${path}`, { nameAr: 'رصيد افتتاحي' })).json(); + const tag = entity === 'customer' ? { customerId: p.id } : { supplierId: p.id }; + const control = c.byKey.get(controlKey)!; + const capital = c.byKey.get('CAPITAL')!; + const lines = entity === 'customer' + ? [{ ...dr(control, '2500.00'), ...tag }, cr(capital, '2500.00')] + : [dr(capital, '2500.00'), { ...cr(control, '2500.00'), ...tag }]; + const entry = await api().post('/api/journal-entries', { entryDate: await dateInYear(t.app, owner.token), description: 'رصيد افتتاحي', post: true, lines }); + expect(entry.statusCode).toBe(201); + const tagged = entry.json().lines.filter((l: { customerId: string | null; supplierId: string | null }) => (l.customerId ?? l.supplierId) === p.id); + expect(tagged).toHaveLength(1); + + const after = (await api().get(`/api/${path}/${p.id}`)).json(); + expect(after.balance).toBe(entity === 'customer' ? '2500.00' : '-2500.00'); + expect((await api().del(`/api/${path}/${p.id}`)).json().error.code).toBe('PARTY_IN_USE'); + + const unused = (await api().post(`/api/${path}`, { nameAr: 'للحذف' })).json(); + expect((await api().del(`/api/${path}/${unused.id}`)).statusCode).toBe(204); + expect((await api().get(`/api/${path}/${unused.id}`)).statusCode).toBe(404); + }); + + it('isolates parties between tenants, including journal tagging', async () => { + const p = (await api().post(`/api/${path}`, { nameAr: 'خاص بالمنشأة' })).json(); + const other = await register(t.app); + const oApi = client(t.app, other.token); + expect((await oApi.get(`/api/${path}/${p.id}`)).statusCode).toBe(404); + expect((await oApi.patch(`/api/${path}/${p.id}`, { nameAr: 'اختراق' })).statusCode).toBe(404); + expect((await oApi.get(`/api/${path}`)).json().total).toBe(0); + const oc = await chart(t.app, other.token); + const tag = entity === 'customer' ? { customerId: p.id } : { supplierId: p.id }; + const res = await oApi.post('/api/journal-entries', { + entryDate: await dateInYear(t.app, other.token), description: 'حقن', + lines: [{ ...dr(oc.byKey.get(controlKey)!, '1.00'), ...tag }, cr(oc.byKey.get('CAPITAL')!, '1.00')], + }); + expect(res.statusCode).toBe(400); + }); +}); + +describe('party permissions', () => { + it('lets each role manage only its own parties', async () => { + const sales = await addMember(t.app, owner, 'SALES_MANAGER'); + const sApi = client(t.app, sales.token); + expect((await sApi.post('/api/customers', { nameAr: 'من المبيعات' })).statusCode).toBe(201); + expect((await sApi.post('/api/suppliers', { nameAr: 'من المبيعات' })).statusCode).toBe(403); + expect((await sApi.get('/api/suppliers')).statusCode).toBe(403); + + const purchase = await addMember(t.app, owner, 'PURCHASE_MANAGER'); + const pApi = client(t.app, purchase.token); + expect((await pApi.post('/api/suppliers', { nameAr: 'من المشتريات' })).statusCode).toBe(201); + expect((await pApi.post('/api/customers', { nameAr: 'من المشتريات' })).statusCode).toBe(403); + + const employee = await addMember(t.app, owner, 'SALES_EMPLOYEE'); + const eApi = client(t.app, employee.token); + expect((await eApi.get('/api/customers')).statusCode).toBe(200); + expect((await eApi.post('/api/customers', { nameAr: 'موظف' })).statusCode).toBe(403); + + const accountant = await addMember(t.app, owner, 'ACCOUNTANT'); + expect((await client(t.app, accountant.token).post('/api/suppliers', { nameAr: 'من المحاسب' })).statusCode).toBe(201); + }); + + it('a party cannot be tagged on both sides of one line', async () => { + const c = await chart(t.app, owner.token); + const cust = (await api().post('/api/customers', { nameAr: 'عميل الوسم' })).json(); + const supp = (await api().post('/api/suppliers', { nameAr: 'مورد الوسم' })).json(); + const res = await api().post('/api/journal-entries', { + entryDate: await dateInYear(t.app, owner.token), description: 'خطأ', + lines: [{ ...dr(c.byKey.get('CASH')!, '1.00'), customerId: cust.id, supplierId: supp.id }, cr(c.byKey.get('CAPITAL')!, '1.00')], + }); + expect(res.statusCode).toBe(400); + }); + + it('records audit entries for party changes', async () => { + const actions = (await api().get('/api/audit-logs?entityType=customer&limit=200')).json().data.map((l: { action: string }) => l.action); + expect(actions).toEqual(expect.arrayContaining(['CREATE', 'UPDATE', 'DELETE'])); + }); +}); diff --git a/alshuyukh-accounting/apps/api/test/payments.test.ts b/alshuyukh-accounting/apps/api/test/payments.test.ts new file mode 100644 index 000000000000..534b4f74d79f --- /dev/null +++ b/alshuyukh-accounting/apps/api/test/payments.test.ts @@ -0,0 +1,193 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { withTx } from '../src/db/tx.js'; +import { balanceOf, commerce, type Commerce } from './commerce-helpers.js'; +import { addMember, chart, client, fiscalYears, register, setupApp, type Session, type TestContext } from './helpers.js'; + +let t: TestContext; +let owner: Session; +let c: Commerce; + +beforeAll(async () => { + t = await setupApp(); + owner = await register(t.app); + c = await commerce(t.app, owner); +}); +afterAll(async () => { await t.close(); }); + +const receipt = async (customerId: string, amount: string, allocations: { documentId: string; amount: string }[] = [], method = 'CASH') => + c.api.post('/api/payments', { + direction: 'RECEIPT', customerId, paymentDate: c.date, methodId: await c.method(method), amount, + allocations: allocations.map((a) => ({ documentType: 'SALES_INVOICE', ...a })), + }); + +const newInvoice = async (price = '1000') => { + const cust = await c.customer(); + const inv = await c.postInvoice(cust.id, [{ productId: (await c.product({ salePrice: price })).id, quantity: '1' }]); + return { cust, inv }; +}; + +describe('customer receipts', () => { + it('collects an invoice in full: Dr Cash / Cr AR, invoice PAID', async () => { + const { cust, inv } = await newInvoice(); + const res = await receipt(cust.id, '1150.00', [{ documentId: inv.id, amount: '1150.00' }]); + expect(res.statusCode).toBe(201); + const pay = res.json(); + expect(pay).toMatchObject({ status: 'POSTED', amount: '1150.00', allocatedAmount: '1150.00', unallocatedAmount: '0.00' }); + expect(pay.number).toMatch(/^RCPT-\d{6}$/); + const lines = await c.journal(pay.journalEntryId); + expect(lines.map((l) => [l.accountCode, l.debit, l.credit])).toEqual([['1100', '1150.00', '0.00'], ['1300', '0.00', '1150.00']]); + expect(lines[1]!.customerId).toBe(cust.id); + expect((await c.api.get(`/api/invoices/${inv.id}`)).json()).toMatchObject({ status: 'PAID', paidAmount: '1150.00', remainingAmount: '0.00' }); + expect(await balanceOf(c.api, 'customers', cust.id)).toBe('0.00'); + }); + + it('records partial payments and settles several invoices with one receipt', async () => { + const cust = await c.customer(); + const prod = await c.product({ salePrice: '100' }); + const a = await c.postInvoice(cust.id, [{ productId: prod.id, quantity: '1' }]); // 115 + const b = await c.postInvoice(cust.id, [{ productId: prod.id, quantity: '2' }]); // 230 + await receipt(cust.id, '50.00', [{ documentId: a.id, amount: '50.00' }]); + expect((await c.api.get(`/api/invoices/${a.id}`)).json()).toMatchObject({ status: 'PARTIALLY_PAID', remainingAmount: '65.00' }); + const res = await receipt(cust.id, '295.00', [{ documentId: a.id, amount: '65.00' }, { documentId: b.id, amount: '230.00' }], 'BANK'); + expect(res.statusCode).toBe(201); + expect((await c.api.get(`/api/invoices/${a.id}`)).json().status).toBe('PAID'); + expect((await c.api.get(`/api/invoices/${b.id}`)).json().status).toBe('PAID'); + const open = (await c.api.get(`/api/invoices?partyId=${cust.id}&open=true`)).json(); + expect(open.total).toBe(0); + }); + + it('keeps an advance on account and allocates it later', async () => { + const cust = await c.customer(); + const adv = (await receipt(cust.id, '500.00')).json(); + expect(adv.unallocatedAmount).toBe('500.00'); + expect(await balanceOf(c.api, 'customers', cust.id)).toBe('-500.00'); + const inv = await c.postInvoice(cust.id, [{ productId: (await c.product({ salePrice: '200' })).id, quantity: '1' }]); // 230 + const res = await c.api.post(`/api/payments/${adv.id}/allocations`, { allocations: [{ documentType: 'SALES_INVOICE', documentId: inv.id, amount: '230.00' }] }); + expect(res.json()).toMatchObject({ allocatedAmount: '230.00', unallocatedAmount: '270.00' }); + expect((await c.api.get(`/api/invoices/${inv.id}`)).json().status).toBe('PAID'); + expect(await balanceOf(c.api, 'customers', cust.id)).toBe('-270.00'); + }); + + it('rejects invalid allocations', async () => { + const { cust, inv } = await newInvoice('100'); // 115 + const other = await newInvoice('100'); + const cases: [unknown[], string, string][] = [ + [[{ documentId: inv.id, amount: '200.00' }], '300.00', 'OVER_ALLOCATED'], // more than the invoice + [[{ documentId: inv.id, amount: '100.00' }], '50.00', 'OVER_ALLOCATED'], // more than the payment + [[{ documentId: other.inv.id, amount: '10.00' }], '50.00', 'INVALID_ALLOCATION'], // another customer's invoice + [[{ documentId: inv.id, amount: '0.00' }], '50.00', 'INVALID_ALLOCATION'], // zero + ]; + for (const [allocations, amount, code] of cases) { + const res = await receipt(cust.id, amount, allocations as never); + expect(res.json().error?.code, JSON.stringify(allocations)).toBe(code); + } + const wrongType = await c.api.post('/api/payments', { + direction: 'RECEIPT', customerId: cust.id, paymentDate: c.date, methodId: await c.method('CASH'), amount: '10.00', + allocations: [{ documentType: 'SALES_RETURN', documentId: inv.id, amount: '10.00' }], + }); + expect(wrongType.json().error.code).toBe('INVALID_ALLOCATION'); + // Nothing was recorded by the failed attempts. + expect((await c.api.get(`/api/payments?customerId=${cust.id}`)).json().total).toBe(0); + expect(await balanceOf(c.api, 'customers', cust.id)).toBe('115.00'); + }); + + it('voids a payment: reverses the entry and reopens the invoices', async () => { + const { cust, inv } = await newInvoice('100'); + const pay = (await receipt(cust.id, '115.00', [{ documentId: inv.id, amount: '115.00' }])).json(); + const res = await c.api.post(`/api/payments/${pay.id}/void`, { reason: 'شيك مرتجع' }); + expect(res.json()).toMatchObject({ status: 'VOIDED', allocatedAmount: '0.00', voidReason: 'شيك مرتجع' }); + expect(res.json().allocations[0].reversedAt).not.toBeNull(); + expect((await c.api.get(`/api/invoices/${inv.id}`)).json()).toMatchObject({ status: 'ISSUED', paidAmount: '0.00' }); + expect((await c.api.get(`/api/journal-entries/${pay.journalEntryId}`)).json().status).toBe('REVERSED'); + expect(await balanceOf(c.api, 'customers', cust.id)).toBe('115.00'); + expect((await c.api.post(`/api/payments/${pay.id}/void`, { reason: 'مرة ثانية' })).statusCode).toBe(409); + }); + + it('refunds a customer after a return of a paid invoice', async () => { + const { cust, inv } = await newInvoice('100'); + await receipt(cust.id, '115.00', [{ documentId: inv.id, amount: '115.00' }]); + const r = (await c.api.post('/api/sales-returns', { originalInvoiceId: inv.id, docDate: c.date, reason: 'إرجاع', lines: [{ sourceItemId: inv.lines[0].id, quantity: '1' }] })).json(); + const ret = (await c.api.post(`/api/sales-returns/${r.id}/post`)).json(); + // Paid in full, so nothing is applied to the invoice: the full credit is owed to the customer. + expect(ret).toMatchObject({ appliedAmount: '0.00', remainingAmount: '115.00' }); + expect(await balanceOf(c.api, 'customers', cust.id)).toBe('-115.00'); + const refund = await c.api.post('/api/payments', { + direction: 'DISBURSEMENT', customerId: cust.id, paymentDate: c.date, methodId: await c.method('CASH'), amount: '115.00', + allocations: [{ documentType: 'SALES_RETURN', documentId: ret.id, amount: '115.00' }], + }); + expect(refund.statusCode).toBe(201); + expect((await c.journal(refund.json().journalEntryId)).map((l) => [l.accountCode, l.debit, l.credit])).toEqual([['1100', '0.00', '115.00'], ['1300', '115.00', '0.00']]); + expect((await c.api.get(`/api/sales-returns/${ret.id}`)).json()).toMatchObject({ refundedAmount: '115.00', remainingAmount: '0.00' }); + expect(await balanceOf(c.api, 'customers', cust.id)).toBe('0.00'); + }); + + it('cannot change a payment directly in the database', async () => { + const { cust } = await newInvoice('100'); + const pay = (await receipt(cust.id, '10.00')).json(); + await expect(withTx(t.pool, { tenantId: owner.tenantId, userId: owner.userId }, (db) => + db.query(`UPDATE payments SET amount = 1 WHERE id = $1`, [pay.id]))).rejects.toThrow(/cannot be modified/); + }); +}); + +describe('supplier payments', () => { + it('pays a purchase invoice: Dr AP / Cr Bank', async () => { + const sup = await c.supplier(); + const bill = await c.purchase(sup.id, [{ productId: (await c.goods({ purchasePrice: '400' })).id, quantity: '1' }]); // 460 + const res = await c.api.post('/api/payments', { + direction: 'DISBURSEMENT', supplierId: sup.id, paymentDate: c.date, methodId: await c.method('BANK'), amount: '460.00', + allocations: [{ documentType: 'PURCHASE_INVOICE', documentId: bill.id, amount: '460.00' }], + }); + expect(res.statusCode).toBe(201); + expect(res.json().number).toMatch(/^PAY-\d{6}$/); + expect((await c.journal(res.json().journalEntryId)).map((l) => [l.accountCode, l.debit, l.credit])).toEqual([['1200', '0.00', '460.00'], ['2100', '460.00', '0.00']]); + expect((await c.api.get(`/api/purchase-invoices/${bill.id}`)).json().status).toBe('PAID'); + expect(await balanceOf(c.api, 'suppliers', sup.id)).toBe('0.00'); + }); +}); + +describe('payment methods', () => { + it('seeds the Saudi methods and accepts custom ones on asset accounts only', async () => { + const methods = (await c.api.get('/api/payment-methods')).json().data.map((m: { code: string }) => m.code); + expect(methods).toEqual(['CASH', 'BANK', 'CARD', 'STC_PAY', 'TAMARA']); + const ch = await chart(t.app, owner.token); + const clearing = (await c.api.post('/api/accounts', { code: '1250', nameAr: 'مستحقات تمارا', parentId: ch.byCode.get('1000') })).json(); + const ok = await c.api.post('/api/payment-methods', { code: 'TAMARA_CLR', nameAr: 'تمارا (تسوية)', methodType: 'TAMARA', accountId: clearing.id }); + expect(ok.statusCode).toBe(201); + expect((await c.api.post('/api/payment-methods', { code: 'BAD', nameAr: 'خطأ', methodType: 'OTHER', accountId: ch.byKey.get('SALES') })).statusCode).toBe(400); + const { cust, inv } = await newInvoice('100'); + const res = await c.api.post('/api/payments', { + direction: 'RECEIPT', customerId: cust.id, paymentDate: c.date, methodId: ok.json().id, amount: '115.00', + allocations: [{ documentType: 'SALES_INVOICE', documentId: inv.id, amount: '115.00' }], + }); + expect((await c.journal(res.json().journalEntryId))[0]!.accountCode).toBe('1250'); + }); +}); + +describe('payment permissions and isolation', () => { + it('applies payment permissions', async () => { + const { cust } = await newInvoice('100'); + const sales = await addMember(t.app, owner, 'SALES_MANAGER'); + const sApi = client(t.app, sales.token); + const pay = await sApi.post('/api/payments', { direction: 'RECEIPT', customerId: cust.id, paymentDate: c.date, methodId: await c.method('CASH'), amount: '10.00' }); + expect(pay.statusCode).toBe(201); + expect((await sApi.post(`/api/payments/${pay.json().id}/void`, { reason: 'test' })).statusCode).toBe(403); + const viewer = await addMember(t.app, owner, 'VIEWER'); + expect((await client(t.app, viewer.token).post('/api/payments', { direction: 'RECEIPT', customerId: cust.id, paymentDate: c.date, methodId: await c.method('CASH'), amount: '10.00' })).statusCode).toBe(403); + }); + + it('cannot pay another tenant\'s invoice', async () => { + const { inv } = await newInvoice('100'); + const other = await register(t.app); + const oc = await commerce(t.app, other); + const res = await oc.api.post('/api/payments', { + direction: 'RECEIPT', customerId: (await oc.customer()).id, paymentDate: oc.date, methodId: await oc.method('CASH'), amount: '10.00', + allocations: [{ documentType: 'SALES_INVOICE', documentId: inv.id, amount: '10.00' }], + }); + expect(res.json().error.code).toBe('INVALID_ALLOCATION'); + }); + + it('keeps the ledger balanced', async () => { + const [year] = await fiscalYears(t.app, owner.token); + expect((await c.api.get(`/api/reports/trial-balance?dateFrom=${year!.startDate}&dateTo=${year!.endDate}`)).json().balanced).toBe(true); + }); +}); diff --git a/alshuyukh-accounting/apps/api/test/products.test.ts b/alshuyukh-accounting/apps/api/test/products.test.ts new file mode 100644 index 000000000000..fcf3f9947b4a --- /dev/null +++ b/alshuyukh-accounting/apps/api/test/products.test.ts @@ -0,0 +1,110 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { addMember, chart, client, register, setupApp, type Session, type TestContext } from './helpers.js'; + +let t: TestContext; +let owner: Session; + +beforeAll(async () => { + t = await setupApp(); + owner = await register(t.app); +}); +afterAll(async () => { await t.close(); }); + +const api = () => client(t.app, owner.token); +const unitId = async (code: string) => (await api().get('/api/units')).json().data.find((u: { code: string }) => u.code === code).id as string; + +describe('units and categories', () => { + it('seeds UN/ECE units for every company', async () => { + const codes = (await api().get('/api/units')).json().data.map((u: { code: string }) => u.code); + expect(codes).toEqual(expect.arrayContaining(['PCE', 'BX', 'KGM', 'LTR', 'HUR'])); + }); + + it('adds units and nested categories', async () => { + expect((await api().post('/api/units', { code: 'pk', nameAr: 'باكيت' })).json().code).toBe('PK'); + expect((await api().post('/api/units', { code: 'PK', nameAr: 'مكرر' })).statusCode).toBe(409); + const parent = (await api().post('/api/product-categories', { nameAr: 'مواد غذائية' })).json(); + const child = await api().post('/api/product-categories', { nameAr: 'ألبان', parentId: parent.id }); + expect(child.json().parentId).toBe(parent.id); + expect((await api().post('/api/product-categories', { nameAr: 'ألبان', parentId: parent.id })).statusCode).toBe(409); + }); +}); + +describe('products', () => { + it('creates goods with an automatic SKU and exact prices', async () => { + const res = await api().post('/api/products', { + nameAr: 'حليب طازج 1 لتر', barcode: '6281007000017', unitId: await unitId('LTR'), + salePrice: '6.5000', purchasePrice: '4.1250', salePriceIncludesVat: true, + }); + expect(res.statusCode).toBe(201); + expect(res.json()).toMatchObject({ + productType: 'GOODS', trackInventory: true, vatCategory: 'S', unitCode: 'LTR', + salePrice: '6.5000', purchasePrice: '4.1250', salePriceIncludesVat: true, + }); + expect(res.json().sku).toMatch(/^PRD-\d{5}$/); + }); + + it('creates services without stock and defaults to hours', async () => { + const res = await api().post('/api/products', { nameAr: 'خدمة صيانة', productType: 'SERVICE', salePrice: '250' }); + expect(res.json()).toMatchObject({ productType: 'SERVICE', trackInventory: false, unitCode: 'HUR' }); + expect((await api().post('/api/products', { nameAr: 'خدمة خاطئة', productType: 'SERVICE', trackInventory: true })).statusCode).toBe(400); + const goods = (await api().post('/api/products', { nameAr: 'سلعة تتحول' })).json(); + const changed = (await api().patch(`/api/products/${goods.id}`, { productType: 'SERVICE' })).json(); + expect(changed).toMatchObject({ productType: 'SERVICE', trackInventory: false }); + }); + + it('validates prices, codes and uniqueness', async () => { + const bad = [ + { nameAr: 'سعر رقمي', salePrice: 10 }, + { nameAr: 'خمس خانات', salePrice: '1.12345' }, + { nameAr: 'سالب', salePrice: '-1' }, + { nameAr: 'رمز خاطئ', sku: 'a b' }, + { nameAr: 'فئة ضريبية', vatCategory: 'X' }, + ]; + for (const body of bad) expect((await api().post('/api/products', body)).statusCode, JSON.stringify(body)).toBe(400); + await api().post('/api/products', { nameAr: 'منتج أ', sku: 'SKU-1', barcode: '1111222233334' }); + expect((await api().post('/api/products', { nameAr: 'منتج ب', sku: 'SKU-1' })).statusCode).toBe(409); + expect((await api().post('/api/products', { nameAr: 'منتج ج', barcode: '1111222233334' })).statusCode).toBe(409); + }); + + it('accepts account overrides of the right type only', async () => { + const c = await chart(t.app, owner.token); + expect((await api().post('/api/products', { nameAr: 'حساب خاطئ', salesAccountId: c.byKey.get('CASH') })).statusCode).toBe(400); + expect((await api().post('/api/products', { nameAr: 'حساب تجميعي', salesAccountId: c.byCode.get('4000') })).statusCode).toBe(400); + const ok = await api().post('/api/products', { nameAr: 'حساب صحيح', salesAccountId: c.byKey.get('SALES'), purchaseAccountId: c.byKey.get('INVENTORY') }); + expect(ok.statusCode).toBe(201); + }); + + it('searches by name, SKU and exact barcode, and filters by category', async () => { + const cat = (await api().post('/api/product-categories', { nameAr: 'منظفات' })).json(); + await api().post('/api/products', { nameAr: 'منظف أرضيات', sku: 'CLN-1', barcode: '9990001112223', categoryId: cat.id }); + for (const term of ['أرضيات', 'CLN-1', '9990001112223']) { + expect((await api().get(`/api/products?search=${encodeURIComponent(term)}`)).json().data.map((p: { sku: string }) => p.sku), term).toEqual(['CLN-1']); + } + expect((await api().get(`/api/products?categoryId=${cat.id}`)).json().total).toBe(1); + }); + + it('soft-deletes and frees the SKU', async () => { + const p = (await api().post('/api/products', { nameAr: 'مؤقت', sku: 'TMP-1' })).json(); + expect((await api().del(`/api/products/${p.id}`)).statusCode).toBe(204); + expect((await api().get(`/api/products/${p.id}`)).statusCode).toBe(404); + expect((await api().post('/api/products', { nameAr: 'بديل', sku: 'TMP-1' })).statusCode).toBe(201); + }); + + it('refuses units and categories from another company or tenant', async () => { + const other = await register(t.app); + const foreignUnit = (await client(t.app, other.token).get('/api/units')).json().data[0].id; + expect((await api().post('/api/products', { nameAr: 'وحدة أجنبية', unitId: foreignUnit })).json().error.code).toBe('INVALID_UNIT'); + const p = (await api().post('/api/products', { nameAr: 'سري' })).json(); + expect((await client(t.app, other.token).get(`/api/products/${p.id}`)).statusCode).toBe(404); + expect((await client(t.app, other.token).get('/api/products')).json().total).toBe(0); + }); + + it('applies product permissions per role', async () => { + const wh = await addMember(t.app, owner, 'WAREHOUSE_MANAGER'); + expect((await client(t.app, wh.token).post('/api/products', { nameAr: 'من المستودع' })).statusCode).toBe(201); + const sales = await addMember(t.app, owner, 'SALES_EMPLOYEE'); + expect((await client(t.app, sales.token).get('/api/products')).statusCode).toBe(200); + expect((await client(t.app, sales.token).post('/api/products', { nameAr: 'من المبيعات' })).statusCode).toBe(403); + expect((await client(t.app, sales.token).post('/api/units', { code: 'X', nameAr: 'س' })).statusCode).toBe(403); + }); +}); diff --git a/alshuyukh-accounting/apps/api/test/purchases.test.ts b/alshuyukh-accounting/apps/api/test/purchases.test.ts new file mode 100644 index 000000000000..074e1eb2d680 --- /dev/null +++ b/alshuyukh-accounting/apps/api/test/purchases.test.ts @@ -0,0 +1,108 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { balanceOf, commerce, type Commerce } from './commerce-helpers.js'; +import { addMember, chart, client, fiscalYears, register, setupApp, type Session, type TestContext } from './helpers.js'; + +let t: TestContext; +let owner: Session; +let c: Commerce; + +beforeAll(async () => { + t = await setupApp(); + owner = await register(t.app); + c = await commerce(t.app, owner); +}); +afterAll(async () => { await t.close(); }); + +describe('purchase invoices', () => { + it('posts goods to inventory: Dr Inventory + Dr VAT input / Cr AP', async () => { + const sup = await c.supplier({ vatNumber: '310000000000003' }); + const prod = await c.goods({ purchasePrice: '60' }); + const bill = await c.purchase(sup.id, [{ productId: prod.id, quantity: '10' }], { supplierInvoiceNumber: 'S-9001' }); + expect(bill).toMatchObject({ status: 'POSTED', taxableAmount: '600.00', taxAmount: '90.00', total: '690.00', supplierInvoiceNumber: 'S-9001' }); + expect(bill.number).toMatch(/^PINV-\d{6}$/); + const lines = await c.journal(bill.journalEntryId); + expect(lines.map((l) => [l.accountCode, l.debit, l.credit])).toEqual([ + ['2100', '0.00', '690.00'], ['1400', '600.00', '0.00'], ['2220', '90.00', '0.00'], + ]); + expect(lines[0]!.supplierId).toBe(sup.id); + expect(await balanceOf(c.api, 'suppliers', sup.id)).toBe('-690.00'); + }); + + it('records the supplier invoice number once per supplier', async () => { + const sup = await c.supplier(); + const prod = await c.goods(); + await c.purchase(sup.id, [{ productId: prod.id, quantity: '1' }], { supplierInvoiceNumber: 'DUP-1' }); + const dup = await c.api.post('/api/purchase-invoices', { partyId: sup.id, docDate: c.date, supplierInvoiceNumber: 'DUP-1', lines: [{ productId: prod.id, quantity: '1' }] }); + expect(dup.statusCode).toBe(409); + }); + + it('posts expense lines to their account and requires one for untracked services', async () => { + const sup = await c.supplier(); + const ch = await chart(t.app, owner.token); + const service = await c.product({ purchasePrice: '1000' }); + const draft = (await c.api.post('/api/purchase-invoices', { partyId: sup.id, docDate: c.date, lines: [{ productId: service.id, quantity: '1' }] })).json(); + expect((await c.api.post(`/api/purchase-invoices/${draft.id}/post`)).json().error.code).toBe('ACCOUNT_REQUIRED'); + + const rent = await c.purchase(sup.id, [{ accountId: ch.byCode.get('6100'), description: 'إيجار مارس', quantity: '1', unitPrice: '5000' }]); + expect((await c.journal(rent.journalEntryId)).map((l) => [l.accountCode, l.debit, l.credit])).toEqual([ + ['2100', '0.00', '5750.00'], ['6100', '5000.00', '0.00'], ['2220', '750.00', '0.00'], + ]); + // Exempt line: no VAT. + const exempt = await c.purchase(sup.id, [{ accountId: ch.byCode.get('6400'), quantity: '1', unitPrice: '300', vatCategory: 'E' }]); + expect(exempt).toMatchObject({ taxAmount: '0.00', total: '300.00' }); + // Sales lines cannot use bare accounts. + const cust = await c.customer(); + expect((await c.api.post('/api/invoices', { partyId: cust.id, docDate: c.date, lines: [{ accountId: ch.byCode.get('6100'), quantity: '1', unitPrice: '1' }] })).statusCode).toBe(400); + }); + + it('returns goods to the supplier: Dr AP / Cr Inventory + Cr VAT input', async () => { + const sup = await c.supplier(); + const bill = await c.purchase(sup.id, [{ productId: (await c.goods()).id, quantity: '4', unitPrice: '50' }]); // 230 + const r = (await c.api.post('/api/purchase-returns', { originalInvoiceId: bill.id, docDate: c.date, reason: 'معيب', lines: [{ sourceItemId: bill.lines[0].id, quantity: '1' }] })).json(); + const ret = (await c.api.post(`/api/purchase-returns/${r.id}/post`)).json(); + expect(ret.number).toMatch(/^DN-\d{6}$/); + expect((await c.journal(ret.journalEntryId)).map((l) => [l.accountCode, l.debit, l.credit])).toEqual([ + ['2100', '57.50', '0.00'], ['1400', '0.00', '50.00'], ['2220', '0.00', '7.50'], + ]); + expect((await c.api.get(`/api/purchase-invoices/${bill.id}`)).json()).toMatchObject({ returnedAmount: '57.50', remainingAmount: '172.50' }); + expect(await balanceOf(c.api, 'suppliers', sup.id)).toBe('-172.50'); + }); + + it('cancels an unpaid purchase invoice', async () => { + const sup = await c.supplier(); + const bill = await c.purchase(sup.id, [{ productId: (await c.goods()).id, quantity: '1' }]); + expect((await c.api.post(`/api/purchase-invoices/${bill.id}/cancel`, { reason: 'مكرر' })).json().status).toBe('CANCELLED'); + expect(await balanceOf(c.api, 'suppliers', sup.id)).toBe('0.00'); + }); +}); + +describe('purchase orders', () => { + it('approves an order and converts it to a draft bill', async () => { + const sup = await c.supplier(); + const po = (await c.api.post('/api/purchase-orders', { partyId: sup.id, docDate: c.date, lines: [{ productId: (await c.goods()).id, quantity: '12' }] })).json(); + expect(po.number).toMatch(/^PO-\d{6}$/); + expect((await c.api.post(`/api/purchase-orders/${po.id}/convert`, {})).statusCode).toBe(409); + expect((await c.api.post(`/api/purchase-orders/${po.id}/status`, { status: 'APPROVED' })).json().status).toBe('APPROVED'); + const bill = (await c.api.post(`/api/purchase-orders/${po.id}/convert`, { docDate: c.date })).json(); + expect(bill).toMatchObject({ status: 'DRAFT', sourceOrderId: po.id, total: po.total }); + expect((await c.api.post(`/api/purchase-invoices/${bill.id}/post`)).json().status).toBe('POSTED'); + }); +}); + +describe('purchase permissions', () => { + it('lets purchase managers post bills and sales staff see nothing', async () => { + const sup = await c.supplier(); + const prod = await c.goods(); + const pm = await addMember(t.app, owner, 'PURCHASE_MANAGER'); + const pApi = client(t.app, pm.token); + const draft = await pApi.post('/api/purchase-invoices', { partyId: sup.id, docDate: c.date, lines: [{ productId: prod.id, quantity: '1' }] }); + expect((await pApi.post(`/api/purchase-invoices/${draft.json().id}/post`)).statusCode).toBe(200); + const sales = await addMember(t.app, owner, 'SALES_EMPLOYEE'); + expect((await client(t.app, sales.token).get('/api/purchase-invoices')).statusCode).toBe(403); + }); + + it('keeps the ledger balanced', async () => { + const [year] = await fiscalYears(t.app, owner.token); + expect((await c.api.get(`/api/reports/trial-balance?dateFrom=${year!.startDate}&dateTo=${year!.endDate}`)).json().balanced).toBe(true); + }); +}); diff --git a/alshuyukh-accounting/apps/api/test/rbac.test.ts b/alshuyukh-accounting/apps/api/test/rbac.test.ts new file mode 100644 index 000000000000..dce3fee685e7 --- /dev/null +++ b/alshuyukh-accounting/apps/api/test/rbac.test.ts @@ -0,0 +1,135 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { ALL_PERMISSION_CODES, SYSTEM_ROLES, resolveRolePermissions } from '../src/modules/rbac/catalog.js'; +import { addMember, client, register, roleId, setupApp, type Session, type TestContext } from './helpers.js'; + +let t: TestContext; +let owner: Session; +let companyId: string; + +beforeAll(async () => { + t = await setupApp(); + owner = await register(t.app); + companyId = (await client(t.app, owner.token).get('/api/companies')).json().data[0].id; +}); +afterAll(async () => { await t.close(); }); + +describe('catalog', () => { + it('only references permissions that exist', () => { + for (const role of SYSTEM_ROLES) { + for (const p of resolveRolePermissions(role)) expect(ALL_PERMISSION_CODES).toContain(p); + } + }); + + it('gives VIEWER read-only permissions', () => { + const viewer = SYSTEM_ROLES.find((r) => r.code === 'VIEWER')!; + expect(resolveRolePermissions(viewer).every((p) => p.endsWith('.view'))).toBe(true); + }); + + it('is synced to the database', async () => { + const { rows } = await t.ownerPool.query(`SELECT code FROM roles WHERE is_system ORDER BY code`); + expect(rows.map((r) => r.code).sort()).toEqual(SYSTEM_ROLES.map((r) => r.code).sort()); + }); +}); + +describe('permission checks', () => { + it('lets an accountant view companies but not create them or manage users', async () => { + const acc = await addMember(t.app, owner, 'ACCOUNTANT'); + const api = client(t.app, acc.token); + expect((await api.get('/api/companies')).statusCode).toBe(200); + expect((await api.post('/api/companies', { name: 'Nope' })).statusCode).toBe(403); + expect((await api.get('/api/users')).statusCode).toBe(403); + expect((await api.patch('/api/settings/tenant', { locale: 'en' })).statusCode).toBe(403); + expect((await api.get('/api/audit-logs')).statusCode).toBe(403); + }); + + it('lets a viewer read but not write', async () => { + const viewer = await addMember(t.app, owner, 'VIEWER'); + const api = client(t.app, viewer.token); + expect((await api.get('/api/users')).statusCode).toBe(200); + expect((await api.patch(`/api/companies/${companyId}`, { city: 'Riyadh' })).statusCode).toBe(403); + expect((await api.post(`/api/companies/${companyId}/branches`, { code: 'B2', name: 'Jeddah' })).statusCode).toBe(403); + }); + + it('applies role changes to existing tokens immediately', async () => { + const viewer = await addMember(t.app, owner, 'VIEWER'); + const api = client(t.app, viewer.token); + expect((await api.post(`/api/companies/${companyId}/branches`, { code: 'DMM', name: 'Dammam' })).statusCode).toBe(403); + const res = await client(t.app, owner.token).put(`/api/users/${viewer.userId}/roles`, { roleIds: [await roleId(t.app, owner.token, 'COMPANY_ADMIN')] }); + expect(res.statusCode).toBe(200); + expect((await api.post(`/api/companies/${companyId}/branches`, { code: 'DMM', name: 'Dammam' })).statusCode).toBe(201); + }); + + it('blocks a disabled member immediately', async () => { + const m = await addMember(t.app, owner, 'VIEWER'); + expect((await client(t.app, owner.token).patch(`/api/users/${m.userId}`, { status: 'DISABLED' })).statusCode).toBe(200); + expect((await client(t.app, m.token).get('/api/companies')).statusCode).toBe(401); + const relogin = await t.app.inject({ method: 'POST', url: '/api/auth/login', payload: { email: m.email, password: 'Str0ng-Passw0rd!' } }); + expect(relogin.statusCode).toBe(403); + }); +}); + +describe('privilege escalation', () => { + it('prevents a company admin from granting TENANT_OWNER', async () => { + const admin = await addMember(t.app, owner, 'COMPANY_ADMIN'); + const res = await client(t.app, admin.token).post('/api/users', { + email: `esc-${Date.now()}@example.test`, fullName: 'Escalate', initialPassword: 'Str0ng-Passw0rd!', + roleIds: [await roleId(t.app, owner.token, 'TENANT_OWNER')], + }); + expect(res.statusCode).toBe(403); + }); + + it('prevents creating a custom role with permissions the actor lacks', async () => { + const admin = await addMember(t.app, owner, 'COMPANY_ADMIN'); + const res = await client(t.app, admin.token).post('/api/roles', { + code: 'SNEAKY', nameAr: 'خفي', nameEn: 'Sneaky', permissions: ['subscription.manage'], + }); + expect(res.statusCode).toBe(403); + }); + + it('prevents users from changing their own roles or status', async () => { + const admin = await addMember(t.app, owner, 'COMPANY_ADMIN'); + const api = client(t.app, admin.token); + expect((await api.put(`/api/users/${admin.userId}/roles`, { roleIds: [await roleId(t.app, owner.token, 'VIEWER')] })).statusCode).toBe(403); + expect((await api.patch(`/api/users/${admin.userId}`, { status: 'DISABLED' })).statusCode).toBe(403); + }); + + it('protects the owner from being disabled or demoted', async () => { + const admin = await addMember(t.app, owner, 'COMPANY_ADMIN'); + const api = client(t.app, admin.token); + expect((await api.patch(`/api/users/${owner.userId}`, { status: 'DISABLED' })).statusCode).toBe(403); + expect((await api.put(`/api/users/${owner.userId}/roles`, { roleIds: [await roleId(t.app, owner.token, 'VIEWER')] })).statusCode).toBe(403); + }); + + it('refuses to modify or delete system roles', async () => { + const id = await roleId(t.app, owner.token, 'ACCOUNTANT'); + const api = client(t.app, owner.token); + expect((await api.patch(`/api/roles/${id}`, { nameEn: 'Changed' })).statusCode).toBe(403); + expect((await api.del(`/api/roles/${id}`)).statusCode).toBe(403); + }); +}); + +describe('custom roles', () => { + it('creates, assigns and soft-deletes a custom role', async () => { + const api = client(t.app, owner.token); + const created = await api.post('/api/roles', { + code: 'branch_auditor', nameAr: 'مدقق فروع', nameEn: 'Branch auditor', permissions: ['company.view', 'audit.view'], + }); + expect(created.statusCode).toBe(201); + expect(created.json()).toMatchObject({ code: 'BRANCH_AUDITOR', isSystem: false, permissions: ['audit.view', 'company.view'] }); + + const dup = await api.post('/api/roles', { code: 'BRANCH_AUDITOR', nameAr: 'مكرر', nameEn: 'Dup', permissions: [] }); + expect(dup.statusCode).toBe(409); + const reserved = await api.post('/api/roles', { code: 'VIEWER', nameAr: 'مشاهد', nameEn: 'Viewer', permissions: [] }); + expect(reserved.statusCode).toBe(409); + const unknown = await api.post('/api/roles', { code: 'BAD', nameAr: 'سيء', nameEn: 'Bad', permissions: ['does.not_exist'] }); + expect(unknown.statusCode).toBe(400); + + const m = await addMember(t.app, owner, 'VIEWER'); + await api.put(`/api/users/${m.userId}/roles`, { roleIds: [created.json().id] }); + expect((await api.del(`/api/roles/${created.json().id}`)).statusCode).toBe(409); + await api.put(`/api/users/${m.userId}/roles`, { roleIds: [await roleId(t.app, owner.token, 'VIEWER')] }); + expect((await api.del(`/api/roles/${created.json().id}`)).statusCode).toBe(204); + const roles = (await api.get('/api/roles')).json().data; + expect(roles.find((r: { code: string }) => r.code === 'BRANCH_AUDITOR')).toBeUndefined(); + }); +}); diff --git a/alshuyukh-accounting/apps/api/test/reports.test.ts b/alshuyukh-accounting/apps/api/test/reports.test.ts new file mode 100644 index 000000000000..e4a35684fa19 --- /dev/null +++ b/alshuyukh-accounting/apps/api/test/reports.test.ts @@ -0,0 +1,263 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { commerce, type Commerce } from './commerce-helpers.js'; +import { addMember, chart, client, cr, dr, fiscalYears, register, setupApp, type Chart, type Session, type TestContext } from './helpers.js'; + +/** + * One company, one realistic month. Every report must agree with the others: + * capital 15,000 (cash 10,000 + bank 5,000) + * purchase 10 goods × 60 on credit (VAT 90) → AP 690 + * invoice to A: 4 goods × 100 + 1 service × 100 (VAT 75) → AR 575, COGS 240 + * sales return of 1 good (−100, VAT −15, COGS −60) + * receipt from A 300 (cash), advance from B 50 (cash) + * cash expense: marketing 200 + VAT 30 + * supplier payment 400 (bank) + * an invoice to B that is cancelled (must vanish everywhere) + * Expected: revenue 400, COGS 180, gross 220, expenses 200, net profit 20. + */ +let t: TestContext; +let owner: Session; +let c: Commerce; +let ch: Chart; +let y: { from: string; to: string }; +let A: string, B: string, S: string, goods: string, service: string, invoice: Record; +const get = async (path: string) => { + const r = await c.api.get(path); + if (r.statusCode !== 200) throw new Error(`${path}: ${r.statusCode} ${r.body}`); + return r.json(); +}; +const range = () => `dateFrom=${y.from}&dateTo=${y.to}`; + +beforeAll(async () => { + t = await setupApp(); + owner = await register(t.app); + c = await commerce(t.app, owner); + ch = await chart(t.app, owner.token); + const [year] = await fiscalYears(t.app, owner.token); + y = { from: year!.startDate, to: year!.endDate }; + + await c.api.post('/api/journal-entries', { entryDate: c.date, description: 'رأس المال', post: true, + lines: [dr(ch.byKey.get('CASH')!, '10000.00'), dr(ch.byKey.get('BANK')!, '5000.00'), cr(ch.byKey.get('CAPITAL')!, '15000.00')] }); + A = (await c.customer()).id; + B = (await c.customer()).id; + S = (await c.supplier()).id; + goods = (await c.goods({ salePrice: '100', purchasePrice: '60' })).id; + service = (await c.product({ salePrice: '100' })).id; + const bill = await c.purchase(S, [{ productId: goods, quantity: '10' }]); + invoice = await c.postInvoice(A, [{ productId: goods, quantity: '4' }, { productId: service, quantity: '1' }], { dueDate: c.date }); + const ret = (await c.api.post('/api/sales-returns', { originalInvoiceId: invoice.id, docDate: c.date, reason: 'تالف', lines: [{ sourceItemId: invoice.lines[0].id, quantity: '1' }] })).json(); + await c.api.post(`/api/sales-returns/${ret.id}/post`); + const cash = await c.method('CASH'); + await c.api.post('/api/payments', { direction: 'RECEIPT', customerId: A, paymentDate: c.date, methodId: cash, amount: '300.00', + allocations: [{ documentType: 'SALES_INVOICE', documentId: invoice.id, amount: '300.00' }] }); + await c.api.post('/api/payments', { direction: 'RECEIPT', customerId: B, paymentDate: c.date, methodId: cash, amount: '50.00', allocations: [] }); + const cats = (await c.api.get('/api/expense-categories')).json().data; + await c.api.post('/api/expenses', { expenseDate: c.date, paymentType: 'CASH', methodId: cash, post: true, + lines: [{ categoryId: cats.find((x: { code: string }) => x.code === 'MARKETING').id, amount: '200' }] }); + await c.api.post('/api/payments', { direction: 'DISBURSEMENT', supplierId: S, paymentDate: c.date, methodId: await c.method('BANK'), amount: '400.00', + allocations: [{ documentType: 'PURCHASE_INVOICE', documentId: bill.id, amount: '400.00' }] }); + const wrong = await c.postInvoice(B, [{ productId: service, quantity: '3' }]); + await c.api.post(`/api/invoices/${wrong.id}/cancel`, { reason: 'خطأ في العميل' }); +}); +afterAll(async () => { await t.close(); }); + +describe('financial statements', () => { + it('builds the income statement from the ledger', async () => { + const pl = await get(`/api/reports/profit-loss?${range()}`); + expect(pl).toMatchObject({ revenue: '400.00', costOfSales: '180.00', grossProfit: '220.00', expenses: '200.00', netProfit: '20.00' }); + expect(pl.sections[0].accounts.map((a: { code: string; amount: string }) => [a.code, a.amount])).toEqual([['4100', '400.00']]); + expect(pl.sections[2].accounts.map((a: { code: string; amount: string }) => [a.code, a.amount])).toEqual([['6300', '200.00']]); + }); + + it('balances the balance sheet with current earnings', async () => { + const bs = await get(`/api/reports/balance-sheet?asOf=${y.to}`); + const amounts = (s: { accounts: { code: string; amount: string }[] }) => Object.fromEntries(s.accounts.map((a) => [a.code, a.amount])); + expect(amounts(bs.assets)).toEqual({ 1100: '10120.00', 1200: '4600.00', 1300: '110.00', 1400: '420.00' }); + expect(amounts(bs.liabilities)).toEqual({ 2100: '290.00', 2210: '60.00', 2220: '-120.00' }); + expect(bs.equity).toMatchObject({ currentEarnings: '20.00', total: '15020.00' }); + expect(bs).toMatchObject({ totalAssets: '15250.00', totalLiabilitiesAndEquity: '15250.00', balanced: true }); + const before = await get(`/api/reports/balance-sheet?asOf=${y.from}`); + expect(before).toMatchObject({ totalAssets: '0.00', balanced: true }); + }); + + it('explains every riyal of cash movement (direct method)', async () => { + const cf = await get(`/api/reports/cash-flow?${range()}`); + expect(cf).toMatchObject({ openingCash: '0.00', netChange: '14720.00', closingCash: '14720.00', reconciled: true }); + const [operating, investing, financing] = cf.sections; + expect(financing.total).toBe('15000.00'); + expect(investing.total).toBe('0.00'); + expect(operating.total).toBe('-280.00'); + expect(operating.lines.map((l: { code: string; net: string }) => [l.code, l.net])).toEqual([['1300', '350.00'], ['2100', '-400.00'], ['2220', '-30.00'], ['6300', '-200.00']]); + }); + + it('lists an account with opening and running balances', async () => { + const gl = await get(`/api/reports/general-ledger?${range()}&accountId=${ch.byKey.get('CASH')}`); + expect(gl.openingBalance).toBe('0.00'); + expect(gl.lines.map((l: { debit: string; credit: string; balance: string }) => [l.debit, l.credit, l.balance])).toEqual([ + ['10000.00', '0.00', '10000.00'], ['300.00', '0.00', '10300.00'], ['50.00', '0.00', '10350.00'], ['0.00', '230.00', '10120.00'], + ]); + expect(gl.lines[1].documentNumber).toMatch(/^RCPT-\d{6}$/); + expect(gl.lines[3].documentNumber).toMatch(/^EXP-\d{6}$/); + expect(gl).toMatchObject({ closingBalance: '10120.00', totals: { debit: '10350.00', credit: '230.00' }, truncated: false }); + // A later window starts from the closing balance. + const later = await get(`/api/reports/general-ledger?dateFrom=${y.to}&dateTo=${y.to}&accountId=${ch.byKey.get('CASH')}`); + expect(later).toMatchObject({ openingBalance: '10120.00', lines: [], closingBalance: '10120.00' }); + // Credit-normal accounts show credit − debit. + const ap = await get(`/api/reports/general-ledger?${range()}&accountId=${ch.byKey.get('ACCOUNTS_PAYABLE')}`); + expect(ap.closingBalance).toBe('290.00'); + const header = await c.api.get(`/api/reports/general-ledger?${range()}&accountId=${ch.byCode.get('1000')}`); + expect(header.json().error.code).toBe('HEADER_ACCOUNT'); + }); + + it('lists the journal with balanced totals', async () => { + const j = await get(`/api/reports/journal?${range()}`); + expect(j.total).toBe(j.data.length); + expect(j.totals.debit).toBe(j.totals.credit); + expect(j.data.every((e: { totalDebit: string; lines: { debit: string }[] }) => e.lines.length >= 2)).toBe(true); + const manual = await get(`/api/reports/journal?${range()}&referenceType=MANUAL`); + expect(manual.total).toBe(1); + }); + + it('rejects an inverted range', async () => { + expect((await c.api.get(`/api/reports/profit-loss?dateFrom=${y.to}&dateTo=${y.from}`)).json().error.code).toBe('INVALID_RANGE'); + }); +}); + +describe('receivables, payables and statements', () => { + it('ages receivables and reconciles each customer to the ledger', async () => { + const r = await get('/api/reports/receivables-aging'); + const a = r.data.find((x: { partyId: string }) => x.partyId === A); + const b = r.data.find((x: { partyId: string }) => x.partyId === B); + expect(a.documents).toHaveLength(1); + expect(a.documents[0]).toMatchObject({ number: invoice.number, remaining: '160.00' }); + const days = Math.round((Date.parse(r.asOf) - Date.parse(c.date)) / 86_400_000); + const bucket = days <= 0 ? 'current' : days <= 30 ? 'days1to30' : days <= 60 ? 'days31to60' : days <= 90 ? 'days61to90' : 'over90'; + expect(a[bucket]).toBe('160.00'); + expect(a).toMatchObject({ unapplied: '0.00', balance: '160.00' }); + // B paid in advance and its invoice was cancelled: only the credit remains. + expect(b).toMatchObject({ documents: [], unapplied: '-50.00', balance: '-50.00' }); + expect(r.totals.balance).toBe('110.00'); // equals AR in the balance sheet + }); + + it('ages payables', async () => { + const r = await get('/api/reports/payables-aging'); + expect(r.data).toHaveLength(1); + expect(r.data[0]).toMatchObject({ partyId: S, unapplied: '0.00', balance: '290.00' }); + expect(r.totals.balance).toBe('290.00'); + }); + + it('produces customer and supplier statements with running balances', async () => { + const st = await get(`/api/reports/customer-statement?${range()}&partyId=${A}`); + expect(st.lines.map((l: { referenceType: string; debit: string; credit: string; balance: string }) => [l.referenceType, l.debit, l.credit, l.balance])).toEqual([ + ['SALES_INVOICE', '575.00', '0.00', '575.00'], ['SALES_RETURN', '0.00', '115.00', '460.00'], ['PAYMENT_RECEIPT', '0.00', '300.00', '160.00'], + ]); + expect(st.lines[0].documentNumber).toBe(invoice.number); + expect(st.closingBalance).toBe('160.00'); + // The cancelled invoice and its reversal both appear and cancel out. + const sb = await get(`/api/reports/customer-statement?${range()}&partyId=${B}`); + expect(sb.lines.map((l: { isReversal: boolean; debit: string; credit: string }) => [l.isReversal, l.debit, l.credit])).toEqual([ + [false, '0.00', '50.00'], [false, '345.00', '0.00'], [true, '0.00', '345.00'], + ]); + expect(sb.closingBalance).toBe('-50.00'); + const ss = await get(`/api/reports/supplier-statement?${range()}&partyId=${S}`); + expect(ss).toMatchObject({ openingBalance: '0.00', closingBalance: '290.00' }); + expect((await c.api.get(`/api/reports/supplier-statement?${range()}&partyId=${A}`)).statusCode).toBe(404); + }); +}); + +describe('operational reports', () => { + it('reports sales by product with cost and gross profit, net of returns and without cancelled invoices', async () => { + const r = await get(`/api/reports/sales?${range()}&groupBy=product`); + const row = (id: string) => r.data.find((x: { key: string }) => x.key === id); + expect(row(goods)).toMatchObject({ quantity: '3.0000', netAmount: '300.00', cost: '180.00', grossProfit: '120.00', documents: 1, returns: 1 }); + expect(row(service)).toMatchObject({ quantity: '1.0000', netAmount: '100.00', cost: '0.00', documents: 1 }); + // Agrees with the income statement. + expect(r.totals).toMatchObject({ netAmount: '400.00', vatAmount: '60.00', totalAmount: '460.00', cost: '180.00', grossProfit: '220.00' }); + const byCustomer = await get(`/api/reports/sales?${range()}&groupBy=party`); + expect(byCustomer.data.map((x: { key: string; totalAmount: string }) => [x.key, x.totalAmount])).toEqual([[A, '460.00']]); + const byMonth = await get(`/api/reports/sales?${range()}&groupBy=month`); + expect(byMonth.data).toEqual([expect.objectContaining({ key: c.date.slice(0, 7), netAmount: '400.00' })]); + const filtered = await get(`/api/reports/sales?${range()}&groupBy=document&productId=${service}`); + expect(filtered.totals.netAmount).toBe('100.00'); + }); + + it('reports purchases by supplier', async () => { + const r = await get(`/api/reports/purchases?${range()}`); + expect(r.data).toEqual([expect.objectContaining({ key: S, netAmount: '600.00', vatAmount: '90.00', totalAmount: '690.00' })]); + }); + + it('reports expenses from the ledger', async () => { + const r = await get(`/api/reports/expenses?${range()}`); + expect(r.data.map((x: { code: string; amount: string }) => [x.code, x.amount])).toEqual([['6300', '200.00']]); + expect(r.total).toBe('200.00'); + }); +}); + +describe('dashboard', () => { + it('shows ledger-based figures and a monthly series', async () => { + const d = await get(`/api/dashboard?${range()}`); + expect(d.kpis).toEqual({ + totalSales: '400.00', totalPurchases: '600.00', costOfSales: '180.00', expenses: '200.00', netProfit: '20.00', + receivables: '110.00', payables: '290.00', cash: '10120.00', bank: '4600.00', inventoryValue: '420.00', vatPayable: '-60.00', + }); + expect(d.monthly).toHaveLength(12); + const m = d.monthly.find((x: { month: string }) => x.month === c.date.slice(0, 7)); + expect(m).toMatchObject({ revenue: '400.00', costs: '380.00', netProfit: '20.00', cashBalance: '14720.00' }); + expect(d.monthly.at(-1).cashBalance).toBe('14720.00'); + }); + + it('defaults to the fiscal year to date', async () => { + const d = await get('/api/dashboard'); + expect(d.dateFrom <= d.dateTo).toBe(true); + }); +}); + +describe('year closing', () => { + it('keeps the closed year\'s profit in the income statement and moves it to retained earnings', async () => { + const s = await register(t.app); + const api = client(t.app, s.token); + const k = await chart(t.app, s.token); + const [year] = await fiscalYears(t.app, s.token); + const date = year!.periods[1]!.startDate; + await api.post('/api/journal-entries', { entryDate: date, description: 'بيع نقدي', post: true, lines: [dr(k.byKey.get('CASH')!, '1000.00'), cr(k.byKey.get('SALES')!, '1000.00')] }); + expect((await api.post(`/api/fiscal-years/${year!.id}/close`)).statusCode).toBe(200); + const pl = (await api.get(`/api/reports/profit-loss?dateFrom=${year!.startDate}&dateTo=${year!.endDate}`)).json(); + expect(pl.netProfit).toBe('1000.00'); + const bs = (await api.get(`/api/reports/balance-sheet?asOf=${year!.endDate}`)).json(); + expect(bs.equity).toMatchObject({ currentEarnings: '0.00', total: '1000.00' }); + expect(bs.equity.accounts.map((a: { code: string; amount: string }) => [a.code, a.amount])).toEqual([['3200', '1000.00']]); + expect(bs.balanced).toBe(true); + const d = (await api.get(`/api/dashboard?dateFrom=${year!.startDate}&dateTo=${year!.endDate}`)).json(); + expect(d.kpis.netProfit).toBe('1000.00'); + }); +}); + +describe('permissions and isolation', () => { + it('separates financial statements from operational reports', async () => { + const sales = await addMember(t.app, owner, 'SALES_MANAGER'); + const sApi = client(t.app, sales.token); + expect((await sApi.get(`/api/reports/sales?${range()}`)).statusCode).toBe(200); + expect((await sApi.get('/api/reports/receivables-aging')).statusCode).toBe(200); + for (const path of [`/api/reports/profit-loss?${range()}`, `/api/reports/balance-sheet?asOf=${y.to}`, '/api/dashboard', + `/api/reports/trial-balance?${range()}`, `/api/reports/general-ledger?${range()}&accountId=${ch.byKey.get('CASH')}`]) { + expect((await sApi.get(path)).statusCode).toBe(403); + } + const accountant = await addMember(t.app, owner, 'ACCOUNTANT'); + expect((await client(t.app, accountant.token).get(`/api/reports/profit-loss?${range()}`)).statusCode).toBe(200); + const viewer = await addMember(t.app, owner, 'VIEWER'); + expect((await client(t.app, viewer.token).get('/api/dashboard')).statusCode).toBe(200); + const clerk = await addMember(t.app, owner, 'SALES_EMPLOYEE'); + expect((await client(t.app, clerk.token).get(`/api/reports/sales?${range()}`)).statusCode).toBe(403); + }); + + it('never shows another tenant\'s figures', async () => { + const other = await register(t.app); + const o = client(t.app, other.token); + expect((await o.get(`/api/reports/general-ledger?${range()}&accountId=${ch.byKey.get('CASH')}`)).statusCode).toBe(404); + expect((await o.get(`/api/reports/customer-statement?${range()}&partyId=${A}`)).statusCode).toBe(404); + expect((await o.get(`/api/reports/sales?${range()}&partyId=${A}`)).json().data).toEqual([]); + const pl = (await o.get(`/api/reports/profit-loss?${range()}`)).json(); + expect(pl.netProfit).toBe('0.00'); + expect((await o.get('/api/reports/receivables-aging')).json().data).toEqual([]); + expect((await o.get('/api/dashboard')).json().kpis.cash).toBe('0.00'); + }); +}); diff --git a/alshuyukh-accounting/apps/api/test/sales.test.ts b/alshuyukh-accounting/apps/api/test/sales.test.ts new file mode 100644 index 000000000000..7dc546335e3b --- /dev/null +++ b/alshuyukh-accounting/apps/api/test/sales.test.ts @@ -0,0 +1,250 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { withTx } from '../src/db/tx.js'; +import { balanceOf, commerce, type Commerce } from './commerce-helpers.js'; +import { addMember, client, fiscalYears, register, setupApp, type Session, type TestContext } from './helpers.js'; + +let t: TestContext; +let owner: Session; +let c: Commerce; + +beforeAll(async () => { + t = await setupApp(); + owner = await register(t.app); + c = await commerce(t.app, owner); +}); +afterAll(async () => { await t.close(); }); + +const address = { buildingNumber: '1234', street: 'طريق الملك فهد', district: 'العليا', city: 'الرياض', postalCode: '12345', additionalNumber: '6789' }; + +describe('sales invoice posting', () => { + it('posts the reference example: 1000 + 150 VAT → Dr AR 1150 / Cr Sales 1000 / Cr VAT 150', async () => { + const customer = await c.customer({ vatNumber: '300000000000003', paymentTermsDays: 30, addresses: [address] }); + const product = await c.product({ salePrice: '1000' }); + const draft = await c.invoice(customer.id, [{ productId: product.id, quantity: '1' }]); + expect(draft).toMatchObject({ status: 'DRAFT', number: null, subtotal: '1000.00', taxableAmount: '1000.00', taxAmount: '150.00', total: '1150.00' }); + + const inv = (await c.api.post(`/api/invoices/${draft.id}/post`)).json(); + expect(inv).toMatchObject({ status: 'ISSUED', total: '1150.00', paidAmount: '0.00', remainingAmount: '1150.00', invoiceKind: 'STANDARD' }); + expect(inv.number).toMatch(/^INV-\d{6}$/); + expect(inv.partySnapshot).toMatchObject({ vatNumber: '300000000000003', address: expect.objectContaining({ postalCode: '12345' }) }); + const due = new Date(`${c.date}T00:00:00Z`); due.setUTCDate(due.getUTCDate() + 30); + expect(inv.dueDate).toBe(due.toISOString().slice(0, 10)); + + const lines = await c.journal(inv.journalEntryId); + expect(lines.map((l) => [l.accountCode, l.debit, l.credit])).toEqual([ + ['1300', '1150.00', '0.00'], ['4100', '0.00', '1000.00'], ['2210', '0.00', '150.00'], + ]); + expect(lines[0]!.customerId).toBe(customer.id); + const entry = (await c.api.get(`/api/journal-entries/${inv.journalEntryId}`)).json(); + expect(entry).toMatchObject({ status: 'POSTED', source: 'SYSTEM', referenceType: 'SALES_INVOICE', referenceId: inv.id, totalDebit: '1150.00', totalCredit: '1150.00' }); + expect(await balanceOf(c.api, 'customers', customer.id)).toBe('1150.00'); + }); + + it('marks invoices to customers without a VAT number as simplified (B2C)', async () => { + const inv = await c.postInvoice((await c.customer()).id, [{ productId: (await c.product()).id, quantity: '1' }]); + expect(inv.invoiceKind).toBe('SIMPLIFIED'); + }); + + it('numbers issued invoices sequentially; drafts have no number', async () => { + const cust = await c.customer(); + const prod = await c.product(); + const a = await c.postInvoice(cust.id, [{ productId: prod.id, quantity: '1' }]); + const draft = await c.invoice(cust.id, [{ productId: prod.id, quantity: '1' }]); + const b = await c.postInvoice(cust.id, [{ productId: prod.id, quantity: '1' }]); + expect(draft.number).toBeNull(); + expect(Number(b.number.slice(-6))).toBe(Number(a.number.slice(-6)) + 1); + }); + + it('calculates on the server without saving', async () => { + const cust = await c.customer(); + const prod = await c.product({ salePrice: '200' }); + const before = (await c.api.get('/api/invoices')).json().total; + const res = await c.api.post('/api/invoices/calculate', { partyId: cust.id, docDate: c.date, lines: [{ productId: prod.id, quantity: '3', discountPercent: '10' }] }); + expect(res.json().totals).toMatchObject({ subtotal: '600.00', discountTotal: '60.00', taxableAmount: '540.00', taxAmount: '81.00', total: '621.00' }); + expect((await c.api.get('/api/invoices')).json().total).toBe(before); + }); + + it('converts VAT-inclusive list prices to the document basis', async () => { + const prod = await c.product({ salePrice: '115', salePriceIncludesVat: true }); + const inv = await c.invoice((await c.customer()).id, [{ productId: prod.id, quantity: '2' }]); + expect(inv.lines[0]).toMatchObject({ unitPrice: '100.0000', netAmount: '200.00' }); + expect(inv.total).toBe('230.00'); + const incl = await c.invoice((await c.customer()).id, [{ productId: prod.id, quantity: '2' }], { pricesIncludeVat: true }); + expect(incl).toMatchObject({ taxableAmount: '200.00', taxAmount: '30.00', total: '230.00' }); + }); + + it('edits and deletes drafts, then refuses changes once issued', async () => { + const cust = await c.customer(); + const prod = await c.product(); + const draft = await c.invoice(cust.id, [{ productId: prod.id, quantity: '1' }]); + const edited = (await c.api.patch(`/api/invoices/${draft.id}`, { lines: [{ productId: prod.id, quantity: '5', unitPrice: '10' }] })).json(); + expect(edited.total).toBe('57.50'); + const other = await c.invoice(cust.id, [{ productId: prod.id, quantity: '1' }]); + expect((await c.api.del(`/api/invoices/${other.id}`)).statusCode).toBe(204); + + const issued = (await c.api.post(`/api/invoices/${draft.id}/post`)).json(); + expect((await c.api.patch(`/api/invoices/${issued.id}`, { notes: 'x' })).statusCode).toBe(409); + expect((await c.api.del(`/api/invoices/${issued.id}`)).statusCode).toBe(409); + expect((await c.api.post(`/api/invoices/${issued.id}/post`)).statusCode).toBe(409); + const ctx = { tenantId: owner.tenantId, userId: owner.userId }; + await expect(withTx(t.pool, ctx, (db) => db.query(`UPDATE sales_invoices SET total = 1 WHERE id = $1`, [issued.id]))).rejects.toThrow(/cannot be modified/); + await expect(withTx(t.pool, ctx, (db) => db.query(`UPDATE sales_invoice_items SET quantity = 1 WHERE document_id = $1`, [issued.id]))).rejects.toThrow(/cannot be changed/); + await expect(withTx(t.pool, ctx, (db) => db.query(`UPDATE sales_invoices SET status = 'DRAFT' WHERE id = $1`, [issued.id]))).rejects.toThrow(/draft/); + }); + + it('enforces the credit limit from the ledger balance', async () => { + const cust = await c.customer({ creditLimit: '1000.00' }); + const prod = await c.product({ salePrice: '500' }); + await c.postInvoice(cust.id, [{ productId: prod.id, quantity: '1' }]); // 575 + const second = await c.invoice(cust.id, [{ productId: prod.id, quantity: '1' }]); + const res = await c.api.post(`/api/invoices/${second.id}/post`); + expect(res.json().error.code).toBe('CREDIT_LIMIT_EXCEEDED'); + expect(await balanceOf(c.api, 'customers', cust.id)).toBe('575.00'); + }); + + it('uses the VAT rate effective on the invoice date', async () => { + const s = await register(t.app); + const sc = await commerce(t.app, s); + const [year] = await fiscalYears(t.app, s.token); + const july = year!.periods[6]!.startDate; + expect((await sc.api.post('/api/tax-rates', { nameAr: 'نسبة جديدة', rate: '0.2', effectiveFrom: july })).statusCode).toBe(201); + const cust = await sc.customer(); + const prod = await sc.product({ salePrice: '100' }); + const before = await sc.invoice(cust.id, [{ productId: prod.id, quantity: '1' }], { docDate: year!.periods[5]!.startDate }); + const after = await sc.invoice(cust.id, [{ productId: prod.id, quantity: '1' }], { docDate: july }); + expect(before.taxAmount).toBe('15.00'); + expect(after.taxAmount).toBe('20.00'); + const rates = (await sc.api.get('/api/tax-rates')).json().data; + expect(rates.find((r: { rate: string }) => r.rate === '0.1500').effectiveTo).toBe(year!.periods[5]!.endDate); + }); +}); + +describe('cancellation', () => { + it('cancels an unpaid invoice by reversing its entry', async () => { + const cust = await c.customer(); + const inv = await c.postInvoice(cust.id, [{ productId: (await c.product()).id, quantity: '2' }]); + const res = await c.api.post(`/api/invoices/${inv.id}/cancel`, { reason: 'خطأ في العميل' }); + expect(res.json()).toMatchObject({ status: 'CANCELLED', cancelReason: 'خطأ في العميل', number: inv.number }); + expect((await c.api.get(`/api/journal-entries/${inv.journalEntryId}`)).json().status).toBe('REVERSED'); + expect(await balanceOf(c.api, 'customers', cust.id)).toBe('0.00'); + expect((await c.api.post(`/api/invoices/${inv.id}/cancel`, { reason: 'مرة ثانية' })).statusCode).toBe(409); + }); + + it('refuses to cancel a paid invoice', async () => { + const cust = await c.customer(); + const inv = await c.postInvoice(cust.id, [{ productId: (await c.product()).id, quantity: '1' }]); + await c.api.post('/api/payments', { + direction: 'RECEIPT', customerId: cust.id, paymentDate: c.date, methodId: await c.method('CASH'), amount: '50.00', + allocations: [{ documentType: 'SALES_INVOICE', documentId: inv.id, amount: '50.00' }], + }); + expect((await c.api.post(`/api/invoices/${inv.id}/cancel`, { reason: 'إلغاء' })).json().error.code).toBe('HAS_PAYMENTS'); + }); +}); + +describe('sales returns (credit notes)', () => { + it('returns part of an invoice, then the rest, and nets exactly to zero', async () => { + const cust = await c.customer(); + const prod = await c.product({ salePrice: '33.33' }); + const inv = await c.postInvoice(cust.id, [{ productId: prod.id, quantity: '3' }]); + expect(inv).toMatchObject({ taxableAmount: '99.99', taxAmount: '15.00', total: '114.99' }); + const itemId = inv.lines[0].id; + + const r1 = (await c.api.post('/api/sales-returns', { originalInvoiceId: inv.id, docDate: c.date, reason: 'تالف', lines: [{ sourceItemId: itemId, quantity: '1' }] })).json(); + expect(r1).toMatchObject({ status: 'DRAFT', taxableAmount: '33.33', taxAmount: '5.00', total: '38.33' }); + const p1 = (await c.api.post(`/api/sales-returns/${r1.id}/post`)).json(); + expect(p1.number).toMatch(/^CN-\d{6}$/); + expect(p1.appliedAmount).toBe('38.33'); + const lines = await c.journal(p1.journalEntryId); + expect(lines.map((l) => [l.accountCode, l.debit, l.credit])).toEqual([ + ['1300', '0.00', '38.33'], ['4100', '33.33', '0.00'], ['2210', '5.00', '0.00'], + ]); + const mid = (await c.api.get(`/api/invoices/${inv.id}`)).json(); + expect(mid).toMatchObject({ status: 'ISSUED', returnedAmount: '38.33', remainingAmount: '76.66' }); + + const over = await c.api.post('/api/sales-returns', { originalInvoiceId: inv.id, docDate: c.date, reason: 'زيادة', lines: [{ sourceItemId: itemId, quantity: '3' }] }); + expect(over.json().error.code).toBe('RETURN_EXCEEDS_INVOICE'); + + const r2 = (await c.api.post('/api/sales-returns', { originalInvoiceId: inv.id, docDate: c.date, reason: 'الباقي', lines: [{ sourceItemId: itemId, quantity: '2' }] })).json(); + const p2 = (await c.api.post(`/api/sales-returns/${r2.id}/post`)).json(); + expect(p2).toMatchObject({ taxableAmount: '66.66', taxAmount: '10.00', total: '76.66' }); + const final = (await c.api.get(`/api/invoices/${inv.id}`)).json(); + expect(final).toMatchObject({ status: 'RETURNED', returnedAmount: '114.99', remainingAmount: '0.00' }); + expect(await balanceOf(c.api, 'customers', cust.id)).toBe('0.00'); + }); + + it('cancelling a return restores the invoice', async () => { + const cust = await c.customer(); + const inv = await c.postInvoice(cust.id, [{ productId: (await c.product()).id, quantity: '2' }]); + const r = (await c.api.post('/api/sales-returns', { originalInvoiceId: inv.id, docDate: c.date, reason: 'خطأ', lines: [{ sourceItemId: inv.lines[0].id, quantity: '2' }] })).json(); + await c.api.post(`/api/sales-returns/${r.id}/post`); + expect((await c.api.get(`/api/invoices/${inv.id}`)).json().status).toBe('RETURNED'); + expect((await c.api.post(`/api/invoices/${inv.id}/cancel`, { reason: 'إلغاء' })).json().error.code).toBe('HAS_RETURNS'); + await c.api.post(`/api/sales-returns/${r.id}/cancel`, { reason: 'أُدخل خطأ' }); + expect((await c.api.get(`/api/invoices/${inv.id}`)).json()).toMatchObject({ status: 'ISSUED', returnedAmount: '0.00', remainingAmount: inv.total }); + expect(await balanceOf(c.api, 'customers', cust.id)).toBe(inv.total); + }); + + it('refuses returns of drafts and of lines from another invoice', async () => { + const cust = await c.customer(); + const prod = await c.product(); + const draft = await c.invoice(cust.id, [{ productId: prod.id, quantity: '1' }]); + expect((await c.api.post('/api/sales-returns', { originalInvoiceId: draft.id, docDate: c.date, reason: 'مسودة', lines: [{ sourceItemId: draft.lines[0].id, quantity: '1' }] })).json().error.code).toBe('INVOICE_NOT_ISSUED'); + const a = await c.postInvoice(cust.id, [{ productId: prod.id, quantity: '1' }]); + const b = await c.postInvoice(cust.id, [{ productId: prod.id, quantity: '1' }]); + expect((await c.api.post('/api/sales-returns', { originalInvoiceId: a.id, docDate: c.date, reason: 'خلط', lines: [{ sourceItemId: b.lines[0].id, quantity: '1' }] })).statusCode).toBe(400); + }); +}); + +describe('quotes', () => { + it('sends, accepts and converts a quote into a draft invoice', async () => { + const cust = await c.customer(); + const prod = await c.product({ salePrice: '80' }); + const q = (await c.api.post('/api/sales-quotes', { partyId: cust.id, docDate: c.date, lines: [{ productId: prod.id, quantity: '5', discountAmount: '20' }] })).json(); + expect(q.number).toMatch(/^QT-\d{6}$/); + expect((await c.api.post(`/api/sales-quotes/${q.id}/status`, { status: 'SENT' })).json().status).toBe('SENT'); + expect((await c.api.post(`/api/sales-quotes/${q.id}/status`, { status: 'ACCEPTED' })).json().status).toBe('ACCEPTED'); + const inv = (await c.api.post(`/api/sales-quotes/${q.id}/convert`, { docDate: c.date })).json(); + expect(inv).toMatchObject({ status: 'DRAFT', sourceQuoteId: q.id, total: q.total }); + expect((await c.api.get(`/api/sales-quotes/${q.id}`)).json()).toMatchObject({ status: 'CONVERTED', convertedInvoiceId: inv.id }); + expect((await c.api.post(`/api/sales-quotes/${q.id}/convert`, {})).statusCode).toBe(409); + expect((await c.api.post(`/api/sales-quotes/${q.id}/post`)).statusCode).toBe(404); + }); +}); + +describe('permissions and isolation', () => { + it('lets sales employees draft but not issue invoices', async () => { + const cust = await c.customer(); + const prod = await c.product(); + const emp = await addMember(t.app, owner, 'SALES_EMPLOYEE'); + const eApi = client(t.app, emp.token); + const draft = await eApi.post('/api/invoices', { partyId: cust.id, docDate: c.date, lines: [{ productId: prod.id, quantity: '1' }] }); + expect(draft.statusCode).toBe(201); + expect((await eApi.post(`/api/invoices/${draft.json().id}/post`)).statusCode).toBe(403); + const purchase = await addMember(t.app, owner, 'PURCHASE_MANAGER'); + expect((await client(t.app, purchase.token).get('/api/invoices')).statusCode).toBe(403); + }); + + it('hides invoices and products of other tenants', async () => { + const inv = await c.postInvoice((await c.customer()).id, [{ productId: (await c.product()).id, quantity: '1' }]); + const other = await register(t.app); + const oc = await commerce(t.app, other); + expect((await oc.api.get(`/api/invoices/${inv.id}`)).statusCode).toBe(404); + expect((await oc.api.post(`/api/invoices/${inv.id}/cancel`, { reason: 'اختراق' })).statusCode).toBe(404); + const foreign = await oc.api.post('/api/invoices', { partyId: (await oc.customer()).id, docDate: oc.date, lines: [{ productId: inv.lines[0].productId, quantity: '1' }] }); + expect(foreign.json().error.code).toBe('INVALID_PRODUCT'); + }); + + it('refuses to delete customers and products used in documents', async () => { + const cust = await c.customer(); + const prod = await c.product(); + await c.invoice(cust.id, [{ productId: prod.id, quantity: '1' }]); + expect((await c.api.del(`/api/customers/${cust.id}`)).json().error.code).toBe('PARTY_IN_USE'); + expect((await c.api.del(`/api/products/${prod.id}`)).json().error.code).toBe('PRODUCT_IN_USE'); + }); + + it('keeps the ledger balanced', async () => { + const [year] = await fiscalYears(t.app, owner.token); + const tb = (await c.api.get(`/api/reports/trial-balance?dateFrom=${year!.startDate}&dateTo=${year!.endDate}`)).json(); + expect(tb.balanced).toBe(true); + }); +}); diff --git a/alshuyukh-accounting/apps/api/test/subscriptions.test.ts b/alshuyukh-accounting/apps/api/test/subscriptions.test.ts new file mode 100644 index 000000000000..904e8d9f3017 --- /dev/null +++ b/alshuyukh-accounting/apps/api/test/subscriptions.test.ts @@ -0,0 +1,162 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { flushUsage } from '../src/modules/subscriptions/service.js'; +import { commerce } from './commerce-helpers.js'; +import { addMember, client, register, roleId, setupApp, type Session, type TestContext } from './helpers.js'; + +let t: TestContext; +let admin: ReturnType; + +/** A platform administrator (the flag is set directly; the API never lets a tenant grant it). */ +async function makeAdmin(): Promise { + const s = await register(t.app); + await t.ownerPool.query(`UPDATE users SET is_platform_admin = true WHERE id = $1`, [s.userId]); + return s; +} +const setLimits = async (tenantId: string, overrides: Record) => { + const r = await admin.post(`/api/admin/tenants/${tenantId}/subscription/limits`, { overrides }); + expect(r.statusCode).toBe(200); +}; +/** Moves the subscription period into the past (the API only extends periods). */ +const endPeriod = (s: Session, daysAgo: number) => t.ownerPool.query(`UPDATE subscriptions SET current_period_start = now() - interval '60 days', current_period_end = now() - make_interval(days => $2) + WHERE tenant_id = $1 AND status <> 'CANCELLED'`, [s.tenantId, daysAgo]); + +beforeAll(async () => { + t = await setupApp(); + admin = client(t.app, (await makeAdmin()).token); +}); +afterAll(async () => { await t.close(); }); + +describe('trial on sign-up', () => { + it('starts every new organization on the default plan as a trial', async () => { + const s = await register(t.app); + const api = client(t.app, s.token); + const sub = (await api.get('/api/subscription')).json(); + expect(sub).toMatchObject({ planCode: 'TRIAL', status: 'TRIALING', state: 'TRIALING', writable: true, billingCycle: 'MONTHLY' }); + expect(new Date(sub.periodEnd).getTime() - Date.now()).toBeGreaterThan(13 * 86_400_000); + expect(sub.items).toEqual([expect.objectContaining({ code: 'TRIAL', unitPrice: '0.00' })]); + expect(sub.usage).toMatchObject({ max_users: 1, max_companies: 1, max_branches: 1, max_warehouses: 1 }); + const me = (await api.get('/api/auth/me')).json(); + expect(me.subscription).toMatchObject({ state: 'TRIALING', writable: true, planName: 'الباقة التجريبية' }); + expect(me.features).toMatchObject({ zatca_einvoicing: true }); + const events = (await api.get('/api/subscription/billing-events')).json().data; + expect(events.map((e: { eventType: string }) => e.eventType)).toEqual(['TRIAL_STARTED']); + }); +}); + +describe('plan limits', () => { + it('counts reactivated members and accepted invitations against the user limit', async () => { + const s = await register(t.app); + const other = await register(t.app); + const api = client(t.app, s.token); + const viewer = await roleId(t.app, s.token, 'VIEWER'); + const member = (await api.post('/api/users', { email: `m-${Date.now()}@example.test`, fullName: 'عضو', initialPassword: 'Str0ng-Passw0rd!', roleIds: [viewer] })).json(); + expect((await api.post('/api/users', { email: other.email, fullName: 'مدعو', roleIds: [viewer] })).statusCode).toBe(201); + expect((await api.patch(`/api/users/${member.id}`, { status: 'DISABLED' })).statusCode).toBe(200); + await setLimits(s.tenantId, { max_users: 1 }); + const reactivate = await api.patch(`/api/users/${member.id}`, { status: 'ACTIVE' }); + expect(reactivate.json().error).toMatchObject({ code: 'PLAN_LIMIT_REACHED', details: { limit: 'max_users' } }); + const accept = await client(t.app, other.token).post(`/api/auth/invitations/${s.tenantId}/accept`); + expect(accept.json().error.code).toBe('PLAN_LIMIT_REACHED'); + await setLimits(s.tenantId, { max_users: null }); + expect((await client(t.app, other.token).post(`/api/auth/invitations/${s.tenantId}/accept`)).statusCode).toBe(204); + }); + + it('stops at each limit with 402 and leaves nothing half-created', async () => { + const s = await register(t.app); + const c = await commerce(t.app, s); + const companyId = (await c.api.get('/api/companies')).json().data[0].id; + await setLimits(s.tenantId, { max_users: 1, max_companies: 1, max_branches: 1, max_warehouses: 1, max_products: 2, max_invoices_per_month: 1 }); + + const member = await c.api.post('/api/users', { email: `x-${Date.now()}@example.test`, fullName: 'عضو', initialPassword: 'Str0ng-Passw0rd!', roleIds: [await roleId(t.app, s.token, 'VIEWER')] }); + expect(member.statusCode).toBe(402); + expect(member.json().error).toMatchObject({ code: 'PLAN_LIMIT_REACHED', details: { limit: 'max_users', max: 1, used: 1 } }); + expect((await c.api.post('/api/companies', { name: 'شركة ثانية' })).json().error.code).toBe('PLAN_LIMIT_REACHED'); + expect((await c.api.post(`/api/companies/${companyId}/branches`, { code: 'B2', name: 'فرع جدة' })).json().error.code).toBe('PLAN_LIMIT_REACHED'); + expect((await c.api.post(`/api/companies/${companyId}/warehouses`, { code: 'W2', name: 'مستودع جدة' })).json().error.code).toBe('PLAN_LIMIT_REACHED'); + + await c.product(); + await c.product(); + expect((await c.api.post('/api/products', { nameAr: 'منتج ثالث' })).statusCode).toBe(402); + + const cust = (await c.customer()).id; + const svc = (await c.api.get('/api/products')).json().data[0].id; + await c.postInvoice(cust, [{ productId: svc, quantity: '1' }]); + const second = await c.invoice(cust, [{ productId: svc, quantity: '1' }]); + expect((await c.api.post(`/api/invoices/${second.id}/post`)).json().error.code).toBe('PLAN_LIMIT_REACHED'); + expect((await c.api.get(`/api/invoices/${second.id}`)).json()).toMatchObject({ status: 'DRAFT', journalEntryId: null }); + + // Raising a limit (null = unlimited) applies immediately. + await setLimits(s.tenantId, { max_products: null }); + expect((await c.api.post('/api/products', { nameAr: 'منتج ثالث' })).statusCode).toBe(201); + }); + + it('does not overshoot a limit under concurrent requests', async () => { + const s = await register(t.app); + const api = client(t.app, s.token); + await setLimits(s.tenantId, { max_products: 3 }); + const results = await Promise.all(Array.from({ length: 6 }, (_, i) => api.post('/api/products', { nameAr: `منتج متزامن ${i}` }))); + expect(results.filter((r) => r.statusCode === 201)).toHaveLength(3); + expect(results.filter((r) => r.statusCode === 402)).toHaveLength(3); + }); + + it('meters API calls and refuses them past the monthly allowance', async () => { + const s = await register(t.app); + const api = client(t.app, s.token); + await flushUsage(t.pool); + await setLimits(s.tenantId, { max_api_calls_per_month: 5 }); + const codes: number[] = []; + for (let i = 0; i < 8; i++) codes.push((await api.get('/api/companies')).statusCode); + expect(codes.filter((x) => x === 429).length).toBeGreaterThan(0); + expect(codes.indexOf(429)).toBeLessThanOrEqual(5); + // Sign-in and the subscription page stay reachable. + expect((await api.get('/api/subscription')).statusCode).toBe(200); + await flushUsage(t.pool); + expect((await api.get('/api/subscription')).json().usage.max_api_calls_per_month).toBeGreaterThanOrEqual(5); + await setLimits(s.tenantId, {}); + expect((await api.get('/api/companies')).statusCode).toBe(200); + }); +}); + +describe('expiry', () => { + it('keeps full access during the grace days, then becomes read-only until payment', async () => { + const s = await register(t.app); + const api = client(t.app, s.token); + await endPeriod(s, 2); // within the 7 grace days + expect((await api.get('/api/auth/me')).json().subscription).toMatchObject({ state: 'GRACE', writable: true }); + expect((await api.post('/api/products', { nameAr: 'أثناء المهلة' })).statusCode).toBe(201); + + await endPeriod(s, 30); + expect((await api.get('/api/auth/me')).json().subscription).toMatchObject({ state: 'EXPIRED', writable: false }); + expect((await api.get('/api/products')).statusCode).toBe(200); + const write = await api.post('/api/products', { nameAr: 'بعد الانتهاء' }); + expect(write.statusCode).toBe(402); + expect(write.json().error.code).toBe('SUBSCRIPTION_INACTIVE'); + // The owner can still ask for a plan and sign out. + const plans = (await admin.get('/api/admin/plans')).json().data; + const paid = (await admin.post('/api/admin/plans', { + code: `BASIC_${Date.now() % 100000}`, nameAr: 'الأساسية', priceMonthly: '99.00', priceYearly: '990.00', trialDays: 0, graceDays: 7, + max_users: 5, max_companies: 1, max_branches: 3, max_warehouses: 3, max_products: 1000, max_invoices_per_month: 500, max_storage_mb: 1024, + max_api_calls_per_month: null, isPublic: true, isActive: true, isDefault: false, sortOrder: 10, + })).json(); + expect(plans.length).toBeGreaterThan(0); + expect((await api.post('/api/subscription/request-change', { planId: paid.id, billingCycle: 'MONTHLY' })).json()).toEqual({ requested: true }); + + // The administrator applies the plan and records the payment: access returns. + await admin.post(`/api/admin/tenants/${s.tenantId}/subscription/plan`, { planId: paid.id, billingCycle: 'MONTHLY' }); + const paidSub = (await admin.post(`/api/admin/tenants/${s.tenantId}/subscription/payment`, { amount: '99.00', reference: 'TRF-1001' })).json(); + expect(paidSub).toMatchObject({ status: 'ACTIVE', state: 'ACTIVE', writable: true, planName: 'الأساسية' }); + expect(new Date(paidSub.periodEnd).getTime()).toBeGreaterThan(Date.now() + 27 * 86_400_000); + expect((await api.post('/api/products', { nameAr: 'بعد التجديد' })).statusCode).toBe(201); + const events = (await api.get('/api/subscription/billing-events')).json().data.map((e: { eventType: string }) => e.eventType); + expect(events).toEqual(['PAYMENT_RECORDED', 'PLAN_CHANGED', 'PLAN_CHANGE_REQUESTED', 'TRIAL_STARTED']); + expect((await api.get('/api/subscription')).json().items).toEqual([expect.objectContaining({ unitPrice: '99.00' })]); + }); + + it('limits plan change requests to subscription managers', async () => { + const s = await register(t.app); + const accountant = client(t.app, (await addMember(t.app, s, 'ACCOUNTANT')).token); + const plan = (await client(t.app, s.token).get('/api/subscription/plans')).json().data[0]; + expect((await accountant.post('/api/subscription/request-change', { planId: plan.id, billingCycle: 'MONTHLY' })).statusCode).toBe(403); + expect((await accountant.get('/api/subscription')).statusCode).toBe(200); + }); +}); diff --git a/alshuyukh-accounting/apps/api/test/tenant-isolation.test.ts b/alshuyukh-accounting/apps/api/test/tenant-isolation.test.ts new file mode 100644 index 000000000000..820d2b0301ce --- /dev/null +++ b/alshuyukh-accounting/apps/api/test/tenant-isolation.test.ts @@ -0,0 +1,121 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { withTx } from '../src/db/tx.js'; +import { client, register, setupApp, type Session, type TestContext } from './helpers.js'; + +let t: TestContext; +let a: Session; +let b: Session; +let companyA: { id: string }; +let branchA: { id: string }; + +beforeAll(async () => { + t = await setupApp(); + a = await register(t.app, { tenantName: 'Tenant A', companyName: 'Company A' }); + b = await register(t.app, { tenantName: 'Tenant B', companyName: 'Company B' }); + companyA = (await client(t.app, a.token).get('/api/companies')).json().data[0]; + branchA = (await client(t.app, a.token).get(`/api/companies/${companyA.id}/branches`)).json().data[0]; +}); +afterAll(async () => { await t.close(); }); + +describe('API-level isolation (tenant B attacking tenant A by ID)', () => { + it('lists only its own companies', async () => { + const list = (await client(t.app, b.token).get('/api/companies')).json().data; + expect(list.map((c: { name: string }) => c.name)).toEqual(['Company B']); + }); + + it('cannot read, update or delete another tenant\'s company (404, no existence leak)', async () => { + const api = client(t.app, b.token); + expect((await api.get(`/api/companies/${companyA.id}`)).statusCode).toBe(404); + expect((await api.patch(`/api/companies/${companyA.id}`, { name: 'Hacked' })).statusCode).toBe(404); + expect((await api.del(`/api/companies/${companyA.id}`)).statusCode).toBe(404); + const still = (await client(t.app, a.token).get(`/api/companies/${companyA.id}`)).json(); + expect(still.name).toBe('Company A'); + }); + + it('cannot list or create branches/warehouses under another tenant\'s company', async () => { + const api = client(t.app, b.token); + expect((await api.get(`/api/companies/${companyA.id}/branches`)).statusCode).toBe(404); + expect((await api.post(`/api/companies/${companyA.id}/branches`, { code: 'X1', name: 'Injected' })).statusCode).toBe(404); + expect((await api.patch(`/api/branches/${branchA.id}`, { name: 'Hacked' })).statusCode).toBe(404); + expect((await api.post(`/api/companies/${companyA.id}/warehouses`, { code: 'W1', name: 'Injected' })).statusCode).toBe(404); + }); + + it('cannot see or manage another tenant\'s users', async () => { + const api = client(t.app, b.token); + const users = (await api.get('/api/users')).json().data; + expect(users.map((u: { id: string }) => u.id)).toEqual([b.userId]); + expect((await api.get(`/api/users/${a.userId}`)).statusCode).toBe(404); + expect((await api.patch(`/api/users/${a.userId}`, { status: 'DISABLED' })).statusCode).toBe(404); + }); + + it('cannot read another tenant\'s audit log', async () => { + const logs = (await client(t.app, b.token).get('/api/audit-logs?limit=200')).json().data; + expect(logs.every((l: { userId: string | null }) => l.userId === b.userId)).toBe(true); + const byEntity = (await client(t.app, b.token).get(`/api/audit-logs?entityId=${a.tenantId}`)).json().data; + expect(byEntity).toEqual([]); + }); + + it('cannot switch into a tenant it is not a member of', async () => { + const res = await client(t.app, b.token).post('/api/auth/switch-tenant', { tenantId: a.tenantId }); + expect(res.statusCode).toBe(403); + }); + + it('cannot assign another tenant\'s custom role', async () => { + const role = await client(t.app, a.token).post('/api/roles', { code: 'A_ONLY', nameAr: 'دور خاص', nameEn: 'A only', permissions: ['company.view'] }); + expect(role.statusCode).toBe(201); + const res = await client(t.app, b.token).post('/api/users', { + email: 'victim@example.test', fullName: 'Victim', initialPassword: 'Str0ng-Passw0rd!', roleIds: [role.json().id], + }); + expect(res.statusCode).toBe(400); + expect(res.json().error.code).toBe('INVALID_ROLE'); + }); +}); + +describe('database-level isolation (Row-Level Security)', () => { + it('runs the API with a role that cannot bypass RLS', async () => { + const { rows: [r] } = await t.pool.query(`SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user`); + expect(r).toEqual({ rolsuper: false, rolbypassrls: false }); + }); + + it('returns only the context tenant\'s rows even without a WHERE tenant_id filter', async () => { + const rows = await withTx(t.pool, { tenantId: b.tenantId, userId: b.userId }, async (db) => + (await db.query<{ tenant_id: string }>('SELECT tenant_id FROM companies')).rows); + expect(rows.length).toBeGreaterThan(0); + expect(rows.every((r) => r.tenant_id === b.tenantId)).toBe(true); + }); + + it('returns nothing when no tenant context is set', async () => { + const rows = await withTx(t.pool, {}, async (db) => (await db.query('SELECT id FROM companies')).rows); + expect(rows).toEqual([]); + }); + + it('rejects writing a row into another tenant', async () => { + await expect(withTx(t.pool, { tenantId: b.tenantId, userId: b.userId }, (db) => + db.query(`INSERT INTO companies (tenant_id, name) VALUES ($1, 'Injected')`, [a.tenantId]), + )).rejects.toThrow(/row-level security/); + }); + + it('rejects linking a branch to another tenant\'s company (composite FK)', async () => { + await expect(withTx(t.pool, { tenantId: b.tenantId, userId: b.userId }, (db) => + db.query(`INSERT INTO branches (tenant_id, company_id, code, name) VALUES ($1, $2, 'X', 'Injected')`, [b.tenantId, companyA.id]), + )).rejects.toThrow(/foreign key/); + }); + + it('makes cross-tenant UPDATEs affect zero rows', async () => { + const res = await withTx(t.pool, { tenantId: b.tenantId, userId: b.userId }, (db) => + db.query(`UPDATE companies SET name = 'Hacked' WHERE id = $1`, [companyA.id])); + expect(res.rowCount).toBe(0); + }); + + it('does not allow the app role to modify system roles', async () => { + const res = await withTx(t.pool, { tenantId: b.tenantId, userId: b.userId }, (db) => + db.query(`UPDATE roles SET name_en = 'pwned' WHERE code = 'TENANT_OWNER' AND tenant_id IS NULL`)); + expect(res.rowCount).toBe(0); + }); + + it('does not allow the app role to hard-delete business records', async () => { + await expect(withTx(t.pool, { tenantId: a.tenantId, userId: a.userId }, (db) => + db.query('DELETE FROM companies WHERE id = $1', [companyA.id]), + )).rejects.toThrow(/permission denied/); + }); +}); diff --git a/alshuyukh-accounting/apps/api/test/vat-return.test.ts b/alshuyukh-accounting/apps/api/test/vat-return.test.ts new file mode 100644 index 000000000000..2a6b96e4f8bd --- /dev/null +++ b/alshuyukh-accounting/apps/api/test/vat-return.test.ts @@ -0,0 +1,90 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { commerce, type Commerce } from './commerce-helpers.js'; +import { chart, cr, dr, fiscalYears, register, setupApp, type Session, type TestContext } from './helpers.js'; + +let t: TestContext; +let owner: Session; +let c: Commerce; +let range: string; + +beforeAll(async () => { + t = await setupApp(); + owner = await register(t.app); + c = await commerce(t.app, owner); + const [year] = await fiscalYears(t.app, owner.token); + const p = year!.periods[2]!; // the month that contains c.date + range = `dateFrom=${p.startDate}&dateTo=${p.endDate}`; + + const cust = (await c.customer()).id; + const sup = (await c.supplier()).id; + const std = (await c.product({ salePrice: '200' })).id; + const zero = (await c.product({ salePrice: '200', vatCategory: 'Z' })).id; + const exempt = (await c.product({ salePrice: '50', vatCategory: 'E' })).id; + const goods = (await c.goods({ purchasePrice: '60' })).id; + const cats = Object.fromEntries((await c.api.get('/api/expense-categories')).json().data.map((x: { code: string; id: string }) => [x.code, x.id])); + + // Sales: standard 1000 (VAT 150), zero-rated 200, exempt 50. + const inv = await c.postInvoice(cust, [{ productId: std, quantity: '5' }, { productId: zero, quantity: '1' }, { productId: exempt, quantity: '1' }]); + // Sales return of one standard unit: −200 / −30. + const r = (await c.api.post('/api/sales-returns', { originalInvoiceId: inv.id, docDate: c.date, reason: 'إرجاع', lines: [{ sourceItemId: inv.lines[0].id, quantity: '1' }] })).json(); + await c.api.post(`/api/sales-returns/${r.id}/post`); + // A cancelled invoice nets to zero. + const cancelled = await c.postInvoice(cust, [{ productId: std, quantity: '1' }]); + await c.api.post(`/api/invoices/${cancelled.id}/cancel`, { reason: 'خطأ' }); + // Purchases: goods 600 (VAT 90), marketing expense 1000 (150), exempt rent 5000. + const bill = await c.purchase(sup, [{ productId: goods, quantity: '10' }]); + await c.api.post('/api/expenses', { expenseDate: c.date, paymentType: 'CASH', methodId: await c.method('CASH'), post: true, lines: [{ categoryId: cats.MARKETING, amount: '1000' }] }); + await c.api.post('/api/expenses', { expenseDate: c.date, paymentType: 'CASH', methodId: await c.method('CASH'), post: true, lines: [{ categoryId: cats.RENT, amount: '5000', vatCategory: 'E' }] }); + // Purchase return of goods worth 100: −100 / −15. + const pr = (await c.api.post('/api/purchase-returns', { originalInvoiceId: bill.id, docDate: c.date, reason: 'معيب', lines: [{ sourceItemId: bill.lines[0].id, quantity: '1' }] })).json(); + await c.api.post(`/api/purchase-returns/${pr.id}/post`); +}); +afterAll(async () => { await t.close(); }); + +describe('VAT return', () => { + it('fills the ZATCA boxes from posted documents', async () => { + const res = await c.api.get(`/api/reports/vat-return?${range}`); + expect(res.statusCode).toBe(200); + const v = res.json(); + expect(v.sales['1_standardRated']).toEqual({ amount: '1000.00', adjustment: '-200.00', vat: '120.00' }); + expect(v.sales['3_zeroRatedDomestic']).toEqual({ amount: '200.00', adjustment: '0.00', vat: '0.00' }); + expect(v.sales['5_exempt']).toEqual({ amount: '50.00', adjustment: '0.00', vat: '0.00' }); + expect(v.sales['6_total']).toEqual({ amount: '1250.00', adjustment: '-200.00', vat: '120.00' }); + // Purchases: goods 600 + expense 1000 (standard), return −60 / −9, rent 5000 exempt. + expect(v.purchases['7_standardRatedDomestic']).toEqual({ amount: '1600.00', adjustment: '-60.00', vat: '231.00' }); + expect(v.purchases['11_exempt']).toEqual({ amount: '5000.00', adjustment: '0.00', vat: '0.00' }); + expect(v.purchases['12_total']).toEqual({ amount: '6600.00', adjustment: '-60.00', vat: '231.00' }); + expect(v['13_totalVatDue']).toBe('-111.00'); + expect(v['16_netVatDue']).toBe('-111.00'); + expect(v.notSupported).toContain('4_exports'); + }); + + it('reconciles with the VAT accounts in the ledger', async () => { + const v = (await c.api.get(`/api/reports/vat-return?${range}`)).json(); + expect(v.ledger).toMatchObject({ vatOutput: '120.00', vatInput: '231.00', reconciled: true }); + }); + + it('flags manual journal entries to VAT accounts', async () => { + const s = await register(t.app); + const sc = await commerce(t.app, s); + const ch = await chart(t.app, s.token); + await sc.api.post('/api/journal-entries', { entryDate: sc.date, description: 'قيد يدوي على الضريبة', post: true, + lines: [dr(ch.byKey.get('CASH')!, '10.00'), cr(ch.byKey.get('VAT_OUTPUT')!, '10.00')] }); + const [year] = await fiscalYears(t.app, s.token); + const v = (await sc.api.get(`/api/reports/vat-return?dateFrom=${year!.startDate}&dateTo=${year!.endDate}`)).json(); + expect(v.ledger).toMatchObject({ outputDifference: '10.00', reconciled: false }); + }); + + it('only counts transactions inside the period', async () => { + const [year] = await fiscalYears(t.app, owner.token); + const v = (await c.api.get(`/api/reports/vat-return?dateFrom=${year!.periods[5]!.startDate}&dateTo=${year!.periods[5]!.endDate}`)).json(); + expect(v.sales['6_total']).toEqual({ amount: '0.00', adjustment: '0.00', vat: '0.00' }); + }); + + it('lists the transactions behind the return', async () => { + const rows = (await c.api.get(`/api/reports/vat-transactions?${range}&direction=OUTPUT&vatCategory=S`)).json().data; + expect(rows.map((r: { sourceType: string; taxAmount: string }) => [r.sourceType, r.taxAmount])).toEqual([ + ['SALES_INVOICE', '150.00'], ['SALES_RETURN', '-30.00'], ['SALES_INVOICE', '30.00'], ['SALES_INVOICE', '-30.00'], + ]); + }); +}); diff --git a/alshuyukh-accounting/apps/api/test/zatca-fake.ts b/alshuyukh-accounting/apps/api/test/zatca-fake.ts new file mode 100644 index 000000000000..9e5d1ca8b575 --- /dev/null +++ b/alshuyukh-accounting/apps/api/test/zatca-fake.ts @@ -0,0 +1,106 @@ +import { execFileSync } from 'node:child_process'; +import { createPublicKey, verify, X509Certificate } from 'node:crypto'; +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DOMParser } from '@xmldom/xmldom'; +import { invoiceHash } from '../src/modules/zatca/sign.js'; +import type { Transport } from '../src/modules/zatca/client.js'; + +/** + * A stand-in for the ZATCA gateway. It issues real X.509 certificates from a + * throw-away secp256k1 CA (via the openssl CLI), and on every invoice it + * recomputes the hash and verifies the ECDSA signature with the certificate + * embedded in the XML — so the tests check our cryptography, not just our flow. + */ +export interface Call { path: string; headers: Record; body: any } +export type Behaviour = 'ok' | 'warning' | 'reject' | 'server-error' | 'network-error'; + +export class FakeZatca { + readonly calls: Call[] = []; + behaviour: Behaviour = 'ok'; + private dir = mkdtempSync(join(tmpdir(), 'zatca-ca-')); + private serial = 1000; + + constructor() { + execFileSync('openssl', ['ecparam', '-name', 'secp256k1', '-genkey', '-noout', '-out', join(this.dir, 'ca.key')]); + execFileSync('openssl', ['req', '-x509', '-new', '-key', join(this.dir, 'ca.key'), '-subj', '/DC=local/DC=gov/DC=extgazt/CN=TSZEINVOICE-SubCA-1', '-days', '30', '-out', join(this.dir, 'ca.pem')]); + } + + /** Signs a CSR; returns the token ZATCA would return (base64 of the base64 DER certificate). */ + issue(csrPem: string): string { + const csr = join(this.dir, `req-${++this.serial}.csr`); + const out = join(this.dir, `cert-${this.serial}.der`); + writeFileSync(csr, csrPem); + execFileSync('openssl', ['x509', '-req', '-in', csr, '-CA', join(this.dir, 'ca.pem'), '-CAkey', join(this.dir, 'ca.key'), + '-set_serial', String(this.serial), '-days', '10', '-outform', 'DER', '-out', out]); + return Buffer.from(readFileSync(out).toString('base64')).toString('base64'); + } + + /** Checks the hash and signature of a submitted invoice; returns problems found. */ + verifyInvoice(body: { invoiceHash: string; uuid: string; invoice: string }): string[] { + const xml = Buffer.from(body.invoice, 'base64').toString('utf8'); + const problems: string[] = []; + if (invoiceHash(xml) !== body.invoiceHash) problems.push('hash mismatch'); + const doc = new DOMParser().parseFromString(xml, 'text/xml'); + const text = (ns: string, name: string) => doc.getElementsByTagNameNS(ns, name)[0]?.textContent ?? ''; + const ds = 'http://www.w3.org/2000/09/xmldsig#'; + if (text('urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2', 'UUID') !== body.uuid) problems.push('uuid mismatch'); + if (text(ds, 'DigestValue') !== body.invoiceHash) problems.push('digest mismatch'); + const cert = new X509Certificate(Buffer.from(text(ds, 'X509Certificate'), 'base64')); + const ok = verify('sha256', Buffer.from(body.invoiceHash, 'base64'), createPublicKey(cert.publicKey.export({ type: 'spki', format: 'pem' })), Buffer.from(text(ds, 'SignatureValue'), 'base64')); + if (!ok) problems.push('bad signature'); + return problems; + } + + transport: Transport = async (url, init) => { + // Match on the endpoint so any base URL (real gateway or ZATCA_GATEWAY_URL) works. + const path = url.match(/\/(compliance\/invoices|compliance|production\/csids|invoices\/reporting\/single|invoices\/clearance\/single)$/)?.[0] ?? url; + const body = JSON.parse(init.body); + this.calls.push({ path, headers: init.headers, body }); + const reply = (status: number, payload: unknown) => ({ status, text: async () => JSON.stringify(payload) }); + if (this.behaviour === 'network-error') throw new Error('connect ETIMEDOUT'); + if (this.behaviour === 'server-error') return reply(503, { message: 'Service unavailable' }); + + if (path === '/compliance') { + if (init.headers.OTP !== '123456') return reply(400, { errors: ['Invalid-OTP'] }); + const csr = Buffer.from(body.csr, 'base64').toString('utf8'); + return reply(200, { requestID: 1234567890123, dispositionMessage: 'ISSUED', binarySecurityToken: this.issue(csr), secret: 'compliance-secret' }); + } + if (path === '/production/csids') { + if (!init.headers.Authorization) return reply(401, { message: 'unauthorized' }); + const token = Buffer.from(init.headers.Authorization.slice(6), 'base64').toString('utf8').split(':')[0]!; + // Re-issue for the same key: read the public key back from the compliance certificate. + const cert = new X509Certificate(Buffer.from(Buffer.from(token, 'base64').toString('utf8'), 'base64')); + return reply(200, { requestID: 99, binarySecurityToken: this.reissue(cert), secret: 'production-secret' }); + } + const problems = this.verifyInvoice(body); + if (problems.length || this.behaviour === 'reject') { + return reply(400, { + validationResults: { status: 'ERROR', infoMessages: [], warningMessages: [], + errorMessages: (problems.length ? problems : ['BR-KSA-37 seller address']).map((p) => ({ type: 'ERROR', code: 'BR-KSA-37', category: 'KSA', message: p, status: 'ERROR' })) }, + reportingStatus: 'NOT_REPORTED', clearanceStatus: 'NOT_CLEARED', + }); + } + const warn = this.behaviour === 'warning'; + const validationResults = { status: warn ? 'WARNING' : 'PASS', infoMessages: [], errorMessages: [], + warningMessages: warn ? [{ type: 'WARNING', code: 'BR-KSA-08', category: 'KSA', message: 'buyer identification', status: 'WARNING' }] : [] }; + if (path === '/compliance/invoices') return reply(warn ? 202 : 200, { validationResults, reportingStatus: 'REPORTED', clearanceStatus: 'CLEARED' }); + if (path === '/invoices/reporting/single') return reply(warn ? 202 : 200, { validationResults, reportingStatus: 'REPORTED' }); + if (path === '/invoices/clearance/single') { + const cleared = Buffer.from(body.invoice, 'base64').toString('utf8').replace('', ''); + return reply(warn ? 202 : 200, { validationResults, clearanceStatus: 'CLEARED', clearedInvoice: Buffer.from(cleared).toString('base64') }); + } + return reply(404, { message: 'not found' }); + }; + + /** Issues a second certificate for the public key of an existing one (stands in for the production CSID). */ + private reissue(cert: X509Certificate): string { + const key = join(this.dir, `pub-${++this.serial}.pem`); + const out = join(this.dir, `prod-${this.serial}.der`); + writeFileSync(key, cert.publicKey.export({ type: 'spki', format: 'pem' }) as string); + execFileSync('openssl', ['x509', '-new', '-force_pubkey', key, '-subj', '/CN=EGS-production', '-CA', join(this.dir, 'ca.pem'), + '-CAkey', join(this.dir, 'ca.key'), '-set_serial', String(this.serial), '-days', '10', '-outform', 'DER', '-out', out]); + return Buffer.from(readFileSync(out).toString('base64')).toString('base64'); + } +} diff --git a/alshuyukh-accounting/apps/api/test/zatca.test.ts b/alshuyukh-accounting/apps/api/test/zatca.test.ts new file mode 100644 index 000000000000..41f0168a0e46 --- /dev/null +++ b/alshuyukh-accounting/apps/api/test/zatca.test.ts @@ -0,0 +1,368 @@ +import { execFileSync } from 'node:child_process'; +import { createPublicKey, verify } from 'node:crypto'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DOMParser } from '@xmldom/xmldom'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { withTx } from '../src/db/tx.js'; +import { setTransport } from '../src/modules/zatca/client.js'; +import { buildCsr, decrypt, encrypt, generateKeyPair, parseCertificate, certificateFromCsid } from '../src/modules/zatca/crypto.js'; +import { decodeQr, encodeQr } from '../src/modules/zatca/qr.js'; +import { INITIAL_PIH, invoiceHash, signInvoice } from '../src/modules/zatca/sign.js'; +import { buildInvoiceXml, type EInvoice } from '../src/modules/zatca/xml.js'; +import { commerce, type Commerce } from './commerce-helpers.js'; +import { addMember, client, register, setupApp, type Session, type TestContext } from './helpers.js'; +import { FakeZatca } from './zatca-fake.js'; +import { runDueSubmissions } from '../src/modules/zatca/worker.js'; + +const UBL_CBC = 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2'; +const SELLER_ADDRESS = { street: 'طريق الملك عبدالعزيز', buildingNumber: '2322', district: 'الملقا', city: 'الرياض', postalCode: '13521', country: 'SA' }; + +const sample = (over: Partial = {}): EInvoice => ({ + number: 'INV-000001', uuid: '3cf5ee18-ee25-44ea-a444-2c37ba7f28be', issueDate: '2026-03-10', issueTime: '14:30:00', + typeCode: '388', subtype: '0200000', icv: 1, previousHash: INITIAL_PIH, currency: 'SAR', + seller: { name: 'مؤسسة الشيوخ & أبناؤه', vatNumber: '399999999900003', crn: '1010010000', address: SELLER_ADDRESS }, + buyer: null, supplyDate: '2026-03-10', paymentMeansCode: '10', + lines: [{ id: 1, name: 'قلم <أزرق>', quantity: '3', unitCode: 'PCE', netAmount: '100.00', vatCategory: 'S', vatRate: '0.15', vatAmount: '15.00' }], + taxSubtotals: [{ vatCategory: 'S', vatRate: '0.15', taxableAmount: '100.00', taxAmount: '15.00' }], + totals: { lineExtension: '100.00', taxExclusive: '100.00', taxAmount: '15.00', taxInclusive: '115.00', payable: '115.00' }, + ...over, +}); + +describe('e-invoice building blocks', () => { + it('encodes and decodes the TLV QR code, Arabic included', () => { + const qr = encodeQr({ sellerName: 'مؤسسة الشيوخ', vatNumber: '399999999900003', timestamp: '2026-03-10T14:30:00', total: '115.00', vatTotal: '15.00' }); + const tags = decodeQr(qr); + expect(tags.get(1)!.toString('utf8')).toBe('مؤسسة الشيوخ'); + expect([2, 3, 4, 5].map((t) => tags.get(t)!.toString())).toEqual(['399999999900003', '2026-03-10T14:30:00', '115.00', '15.00']); + }); + + it('builds a CSR that openssl accepts, with ZATCA template and SAN fields per environment', () => { + const dir = mkdtempSync(join(tmpdir(), 'csr-')); + for (const [environment, template] of [['DEVELOPER', 'TSTZATCA-Code-Signing'], ['SIMULATION', 'PREZATCA-Code-Signing'], ['PRODUCTION', 'ZATCA-Code-Signing']] as const) { + const csr = buildCsr({ privateKeyPem: generateKeyPair().privateKeyPem, environment, commonName: 'EGS-1', organizationUnit: 'الفرع الرئيسي', organization: 'مؤسسة الشيوخ', + country: 'SA', egsSerial: '1-ALSHUYUKH|2-1.0|3-x', vatNumber: '399999999900003', invoiceTypes: '1100', registeredAddress: 'Riyadh', businessCategory: 'Trading' }); + writeFileSync(join(dir, 'r.csr'), csr); + const text = execFileSync('openssl', ['req', '-in', join(dir, 'r.csr'), '-noout', '-verify', '-text'], { stdio: ['ignore', 'pipe', 'pipe'] }).toString(); + expect(text).toContain('ASN1 OID: secp256k1'); + expect(text).toContain(template); + expect(text).toContain('UID=399999999900003/title=1100'); + } + }); + + it('hashes the invoice without signature or QR, and the hash follows the content', () => { + const k = generateKeyPair(); + const fake = new FakeZatca(); + const token = fake.issue(buildCsr({ privateKeyPem: k.privateKeyPem, environment: 'DEVELOPER', commonName: 'c', organizationUnit: 'u', organization: 'o', country: 'SA', + egsSerial: '1-a|2-b|3-c', vatNumber: '399999999900003', invoiceTypes: '1100', registeredAddress: 'r', businessCategory: 'b' })); + const cert = parseCertificate(certificateFromCsid(token)); + const signed = signInvoice(sample(), { privateKeyPem: k.privateKeyPem, cert }, '2026-03-10T14:30:00'); + expect(signed.hash).toBe(invoiceHash(buildInvoiceXml(sample()))); + expect(invoiceHash(signed.xml)).toBe(signed.hash); + expect(invoiceHash(buildInvoiceXml(sample({ number: 'INV-000002' })))).not.toBe(signed.hash); + expect(fake.verifyInvoice({ invoiceHash: signed.hash, uuid: sample().uuid, invoice: Buffer.from(signed.xml).toString('base64') })).toEqual([]); + // Tampering is detected: a changed amount breaks the hash, a foreign signature fails verification. + const tampered = signed.xml.replace('115.00', '15.00'); + expect(fake.verifyInvoice({ invoiceHash: signed.hash, uuid: sample().uuid, invoice: Buffer.from(tampered).toString('base64') })).toContain('hash mismatch'); + const other = signInvoice(sample({ number: 'X' }), { privateKeyPem: generateKeyPair().privateKeyPem, cert }, '2026-03-10T14:30:00'); + expect(fake.verifyInvoice({ invoiceHash: other.hash, uuid: sample().uuid, invoice: Buffer.from(other.xml).toString('base64') })).toContain('bad signature'); + // Simplified QR: tags 1–9; tag 8 is the public key, tag 9 the CA's signature. + const tags = decodeQr(signed.qr); + expect([...tags.keys()]).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]); + expect(tags.get(6)!.toString()).toBe(signed.hash); + expect(verify('sha256', Buffer.from(signed.hash, 'base64'), createPublicKey({ key: tags.get(8)!, format: 'der', type: 'spki' }), Buffer.from(tags.get(7)!.toString(), 'base64'))).toBe(true); + // Escaping keeps the XML well-formed. + const doc = new DOMParser().parseFromString(signed.xml, 'text/xml'); + expect(doc.getElementsByTagNameNS(UBL_CBC, 'Name')[0]!.textContent).toBe('قلم <أزرق>'); + // Standard invoices carry no tag 9. + const std = signInvoice(sample({ subtype: '0100000' }), { privateKeyPem: k.privateKeyPem, cert }, '2026-03-10T14:30:00'); + expect(decodeQr(std.qr).has(9)).toBe(false); + }); + + it('expresses inexact unit prices through BaseQuantity so quantity × price = net', () => { + const xml = buildInvoiceXml(sample()); + expect(xml).toContain('100.003'); + const exact = buildInvoiceXml(sample({ lines: [{ ...sample().lines[0]!, quantity: '4', netAmount: '100.00' }] })); + expect(exact).toContain('25.00'); + }); + + it('encrypts secrets with authentication', () => { + const key = Buffer.alloc(32, 7); + const sealed = encrypt(key, 'private key'); + expect(sealed).not.toContain('private'); + expect(decrypt(key, sealed)).toBe('private key'); + const tampered = sealed.slice(0, -4) + (sealed.endsWith('AAAA') ? 'BBBB' : 'AAAA'); + expect(() => decrypt(key, tampered)).toThrow(); + }); +}); + +let t: TestContext; +let owner: Session; +let c: Commerce; +let fake: FakeZatca; +let deviceId: string; +let companyId: string; + +const issue = async (customerId: string, quantity = '1', extra: Record = {}) => { + const inv = await c.invoice(customerId, [{ productId: (await c.product({ salePrice: '100' })).id, quantity }], extra); + return c.api.post(`/api/invoices/${inv.id}/post`); +}; +const einvoiceOf = async (type: string, id: string) => (await c.api.get(`/api/zatca/document?type=${type}&id=${id}`)).json(); +const db = (fn: (d: Parameters[2]>[0]) => Promise) => withTx(t.pool, { tenantId: owner.tenantId, userId: owner.userId }, fn); + +beforeAll(async () => { + t = await setupApp(); + fake = new FakeZatca(); + setTransport(fake.transport); + owner = await register(t.app); + c = await commerce(t.app, owner); + companyId = (await c.api.get('/api/companies')).json().data[0].id; +}); +afterAll(async () => { setTransport(null); await t.close(); }); + +describe('onboarding', () => { + it('requires the seller data ZATCA needs before creating a unit', async () => { + const res = await c.api.post('/api/zatca/devices', { name: 'الوحدة الرئيسية', environment: 'DEVELOPER', businessCategory: 'تجارة' }); + expect(res.statusCode).toBe(400); + expect(res.json().error.code).toBe('ZATCA_INVALID'); + expect(res.json().error.details.map((d: { path: string }) => d.path)).toEqual(expect.arrayContaining(['company.vatNumber', 'company.commercialRegistration', 'company.buildingNumber', 'company.postalCode'])); + }); + + it('creates the unit, gets the compliance CSID, passes the checks and activates', async () => { + expect((await c.api.patch(`/api/companies/${companyId}`, { + vatNumber: '399999999900003', commercialRegistration: '1010010000', ...SELLER_ADDRESS, additionalNumber: '8888', + })).statusCode).toBe(200); + const created = await c.api.post('/api/zatca/devices', { name: 'الوحدة الرئيسية', environment: 'DEVELOPER', businessCategory: 'تجارة' }); + expect(created.statusCode).toBe(201); + const device = created.json(); + deviceId = device.id; + expect(device).toMatchObject({ status: 'NEW', invoiceTypes: '1100', icv: '0', lastHash: INITIAL_PIH }); + expect(device.csr).toContain('BEGIN CERTIFICATE REQUEST'); + expect(JSON.stringify(device)).not.toMatch(/PRIVATE KEY|private_key/); + const stored = await db((d) => d.query(`SELECT private_key_enc FROM zatca_devices WHERE id = $1`, [deviceId])); + expect(stored.rows[0].private_key_enc).toMatch(/^v1\./); + + expect((await c.api.post(`/api/zatca/devices/${deviceId}/compliance-csid`, { otp: '999999' })).json().error.code).toBe('ZATCA_REJECTED'); + expect((await c.api.post(`/api/zatca/devices/${deviceId}/activate`)).json().error.code).toBe('DEVICE_STATE'); + const compliance = await c.api.post(`/api/zatca/devices/${deviceId}/compliance-csid`, { otp: '123456' }); + expect(compliance.json().status).toBe('COMPLIANCE'); + expect((await c.api.post(`/api/zatca/devices/${deviceId}/activate`)).json().error.code).toBe('COMPLIANCE_INCOMPLETE'); + + const checks = (await c.api.post(`/api/zatca/devices/${deviceId}/compliance-checks`)).json(); + expect(checks.complianceChecks).toEqual({ + STANDARD_INVOICE: 'PASSED', STANDARD_CREDIT_NOTE: 'PASSED', STANDARD_DEBIT_NOTE: 'PASSED', + SIMPLIFIED_INVOICE: 'PASSED', SIMPLIFIED_CREDIT_NOTE: 'PASSED', SIMPLIFIED_DEBIT_NOTE: 'PASSED', + }); + // Each sample was signed with the compliance certificate and verified by the fake gateway. + expect(fake.calls.filter((x) => x.path === '/compliance/invoices')).toHaveLength(6); + + const active = (await c.api.post(`/api/zatca/devices/${deviceId}/activate`)).json(); + expect(active).toMatchObject({ status: 'ACTIVE', certificateIssuer: 'CN=TSZEINVOICE-SubCA-1, DC=extgazt, DC=gov, DC=local' }); + // The submission log never holds certificates or secrets. + const log = await db((d) => d.query(`SELECT response::text FROM zatca_submissions WHERE device_id = $1`, [deviceId])); + expect(log.rows.length).toBe(9); + expect(log.rows.every((r) => !/secret|binarySecurityToken/.test(r.response))).toBe(true); + // One live unit per company. + expect((await c.api.post('/api/zatca/devices', { name: 'ثانية', environment: 'DEVELOPER', businessCategory: 'تجارة' })).statusCode).toBe(409); + }); +}); + +describe('issuing e-invoices', () => { + let first: { id: string; hash: string }; + + it('signs a simplified invoice at issue, chained from the initial hash', async () => { + const cust = (await c.customer()).id; // no VAT number → simplified + const res = await issue(cust); + expect(res.statusCode).toBe(200); + const doc = await einvoiceOf('SALES_INVOICE', res.json().id); + expect(doc.einvoice).toMatchObject({ status: 'PENDING', invoiceKind: 'SIMPLIFIED', typeCode: '388', subtype: '0200000', icv: '1', previousHash: INITIAL_PIH }); + expect(doc.qrSvg).toContain(' { + const res = await issue((await c.customer()).id); + const doc = await einvoiceOf('SALES_INVOICE', res.json().id); + expect(doc.einvoice).toMatchObject({ icv: '2', previousHash: first.hash }); + }); + + it('reports a simplified invoice and then refuses to cancel it', async () => { + const res = await issue((await c.customer()).id); + const z = (await einvoiceOf('SALES_INVOICE', res.json().id)).einvoice; + const sent = (await c.api.post(`/api/zatca/invoices/${z.id}/submit`)).json(); + expect(sent).toMatchObject({ status: 'REPORTED', hasWarnings: false, attempts: 1 }); + expect(sent.submissions).toEqual([expect.objectContaining({ operation: 'REPORTING', httpStatus: 200, outcome: 'SUCCESS' })]); + expect(fake.calls.at(-1)!.headers['Clearance-Status']).toBe('0'); + expect((await c.api.post(`/api/zatca/invoices/${z.id}/submit`)).json().error.code).toBe('NOT_PENDING'); + const cancel = await c.api.post(`/api/invoices/${res.json().id}/cancel`, { reason: 'خطأ' }); + expect(cancel.json().error.code).toBe('ZATCA_ISSUED'); + + // The correction is a credit note, which joins the chain as type 381. + const inv = res.json(); + const ret = (await c.api.post('/api/sales-returns', { originalInvoiceId: inv.id, docDate: c.date, reason: 'إرجاع', lines: [{ sourceItemId: inv.lines[0].id, quantity: '1' }] })).json(); + const posted = await c.api.post(`/api/sales-returns/${ret.id}/post`); + expect(posted.json().invoiceKind).toBe('SIMPLIFIED'); + const note = (await einvoiceOf('SALES_RETURN', ret.id)).einvoice; + expect(note).toMatchObject({ typeCode: '381', subtype: '0200000', status: 'PENDING' }); + const xml = (await c.api.get(`/api/zatca/invoices/${note.id}/xml`)).body; + expect(xml).toContain(`${inv.number}`); + expect(xml).toContain('إرجاع'); + expect((await c.api.post(`/api/zatca/invoices/${note.id}/submit`)).json().status).toBe('REPORTED'); + }); + + it('clears a standard invoice and keeps ZATCA\'s cleared XML', async () => { + const vatCustomer = (await c.customer({ vatNumber: '311111111111113', addresses: [{ ...SELLER_ADDRESS, buildingNumber: '1111' }] })).id; + const res = await issue(vatCustomer); + const z = (await einvoiceOf('SALES_INVOICE', res.json().id)).einvoice; + expect(z).toMatchObject({ invoiceKind: 'STANDARD', subtype: '0100000' }); + const sent = (await c.api.post(`/api/zatca/invoices/${z.id}/submit`)).json(); + expect(sent).toMatchObject({ status: 'CLEARED' }); + expect(sent.documents.map((d: { kind: string }) => d.kind)).toEqual(['SIGNED', 'CLEARED']); + expect(fake.calls.at(-1)!.path).toBe('/invoices/clearance/single'); + expect((await c.api.get(`/api/zatca/invoices/${z.id}/xml?kind=CLEARED`)).body).toContain('cleared by fake ZATCA'); + }); + + it('refuses to issue a standard invoice without the buyer\'s address — nothing is posted', async () => { + const vatCustomer = (await c.customer({ vatNumber: '322222222222223' })).id; + const inv = await c.invoice(vatCustomer, [{ productId: (await c.product()).id, quantity: '1' }]); + const res = await c.api.post(`/api/invoices/${inv.id}/post`); + expect(res.statusCode).toBe(400); + expect(res.json().error.code).toBe('ZATCA_INVALID'); + expect(res.json().error.details.map((d: { path: string }) => d.path)).toContain('customer.address.street'); + const after = (await c.api.get(`/api/invoices/${inv.id}`)).json(); + expect(after).toMatchObject({ status: 'DRAFT', number: null, journalEntryId: null }); + }); + + it('requires an exemption reason for zero-rated and exempt items', async () => { + const cust = (await c.customer()).id; + const zero = (await c.product({ vatCategory: 'Z', salePrice: '50' })).id; + const inv = await c.invoice(cust, [{ productId: zero, quantity: '1' }]); + expect((await c.api.post(`/api/invoices/${inv.id}/post`)).json().error.code).toBe('ZATCA_INVALID'); + await c.api.patch(`/api/products/${zero}`, { vatExemptionCode: 'VATEX-SA-35', vatExemptionReason: 'أدوية' }); + const ok = await c.api.post(`/api/invoices/${inv.id}/post`); + expect(ok.statusCode).toBe(200); + const z = (await einvoiceOf('SALES_INVOICE', inv.id)).einvoice; + const xml = (await c.api.get(`/api/zatca/invoices/${z.id}/xml`)).body; + expect(xml).toContain('Z0.00VATEX-SA-35'); + }); + + it('records rejections with their messages; a rejected invoice may be cancelled', async () => { + const res = await issue((await c.customer()).id); + const z = (await einvoiceOf('SALES_INVOICE', res.json().id)).einvoice; + fake.behaviour = 'reject'; + const sent = (await c.api.post(`/api/zatca/invoices/${z.id}/submit`)).json(); + fake.behaviour = 'ok'; + expect(sent).toMatchObject({ status: 'REJECTED' }); + expect(sent.lastError).toContain('BR-KSA-37'); + expect(sent.submissions[0].messages).toEqual([expect.objectContaining({ level: 'ERROR', code: 'BR-KSA-37' })]); + expect((await c.api.post(`/api/invoices/${res.json().id}/cancel`, { reason: 'رفضتها الهيئة' })).statusCode).toBe(200); + }); + + it('keeps a failed submission pending with backoff, and marks warnings', async () => { + const res = await issue((await c.customer()).id); + const z = (await einvoiceOf('SALES_INVOICE', res.json().id)).einvoice; + for (const b of ['network-error', 'server-error'] as const) { + fake.behaviour = b; + const sent = (await c.api.post(`/api/zatca/invoices/${z.id}/submit`)).json(); + expect(sent.status).toBe('PENDING'); + expect(new Date(sent.nextAttemptAt).getTime()).toBeGreaterThan(Date.now()); + } + fake.behaviour = 'warning'; + const sent = (await c.api.post(`/api/zatca/invoices/${z.id}/submit`)).json(); + fake.behaviour = 'ok'; + expect(sent).toMatchObject({ status: 'REPORTED', hasWarnings: true, attempts: 3, lastError: null }); + expect(sent.submissions.map((s: { outcome: string }) => s.outcome)).toEqual(['TRANSPORT_ERROR', 'TRANSPORT_ERROR', 'WARNING']); + }); + + it('serialises concurrent issues into one unbroken chain', async () => { + const custs = await Promise.all(Array.from({ length: 6 }, () => c.customer())); + const results = await Promise.all(custs.map((x) => issue(x.id))); + expect(results.every((r) => r.statusCode === 200)).toBe(true); + const chain = await db((d) => d.query<{ icv: string; invoice_hash: string; previous_hash: string }>( + `SELECT icv, invoice_hash, previous_hash FROM invoice_hashes WHERE device_id = $1 ORDER BY icv`, [deviceId])); + chain.rows.forEach((r, i) => { + expect(Number(r.icv)).toBe(i + 1); + expect(r.previous_hash).toBe(i === 0 ? INITIAL_PIH : chain.rows[i - 1]!.invoice_hash); + }); + const dev = (await c.api.get(`/api/zatca/devices/${deviceId}`)).json(); + expect(dev).toMatchObject({ icv: String(chain.rows.length), lastHash: chain.rows.at(-1)!.invoice_hash }); + }); + + it('protects generated e-invoices and the hash chain in the database', async () => { + await expect(db((d) => d.query(`UPDATE zatca_invoices SET invoice_hash = 'x' WHERE device_id = $1`, [deviceId]))).rejects.toThrow(/cannot be modified/); + await expect(db((d) => d.query(`UPDATE invoice_hashes SET invoice_hash = 'x' WHERE device_id = $1`, [deviceId]))).rejects.toThrow(); + await expect(db((d) => d.query(`DELETE FROM zatca_documents`))).rejects.toThrow(); + await expect(db((d) => d.query(`UPDATE zatca_invoices SET status = 'PENDING' WHERE status = 'REPORTED'`))).rejects.toThrow(/cannot change status/); + }); + + it('lists e-invoices with a status summary', async () => { + const list = (await c.api.get('/api/zatca/invoices?limit=200')).json(); + expect(list.total).toBeGreaterThan(5); + expect(list.summary).toMatchObject({ rejected: 1, overdue: 0 }); + expect(list.data[0].qr).toBeUndefined(); + expect((await c.api.get('/api/zatca/invoices?status=CLEARED')).json().total).toBe(1); + }); +}); + +describe('background submitter', () => { + it('reports every due e-invoice in its tenant context', async () => { + const res = await issue((await c.customer()).id); + const z = (await einvoiceOf('SALES_INVOICE', res.json().id)).einvoice; + const warnings: unknown[] = []; + const attempted = await runDueSubmissions(t.pool, { warn: (...a: unknown[]) => warnings.push(a) } as never); + expect(attempted).toBeGreaterThanOrEqual(1); + expect(warnings).toEqual([]); + expect((await einvoiceOf('SALES_INVOICE', res.json().id)).einvoice.status).toBe('REPORTED'); + expect(z.status).toBe('PENDING'); + // Nothing is due any more: a second pass attempts nothing for this tenant. + expect((await c.api.get('/api/zatca/invoices?status=PENDING')).json().total).toBe(0); + }); +}); + +describe('without an active unit', () => { + it('still prints a phase-1 QR and issues normally', async () => { + const s = await register(t.app); + const o = await commerce(t.app, s); + const cid = (await o.api.get('/api/companies')).json().data[0].id; + await o.api.patch(`/api/companies/${cid}`, { vatNumber: '399999999900003' }); + const inv = await o.postInvoice((await o.customer()).id, [{ productId: (await o.product()).id, quantity: '2' }]); + const doc = (await o.api.get(`/api/zatca/document?type=SALES_INVOICE&id=${inv.id}`)).json(); + expect(doc.einvoice).toBeNull(); + expect([...decodeQr(doc.qr).keys()]).toEqual([1, 2, 3, 4, 5]); + expect(decodeQr(doc.qr).get(4)!.toString()).toBe('230.00'); + expect((await o.api.post(`/api/invoices/${inv.id}/cancel`, { reason: 'تجربة' })).statusCode).toBe(200); + }); +}); + +describe('permissions and isolation', () => { + it('separates viewing, managing and submitting', async () => { + const accountant = client(t.app, (await addMember(t.app, owner, 'ACCOUNTANT')).token); + expect((await accountant.get('/api/zatca/invoices')).statusCode).toBe(200); + expect((await accountant.post('/api/zatca/devices', { name: 'x', environment: 'DEVELOPER', businessCategory: 'تجارة' })).statusCode).toBe(403); + expect((await accountant.post(`/api/zatca/devices/${deviceId}/revoke`, { reason: 'test' })).statusCode).toBe(403); + const clerk = client(t.app, (await addMember(t.app, owner, 'SALES_EMPLOYEE')).token); + expect((await clerk.get('/api/zatca/invoices')).statusCode).toBe(403); + }); + + it('hides units and e-invoices from other tenants', async () => { + const z = (await c.api.get('/api/zatca/invoices?limit=1')).json().data[0]; + const other = client(t.app, (await register(t.app)).token); + expect((await other.get(`/api/zatca/invoices/${z.id}`)).statusCode).toBe(404); + expect((await other.get(`/api/zatca/invoices/${z.id}/xml`)).statusCode).toBe(404); + expect((await other.post(`/api/zatca/invoices/${z.id}/submit`)).statusCode).toBe(404); + expect((await other.get(`/api/zatca/devices/${deviceId}`)).statusCode).toBe(404); + expect((await other.get('/api/zatca/invoices')).json().total).toBe(0); + }); + + it('revokes a unit; new invoices are then issued without e-invoice', async () => { + expect((await c.api.post(`/api/zatca/devices/${deviceId}/revoke`, { reason: 'استبدال الجهاز' })).json().status).toBe('REVOKED'); + const res = await issue((await c.customer()).id); + expect((await einvoiceOf('SALES_INVOICE', res.json().id)).einvoice).toBeNull(); + }); +}); diff --git a/alshuyukh-accounting/apps/api/tsconfig.build.json b/alshuyukh-accounting/apps/api/tsconfig.build.json new file mode 100644 index 000000000000..077691c26883 --- /dev/null +++ b/alshuyukh-accounting/apps/api/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist" }, + "include": ["src"] +} diff --git a/alshuyukh-accounting/apps/api/tsconfig.json b/alshuyukh-accounting/apps/api/tsconfig.json new file mode 100644 index 000000000000..0cbc993ccd23 --- /dev/null +++ b/alshuyukh-accounting/apps/api/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": ".", + "resolveJsonModule": true, + "types": ["node"] + }, + "include": ["src", "test", "vitest.config.ts"] +} diff --git a/alshuyukh-accounting/apps/api/vitest.config.ts b/alshuyukh-accounting/apps/api/vitest.config.ts new file mode 100644 index 000000000000..06d5b86cb772 --- /dev/null +++ b/alshuyukh-accounting/apps/api/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globalSetup: ['./test/global-setup.ts'], + setupFiles: ['./test/env.ts'], + fileParallelism: false, + testTimeout: 30_000, + hookTimeout: 60_000, + }, +}); diff --git a/alshuyukh-accounting/apps/web/index.html b/alshuyukh-accounting/apps/web/index.html new file mode 100644 index 000000000000..7f564c8ced20 --- /dev/null +++ b/alshuyukh-accounting/apps/web/index.html @@ -0,0 +1,12 @@ + + + + + + الشيوخ للمحاسبة + + +
+ + + diff --git a/alshuyukh-accounting/apps/web/package.json b/alshuyukh-accounting/apps/web/package.json new file mode 100644 index 000000000000..5b98351c43d0 --- /dev/null +++ b/alshuyukh-accounting/apps/web/package.json @@ -0,0 +1,27 @@ +{ + "name": "@alshuyukh/web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "typecheck": "tsc --noEmit", + "preview": "vite preview", + "test": "vitest run" + }, + "dependencies": { + "@fontsource/ibm-plex-sans-arabic": "^5.3.0", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "react-router-dom": "^7.18.4" + }, + "devDependencies": { + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "typescript": "^5.9", + "vite": "^8.3.2", + "vitest": "^5.0.3" + } +} diff --git a/alshuyukh-accounting/apps/web/src/App.tsx b/alshuyukh-accounting/apps/web/src/App.tsx new file mode 100644 index 000000000000..47af5e211144 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/App.tsx @@ -0,0 +1,76 @@ +import { lazy } from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import { useAuth } from './auth'; +import Shell from './Shell'; +import Accounting from './pages/accounting/Accounting'; +import ChartOfAccounts from './pages/accounting/ChartOfAccounts'; +import FiscalYears from './pages/accounting/FiscalYears'; +import JournalDetail from './pages/accounting/JournalDetail'; +import JournalForm from './pages/accounting/JournalForm'; +import JournalList from './pages/accounting/JournalList'; +import TrialBalance from './pages/accounting/TrialBalance'; +import Parties, { CUSTOMERS, SUPPLIERS } from './pages/parties/Parties'; +import Products from './pages/products/Products'; +import Section from './pages/documents/Section'; +import Home from './pages/Home'; +import Login from './pages/Login'; +import Register from './pages/Register'; +import Users from './pages/Users'; + +// Less frequently used sections load on first visit, keeping the initial bundle small. +const Reports = lazy(() => import('./pages/reports/Reports')); +const EInvoicing = lazy(() => import('./pages/einvoicing/EInvoicing')); +const Admin = lazy(() => import('./pages/admin/Admin')); +const Subscription = lazy(() => import('./pages/subscription/Subscription')); +const Expenses = lazy(() => import('./pages/expenses/Expenses')); +const AuditLog = lazy(() => import('./pages/AuditLog')); +const Roles = lazy(() => import('./pages/Roles')); +const Companies = lazy(() => import('./pages/Companies')); +const Settings = lazy(() => import('./pages/Settings')); + +export default function App() { + const { me, loading } = useAuth(); + if (loading) return
جارٍ التحميل…
; + + if (!me) { + return ( + + } /> + } /> + + ); + } + + return ( + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/alshuyukh-accounting/apps/web/src/Shell.tsx b/alshuyukh-accounting/apps/web/src/Shell.tsx new file mode 100644 index 000000000000..61be03d63efe --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/Shell.tsx @@ -0,0 +1,122 @@ +import { Suspense, useState } from 'react'; +import { api } from './api'; +import { ErrorBox, useLoad } from './ui'; +import { Link, NavLink, Outlet } from 'react-router-dom'; +import { useAuth } from './auth'; + +const NAV: { to: string; label: string; icon: string; phase?: number; feature?: string }[] = [ + { to: '/', label: 'الرئيسية', icon: '⌂' }, + { to: '/sales', label: 'المبيعات', icon: '↗' }, + { to: '/purchases', label: 'المشتريات', icon: '↙' }, + { to: '/inventory', label: 'المخزون', icon: '▦' }, + { to: '/customers', label: 'العملاء', icon: '☺' }, + { to: '/suppliers', label: 'الموردون', icon: '⚑' }, + { to: '/expenses', label: 'المصروفات', icon: '−' }, + { to: '/accounting', label: 'المحاسبة', icon: '⚖' }, + { to: '/reports', label: 'التقارير', icon: '▤' }, + { to: '/e-invoicing', label: 'الفوترة الإلكترونية', icon: '⎙', feature: 'zatca_einvoicing' }, + { to: '/settings', label: 'الإعدادات', icon: '⚙' }, +]; + +export default function Shell() { + const { me, logout, switchTenant } = useAuth(); + const [open, setOpen] = useState(false); + if (!me) return null; + + return ( +
+ + {open &&
setOpen(false)} />} + +
+
+ +
+ {me.memberships.length > 1 ? ( + + ) : {me.tenant.name}} +
+
+ {me.user.fullName} + {me.roles.map((r) => r.nameAr).join('، ')} +
+ +
+
+ + + {me.user.mustChangePassword && ( +
يجب تغيير كلمة المرور المؤقتة من صفحة الإعدادات.
+ )} + جارٍ التحميل…
}> + +
+
+ ); +} + +/** Trial ending soon, grace period, or expired (read-only) subscription. */ +function SubscriptionBanner() { + const { me, can } = useAuth(); + const s = me?.subscription; + if (!s) return null; + const days = s.periodEnd ? Math.ceil((new Date(s.periodEnd).getTime() - Date.now()) / 86_400_000) : 0; + const link = can('subscription.manage') ? إدارة الاشتراك : تواصل مع مالك المنشأة.; + if (s.state === 'EXPIRED' || s.state === 'NONE' || s.state === 'CANCELLED') { + return
انتهى الاشتراك؛ البيانات متاحة للاطلاع فقط ولا يمكن إضافة أو تعديل شيء حتى التجديد. {link}
; + } + if (s.state === 'GRACE') { + const left = s.graceEnd ? Math.max(0, Math.ceil((new Date(s.graceEnd).getTime() - Date.now()) / 86_400_000)) : 0; + return
انتهت فترة الاشتراك. يبقى الوصول الكامل {left} يومًا ثم يتحول النظام للاطلاع فقط. {link}
; + } + if (s.state === 'TRIALING' && days <= 3) { + return
تنتهي الفترة التجريبية خلال {Math.max(days, 0)} يوم. {link}
; + } + return null; +} + +/** Organizations that invited this user; joining needs the user's consent. */ +function Invitations() { + const { reload } = useAuth(); + const list = useLoad(() => api<{ data: { tenantId: string; tenantName: string }[] }>('GET', '/api/auth/invitations')); + const [error, setError] = useState(null); + async function answer(tenantId: string, action: 'accept' | 'decline') { + setError(null); + try { await api('POST', `/api/auth/invitations/${tenantId}/${action}`); list.reload(); if (action === 'accept') await reload(); } catch (e) { setError(e); } + } + if (!list.data?.data.length) return null; + return ( +
+ + {list.data.data.map((i) => ( +
+ دعتك منشأة «{i.tenantName}» للانضمام إليها. + + +
+ ))} +
+ ); +} diff --git a/alshuyukh-accounting/apps/web/src/api.ts b/alshuyukh-accounting/apps/web/src/api.ts new file mode 100644 index 000000000000..d92c838d31d6 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/api.ts @@ -0,0 +1,133 @@ +/** + * API client. The access token is kept in memory only (never localStorage). + * The refresh token lives in an HttpOnly cookie the browser sends to + * /api/auth/refresh; on a 401 the client refreshes once and retries. + */ +let accessToken: string | null = null; +let refreshing: Promise | null = null; +let onSessionExpired: () => void = () => {}; + +export const setAccessToken = (t: string | null) => { accessToken = t; }; +export const setSessionExpiredHandler = (fn: () => void) => { onSessionExpired = fn; }; + +export class ApiError extends Error { + constructor(public status: number, public code: string, message: string, public details?: unknown) { + super(message); + } +} + +/** + * Refreshes the access token. Tabs share the refresh cookie, and presenting a + * token that another tab has just rotated looks like token theft to the server + * (it then signs out every session). The Web Locks API makes tabs take turns, + * so each refresh uses the current cookie. + */ +export async function refreshSession(): Promise { + if (!refreshing) { + const run = (): Promise => fetchRefresh(); + const locked = navigator.locks ? (navigator.locks.request('alshuyukh-refresh', run) as unknown as Promise) : run(); + refreshing = locked.finally(() => { + setTimeout(() => { refreshing = null; }, 0); + }); + } + return refreshing; +} + +async function fetchRefresh(): Promise { + { + try { + const res = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include', headers: { 'X-CSRF-Protection': '1' } }); + if (!res.ok) return false; + setAccessToken((await res.json()).accessToken); + return true; + } catch { + return false; + } + } +} + +export async function api(method: string, path: string, body?: unknown, retry = true): Promise { + const headers: Record = { 'X-CSRF-Protection': '1' }; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + if (accessToken) headers.Authorization = `Bearer ${accessToken}`; + const res = await fetch(path, { method, headers, credentials: 'include', body: body === undefined ? undefined : JSON.stringify(body) }); + + if (res.status === 401 && retry && !path.startsWith('/api/auth/login') && !path.startsWith('/api/auth/register')) { + if (await refreshSession()) return api(method, path, body, false); + setAccessToken(null); + onSessionExpired(); + } + if (res.status === 204) return undefined as T; + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const e = data?.error ?? {}; + throw new ApiError(res.status, e.code ?? 'ERROR', translateError(e.code, e.message), e.details); + } + return data as T; +} + +/** Downloads a file from an authenticated endpoint and hands it to the browser. */ +export async function downloadFile(path: string, filename: string, retry = true): Promise { + const res = await fetch(path, { headers: { 'X-CSRF-Protection': '1', ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}) }, credentials: 'include' }); + if (res.status === 401 && retry && await refreshSession()) return downloadFile(path, filename, false); + if (!res.ok) { + const e = (await res.json().catch(() => ({})))?.error ?? {}; + throw new ApiError(res.status, e.code ?? 'ERROR', translateError(e.code, e.message), e.details); + } + const a = document.createElement('a'); + a.href = URL.createObjectURL(await res.blob()); + a.download = filename; + document.body.append(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); +} + +const MESSAGES: Record = { + UNAUTHORIZED: 'البريد الإلكتروني أو كلمة المرور غير صحيحة، أو أن الدخول أُوقف مؤقتًا بعد محاولات فاشلة متكررة', + EMAIL_TAKEN: 'يوجد حساب مسجل بهذا البريد الإلكتروني', + FORBIDDEN: 'لا تملك صلاحية تنفيذ هذا الإجراء', + NOT_FOUND: 'السجل غير موجود', + VALIDATION_ERROR: 'تحقق من البيانات المدخلة', + DUPLICATE: 'توجد قيمة مكررة لحقل يجب أن يكون فريدًا', + TENANT_SUSPENDED: 'تم تعليق هذه المنشأة', + ALREADY_MEMBER: 'المستخدم عضو في المنشأة بالفعل', + LAST_COMPANY: 'يجب أن تبقى شركة واحدة على الأقل', + ROLE_IN_USE: 'الدور مُسند لمستخدمين؛ أزله منهم أولًا', + INVALID_CURRENT_PASSWORD: 'كلمة المرور الحالية غير صحيحة', + UNBALANCED_ENTRY: 'القيد غير متوازن: مجموع المدين لا يساوي مجموع الدائن', + TOO_FEW_LINES: 'القيد يحتاج سطرين على الأقل', + INVALID_LINE: 'كل سطر يجب أن يحتوي على مبلغ مدين أو دائن فقط', + INVALID_AMOUNT: 'مبلغ غير صحيح: يجب أن يكون موجبًا وبخانتين عشريتين كحد أقصى', + INVALID_ACCOUNT: 'الحساب غير موجود في هذه الشركة', + ACCOUNT_NOT_POSTABLE: 'لا يمكن الترحيل على حساب تجميعي', + ACCOUNT_INACTIVE: 'الحساب غير نشط', + NO_FISCAL_PERIOD: 'لا توجد فترة مالية تغطي هذا التاريخ. أنشئ السنة المالية أولًا.', + PERIOD_CLOSED: 'الفترة المالية لهذا التاريخ مقفلة', + ENTRY_NOT_DRAFT: 'لا يمكن تعديل قيد مرحّل؛ استخدم العكس', + ALREADY_REVERSED: 'تم عكس هذا القيد مسبقًا', + CANNOT_REVERSE_REVERSAL: 'لا يمكن عكس قيد عكسي؛ أنشئ قيدًا جديدًا', + SYSTEM_ENTRY: 'هذا القيد صادر عن مستند؛ صحّحه من المستند نفسه', + ACCOUNT_HAS_BALANCE: 'لا يمكن إيقاف حساب له رصيد', + SYSTEM_ACCOUNT: 'حساب نظامي يستخدمه المحرك المحاسبي ولا يمكن إيقافه أو حذفه', + ACCOUNT_IN_USE: 'الحساب عليه قيود؛ يمكن إيقافه بدل حذفه', + DRAFTS_EXIST: 'توجد مسودات قيود في هذه السنة؛ رحّلها أو احذفها أولًا', + EARLIER_YEAR_OPEN: 'أقفل السنوات المالية السابقة أولًا', + YEAR_CLOSED: 'السنة المالية مقفلة', + FISCAL_YEAR_OVERLAP: 'السنة المالية تتداخل مع سنة موجودة', + FISCAL_YEARS_EXIST: 'لا يمكن تغيير بداية السنة المالية بعد إنشاء سنوات مالية', + DEVICE_STATE: 'لا يمكن تنفيذ هذه الخطوة في المرحلة الحالية لوحدة الفوترة', + COMPLIANCE_INCOMPLETE: 'شغّل فحوص الامتثال حتى تنجح جميعها قبل التفعيل', + NOT_PENDING: 'هذه الفاتورة الإلكترونية ليست بانتظار الإرسال (أو يجري إرسالها الآن)', + ZATCA_NOT_CONFIGURED: 'مفتاح تشفير الفوترة الإلكترونية غير مضبوط في الخادم', + INVITATION_PENDING: 'لم يقبل المستخدم الدعوة بعد', + RANGE_TOO_LONG: 'الفترة طويلة جدًا؛ التقرير يغطي 5 سنوات كحد أقصى', + VALUE_OUT_OF_RANGE: 'رقم كبير جدًا', + QUERY_TIMEOUT: 'استغرق الطلب وقتًا طويلًا؛ ضيّق الفترة أو عوامل التصفية', + OWN_TENANT: 'لا يمكنك إيقاف المنشأة التي سجلت الدخول بها', + SELF: 'لا يمكنك تنفيذ هذا الإجراء على حسابك', + DEFAULT_PLAN: 'الباقة الافتراضية يجب أن تبقى متاحة؛ اختر باقة افتراضية أخرى أولًا', + NO_SUBSCRIPTION: 'اختر باقة لهذه المنشأة أولًا', + PLAN_INACTIVE: 'هذه الباقة لم تعد متاحة', +}; +const translateError = (code?: string, fallback?: string) => (code && MESSAGES[code]) || fallback || 'حدث خطأ غير متوقع'; diff --git a/alshuyukh-accounting/apps/web/src/auth.tsx b/alshuyukh-accounting/apps/web/src/auth.tsx new file mode 100644 index 000000000000..012fb454deea --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/auth.tsx @@ -0,0 +1,72 @@ +import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'; +import { api, refreshSession, setAccessToken, setSessionExpiredHandler } from './api'; + +export interface Me { + user: { id: string; email: string; fullName: string; mustChangePassword: boolean; isPlatformAdmin: boolean }; + tenant: { id: string; name: string; status: string; isOwner: boolean }; + roles: { id: string; code: string; nameAr: string }[]; + permissions: string[]; + memberships: { tenantId: string; tenantName: string; isOwner: boolean }[]; + subscription: { state: 'TRIALING' | 'ACTIVE' | 'GRACE' | 'EXPIRED' | 'CANCELLED' | 'NONE'; writable: boolean; planName: string | null; periodEnd: string | null; graceEnd: string | null }; + features: Record; +} + +interface AuthState { + me: Me | null; + loading: boolean; + can: (permission: string) => boolean; + login: (email: string, password: string) => Promise; + register: (data: Record) => Promise; + logout: () => Promise; + switchTenant: (tenantId: string) => Promise; + reload: () => Promise; +} + +const Ctx = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [me, setMe] = useState(null); + const [loading, setLoading] = useState(true); + + const reload = useCallback(async () => setMe(await api('GET', '/api/auth/me')), []); + + useEffect(() => { + setSessionExpiredHandler(() => setMe(null)); + // Restore the session from the refresh cookie on page load. + refreshSession().then(async (ok) => { if (ok) await reload().catch(() => setMe(null)); }).finally(() => setLoading(false)); + }, [reload]); + + const value: AuthState = { + me, + loading, + can: (p) => !!me?.permissions.includes(p), + login: async (email, password) => { + const r = await api<{ accessToken: string }>('POST', '/api/auth/login', { email, password }); + setAccessToken(r.accessToken); + await reload(); + }, + register: async (data) => { + const r = await api<{ accessToken: string }>('POST', '/api/auth/register', data); + setAccessToken(r.accessToken); + await reload(); + }, + logout: async () => { + await api('POST', '/api/auth/logout').catch(() => undefined); + setAccessToken(null); + setMe(null); + }, + switchTenant: async (tenantId) => { + const r = await api<{ accessToken: string }>('POST', '/api/auth/switch-tenant', { tenantId }); + setAccessToken(r.accessToken); + await reload(); + }, + reload, + }; + return {children}; +} + +export function useAuth(): AuthState { + const v = useContext(Ctx); + if (!v) throw new Error('useAuth outside AuthProvider'); + return v; +} diff --git a/alshuyukh-accounting/apps/web/src/components/Charts.tsx b/alshuyukh-accounting/apps/web/src/components/Charts.tsx new file mode 100644 index 000000000000..49f98805dab1 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/components/Charts.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; +import { formatAmount } from '../money'; + +/** + * Small SVG charts for the dashboard. Values arrive as decimal strings from the + * server; Number() is used only to place marks, never for displayed amounts. + * Colors come from CSS variables (--series-1, --series-2) validated for contrast + * and colour-vision deficiency. + */ +const W = 640; +const H = 220; +const PAD = { top: 12, right: 12, bottom: 28, left: 64 }; + +function scale(values: number[]) { + // With no data at all, show a round empty scale instead of fractions of a riyal. + const empty = values.every((v) => v === 0); + const max = empty ? 100 : Math.max(0, ...values); + const min = Math.min(0, ...values); + const span = max - min || 1; + const step = niceStep(span / 4); + const top = Math.ceil(max / step) * step || step; + const bottom = Math.floor(min / step) * step; + const ticks: number[] = []; + for (let v = bottom; v <= top + step / 2; v += step) ticks.push(v); + const y = (v: number) => PAD.top + (top - v) / (top - bottom) * (H - PAD.top - PAD.bottom); + return { y, ticks }; +} +function niceStep(raw: number) { + const p = 10 ** Math.floor(Math.log10(raw)); + return [1, 2, 2.5, 5, 10].map((m) => m * p).find((s) => s >= raw) ?? 10 * p; +} +const compact = (v: number) => new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(v); +const monthLabel = (m: string) => new Intl.DateTimeFormat('ar-SA-u-nu-latn-ca-gregory', { month: 'short', timeZone: 'UTC' }).format(new Date(`${m}-01T00:00:00Z`)); + +interface Series { key: string; label: string; color: string } + +export function MonthlyBars({ data, series }: { data: ({ month: string } & Record)[]; series: Series[] }) { + const [hover, setHover] = useState(null); + const { y, ticks } = scale(data.flatMap((d) => series.map((s) => Number(d[s.key])))); + const band = (W - PAD.left - PAD.right) / data.length; + const barW = Math.min(14, (band - 10) / series.length); + return ( +
+ +
+ s.label).join(' و')}> + + {data.map((d, i) => { + const x0 = PAD.left + i * band + (band - barW * series.length - 2 * (series.length - 1)) / 2; + return ( + + {series.map((s, j) => { + const v = Number(d[s.key]); + const top = Math.min(y(v), y(0)); + const h = Math.max(Math.abs(y(v) - y(0)), v === 0 ? 0 : 1); + return ; + })} + {monthLabel(d.month)} + setHover(i)} onMouseLeave={() => setHover(null)} onFocus={() => setHover(i)} onBlur={() => setHover(null)} tabIndex={0} /> + + ); + })} + + {hover !== null && [s.label, data[hover]![s.key]!, s.color])} />} +
+
+ ); +} + +export function MonthlyLine({ data, field, label, color }: { data: ({ month: string } & Record)[]; field: string; label: string; color: string }) { + const [hover, setHover] = useState(null); + const { y, ticks } = scale(data.map((d) => Number(d[field]))); + const step = (W - PAD.left - PAD.right) / Math.max(data.length - 1, 1); + const x = (i: number) => PAD.left + i * step; + const path = data.map((d, i) => `${i ? 'L' : 'M'}${x(i)},${y(Number(d[field]))}`).join(' '); + return ( +
+
+ + + + {hover !== null && } + {data.map((d, i) => ( + + {(hover === i || i === data.length - 1) && } + {monthLabel(d.month)} + setHover(i)} onMouseLeave={() => setHover(null)} onFocus={() => setHover(i)} onBlur={() => setHover(null)} tabIndex={0} /> + + ))} + + {hover !== null && } +
+
+ ); +} + +function Axes({ ticks, y }: { ticks: number[]; y: (v: number) => number }) { + return ( + + {ticks.map((t) => ( + + + {compact(t)} + + ))} + + ); +} + +function Legend({ series }: { series: Series[] }) { + return ( +
+ {series.map((s) => {s.label})} +
+ ); +} + +function Tooltip({ x, month, rows }: { x: number; month: string; rows: [string, string, string][] }) { + return ( +
+ {month} + {rows.map(([l, v, c]) =>
{l}{formatAmount(v)}
)} +
+ ); +} diff --git a/alshuyukh-accounting/apps/web/src/main.tsx b/alshuyukh-accounting/apps/web/src/main.tsx new file mode 100644 index 000000000000..6359d4ad10b0 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/main.tsx @@ -0,0 +1,21 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import App from './App'; +import { AuthProvider } from './auth'; +// Self-hosted font: each file declares unicode ranges, so browsers fetch only the subsets a page uses. +import '@fontsource/ibm-plex-sans-arabic/400.css'; +import '@fontsource/ibm-plex-sans-arabic/500.css'; +import '@fontsource/ibm-plex-sans-arabic/600.css'; +import '@fontsource/ibm-plex-sans-arabic/700.css'; +import './styles.css'; + +createRoot(document.getElementById('root')!).render( + + + + + + + , +); diff --git a/alshuyukh-accounting/apps/web/src/money.test.ts b/alshuyukh-accounting/apps/web/src/money.test.ts new file mode 100644 index 000000000000..912f39517e90 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/money.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { formatAmount, fromHalalas, toHalalas } from './money'; + +describe('money display helpers', () => { + it('formats server amounts without floating point', () => { + expect(formatAmount('1150.00')).toBe('1,150.00'); + expect(formatAmount('-1234567.5')).toBe('-1,234,567.50'); + expect(formatAmount('0.1')).toBe('0.10'); + expect(formatAmount('999999999999999.99')).toBe('999,999,999,999,999.99'); + expect(formatAmount(null)).toBe('—'); + }); + + it('parses typed amounts into halalas and back', () => { + expect(toHalalas('0.10')).toBe(10); + expect(toHalalas('0.1')! + toHalalas('0.2')!).toBe(30); // no 0.30000000000000004 + expect(toHalalas('12.345')).toBeNull(); + expect(toHalalas('abc')).toBeNull(); + expect(toHalalas('')).toBe(0); + expect(fromHalalas(-5)).toBe('-0.05'); + expect(fromHalalas(115000)).toBe('1150.00'); + }); +}); diff --git a/alshuyukh-accounting/apps/web/src/money.ts b/alshuyukh-accounting/apps/web/src/money.ts new file mode 100644 index 000000000000..c57505bb8444 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/money.ts @@ -0,0 +1,30 @@ +/** + * Display-only helpers. Amounts arrive from the API as strings ("1150.00"). + * Totals shown while typing are computed in integer halalas, never floats; + * the server recomputes and validates everything. + */ +export function formatAmount(value: string | null | undefined): string { + if (value === null || value === undefined || value === '') return '—'; + const negative = value.startsWith('-'); + const [int, frac = ''] = value.replace('-', '').split('.'); + const grouped = int!.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + return `${negative ? '-' : ''}${grouped}.${frac.padEnd(2, '0').slice(0, 2)}`; +} + +/** "12.5" → 1250 halalas. Returns null for invalid input. */ +export function toHalalas(value: string): number | null { + const v = value.trim(); + if (v === '') return 0; + if (!/^\d{1,13}(\.\d{1,2})?$/.test(v)) return null; + const [int, frac = ''] = v.split('.'); + return Number(int) * 100 + Number(frac.padEnd(2, '0')); +} + +export const fromHalalas = (h: number) => + `${h < 0 ? '-' : ''}${Math.floor(Math.abs(h) / 100)}.${String(Math.abs(h) % 100).padStart(2, '0')}`; + +export const ACCOUNT_TYPE_AR: Record = { + ASSET: 'أصول', LIABILITY: 'التزامات', EQUITY: 'حقوق ملكية', REVENUE: 'إيرادات', EXPENSE: 'مصروفات', COST_OF_GOODS_SOLD: 'تكلفة المبيعات', +}; +export const ENTRY_STATUS_AR: Record = { DRAFT: 'مسودة', POSTED: 'مرحّل', REVERSED: 'معكوس' }; +export const REFERENCE_AR: Record = { MANUAL: 'يدوي', REVERSAL: 'قيد عكسي', YEAR_CLOSING: 'إقفال سنة', SALES_INVOICE: 'فاتورة مبيعات' }; diff --git a/alshuyukh-accounting/apps/web/src/pages/AuditLog.tsx b/alshuyukh-accounting/apps/web/src/pages/AuditLog.tsx new file mode 100644 index 000000000000..65a9aa2059c5 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/AuditLog.tsx @@ -0,0 +1,67 @@ +import { Fragment, useState } from 'react'; +import { api } from '../api'; +import { ErrorBox, PageHeader, formatDateTime, useLoad } from '../ui'; + +interface Entry { id: string; action: string; entityType: string | null; entityId: string | null; userName: string | null; ipAddress: string | null; createdAt: string; oldValues: unknown; newValues: unknown } + +const ACTIONS: Record = { + REGISTER: 'تسجيل منشأة', LOGIN: 'تسجيل دخول', LOGOUT: 'تسجيل خروج', LOGIN_FAILED: 'دخول فاشل', + TOKEN_REUSE_DETECTED: 'إعادة استخدام رمز', TENANT_SWITCH: 'تبديل المنشأة', PASSWORD_CHANGE: 'تغيير كلمة المرور', + CREATE: 'إنشاء', UPDATE: 'تعديل', DELETE: 'حذف', SETTINGS_CHANGE: 'تغيير الإعدادات', PERMISSION_CHANGE: 'تغيير الصلاحيات', +}; + +export default function AuditLog() { + const [action, setAction] = useState(''); + const [cursor, setCursor] = useState(null); + const [open, setOpen] = useState(null); + const page = useLoad(() => { + const q = new URLSearchParams({ limit: '50' }); + if (action) q.set('action', action); + if (cursor) q.set('cursor', cursor); + return api<{ data: Entry[]; nextCursor: string | null }>('GET', `/api/audit-logs?${q}`); + }, [action, cursor]); + + return ( + <> + + + + +
+
+ + + + {page.data?.data.map((e) => ( + + setOpen(open === e.id ? null : e.id)}> + + + + + + + {open === e.id && ( + + )} + + ))} + +
الوقتالمستخدمالعمليةالكيانIP
{formatDateTime(e.createdAt)}{e.userName ?? '—'}{ACTIONS[e.action] ?? e.action}{e.entityType ?? '—'}{e.ipAddress ?? '—'}
+
+
قبل
{JSON.stringify(e.oldValues, null, 2)}
+
بعد
{JSON.stringify(e.newValues, null, 2)}
+
+
+
+
+ {cursor && } + {page.data?.nextCursor && } +
+
+ + ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/Companies.tsx b/alshuyukh-accounting/apps/web/src/pages/Companies.tsx new file mode 100644 index 000000000000..0bfa64aeaecc --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/Companies.tsx @@ -0,0 +1,144 @@ +import { useState, type FormEvent } from 'react'; +import { api } from '../api'; +import { useAuth } from '../auth'; +import { ErrorBox, PageHeader, useLoad } from '../ui'; + +interface Company { id: string; name: string; legalName: string | null; vatNumber: string | null; commercialRegistration: string | null; city: string | null; address: string | null; + buildingNumber: string | null; street: string | null; district: string | null; postalCode: string | null; additionalNumber: string | null; phone: string | null; email: string | null; currency: string } +interface Branch { id: string; code: string; name: string; city: string | null; isMain: boolean; isActive: boolean } +interface Warehouse { id: string; code: string; name: string; branchId: string | null; isActive: boolean } + +const nullIfEmpty = (v: FormDataEntryValue | null) => (v === null || String(v).trim() === '' ? null : String(v).trim()); + +export default function Companies() { + const { can } = useAuth(); + const companies = useLoad(() => api<{ data: Company[] }>('GET', '/api/companies')); + const [selected, setSelected] = useState(null); + const companyId = selected ?? companies.data?.data[0]?.id ?? null; + const company = companies.data?.data.find((c) => c.id === companyId); + const branches = useLoad(() => (companyId ? api<{ data: Branch[] }>('GET', `/api/companies/${companyId}/branches`) : Promise.resolve({ data: [] })), [companyId]); + const warehouses = useLoad(() => (companyId ? api<{ data: Warehouse[] }>('GET', `/api/companies/${companyId}/warehouses`) : Promise.resolve({ data: [] })), [companyId]); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + const manage = can('company.manage'); + + async function run(fn: () => Promise, after: () => void) { + setError(null); setSaved(false); + try { await fn(); setSaved(true); after(); } catch (err) { setError(err); } + } + + function saveCompany(e: FormEvent) { + e.preventDefault(); + const f = new FormData(e.currentTarget); + void run(() => api('PATCH', `/api/companies/${companyId}`, { + name: String(f.get('name')), legalName: nullIfEmpty(f.get('legalName')), vatNumber: nullIfEmpty(f.get('vatNumber')), + commercialRegistration: nullIfEmpty(f.get('commercialRegistration')), city: nullIfEmpty(f.get('city')), + address: nullIfEmpty(f.get('address')), phone: nullIfEmpty(f.get('phone')), email: nullIfEmpty(f.get('email')), + buildingNumber: nullIfEmpty(f.get('buildingNumber')), street: nullIfEmpty(f.get('street')), district: nullIfEmpty(f.get('district')), + postalCode: nullIfEmpty(f.get('postalCode')), additionalNumber: nullIfEmpty(f.get('additionalNumber')), + }), companies.reload); + } + + function addBranch(e: FormEvent) { + e.preventDefault(); + const form = e.currentTarget; + const f = new FormData(form); + void run(() => api('POST', `/api/companies/${companyId}/branches`, { code: String(f.get('code')), name: String(f.get('name')), city: nullIfEmpty(f.get('city')) }), + () => { form.reset(); branches.reload(); }); + } + + function addWarehouse(e: FormEvent) { + e.preventDefault(); + const form = e.currentTarget; + const f = new FormData(form); + void run(() => api('POST', `/api/companies/${companyId}/warehouses`, { code: String(f.get('code')), name: String(f.get('name')), branchId: nullIfEmpty(f.get('branchId')) }), + () => { form.reset(); warehouses.reload(); }); + } + + return ( + <> + + {(companies.data?.data.length ?? 0) > 1 && ( + + )} + + + {saved &&
تم الحفظ
} + + {company && ( +
+

بيانات الشركة

+
+
+ + + + + + + + +
+

العنوان الوطني (مطلوب للفوترة الإلكترونية)

+
+ + + + + +
+

العملة: {company.currency}

+ {manage && } +
+
+ )} + +
+
+

الفروع

+ + + + {branches.data?.data.map((b) => ( + + ))} + +
الرمزالاسمالمدينة
{b.code}{b.name}{b.city ?? '—'}{b.isMain && رئيسي}
+ {manage && ( +
+ + + + +
+ )} +
+ +
+

المستودعات

+ + + + {warehouses.data?.data.map((w) => ( + + ))} + +
الرمزالاسمالفرع
{w.code}{w.name}{branches.data?.data.find((b) => b.id === w.branchId)?.name ?? '—'}
+ {manage && ( +
+ + + + +
+ )} +
+
+ + ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/Home.tsx b/alshuyukh-accounting/apps/web/src/pages/Home.tsx new file mode 100644 index 000000000000..b73cb34fb2a0 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/Home.tsx @@ -0,0 +1,108 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../api'; +import { useAuth } from '../auth'; +import { MonthlyBars, MonthlyLine } from '../components/Charts'; +import { formatAmount } from '../money'; +import { ErrorBox, PageHeader, useLoad } from '../ui'; + +interface Company { id: string; name: string; vatNumber: string | null; commercialRegistration: string | null; city: string | null; currency: string } + +export default function Home() { + const { me, can } = useAuth(); + const companies = useLoad(() => (can('company.view') ? api<{ data: Company[] }>('GET', '/api/companies') : Promise.resolve({ data: [] }))); + const company = companies.data?.data[0]; + + const steps = [ + { done: !!company?.vatNumber, label: 'أضف الرقم الضريبي للشركة', to: '/settings/companies' }, + { done: !!company?.commercialRegistration, label: 'أضف رقم السجل التجاري', to: '/settings/companies' }, + { done: !!company?.city, label: 'أكمل عنوان الشركة', to: '/settings/companies' }, + ]; + + return ( + <> + + {can('financial_report.view') && } +
+ {company && can('company.manage') && ( +
+

إعداد الشركة

+
    + {steps.map((s) => ( +
  • + {s.done ? '✓' : '○'} + {s.done ? s.label : {s.label}} +
  • + ))} +
+
+ )} +
+ + ); +} + +interface DashboardData { + dateFrom: string; dateTo: string; + kpis: Record<'totalSales' | 'totalPurchases' | 'costOfSales' | 'expenses' | 'netProfit' | 'receivables' | 'payables' | 'cash' | 'bank' | 'inventoryValue' | 'vatPayable', string>; + monthly: { month: string; revenue: string; costs: string; netProfit: string; cashBalance: string }[]; +} + +const KPIS: { key: keyof DashboardData['kpis']; label: string; to: string; period?: boolean }[] = [ + { key: 'totalSales', label: 'المبيعات', to: '/reports/sales', period: true }, + { key: 'totalPurchases', label: 'المشتريات', to: '/reports/purchases', period: true }, + { key: 'expenses', label: 'المصروفات', to: '/reports/expenses', period: true }, + { key: 'netProfit', label: 'صافي الربح', to: '/reports/profit-loss', period: true }, + { key: 'receivables', label: 'الذمم المدينة', to: '/reports/receivables' }, + { key: 'payables', label: 'الذمم الدائنة', to: '/reports/payables' }, + { key: 'cash', label: 'النقدية', to: '/reports/cash-flow' }, + { key: 'bank', label: 'البنك', to: '/reports/cash-flow' }, + { key: 'inventoryValue', label: 'قيمة المخزون', to: '/inventory?tab=valuation' }, + { key: 'vatPayable', label: 'ضريبة القيمة المضافة المستحقة', to: '/reports/vat' }, +]; + +function Dashboard() { + const d = useLoad(() => api('GET', '/api/dashboard')); + const [table, setTable] = useState(false); + if (d.error) return ; + if (!d.data) return
جارٍ تحميل المؤشرات…
; + const { kpis, monthly, dateFrom, dateTo } = d.data; + return ( + <> +

المؤشرات من القيود المرحّلة فقط — الفترة {dateFrom} إلى {dateTo}؛ الأرصدة في نهاية الفترة.

+
+ {KPIS.map((k) => ( + + {k.label}{k.period ? '' : ' (رصيد)'} + {formatAmount(kpis[k.key])} + + ))} +
+
+
+

الإيرادات والتكاليف شهريًا

+ {table ? ( +
+ + + {monthly.map((m) => ( + + + ))} +
الشهرالإيراداتالتكاليف والمصروفاتصافي الربحالنقدية آخر الشهر
{m.month}{formatAmount(m.revenue)}{formatAmount(m.costs)}{formatAmount(m.netProfit)}{formatAmount(m.cashBalance)}
+
+ ) : ( + + )} +
+
+

رصيد النقدية والبنوك

+ +
+
+ + ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/Login.tsx b/alshuyukh-accounting/apps/web/src/pages/Login.tsx new file mode 100644 index 000000000000..bf7fb8542ee0 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/Login.tsx @@ -0,0 +1,38 @@ +import { useState, type FormEvent } from 'react'; +import { Link } from 'react-router-dom'; +import { useAuth } from '../auth'; +import { ErrorBox } from '../ui'; + +export default function Login() { + const { login } = useAuth(); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function submit(e: FormEvent) { + e.preventDefault(); + const f = new FormData(e.currentTarget); + setBusy(true); + setError(null); + try { + await login(String(f.get('email')), String(f.get('password'))); + } catch (err) { + setError(err); + } finally { + setBusy(false); + } + } + + return ( +
+
+
ش الشيوخ للمحاسبة
+

تسجيل الدخول

+ + + + +

ليس لديك حساب؟ أنشئ منشأة جديدة

+ +
+ ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/Register.tsx b/alshuyukh-accounting/apps/web/src/pages/Register.tsx new file mode 100644 index 000000000000..ef3dee0bdabf --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/Register.tsx @@ -0,0 +1,49 @@ +import { useState, type FormEvent } from 'react'; +import { Link } from 'react-router-dom'; +import { useAuth } from '../auth'; +import { ErrorBox } from '../ui'; + +export default function Register() { + const { register } = useAuth(); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function submit(e: FormEvent) { + e.preventDefault(); + const f = Object.fromEntries(new FormData(e.currentTarget)) as Record; + setBusy(true); + setError(null); + try { + await register({ + fullName: f.fullName, email: f.email, password: f.password, + tenantName: f.companyName, companyName: f.companyName, + vatNumber: f.vatNumber || null, commercialRegistration: f.commercialRegistration || null, + }); + } catch (err) { + setError(err); + } finally { + setBusy(false); + } + } + + return ( +
+
+
ش الشيوخ للمحاسبة
+

إنشاء منشأة جديدة

+ + + + + +
+ + +
+ +

لديك حساب؟ تسجيل الدخول

+ +
+ ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/Roles.tsx b/alshuyukh-accounting/apps/web/src/pages/Roles.tsx new file mode 100644 index 000000000000..d2ca7e3d768a --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/Roles.tsx @@ -0,0 +1,88 @@ +import { useState, type FormEvent } from 'react'; +import { api } from '../api'; +import { useAuth } from '../auth'; +import { ErrorBox, PageHeader, useLoad } from '../ui'; + +interface Role { id: string; code: string; nameAr: string; nameEn: string; isSystem: boolean; permissions: string[] } +interface Permission { code: string; module: string; descriptionAr: string } + +export default function Roles() { + const { can } = useAuth(); + const roles = useLoad(() => api<{ data: Role[] }>('GET', '/api/roles')); + const perms = useLoad(() => api<{ data: Permission[] }>('GET', '/api/permissions')); + const [open, setOpen] = useState(null); + const [error, setError] = useState(null); + const manage = can('role.manage'); + + const byModule = (perms.data?.data ?? []).reduce>((acc, p) => { + (acc[p.module] ??= []).push(p); + return acc; + }, {}); + const label = (code: string) => perms.data?.data.find((p) => p.code === code)?.descriptionAr ?? code; + + async function create(e: FormEvent) { + e.preventDefault(); + const form = e.currentTarget; + const f = new FormData(form); + setError(null); + try { + await api('POST', '/api/roles', { + code: String(f.get('code')), nameAr: String(f.get('nameAr')), nameEn: String(f.get('nameEn')), + permissions: f.getAll('permissions').map(String), + }); + form.reset(); + roles.reload(); + } catch (err) { setError(err); } + } + + async function remove(id: string) { + setError(null); + try { await api('DELETE', `/api/roles/${id}`); roles.reload(); } catch (err) { setError(err); } + } + + return ( + <> + + +
+ {roles.data?.data.map((r) => ( +
+ + {open === r.id && ( +
+
    {r.permissions.map((p) =>
  • {label(p)}
  • )}
+ {manage && !r.isSystem && } +
+ )} +
+ ))} +
+ + {manage && ( +
+

دور مخصص جديد

+
+ + + +
+

لا يمكنك منح صلاحيات لا تملكها.

+
+ {Object.entries(byModule).map(([mod, list]) => ( +
+ {mod} + {list.map((p) => )} +
+ ))} +
+ +
+ )} + + ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/Settings.tsx b/alshuyukh-accounting/apps/web/src/pages/Settings.tsx new file mode 100644 index 000000000000..8002b75f28cb --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/Settings.tsx @@ -0,0 +1,100 @@ +import { useState, type FormEvent } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../api'; +import { useAuth } from '../auth'; +import { ErrorBox, PageHeader, useLoad } from '../ui'; +import TaxRates from './TaxRates'; + +interface TenantSettings { tenantName: string; defaultCurrency: string; timezone: string; locale: string; fiscalYearStartMonth: number; dateFormat: string } + +const MONTHS = ['يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر']; + +export default function Settings() { + const { can, reload } = useAuth(); + const settings = useLoad(() => api('GET', '/api/settings/tenant')); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + const [pwError, setPwError] = useState(null); + const [pwSaved, setPwSaved] = useState(false); + const editable = can('settings.manage'); + + async function save(e: FormEvent) { + e.preventDefault(); + const f = Object.fromEntries(new FormData(e.currentTarget)) as Record; + setError(null); setSaved(false); + try { + await api('PATCH', '/api/settings/tenant', { + tenantName: f.tenantName, defaultCurrency: f.defaultCurrency, timezone: f.timezone, + fiscalYearStartMonth: Number(f.fiscalYearStartMonth), dateFormat: f.dateFormat, + }); + setSaved(true); + await reload(); + } catch (err) { setError(err); } + } + + async function changePassword(e: FormEvent) { + e.preventDefault(); + const form = e.currentTarget; + const f = Object.fromEntries(new FormData(form)) as Record; + setPwError(null); setPwSaved(false); + try { + await api('POST', '/api/auth/change-password', { currentPassword: f.currentPassword, newPassword: f.newPassword }); + setPwSaved(true); + form.reset(); + await reload(); + } catch (err) { setPwError(err); } + } + + const s = settings.data; + return ( + <> + +
+ {can('company.view') && الشركات والفروع والمستودعات} + {can('user.view') && المستخدمون} + {can('role.view') && الأدوار والصلاحيات} + {can('audit.view') && سجل التدقيق} + الاشتراك والباقة +
+ + {s && ( +
+

إعدادات المنشأة

+ + {saved &&
تم الحفظ
} +
+ +
+ + + + +
+ {editable && } +
+ + )} + + {can('invoice.view') && } + +
+

تغيير كلمة المرور

+ + {pwSaved &&
تم تغيير كلمة المرور وتسجيل الخروج من الأجهزة الأخرى
} +
+ + +
+ + + + ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/TaxRates.tsx b/alshuyukh-accounting/apps/web/src/pages/TaxRates.tsx new file mode 100644 index 000000000000..02528513074d --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/TaxRates.tsx @@ -0,0 +1,62 @@ +import { useState, type FormEvent } from 'react'; +import { api } from '../api'; +import { useAuth } from '../auth'; +import { ErrorBox, useLoad } from '../ui'; + +interface Rate { id: string; vatCategory: string; nameAr: string; rate: string; effectiveFrom: string; effectiveTo: string | null; isActive: boolean } + +/** Standard VAT rate history. A new rate closes the previous one the day before it starts. */ +export default function TaxRates() { + const { can } = useAuth(); + const rates = useLoad(() => api<{ data: Rate[] }>('GET', '/api/tax-rates')); + const [error, setError] = useState(null); + + async function add(e: FormEvent) { + e.preventDefault(); + const form = e.currentTarget; + const f = Object.fromEntries(new FormData(form)) as Record; + const pct = Number(f.percent); + setError(null); + try { + // The API takes the rate as a fraction ("0.15"); build it from the percent text without floating-point math. + const [int = '0', frac = ''] = f.percent!.trim().split('.'); + if (!(pct > 0 && pct < 100) || frac.length > 2) throw new Error('أدخل نسبة بين 0 و100 بحد أقصى منزلتين عشريتين'); + const rate = `0.${int.padStart(2, '0')}${frac}`.replace(/0+$/, '').replace(/\.$/, '.0'); + await api('POST', '/api/tax-rates', { nameAr: f.nameAr, rate, effectiveFrom: f.effectiveFrom }); + form.reset(); + rates.reload(); + } catch (err) { setError(err); } + } + + return ( +
+

نسب ضريبة القيمة المضافة

+ +
+ + + + {rates.data?.data.map((r) => ( + + + + + + ))} + +
الاسمالنسبةمنإلى
{r.nameAr}{(Number(r.rate) * 100).toFixed(2).replace(/\.?0+$/, '')}%{r.effectiveFrom}{r.effectiveTo ?? 'مستمرة'}
+
+

تُطبَّق النسبة حسب تاريخ المستند، فلا تتأثر المستندات السابقة بتغيير النسبة.

+ {can('tax.manage') && ( +
+
+ + + +
+ +
+ )} +
+ ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/Users.tsx b/alshuyukh-accounting/apps/web/src/pages/Users.tsx new file mode 100644 index 000000000000..6299ca702d36 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/Users.tsx @@ -0,0 +1,101 @@ +import { useState, type FormEvent } from 'react'; +import { api } from '../api'; +import { useAuth } from '../auth'; +import { ErrorBox, PageHeader, formatDateTime, useLoad } from '../ui'; + +interface Member { id: string; email: string; fullName: string; status: string; isOwner: boolean; lastLoginAt: string | null; roles: { id: string; code: string; nameAr: string }[] } +interface Role { id: string; code: string; nameAr: string; isSystem: boolean } + +export default function Users() { + const { me, can } = useAuth(); + const users = useLoad(() => api<{ data: Member[] }>('GET', '/api/users')); + const roles = useLoad(() => (can('role.view') ? api<{ data: Role[] }>('GET', '/api/roles') : Promise.resolve({ data: [] }))); + const [error, setError] = useState(null); + const [editing, setEditing] = useState(null); + const manage = can('user.manage'); + + async function run(fn: () => Promise) { + setError(null); + try { await fn(); users.reload(); return true; } catch (err) { setError(err); return false; } + } + + async function add(e: FormEvent) { + e.preventDefault(); + const form = e.currentTarget; + const f = new FormData(form); + const ok = await run(() => api('POST', '/api/users', { + email: String(f.get('email')), fullName: String(f.get('fullName')), + initialPassword: String(f.get('initialPassword')) || undefined, roleIds: [String(f.get('roleId'))], + })); + if (ok) form.reset(); + } + + async function saveRoles(e: FormEvent, userId: string) { + e.preventDefault(); + const roleIds = new FormData(e.currentTarget).getAll('roleIds').map(String); + if (await run(() => api('PUT', `/api/users/${userId}/roles`, { roleIds }))) setEditing(null); + } + + return ( + <> + + +
+
+ + + + {users.data?.data.map((u) => ( + + + + + + + + + ))} + +
الاسمالبريدالأدوارالحالةآخر دخول
{u.fullName} {u.isOwner && المالك}{u.email} + {editing === u.id ? ( +
saveRoles(e, u.id)} className="role-picker"> + {roles.data?.data.map((r) => ( + + ))} + + +
+ ) : u.roles.map((r) => r.nameAr).join('، ')} +
{u.status === 'ACTIVE' ? 'نشط' : u.status === 'INVITED' ? 'بانتظار قبول الدعوة' : 'معطّل'}{formatDateTime(u.lastLoginAt)} + {manage && u.id !== me?.user.id && !u.isOwner && u.status !== 'INVITED' && ( + <> + + + + )} +
+
+
+ + {can('user.invite') && manage && ( +
+

إضافة مستخدم

+
+ + + + +
+ +
+ )} + + ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/accounting/Accounting.tsx b/alshuyukh-accounting/apps/web/src/pages/accounting/Accounting.tsx new file mode 100644 index 000000000000..72cd67e5a8b8 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/accounting/Accounting.tsx @@ -0,0 +1,22 @@ +import { NavLink, Outlet } from 'react-router-dom'; +import { useAuth } from '../../auth'; +import { PageHeader } from '../../ui'; + +export default function Accounting() { + const { can } = useAuth(); + const tabs = [ + { to: 'journal', label: 'القيود اليومية', show: can('journal.view') }, + { to: 'accounts', label: 'دليل الحسابات', show: can('account.view') }, + { to: 'fiscal', label: 'السنوات المالية', show: can('account.view') }, + { to: 'trial-balance', label: 'ميزان المراجعة', show: can('report.view') }, + ].filter((t) => t.show); + return ( + <> + + + + + ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/accounting/ChartOfAccounts.tsx b/alshuyukh-accounting/apps/web/src/pages/accounting/ChartOfAccounts.tsx new file mode 100644 index 000000000000..39ab6fbad8a8 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/accounting/ChartOfAccounts.tsx @@ -0,0 +1,137 @@ +import { useMemo, useState, type FormEvent } from 'react'; +import { api } from '../../api'; +import { useAuth } from '../../auth'; +import { ACCOUNT_TYPE_AR, formatAmount } from '../../money'; +import { ErrorBox, useLoad } from '../../ui'; + +export interface Account { + id: string; code: string; nameAr: string; nameEn: string | null; type: string; parentId: string | null; + level: number; isPostable: boolean; isActive: boolean; isSystem: boolean; systemKey: string | null; balance: string; +} + +export default function ChartOfAccounts() { + const { can } = useAuth(); + const [showInactive, setShowInactive] = useState(false); + const accounts = useLoad(() => api<{ data: Account[] }>('GET', `/api/accounts?includeInactive=${showInactive}`), [showInactive]); + const [error, setError] = useState(null); + const [editing, setEditing] = useState(null); + const manage = can('account.manage'); + + // Depth-first order so children follow their parent. + const ordered = useMemo(() => { + const list = accounts.data?.data ?? []; + const children = new Map(); + for (const a of list) children.set(a.parentId, [...(children.get(a.parentId) ?? []), a]); + const out: Account[] = []; + const walk = (parent: string | null) => (children.get(parent) ?? []).sort((x, y) => x.code.localeCompare(y.code)).forEach((a) => { out.push(a); walk(a.id); }); + walk(null); + // Accounts whose parent is filtered out (inactive) still appear. + for (const a of list) if (!out.includes(a)) out.push(a); + return out; + }, [accounts.data]); + + async function run(fn: () => Promise) { + setError(null); + try { await fn(); accounts.reload(); return true; } catch (e) { setError(e); return false; } + } + + async function add(e: FormEvent) { + e.preventDefault(); + const form = e.currentTarget; + const f = Object.fromEntries(new FormData(form)) as Record; + const ok = await run(() => api('POST', '/api/accounts', { + code: f.code, nameAr: f.nameAr, nameEn: f.nameEn || null, parentId: f.parentId || null, + type: f.parentId ? undefined : f.type, isPostable: f.kind === 'postable', + })); + if (ok) form.reset(); + } + + async function saveEdit(e: FormEvent, a: Account) { + e.preventDefault(); + const f = Object.fromEntries(new FormData(e.currentTarget)) as Record; + if (await run(() => api('PATCH', `/api/accounts/${a.id}`, { code: f.code, nameAr: f.nameAr }))) setEditing(null); + } + + const headers = ordered.filter((a) => !a.isPostable && a.isActive); + + return ( + <> + +
+
+ +
+
+ + + + {ordered.map((a) => ( + + {editing === a.id ? ( + + ) : ( + <> + + + + + + + )} + + ))} + +
الرمزاسم الحسابالنوعالرصيد
+
saveEdit(e, a)}> + + + + +
+
{a.code} + {a.nameAr} + {a.isSystem && نظامي} + {!a.isActive && غير نشط} + {ACCOUNT_TYPE_AR[a.type]}{a.isPostable ? formatAmount(a.balance) : ''} + {manage && } + {manage && !a.isSystem && a.isPostable && ( + + )} +
+
+

الرصيد = المدين − الدائن من القيود المرحّلة. الرصيد السالب يعني رصيدًا دائنًا.

+
+ + {manage && ( +
+

إضافة حساب

+
+ + + + + + +
+ +
+ )} + + ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/accounting/FiscalYears.tsx b/alshuyukh-accounting/apps/web/src/pages/accounting/FiscalYears.tsx new file mode 100644 index 000000000000..e57bd18a61d3 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/accounting/FiscalYears.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react'; +import { api } from '../../api'; +import { useAuth } from '../../auth'; +import { ErrorBox, useLoad } from '../../ui'; + +interface Year { + id: string; name: string; startDate: string; endDate: string; status: string; closingEntryId: string | null; + periods: { id: string; periodNumber: number; name: string; startDate: string; endDate: string; status: string }[]; +} + +export default function FiscalYears() { + const { can } = useAuth(); + const years = useLoad(() => api<{ data: Year[] }>('GET', '/api/fiscal-years')); + const [error, setError] = useState(null); + const [open, setOpen] = useState(null); + const manage = can('fiscal.manage'); + + async function run(fn: () => Promise) { + setError(null); + try { await fn(); years.reload(); } catch (e) { setError(e); } + } + + const latest = years.data?.data[0]; + const nextStart = latest ? new Date(`${latest.endDate}T00:00:00Z`) : null; + nextStart?.setUTCDate(nextStart.getUTCDate() + 1); + + return ( + <> + + {manage && nextStart && ( +
+ +
+ )} + {years.data?.data.map((y) => ( +
+
+ + {manage && y.status === 'OPEN' && ( + + )} +
+ {open === y.id && ( + + + + {y.periods.map((p) => ( + + + + + + + ))} + +
#الفترةمنإلىالحالة
{p.periodNumber}{p.name}{p.startDate}{p.endDate}{p.status === 'OPEN' ? 'مفتوحة' : 'مقفلة'} + {manage && y.status === 'OPEN' && ( + + )} +
+ )} +
+ ))} + + ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/accounting/JournalDetail.tsx b/alshuyukh-accounting/apps/web/src/pages/accounting/JournalDetail.tsx new file mode 100644 index 000000000000..df2e37841470 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/accounting/JournalDetail.tsx @@ -0,0 +1,114 @@ +import { useState } from 'react'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import { api } from '../../api'; +import { useAuth } from '../../auth'; +import { ENTRY_STATUS_AR, REFERENCE_AR, formatAmount } from '../../money'; +import { ErrorBox, formatDateTime, useLoad } from '../../ui'; +import JournalForm from './JournalForm'; + +export interface Entry { + id: string; entryNumber: string | null; entryDate: string; description: string; referenceType: string; referenceId: string | null; + source: string; status: string; currency: string; totalDebit: string; totalCredit: string; + reversalOfId: string | null; reversedByEntryId: string | null; correctionOfId: string | null; postedAt: string | null; createdAt: string; + lines: { id: string; lineNo: number; accountId: string; accountCode: string; accountName: string; debit: string; credit: string; description: string | null; customerId: string | null; supplierId: string | null }[]; +} + +export default function JournalDetail() { + const { id } = useParams(); + const { can } = useAuth(); + const navigate = useNavigate(); + const entry = useLoad(() => api('GET', `/api/journal-entries/${id}`), [id]); + const [error, setError] = useState(null); + const [editing, setEditing] = useState(false); + const [reversing, setReversing] = useState(false); + + async function run(fn: () => Promise) { + setError(null); + try { await fn(); entry.reload(); } catch (e) { setError(e); } + } + + const e = entry.data; + if (!e) return ; + if (editing) return { setEditing(false); entry.reload(); }} />; + + const manual = e.source === 'MANUAL'; + return ( +
+ +
+
+

{e.entryNumber ?? 'مسودة'}

+

{e.description}

+
+ {ENTRY_STATUS_AR[e.status]} +
+
+
التاريخ
{e.entryDate}
+
المصدر
{REFERENCE_AR[e.referenceType] ?? e.referenceType}
+
العملة
{e.currency}
+
تاريخ الترحيل
{formatDateTime(e.postedAt)}
+ {e.reversalOfId &&
يعكس القيد
عرض الأصل
} + {e.reversedByEntryId &&
عُكس بالقيد
عرض القيد العكسي
} + {e.correctionOfId &&
تصحيح للقيد
عرض الأصل
} +
+ +
+ + + + {e.lines.map((l) => ( + + + + + + + + ))} + + + + + + +
#الحسابالبيانمديندائن
{l.lineNo}{l.accountCode} {l.accountName}{l.description ?? ''}{l.debit === '0.00' ? '' : formatAmount(l.debit)}{l.credit === '0.00' ? '' : formatAmount(l.credit)}
الإجمالي{formatAmount(e.totalDebit)}{formatAmount(e.totalCredit)}
+
+ +
+ {e.status === 'DRAFT' && manual && can('journal.create') && } + {e.status === 'DRAFT' && can('journal.post') && } + {e.status === 'DRAFT' && manual && can('journal.create') && ( + + )} + {e.status === 'POSTED' && manual && e.referenceType !== 'REVERSAL' && can('journal.reverse') && !reversing && ( + + )} +
+ {e.status === 'POSTED' && !manual &&

هذا القيد صادر عن مستند. يُصحَّح بإلغاء المستند أو إرجاعه.

} + + {reversing && ( +
{ + ev.preventDefault(); + const f = new FormData(ev.currentTarget); + void run(async () => { + const r = await api<{ reversal: { id: string } }>('POST', `/api/journal-entries/${e.id}/reverse`, { + reason: String(f.get('reason')), ...(f.get('date') ? { date: String(f.get('date')) } : {}), + }); + setReversing(false); + navigate(`/accounting/journal/${r.reversal.id}`); + }); + }}> +

لا يُعدَّل القيد المرحّل. سيُنشأ قيد عكسي مرحّل بنفس المبالغ معكوسة، ثم يمكنك إنشاء قيد تصحيح جديد.

+
+ + +
+
+ + +
+
+ )} +
+ ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/accounting/JournalForm.tsx b/alshuyukh-accounting/apps/web/src/pages/accounting/JournalForm.tsx new file mode 100644 index 000000000000..059a0b7280fe --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/accounting/JournalForm.tsx @@ -0,0 +1,134 @@ +import { useState, type FormEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { api } from '../../api'; +import { useAuth } from '../../auth'; +import { formatAmount, fromHalalas, toHalalas } from '../../money'; +import { ErrorBox, useLoad } from '../../ui'; +import type { Account } from './ChartOfAccounts'; +import type { Entry } from './JournalDetail'; + +interface Line { accountId: string; debit: string; credit: string; description: string; partyId: string } + +const emptyLine = (): Line => ({ accountId: '', debit: '', credit: '', description: '', partyId: '' }); + +interface PartyOption { id: string; code: string; nameAr: string } + +export default function JournalForm({ entry, onSaved }: { entry?: Entry; onSaved?: () => void }) { + const { can } = useAuth(); + const navigate = useNavigate(); + const accounts = useLoad(() => api<{ data: Account[] }>('GET', '/api/accounts?postableOnly=true')); + // Receivable / payable lines can be tagged with a customer or supplier (sub-ledger). + const customers = useLoad(() => (can('customer.view') ? api<{ data: PartyOption[] }>('GET', '/api/customers?limit=200') : Promise.resolve({ data: [] }))); + const suppliers = useLoad(() => (can('supplier.view') ? api<{ data: PartyOption[] }>('GET', '/api/suppliers?limit=200') : Promise.resolve({ data: [] }))); + const partyKind = (accountId: string) => { + const key = accounts.data?.data.find((a) => a.id === accountId)?.systemKey; + return key === 'ACCOUNTS_RECEIVABLE' ? 'customer' : key === 'ACCOUNTS_PAYABLE' ? 'supplier' : null; + }; + const [lines, setLines] = useState(() => + entry ? entry.lines.map((l) => ({ + accountId: l.accountId, debit: l.debit === '0.00' ? '' : l.debit, credit: l.credit === '0.00' ? '' : l.credit, description: l.description ?? '', + partyId: l.customerId ?? l.supplierId ?? '', + })) : [emptyLine(), emptyLine()]); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const update = (i: number, patch: Partial) => setLines((ls) => ls.map((l, j) => (j === i ? { ...l, ...patch } : l))); + const debit = lines.reduce((s, l) => s + (toHalalas(l.debit) ?? 0), 0); + const credit = lines.reduce((s, l) => s + (toHalalas(l.credit) ?? 0), 0); + const diff = debit - credit; + + async function submit(e: FormEvent, postNow: boolean) { + e.preventDefault(); + const f = new FormData(e.currentTarget); + const payload = { + entryDate: String(f.get('entryDate')), + description: String(f.get('description')), + lines: lines.filter((l) => l.accountId).map((l) => ({ + accountId: l.accountId, + ...(l.debit.trim() ? { debit: l.debit.trim() } : {}), + ...(l.credit.trim() ? { credit: l.credit.trim() } : {}), + description: l.description || null, + ...(l.partyId && partyKind(l.accountId) === 'customer' ? { customerId: l.partyId } : {}), + ...(l.partyId && partyKind(l.accountId) === 'supplier' ? { supplierId: l.partyId } : {}), + })), + }; + setBusy(true); + setError(null); + try { + if (entry) { + await api('PATCH', `/api/journal-entries/${entry.id}`, payload); + if (postNow) await api('POST', `/api/journal-entries/${entry.id}/post`); + onSaved?.(); + } else { + const created = await api<{ id: string }>('POST', '/api/journal-entries', { ...payload, post: postNow }); + navigate(`/accounting/journal/${created.id}`); + } + } catch (err) { + setError(err); + } finally { + setBusy(false); + } + } + + const today = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Riyadh' }).format(new Date()); + + return ( +
submit(e, (e.nativeEvent as SubmitEvent).submitter?.getAttribute('value') === 'post')}> +

{entry ? 'تعديل مسودة القيد' : 'قيد يومية جديد'}

+ +
+ + +
+ +
+ + + + {lines.map((l, i) => ( + + + + + + + + ))} + + + + + + + + + +
الحسابمديندائنالبيان
+ + {partyKind(l.accountId) && ( + + )} + update(i, { debit: e.target.value, credit: e.target.value ? '' : l.credit })} /> update(i, { credit: e.target.value, debit: e.target.value ? '' : l.debit })} /> update(i, { description: e.target.value })} aria-label={`بيان السطر ${i + 1}`} />{lines.length > 2 && }
{formatAmount(fromHalalas(debit))}{formatAmount(fromHalalas(credit))} + {diff === 0 && debit > 0 + ? متوازن + : الفرق: {formatAmount(fromHalalas(Math.abs(diff)))}} +
+
+

يتحقق الخادم من توازن القيد وصحة الحسابات والفترة المالية عند الترحيل.

+
+ + {can('journal.post') && } +
+ + ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/accounting/JournalList.tsx b/alshuyukh-accounting/apps/web/src/pages/accounting/JournalList.tsx new file mode 100644 index 000000000000..5a234d01d566 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/accounting/JournalList.tsx @@ -0,0 +1,64 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../../api'; +import { useAuth } from '../../auth'; +import { ENTRY_STATUS_AR, REFERENCE_AR, formatAmount } from '../../money'; +import { ErrorBox, useLoad } from '../../ui'; + +interface Row { id: string; entryNumber: string | null; entryDate: string; description: string; referenceType: string; status: string; totalDebit: string } + +const PAGE = 50; + +export default function JournalList() { + const { can } = useAuth(); + const [status, setStatus] = useState(''); + const [search, setSearch] = useState(''); + const [offset, setOffset] = useState(0); + const list = useLoad(() => { + const q = new URLSearchParams({ limit: String(PAGE), offset: String(offset) }); + if (status) q.set('status', status); + if (search) q.set('search', search); + return api<{ data: Row[]; total: number }>('GET', `/api/journal-entries?${q}`); + }, [status, search, offset]); + + return ( +
+
+ + { if (e.key === 'Enter') { setSearch(e.currentTarget.value); setOffset(0); } }} /> + + {can('journal.create') && قيد جديد} +
+ +
+ + + + {list.data?.data.map((e) => ( + + + + + + + + + ))} + {list.data?.data.length === 0 && } + +
الرقمالتاريخالوصفالمصدرالمبلغالحالة
{e.entryNumber ?? 'مسودة'}{e.entryDate}{e.description}{REFERENCE_AR[e.referenceType] ?? e.referenceType}{formatAmount(e.totalDebit)}{ENTRY_STATUS_AR[e.status]}
لا توجد قيود
+
+ {list.data && list.data.total > PAGE && ( +
+ {offset + 1}–{Math.min(offset + PAGE, list.data.total)} من {list.data.total} + + +
+ )} +
+ ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/accounting/TrialBalance.tsx b/alshuyukh-accounting/apps/web/src/pages/accounting/TrialBalance.tsx new file mode 100644 index 000000000000..b28520fe117d --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/accounting/TrialBalance.tsx @@ -0,0 +1,60 @@ +import { useState, type FormEvent } from 'react'; +import { api } from '../../api'; +import { ACCOUNT_TYPE_AR, formatAmount } from '../../money'; +import { ErrorBox, useLoad } from '../../ui'; + +interface Row { accountId: string; code: string; nameAr: string; type: string; openingDebit: string; openingCredit: string; periodDebit: string; periodCredit: string; closingDebit: string; closingCredit: string } +interface Result { data: Row[]; totals: Omit; balanced: boolean } + +const year = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Riyadh', year: 'numeric' }).format(new Date()); + +export default function TrialBalance() { + const [range, setRange] = useState({ from: `${year}-01-01`, to: `${year}-12-31` }); + const tb = useLoad(() => api('GET', `/api/reports/trial-balance?dateFrom=${range.from}&dateTo=${range.to}`), [range.from, range.to]); + + function submit(e: FormEvent) { + e.preventDefault(); + const f = new FormData(e.currentTarget); + setRange({ from: String(f.get('from')), to: String(f.get('to')) }); + } + + const cell = (v: string) => {v === '0.00' ? '' : formatAmount(v)}; + return ( +
+
+ + + + + {tb.data && {tb.data.balanced ? 'الميزان متوازن' : 'الميزان غير متوازن'}} + + +
+ + + + + + + {tb.data?.data.map((r) => ( + + + + {cell(r.openingDebit)}{cell(r.openingCredit)}{cell(r.periodDebit)}{cell(r.periodCredit)}{cell(r.closingDebit)}{cell(r.closingCredit)} + + ))} + {tb.data?.data.length === 0 && } + + {tb.data && ( + + + {(['openingDebit', 'openingCredit', 'periodDebit', 'periodCredit', 'closingDebit', 'closingCredit'] as const).map((k) => + )} + + + )} +
الرمزالحسابالرصيد الافتتاحيحركة الفترةالرصيد الختامي
مديندائنمديندائنمديندائن
{r.code}{r.nameAr} {ACCOUNT_TYPE_AR[r.type]}
لا توجد حركات في هذه الفترة
الإجمالي{formatAmount(tb.data!.totals[k])}
+
+
+ ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/admin/Admin.tsx b/alshuyukh-accounting/apps/web/src/pages/admin/Admin.tsx new file mode 100644 index 000000000000..f8604414aad6 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/admin/Admin.tsx @@ -0,0 +1,41 @@ +import { NavLink, Navigate, Route, Routes } from 'react-router-dom'; +import { useAuth } from '../../auth'; +import { PageHeader } from '../../ui'; +import { Logs } from './Logs'; +import { Overview } from './Overview'; +import { Plans } from './Plans'; +import { Revenue, Subscriptions, Usage } from './Billing'; +import { TenantDetail, Tenants } from './Tenants'; +import { Flags, Users } from './Users'; + +const TABS: [string, string][] = [ + ['overview', 'نظرة عامة'], ['tenants', 'المنشآت'], ['users', 'المستخدمون'], ['subscriptions', 'الاشتراكات'], ['plans', 'الباقات'], + ['revenue', 'الإيرادات'], ['usage', 'الاستخدام'], ['logs', 'السجلات والأخطاء'], ['flags', 'خصائص التشغيل'], +]; + +/** Platform administration (the SaaS operator). Separate from tenant settings. */ +export default function Admin() { + const { me } = useAuth(); + if (!me?.user.isPlatformAdmin) return ; + return ( + <> + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/admin/Billing.tsx b/alshuyukh-accounting/apps/web/src/pages/admin/Billing.tsx new file mode 100644 index 000000000000..02cd99cf92ca --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/admin/Billing.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../../api'; +import { MonthlyBars } from '../../components/Charts'; +import { formatAmount } from '../../money'; +import { ErrorBox, formatDateTime, useLoad } from '../../ui'; +import { STATE_AR } from '../subscription/Subscription'; + +export function Subscriptions() { + const [state, setState] = useState(''); + const list = useLoad(() => api<{ data: { id: string; tenantId: string; tenantName: string; tenantStatus: string; planName: string; billingCycle: string; state: string; periodEnd: string; monthlyValue: string }[] }>( + 'GET', `/api/admin/subscriptions${state ? `?state=${state}` : ''}`), [state]); + return ( +
+
+ +
+ +
+ + {list.data?.data.map((s) => ( + + + + ))}{list.data?.data.length === 0 && } +
المنشأةالباقةالدورةالحالةنهاية الفترةالقيمة الشهرية
{s.tenantName}{s.planName}{s.billingCycle === 'YEARLY' ? 'سنوية' : 'شهرية'}{STATE_AR[s.state]?.[0] ?? s.state}{formatDateTime(s.periodEnd)}{formatAmount(s.monthlyValue)}
لا توجد اشتراكات
+
+ ); +} + +export function Revenue() { + const r = useLoad(() => api<{ monthly: { month: string; amount: string; payments: number }[]; byPlan: { planName: string; subscriptions: number; mrr: string }[]; payments: { id: string; tenantId: string; tenantName: string; amount: string; currency: string; reference: string | null; createdAt: string }[] }>('GET', '/api/admin/revenue')); + const d = r.data; + // Fill the last 12 months so the chart has a continuous axis. + const months = Array.from({ length: 12 }, (_, i) => { const x = new Date(); x.setUTCDate(1); x.setUTCMonth(x.getUTCMonth() - 11 + i); return x.toISOString().slice(0, 7); }); + const series = months.map((m) => ({ month: m, amount: d?.monthly.find((x) => x.month === m)?.amount ?? '0' })); + return ( + <> + +

الإيراد من الدفعات المسجلة يدويًا (الدفع الإلكتروني غير مفعّل بعد).

+ {d && ( +
+

المحصّل شهريًا

+
+

الإيراد الشهري المتكرر حسب الباقة

+ + {d.byPlan.map((p) => )} + {d.byPlan.length === 0 && } +
الباقةاشتراكاتMRR
{p.planName}{p.subscriptions}{formatAmount(p.mrr)}
لا توجد اشتراكات مدفوعة نشطة
+
+
+ )} + {d && ( +
+

آخر الدفعات

+ + {d.payments.map((p) => + )} +
التاريخالمنشأةالمبلغالمرجع
{formatDateTime(p.createdAt)}{p.tenantName}{formatAmount(p.amount)} {p.currency}{p.reference}
+
+ )} + + ); +} + +export function Usage() { + const u = useLoad(() => api<{ data: Record[] }>('GET', '/api/admin/usage')); + const cell = (used: number, max: number | null) => { + const high = max !== null && used >= max * 0.9; + return {used}{max === null ? '' : ` / ${max}`}; + }; + return ( +
+ +
+ + {u.data?.data.map((r) => ( + + {cell(r.users, r.maxUsers)}{cell(r.companies, r.maxCompanies)}{cell(r.products, r.maxProducts)}{cell(r.invoices, r.maxInvoices)}{cell(r.apiCalls, r.maxApiCalls)} + ))} +
المنشأةالباقةمستخدمونشركاتمنتجاتفواتير الشهرطلبات API الشهر
{r.tenantName}{r.planName ?? '—'}
+

يتجاوز 90% من الحد يظهر بالأحمر. طلبات API تُكتب كل 15 ثانية.

+
+ ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/admin/Logs.tsx b/alshuyukh-accounting/apps/web/src/pages/admin/Logs.tsx new file mode 100644 index 000000000000..677b1afa0e99 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/admin/Logs.tsx @@ -0,0 +1,71 @@ +import { Fragment, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../../api'; +import { ErrorBox, formatDateTime, useLoad } from '../../ui'; + +export function Logs() { + const [tab, setTab] = useState<'platform' | 'audit' | 'errors'>('platform'); + return ( + <> + + {tab === 'platform' && } + {tab === 'audit' && } + {tab === 'errors' && } + + ); +} + +function PlatformLogs() { + const l = useLoad(() => api<{ data: { id: string; adminEmail: string; action: string; entityType: string; entityId: string | null; tenantId: string | null; tenantName: string | null; newValues: unknown; createdAt: string }[] }>('GET', '/api/admin/platform-logs')); + return ( +
+
+ + {l.data?.data.map((x) => ( + + + + ))} +
الوقتالمديرالإجراءالهدفالتفاصيل
{formatDateTime(x.createdAt)}{x.adminEmail}{x.action}{x.entityType}{x.tenantId && <> — {x.tenantName}}{x.newValues ? JSON.stringify(x.newValues).slice(0, 160) : ''}
+
+ ); +} + +function TenantAudit() { + const l = useLoad(() => api<{ data: { id: string; tenantId: string; tenantName: string; userEmail: string | null; action: string; entityType: string; ipAddress: string | null; createdAt: string }[] }>('GET', '/api/admin/audit-logs?limit=200')); + return ( +
+
+ + {l.data?.data.map((x) => ( + + + ))} +
الوقتالمنشأةالمستخدمالإجراءالكيانIP
{formatDateTime(x.createdAt)}{x.tenantName}{x.userEmail ?? '—'}{x.action}{x.entityType}{x.ipAddress}
+
+ ); +} + +function Errors() { + const l = useLoad(() => api<{ data: { id: string; tenantName: string | null; requestId: string | null; method: string | null; path: string | null; errorCode: string | null; message: string; stack: string | null; createdAt: string }[] }>('GET', '/api/admin/errors')); + const [open, setOpen] = useState(null); + return ( +
+
+ + {l.data?.data.map((x) => ( + + setOpen(open === x.id ? null : x.id)} className="clickable"> + + + + {open === x.id && x.stack && } + + ))}{l.data?.data.length === 0 && } +
الوقتالطلبالمنشأةالرسالة
{formatDateTime(x.createdAt)}{x.method} {x.path}{x.tenantName ?? '—'}{x.errorCode ? `[${x.errorCode}] ` : ''}{x.message}
{x.stack}
request {x.requestId}
لا توجد أخطاء مسجلة
+
+ ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/admin/Overview.tsx b/alshuyukh-accounting/apps/web/src/pages/admin/Overview.tsx new file mode 100644 index 000000000000..81c217f2be70 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/admin/Overview.tsx @@ -0,0 +1,47 @@ +import { api } from '../../api'; +import { formatAmount } from '../../money'; +import { ErrorBox, useLoad } from '../../ui'; +import { STATE_AR } from '../subscription/Subscription'; + +interface Overview { tenants: number; suspended: number; users: number; newTenants30d: number; errors24h: number; revenueThisMonth: string; mrr: string; subscriptions: Record } +interface Health { + status: string; version: string; node: string; uptimeSeconds: number; memoryMb: { rss: number; heapUsed: number }; + database: { latencyMs: number; postgres: string; sizeMb: number; migrations: number; lastMigration: string; pool: { total: number; idle: number; waiting: number } }; + zatca: { pending: number; overdue: number; worker: string }; errors24h: number; +} + +export function Overview() { + const o = useLoad(() => api('GET', '/api/admin/overview')); + const h = useLoad(() => api('GET', '/api/admin/health')); + const tiles: [string, string | number, string?][] = o.data ? [ + ['المنشآت', o.data.tenants], ['موقوفة', o.data.suspended, o.data.suspended ? 'negative' : ''], ['المستخدمون', o.data.users], + ['منشآت جديدة (30 يومًا)', o.data.newTenants30d], ['الإيراد الشهري المتكرر (MRR)', `${formatAmount(o.data.mrr)} ر.س`], + ['المحصّل هذا الشهر', `${formatAmount(o.data.revenueThisMonth)} ر.س`], ['أخطاء آخر 24 ساعة', o.data.errors24h, o.data.errors24h ? 'negative' : ''], + ] : []; + return ( + <> + +
{tiles.map(([l, v, c]) =>
{l}{v}
)}
+ {o.data && ( +
+

الاشتراكات حسب الحالة

+
{Object.entries(o.data.subscriptions).map(([k, n]) => {STATE_AR[k]?.[0] ?? k}: {n})}
+
+ )} + {h.data && ( +
+

صحة النظام

{h.data.status === 'ok' ? 'يعمل' : h.data.status}
+
+
الإصدار
{h.data.version} · Node {h.data.node}
+
مدة التشغيل
{Math.floor(h.data.uptimeSeconds / 3600)} ساعة {Math.floor((h.data.uptimeSeconds % 3600) / 60)} دقيقة
+
الذاكرة
{h.data.memoryMb.heapUsed} / {h.data.memoryMb.rss} MB
+
قاعدة البيانات
{h.data.database.postgres} · {h.data.database.latencyMs} ms · {h.data.database.sizeMb} MB
+
الاتصالات
{h.data.database.pool.total} / idle {h.data.database.pool.idle} / waiting {h.data.database.pool.waiting}
+
الترحيلات
{h.data.database.migrations} · {h.data.database.lastMigration}
+
الفوترة الإلكترونية
معلقة {h.data.zatca.pending} · متأخرة {h.data.zatca.overdue} · المرسل {h.data.zatca.worker === 'on' ? 'يعمل' : 'متوقف'}
+
+
+ )} + + ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/admin/Plans.tsx b/alshuyukh-accounting/apps/web/src/pages/admin/Plans.tsx new file mode 100644 index 000000000000..34d7ff0e19a8 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/admin/Plans.tsx @@ -0,0 +1,78 @@ +import { useState, type FormEvent } from 'react'; +import { api } from '../../api'; +import { formatAmount } from '../../money'; +import { ErrorBox, useLoad } from '../../ui'; +import { LIMIT_AR } from '../subscription/Subscription'; + +interface Plan { id: string; code: string; nameAr: string; description: string | null; currency: string; priceMonthly: string; priceYearly: string; trialDays: number; graceDays: number; isPublic: boolean; isActive: boolean; isDefault: boolean; sortOrder: number; subscribers: number; [k: string]: unknown } + +/** Plans, prices and limits are data: edited here, never in code. */ +export function Plans() { + const plans = useLoad(() => api<{ data: Plan[] }>('GET', '/api/admin/plans')); + const [editing, setEditing] = useState(null); + const [error, setError] = useState(null); + async function save(e: FormEvent) { + e.preventDefault(); + const f = new FormData(e.currentTarget); + const num = (k: string) => { const v = String(f.get(k) ?? '').trim(); return v === '' ? null : Number(v); }; + const body = { + code: String(f.get('code')), nameAr: String(f.get('nameAr')), description: String(f.get('description') || '') || null, + priceMonthly: String(f.get('priceMonthly')), priceYearly: String(f.get('priceYearly')), + trialDays: Number(f.get('trialDays')), graceDays: Number(f.get('graceDays')), sortOrder: Number(f.get('sortOrder')), + isPublic: f.get('isPublic') === 'on', isActive: f.get('isActive') === 'on', isDefault: f.get('isDefault') === 'on', + ...Object.fromEntries(Object.keys(LIMIT_AR).map((k) => [k, num(k)])), + }; + setError(null); + try { + if (editing === 'new') await api('POST', '/api/admin/plans', body); + else if (editing) await api('PATCH', `/api/admin/plans/${editing.id}`, body); + setEditing(null); plans.reload(); + } catch (err) { setError(err); } + } + const p = editing && editing !== 'new' ? editing : null; + return ( + <> + + {editing ? ( +
+

{p ? `تعديل ${p.nameAr}` : 'باقة جديدة'}

+ +
+ + + + + + + +
+ +

الحدود (فارغ = غير محدود)

+
{Object.entries(LIMIT_AR).map(([k, l]) => )}
+ + + +

تغيير السعر يطبق على الاشتراكات الجديدة وتغييرات الباقة؛ الاشتراكات الحالية تحتفظ بسعرها.

+
+ + ) :
} +
+ {plans.data?.data.map((x) => ( +
+

{x.nameAr}

{x.code}
+
{formatAmount(x.priceMonthly)} {x.currency} / شهر
+
+ {x.isDefault && افتراضية} + {!x.isPublic && مخفية} + {!x.isActive && موقوفة} + {x.trialDays > 0 && تجربة {x.trialDays} يومًا} + {x.subscribers} مشترك +
+
    {Object.entries(LIMIT_AR).map(([k, l]) =>
  • {l}: {x[k] == null ? 'غير محدود' : String(x[k])}
  • )}
+ +
+ ))} +
+ + ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/admin/Tenants.tsx b/alshuyukh-accounting/apps/web/src/pages/admin/Tenants.tsx new file mode 100644 index 000000000000..27ebdcfa8044 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/admin/Tenants.tsx @@ -0,0 +1,219 @@ +import { useState, type FormEvent } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { api } from '../../api'; +import { formatAmount } from '../../money'; +import { ErrorBox, formatDateTime, useLoad } from '../../ui'; +import { EVENT_AR, LIMIT_AR, STATE_AR, UsageRow } from '../subscription/Subscription'; + +interface TenantRow { id: string; name: string; slug: string; status: string; createdAt: string; ownerEmail: string | null; planName: string | null; subscriptionState: string; periodEnd: string | null; users: number; companies: number } +interface PlanOpt { id: string; nameAr: string; isActive: boolean; priceMonthly: string } +const TENANT_STATUS: Record = { ACTIVE: ['نشطة', 'status-posted'], SUSPENDED: ['موقوفة', 'status-reversed'], CANCELLED: ['ملغاة', 'status-reversed'] }; + +export function Tenants() { + const [search, setSearch] = useState(''); + const [status, setStatus] = useState(''); + const [creating, setCreating] = useState(false); + const list = useLoad(() => api<{ data: TenantRow[]; total: number }>('GET', `/api/admin/tenants?limit=100${search ? `&search=${encodeURIComponent(search)}` : ''}${status ? `&status=${status}` : ''}`), [search, status]); + return ( +
+
+ { if (e.key === 'Enter') setSearch(e.currentTarget.value); }} /> + + +
+ {creating && { setCreating(false); list.reload(); }} />} + +
+ + + + {list.data?.data.map((t) => { + const [sl, sc] = STATE_AR[t.subscriptionState] ?? [t.subscriptionState, '']; + const [tl, tc] = TENANT_STATUS[t.status] ?? [t.status, '']; + return ( + + + + + + + ); + })} + {list.data?.data.length === 0 && } + +
المنشأةالمالكالباقةالاشتراكنهاية الفترةمستخدمونالحالة
{t.name}
{t.slug}
{t.ownerEmail}{t.planName ?? '—'}{sl}{formatDateTime(t.periodEnd)}{t.users}{tl}
لا توجد منشآت
+
+ {list.data &&

{list.data.total} منشأة

} +
+ ); +} + +function NewTenant({ onDone }: { onDone: () => void }) { + const plans = useLoad(() => api<{ data: PlanOpt[] }>('GET', '/api/admin/plans')); + const [error, setError] = useState(null); + const [created, setCreated] = useState<{ ownerEmail: string; temporaryPassword: string; tenantId: string } | null>(null); + async function submit(e: FormEvent) { + e.preventDefault(); + const f = Object.fromEntries(new FormData(e.currentTarget)) as Record; + setError(null); + try { setCreated(await api('POST', '/api/admin/tenants', { ...f, planId: f.planId || null })); } catch (err) { setError(err); } + } + if (created) { + return ( +
+ أُنشئت المنشأة. كلمة المرور المؤقتة للمالك {created.ownerEmail}: {created.temporaryPassword} +
تظهر مرة واحدة فقط، وسيُطلب منه تغييرها عند أول دخول.
+
فتح المنشأة
+
+ ); + } + return ( +
+ +
+ + + + + +
+
+ + ); +} + +interface Detail { + id: string; name: string; slug: string; status: string; createdAt: string; + subscription: { planId: string | null; planName: string | null; state: string; billingCycle: string | null; periodEnd: string | null; limits: Record; limitOverrides: Record; items: { description: string; unitPrice: string; currency: string }[] }; + usage: Record; + billingEvents: { id: string; eventType: string; amount: string | null; currency: string | null; reference: string | null; details: Record; createdAt: string; byEmail: string | null }[]; + members: { id: string; email: string; fullName: string; userStatus: string; memberStatus: string; isOwner: boolean; lastLoginAt: string | null }[]; + companies: { id: string; name: string; vatNumber: string | null }[]; + features: Record; featureOverrides: { key: string; enabled: boolean }[]; +} + +export function TenantDetail() { + const { id } = useParams(); + const d = useLoad(() => api('GET', `/api/admin/tenants/${id}`), [id]); + const plans = useLoad(() => api<{ data: PlanOpt[] }>('GET', '/api/admin/plans')); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + async function act(path: string, body: unknown) { + setError(null); setBusy(true); + try { await api(path.startsWith('PUT ') ? 'PUT' : 'POST', `/api/admin/tenants/${id}/${path.replace('PUT ', '')}`, body); d.reload(); } catch (e) { setError(e); } finally { setBusy(false); } + } + const form = (fn: (f: Record) => void) => (e: FormEvent) => { + e.preventDefault(); + fn(Object.fromEntries(new FormData(e.currentTarget)) as Record); + e.currentTarget.reset(); + }; + const t = d.data; + if (!t) return ; + const [sl, sc] = STATE_AR[t.subscription.state] ?? [t.subscription.state, '']; + return ( + <> + +
+
+

{t.name}

{t.slug} · {t.id}

+ {TENANT_STATUS[t.status]?.[0] ?? t.status} +
+
act(t.status === 'ACTIVE' ? 'suspend' : 'activate', { reason: f.reason }))}> + + +
+
+
+
+

الاشتراك: {t.subscription.planName ?? '—'}

{sl}
+
+
نهاية الفترة
{formatDateTime(t.subscription.periodEnd)}
+
الدورة
{t.subscription.billingCycle === 'YEARLY' ? 'سنوية' : 'شهرية'}
+ {t.subscription.items.map((i) =>
{i.description}
{formatAmount(i.unitPrice)} {i.currency}
)} +
+
act('subscription/plan', { planId: f.planId, billingCycle: f.billingCycle }))}> + + + +
+
act('subscription/payment', { amount: f.amount, reference: f.reference, cycles: Number(f.cycles || 1) }))}> + + + + +
+
act('subscription/extend', { days: Number(f.days), reason: f.reason }))}> + + + +
+
+
+

الاستخدام والحدود

+ {Object.keys(LIMIT_AR).filter((k) => k !== 'max_storage_mb').map((k) => )} + act('subscription/limits', { overrides: o })} busy={busy} /> +
+
+
+

خصائص التشغيل

+
+ + {Object.entries(t.features).map(([k, v]) => { + const o = t.featureOverrides.find((x) => x.key === k); + return ( + + + ); + })} +
الخاصيةالفعّالتخصيص المنشأة
{k}{v ? 'مفعّلة' : 'معطّلة'}
+
+
+

المستخدمون

+ + {t.members.map((m) => )} +
البريدالاسمالحالةآخر دخول
{m.email}{m.isOwner && مالك}{m.fullName}{m.userStatus === 'ACTIVE' && m.memberStatus === 'ACTIVE' ? 'نشط' : 'معطل'}{formatDateTime(m.lastLoginAt)}
+
+
+

سجل الفوترة

+ + {t.billingEvents.map((e) => + )} +
التاريخالحدثالمبلغالمرجعبواسطة
{formatDateTime(e.createdAt)}{EVENT_AR[e.eventType] ?? e.eventType}{typeof e.details?.reason === 'string' ? ` — ${e.details.reason}` : ''}{e.amount ? `${formatAmount(e.amount)} ${e.currency}` : ''}{e.reference ?? ''}{e.byEmail ?? ''}
+
+ + ); +} + +function LimitsForm({ overrides, onSave, busy }: { overrides: Record; onSave: (o: Record) => void; busy: boolean }) { + function submit(e: FormEvent) { + e.preventDefault(); + const f = new FormData(e.currentTarget); + const out: Record = {}; + for (const k of Object.keys(LIMIT_AR)) { + const v = String(f.get(k) ?? '').trim(); + if (v === '') continue; // follow the plan + out[k] = v === '∞' ? null : Number(v); // explicit unlimited or a number + } + onSave(out); + } + return ( +
+ تخصيص الحدود لهذه المنشأة +
+

اترك الحقل فارغًا لاتباع الباقة، أو اكتب ∞ لغير محدود.

+
+ {Object.entries(LIMIT_AR).map(([k, l]) => ( + + ))} +
+ +
+
+ ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/admin/Users.tsx b/alshuyukh-accounting/apps/web/src/pages/admin/Users.tsx new file mode 100644 index 000000000000..99e754f41ff6 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/admin/Users.tsx @@ -0,0 +1,78 @@ +import { useState, type FormEvent } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../../api'; +import { useAuth } from '../../auth'; +import { ErrorBox, formatDateTime, useLoad } from '../../ui'; + +interface U { id: string; email: string; fullName: string; status: string; isPlatformAdmin: boolean; lockedUntil: string | null; failedLogins: number; lastLoginAt: string | null; tenants: { tenantId: string; name: string; isOwner: boolean }[] } + +export function Users() { + const { me } = useAuth(); + const [search, setSearch] = useState(''); + const list = useLoad(() => api<{ data: U[]; total: number }>('GET', `/api/admin/users?limit=100${search ? `&search=${encodeURIComponent(search)}` : ''}`), [search]); + const [error, setError] = useState(null); + async function act(id: string, path: string, body: unknown = {}) { + setError(null); + try { await api('POST', `/api/admin/users/${id}/${path}`, body); list.reload(); } catch (e) { setError(e); } + } + return ( +
+
{ if (e.key === 'Enter') setSearch(e.currentTarget.value); }} />
+ +
+ + {list.data?.data.map((u) => { + const self = u.id === me?.user.id; + const locked = u.lockedUntil && new Date(u.lockedUntil) > new Date(); + return ( + + + + + + + + ); + })} +
المستخدمالمنشآتآخر دخولالحالة
{u.email}
{u.fullName}{u.isPlatformAdmin && مدير منصة}
{u.tenants.map((t) =>
{t.name}{t.isOwner ? ' (مالك)' : ''}
)}
{formatDateTime(u.lastLoginAt)}{u.status === 'ACTIVE' ? 'نشط' : معطل}{locked &&
مقفل مؤقتًا
}
+ {!self && } + {locked && } + {!self && } +
+
+ ); +} + +export function Flags() { + const list = useLoad(() => api<{ data: { key: string; nameAr: string; description: string | null; enabled: boolean; overrides: number }[] }>('GET', '/api/admin/feature-flags')); + const [error, setError] = useState(null); + async function toggle(key: string, enabled: boolean) { + setError(null); + try { await api('PATCH', `/api/admin/feature-flags/${key}`, { enabled }); list.reload(); } catch (e) { setError(e); } + } + async function add(e: FormEvent) { + e.preventDefault(); + const form = e.currentTarget; + const f = Object.fromEntries(new FormData(form)) as Record; + setError(null); + try { await api('POST', '/api/admin/feature-flags', { key: f.key, nameAr: f.nameAr, description: f.description || null, enabled: false }); form.reset(); list.reload(); } catch (err) { setError(err); } + } + return ( +
+ +

القيمة العامة تطبق على كل المنشآت إلا من خُصّصت له قيمة من صفحة المنشأة.

+ + {list.data?.data.map((f) => ( + + + ))} +
المفتاحالاسمتخصيصاتعام
{f.key}{f.nameAr}
{f.description}
{f.overrides}
+
+ + + + +
+
+ ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/documents/DocumentDetail.tsx b/alshuyukh-accounting/apps/web/src/pages/documents/DocumentDetail.tsx new file mode 100644 index 000000000000..cb4c892a83c2 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/documents/DocumentDetail.tsx @@ -0,0 +1,208 @@ +import { useState, type FormEvent } from 'react'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import { api } from '../../api'; +import { useAuth } from '../../auth'; +import { formatAmount } from '../../money'; +import { ErrorBox, useLoad } from '../../ui'; +import { DOCS, STATUS_AR, statusClass, type DocConfig, type Doc } from './config'; +import DocumentForm from './DocumentForm'; +import Totals from './Totals'; +import EInvoicePanel from './EInvoicePanel'; + +const today = () => new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Riyadh' }).format(new Date()); + +interface Method { id: string; code: string; nameAr: string; isActive: boolean } + +export default function DocumentDetail({ config }: { config: DocConfig }) { + const { id } = useParams(); + const { can } = useAuth(); + const navigate = useNavigate(); + const doc = useLoad(() => api('GET', `/api/${config.api}/${id}`), [id, config.api]); + const [mode, setMode] = useState<'view' | 'edit' | 'return' | 'pay' | 'cancel'>('view'); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function run(fn: () => Promise) { + setError(null); + setBusy(true); + try { await fn(); setMode('view'); doc.reload(); } catch (e) { setError(e); } finally { setBusy(false); } + } + + const d = doc.data; + if (!d) return ; + if (mode === 'edit') return { setMode('view'); doc.reload(); }} />; + + const isInvoice = config.key === 'SALES_INVOICE' || config.key === 'PURCHASE_INVOICE'; + const open = !['DRAFT', 'CANCELLED'].includes(d.status) && Number(d.remainingAmount ?? 0) > 0; + const returnConfig = config.key === 'SALES_INVOICE' ? DOCS.SALES_RETURN : DOCS.PURCHASE_RETURN; + const invoiceConfig = config.party === 'customer' ? DOCS.SALES_INVOICE : DOCS.PURCHASE_INVOICE; + const canPay = can('payment.create') && open && (isInvoice || config.isReturn); + + return ( +
+ +
+
+

{d.number ?? 'مسودة'}

+

{config.singular} — {d.partyName}

+
+ {STATUS_AR[d.status] ?? d.status} +
+ {(config.key === 'SALES_INVOICE' || config.key === 'SALES_RETURN') && d.status !== 'DRAFT' && ( + + )} +
+
التاريخ
{d.date}
+ {d.dueDate &&
الاستحقاق
{d.dueDate}
} + {d.invoiceKind && d.status !== 'DRAFT' &&
نوع الفاتورة الضريبية
{d.invoiceKind === 'STANDARD' ? 'فاتورة ضريبية' : 'فاتورة ضريبية مبسطة'}
} + {d.supplierInvoiceNumber &&
رقم فاتورة المورد
{d.supplierInvoiceNumber}
} + {d.originalInvoiceId &&
الفاتورة الأصلية
{d.originalInvoiceNumber}
} + {d.reason &&
سبب الإرجاع
{d.reason}
} + {d.journalEntryId &&
القيد
عرض القيد المحاسبي
} + {d.convertedInvoiceId &&
الفاتورة
عرض الفاتورة
} + {d.cancelReason &&
سبب الإلغاء
{d.cancelReason}
} +
الأسعار
{d.pricesIncludeVat ? 'شاملة الضريبة' : 'غير شاملة الضريبة'}
+
+ +
+ + + + {d.lines.map((l) => ( + + + + + + + + + + + ))} + +
#الصنفالكميةالسعرالخصمالصافيالضريبةالإجمالي
{l.lineNo}{l.sku && <>{l.sku}{' — '}}{l.description ?? l.productName}{l.quantity.replace(/\.?0+$/, '')} {l.unitCode}{l.unitPrice.replace(/(\.\d\d)00$/, '$1')}{l.discountAmount === '0.00' ? '' : formatAmount(l.discountAmount)}{formatAmount(l.netAmount)}{formatAmount(l.vatAmount)} {l.vatCategory === 'S' ? `${(Number(l.vatRate) * 100).toFixed(0)}%` : l.vatCategory}{formatAmount(l.totalAmount)}
+
+
+
{d.notes &&

ملاحظات: {d.notes}

}
+ +
+ + {mode === 'view' && ( +
+ {d.status === 'DRAFT' && can(config.perm.create) && !config.isReturn && } + {d.status === 'DRAFT' && config.legal && can(config.perm.issue) && ( + + )} + {config.key === 'SALES_QUOTE' && d.status === 'DRAFT' && } + {config.key === 'SALES_QUOTE' && d.status === 'SENT' && ( + <> + + + + )} + {config.key === 'PURCHASE_ORDER' && d.status === 'DRAFT' && can(config.perm.issue) && } + {((config.key === 'SALES_QUOTE' && ['DRAFT', 'SENT', 'ACCEPTED'].includes(d.status)) || (config.key === 'PURCHASE_ORDER' && d.status === 'APPROVED')) && can(config.perm.create) && ( + + )} + {canPay && } + {isInvoice && ['ISSUED', 'POSTED', 'PARTIALLY_PAID', 'PAID'].includes(d.status) && can(config.perm.create) && } + {d.status === 'DRAFT' && can(config.perm.create) && ( + + )} + {!['DRAFT', 'CANCELLED', 'CONVERTED', 'REJECTED'].includes(d.status) && can(config.perm.cancel) && } +
+ )} + + {mode === 'cancel' && ( +
{ e.preventDefault(); const reason = String(new FormData(e.currentTarget).get('reason')); void run(() => api('POST', `/api/${config.api}/${d.id}/cancel`, { reason })); }}> +

{config.legal ? 'يُعكس القيد المحاسبي بتاريخ المستند ويبقى الرقم محفوظًا. لا يمكن إلغاء مستند عليه دفعات أو مرتجعات.' : 'سيُلغى المستند.'}

+ +
+
+ )} + + {mode === 'pay' && setMode('view')} />} + + {mode === 'return' && ( +
) => { + e.preventDefault(); + const f = new FormData(e.currentTarget); + const lines = d.lines.map((l) => ({ sourceItemId: l.id, quantity: String(f.get(`q-${l.id}`) ?? '').trim() })).filter((l) => l.quantity && Number(l.quantity) > 0); + void run(async () => { + const r = await api<{ id: string }>('POST', `/api/${returnConfig.api}`, { originalInvoiceId: d.id, docDate: String(f.get('date')), reason: String(f.get('reason')), lines }); + navigate(`../../returns/${r.id}`, { relative: 'path' }); + }); + }}> +

{returnConfig.singular} على {d.number}

+ + + {d.lines.map((l) => ( + + + ))} +
الصنفالكمية في الفاتورةالكمية المرتجعة
{l.description ?? l.productName}{l.quantity.replace(/\.?0+$/, '')}
+
+ + +
+
+
+ )} + + {(d.allocations?.length || d.returns?.length) ? ( +
+ {d.allocations && d.allocations.length > 0 && ( +

الدفعات

{d.allocations.map((a) => ( + + ))}
{a.paymentNumber}{a.paymentDate}{formatAmount(a.amount)}
+ )} + {d.returns && d.returns.length > 0 && ( +

المرتجعات

{d.returns.map((r) => ( + + + ))}
{r.number ?? 'مسودة'}{r.date}{formatAmount(r.total)}{STATUS_AR[r.status]}
+ )} +
+ ) : null} +
+ ); +} + +function PaymentBox({ config, doc, busy, run, onCancel }: { + config: DocConfig; doc: Doc; invoiceConfig: DocConfig; busy: boolean; run: (fn: () => Promise) => Promise; onCancel: () => void; +}) { + const methods = useLoad(() => api<{ data: Method[] }>('GET', '/api/payment-methods')); + const direction = (config.party === 'customer') !== config.isReturn ? 'RECEIPT' : 'DISBURSEMENT'; + return ( +
{ + e.preventDefault(); + const f = new FormData(e.currentTarget); + const amount = String(f.get('amount')); + void run(() => api('POST', '/api/payments', { + direction, [config.party === 'customer' ? 'customerId' : 'supplierId']: doc.partyId, + paymentDate: String(f.get('date')), methodId: String(f.get('methodId')), amount, reference: f.get('reference') || null, + allocations: [{ documentType: config.key, documentId: doc.id, amount }], + })); + }}> +

{direction === 'RECEIPT' ? 'سند قبض' : 'سند صرف'} لـ {doc.number}

+
+ + + + +
+
+
+ ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/documents/DocumentForm.tsx b/alshuyukh-accounting/apps/web/src/pages/documents/DocumentForm.tsx new file mode 100644 index 000000000000..787dee6a47b3 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/documents/DocumentForm.tsx @@ -0,0 +1,189 @@ +import { useEffect, useRef, useState, type FormEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { api } from '../../api'; +import { formatAmount } from '../../money'; +import { ErrorBox, useLoad } from '../../ui'; +import type { Account } from '../accounting/ChartOfAccounts'; +import type { DocConfig, Doc } from './config'; +import Totals, { type TotalsData } from './Totals'; +import { loadWarehouses } from '../inventory/shared'; + +interface Product { id: string; sku: string; nameAr: string; salePrice: string; purchasePrice: string; productType: string; vatCategory: string } +interface Party { id: string; code: string; nameAr: string } +interface Line { productId: string; accountId: string; description: string; quantity: string; unitPrice: string; discountPercent: string; vatCategory: string } + +const empty = (): Line => ({ productId: '', accountId: '', description: '', quantity: '1', unitPrice: '', discountPercent: '', vatCategory: 'S' }); +const today = () => new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Riyadh' }).format(new Date()); + +/** Builds the API payload from the form lines; blank optional fields are left out so the server fills defaults. */ +function payloadLines(lines: Line[]) { + return lines.filter((l) => l.productId || l.accountId).map((l) => ({ + ...(l.productId ? { productId: l.productId } : { accountId: l.accountId, vatCategory: l.vatCategory }), + quantity: l.quantity.trim(), + ...(l.unitPrice.trim() ? { unitPrice: l.unitPrice.trim() } : {}), + ...(l.discountPercent.trim() ? { discountPercent: l.discountPercent.trim() } : {}), + ...(l.description.trim() ? { description: l.description.trim() } : {}), + })); +} + +export default function DocumentForm({ config, doc, onSaved }: { config: DocConfig; doc?: Doc; onSaved?: () => void }) { + const navigate = useNavigate(); + const isPurchase = config.party === 'supplier'; + const parties = useLoad(() => api<{ data: Party[] }>('GET', `/api/${config.party}s?limit=200`)); + const products = useLoad(() => api<{ data: Product[] }>('GET', '/api/products?limit=200')); + const accounts = useLoad(() => (isPurchase ? api<{ data: Account[] }>('GET', '/api/accounts?postableOnly=true') : Promise.resolve({ data: [] as Account[] }))); + const warehouses = useLoad(loadWarehouses); + const [partyId, setPartyId] = useState(doc?.partyId ?? ''); + const [docDate, setDocDate] = useState(doc?.date ?? today()); + const [includeVat, setIncludeVat] = useState(doc?.pricesIncludeVat ?? false); + const [lines, setLines] = useState(() => doc?.lines.length + ? doc.lines.map((l) => ({ + productId: l.productId ?? '', accountId: l.productId ? '' : l.accountId ?? '', description: l.description ?? '', + quantity: l.quantity, unitPrice: l.unitPrice, discountPercent: '', vatCategory: l.vatCategory, + })) + : [empty()]); + // Existing discounts are kept as amounts when editing. + const [discountAmounts] = useState(() => doc?.lines.map((l) => l.discountBasis) ?? []); + const [preview, setPreview] = useState<{ totals: TotalsData; lines: { netAmount: string; vatAmount: string; totalAmount: string }[] } | null>(null); + const [previewError, setPreviewError] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const timer = useRef>(undefined); + + const update = (i: number, patch: Partial) => setLines((ls) => ls.map((l, j) => (j === i ? { ...l, ...patch } : l))); + const withDiscounts = (ls: ReturnType) => + ls.map((l, i) => (!('discountPercent' in l) && discountAmounts[i] && discountAmounts[i] !== '0.00' ? { ...l, discountAmount: discountAmounts[i] } : l)); + + // Totals always come from the server (POST …/calculate), debounced while typing. + useEffect(() => { + clearTimeout(timer.current); + const body = withDiscounts(payloadLines(lines)); + if (!body.length) { setPreview(null); return; } + timer.current = setTimeout(() => { + api<{ totals: TotalsData; lines: { netAmount: string; vatAmount: string; totalAmount: string }[] }>( + 'POST', `/api/${config.api}/calculate`, { docDate, pricesIncludeVat: includeVat, lines: body }) + .then((r) => { setPreview(r); setPreviewError(null); }) + .catch((e) => { setPreview(null); setPreviewError(e); }); + }, 350); + return () => clearTimeout(timer.current); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lines, docDate, includeVat, config.api]); + + async function submit(e: FormEvent) { + e.preventDefault(); + const f = new FormData(e.currentTarget); + const opt = (k: string) => (f.get(k) ? String(f.get(k)) : null); + const body: Record = { + partyId, docDate, pricesIncludeVat: includeVat, notes: opt('notes'), lines: withDiscounts(payloadLines(lines)), + }; + if (config.key === 'SALES_INVOICE' || config.key === 'PURCHASE_INVOICE') body.dueDate = opt('dueDate'); + if (config.key === 'SALES_QUOTE') body.validUntil = opt('validUntil'); + if (config.key === 'PURCHASE_ORDER') body.expectedDate = opt('expectedDate'); + if (config.key === 'PURCHASE_INVOICE') body.supplierInvoiceNumber = opt('supplierInvoiceNumber'); + if (opt('warehouseId')) body.warehouseId = opt('warehouseId'); + setBusy(true); + setError(null); + try { + if (doc) { + await api('PATCH', `/api/${config.api}/${doc.id}`, body); + onSaved?.(); + } else { + const created = await api<{ id: string }>('POST', `/api/${config.api}`, body); + navigate(`../${created.id}`, { relative: 'path' }); + } + } catch (err) { setError(err); } finally { setBusy(false); } + } + + const productById = new Map(products.data?.data.map((p) => [p.id, p])); + return ( +
+

{doc ? `تعديل ${config.singular}` : config.newLabel}

+ +
+ + + {(config.key === 'SALES_INVOICE' || config.key === 'PURCHASE_INVOICE') && ( + + )} + {config.key === 'SALES_QUOTE' && } + {config.key === 'PURCHASE_ORDER' && } + {(warehouses.data?.length ?? 0) > 1 && ( + + )} + {config.key === 'PURCHASE_INVOICE' && } +
+ + +
+ + + + + + {lines.map((l, i) => { + const p = productById.get(l.productId); + const pl = preview?.lines[payloadLines(lines.slice(0, i + 1)).length - 1]; + const hasTarget = !!(l.productId || l.accountId); + return ( + + + + + + + + + + ); + })} + +
{isPurchase ? 'الصنف أو الحساب' : 'الصنف'}الكميةسعر الوحدةخصم %الصافيالضريبة
+ + {isPurchase && !l.productId && ( +
+ + {l.accountId && ( + + )} +
+ )} + {(l.accountId || p) && update(i, { description: e.target.value })} />} +
update(i, { quantity: e.target.value })} /> update(i, { unitPrice: e.target.value })} /> update(i, { discountPercent: e.target.value })} />{hasTarget && pl ? formatAmount(pl.netAmount) : ''}{hasTarget && pl ? formatAmount(pl.vatAmount) : ''}{lines.length > 1 && }
+
+ + +
+ +
+ {preview && } + {previewError ? : null} +

تُحسب المبالغ والضريبة في الخادم.

+
+
+
+ + {doc && } +
+ + ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/documents/DocumentList.tsx b/alshuyukh-accounting/apps/web/src/pages/documents/DocumentList.tsx new file mode 100644 index 000000000000..566e7182e310 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/documents/DocumentList.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../../api'; +import { useAuth } from '../../auth'; +import { formatAmount } from '../../money'; +import { ErrorBox, useLoad } from '../../ui'; +import { STATUS_AR, statusClass, type DocConfig, type Doc } from './config'; + +const PAGE = 50; + +export default function DocumentList({ config }: { config: DocConfig }) { + const { can } = useAuth(); + const [status, setStatus] = useState(''); + const [search, setSearch] = useState(''); + const [offset, setOffset] = useState(0); + const list = useLoad(() => { + const q = new URLSearchParams({ limit: String(PAGE), offset: String(offset) }); + if (status === 'OPEN') q.set('open', 'true'); + else if (status) q.set('status', status); + if (search) q.set('search', search); + return api<{ data: Doc[]; total: number }>('GET', `/api/${config.api}?${q}`); + }, [config.api, status, search, offset]); + const statuses = Object.keys(STATUS_AR).filter((s) => s !== 'VOIDED'); + + return ( +
+
+ { if (e.key === 'Enter') { setSearch(e.currentTarget.value); setOffset(0); } }} /> + + {can(config.perm.create) && !config.isReturn && {config.newLabel}} +
+ +
+ + + + + {config.legal && } + + + + {!list.data && !list.error && } + {list.data?.data.map((d) => ( + + + + + + {config.legal && } + + + ))} + {list.data?.data.length === 0 && } + +
الرقمالتاريخ{config.party === 'customer' ? 'العميل' : 'المورد'}الإجماليالمتبقيالحالة
جارٍ التحميل…
{d.number ?? 'مسودة'}{d.date}{d.partyName}{formatAmount(d.total)}{d.status === 'DRAFT' || d.status === 'CANCELLED' ? '' : formatAmount(d.remainingAmount)}{STATUS_AR[d.status] ?? d.status}
لا توجد مستندات
+
+ {list.data && list.data.total > PAGE && ( +
+ {offset + 1}–{Math.min(offset + PAGE, list.data.total)} من {list.data.total} + + +
+ )} +
+ ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/documents/EInvoicePanel.tsx b/alshuyukh-accounting/apps/web/src/pages/documents/EInvoicePanel.tsx new file mode 100644 index 000000000000..2aaf785f1741 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/documents/EInvoicePanel.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../../api'; +import { useAuth } from '../../auth'; +import { ErrorBox, useLoad } from '../../ui'; +import { EINVOICE_STATUS } from '../einvoicing/EInvoicing'; + +export interface ZatcaDocument { + einvoice: { id: string; status: string; invoiceKind: string; icv: string; hasWarnings: boolean; lastError: string | null; reportingOverdue: boolean } | null; + qr: string | null; qrSvg: string | null; missing?: string; +} + +export const loadZatcaDocument = (type: string, id: string) => api('GET', `/api/zatca/document?type=${type}&id=${id}`); + +/** E-invoice status on a sales invoice or credit note, with the submit action and the print link. */ +export default function EInvoicePanel({ type, id, printPath }: { type: 'SALES_INVOICE' | 'SALES_RETURN'; id: string; printPath: string }) { + const { can } = useAuth(); + const z = useLoad(() => loadZatcaDocument(type, id), [type, id]); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const e = z.data?.einvoice; + async function submit() { + setError(null); setBusy(true); + try { await api('POST', `/api/zatca/invoices/${e!.id}/submit`); z.reload(); } catch (err) { setError(err); } finally { setBusy(false); } + } + if (!z.data) return ; + const [label, cls] = e ? EINVOICE_STATUS[e.status] ?? [e.status, ''] : ['', '']; + return ( +
+ + {e ? ( + <> + الفاتورة الإلكترونية: {label}{e.hasWarnings ? ' (تحذيرات)' : ''} + ICV {e.icv} + {e.reportingOverdue && تجاوزت مهلة الإبلاغ (24 ساعة)} + {e.status === 'PENDING' && can('invoice.post') && ( + + )} + {e.lastError &&
{e.lastError}
} + {e.invoiceKind === 'STANDARD' && e.status !== 'CLEARED' &&

الفاتورة الضريبية لا تُسلَّم للعميل قبل اعتمادها من الهيئة.

} + {can('zatca.view') && سجل الفوترة الإلكترونية} + + ) : ( + {z.data.qr ? 'لا توجد وحدة فوترة مفعّلة؛ تُطبع الفاتورة برمز QR للمرحلة الأولى.' : z.data.missing ? 'أضف الرقم الضريبي للشركة لطباعة رمز QR.' : ''} + )} + طباعة +
+ ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/documents/Payments.tsx b/alshuyukh-accounting/apps/web/src/pages/documents/Payments.tsx new file mode 100644 index 000000000000..1c75ac09b2b3 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/documents/Payments.tsx @@ -0,0 +1,153 @@ +import { useState, type FormEvent } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../../api'; +import { useAuth } from '../../auth'; +import { formatAmount } from '../../money'; +import { ErrorBox, useLoad } from '../../ui'; +import { STATUS_AR, statusClass, type Doc } from './config'; + +interface Payment { + id: string; number: string; direction: string; date: string; partyName: string; methodName: string; amount: string; + allocatedAmount: string; unallocatedAmount: string; status: string; reference: string | null; journalEntryId: string; + allocations?: { id: string; amount: string; documentNumber: string; reversedAt: string | null }[]; +} +interface Party { id: string; code: string; nameAr: string } +interface Method { id: string; nameAr: string; isActive: boolean } + +const today = () => new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Riyadh' }).format(new Date()); + +/** Receipts from customers (sales) or payments to suppliers (purchases). */ +export default function Payments({ party }: { party: 'customer' | 'supplier' }) { + const { can } = useAuth(); + const direction = party === 'customer' ? 'RECEIPT' : 'DISBURSEMENT'; + const list = useLoad(() => api<{ data: Payment[]; total: number }>('GET', `/api/payments?direction=${direction}&limit=100`), [direction]); + const [creating, setCreating] = useState(false); + const [voiding, setVoiding] = useState(null); + const [error, setError] = useState(null); + + async function voidPayment(e: FormEvent, id: string) { + e.preventDefault(); + setError(null); + try { + await api('POST', `/api/payments/${id}/void`, { reason: String(new FormData(e.currentTarget).get('reason')) }); + setVoiding(null); + list.reload(); + } catch (err) { setError(err); } + } + + return ( + <> + + {creating && { setCreating(false); list.reload(); }} />} +
+
+ + {can('payment.create') && !creating && } +
+
+ + + + {!list.data && !list.error && } + {list.data?.data.map((p) => ( + + + + + + + + + ))} + {list.data?.data.length === 0 && } + +
الرقمالتاريخ{party === 'customer' ? 'العميل' : 'المورد'}الطريقةالمبلغغير مخصصالحالة
جارٍ التحميل…
{p.number}{p.date}{p.partyName}{p.methodName}{formatAmount(p.amount)}{p.status === 'VOIDED' ? '' : formatAmount(p.unallocatedAmount)}{p.status === 'POSTED' ? 'مرحّل' : STATUS_AR[p.status]} + {p.status === 'POSTED' && can('payment.void') && (voiding === p.id + ?
voidPayment(e, p.id)}>
+ : )} +
لا توجد سندات
+
+
+ + ); +} + +function NewPayment({ party, onDone }: { party: 'customer' | 'supplier'; onDone: () => void }) { + const { can } = useAuth(); + const parties = useLoad(() => api<{ data: Party[] }>('GET', `/api/${party}s?limit=200`)); + const methods = useLoad(() => api<{ data: Method[] }>('GET', '/api/payment-methods')); + const [partyId, setPartyId] = useState(''); + const docPath = party === 'customer' ? 'invoices' : 'purchase-invoices'; + // Supplier payments can also settle credit expenses. + const openDocs = useLoad(async () => { + if (!partyId) return { data: [] as (Doc & { documentType: string })[] }; + const docs = (await api<{ data: Doc[] }>('GET', `/api/${docPath}?partyId=${partyId}&open=true&limit=200`)).data + .map((d) => ({ ...d, documentType: party === 'customer' ? 'SALES_INVOICE' : 'PURCHASE_INVOICE' })); + if (party === 'supplier' && can('expense.view')) { + const exp = (await api<{ data: Doc[] }>('GET', `/api/expenses?supplierId=${partyId}&open=true&limit=200`)).data; + docs.push(...exp.map((d) => ({ ...d, documentType: 'EXPENSE' }))); + } + return { data: docs }; + }, [partyId]); + const [alloc, setAlloc] = useState>({}); + const [error, setError] = useState(null); + + async function submit(e: FormEvent) { + e.preventDefault(); + const f = new FormData(e.currentTarget); + setError(null); + try { + await api('POST', '/api/payments', { + direction: party === 'customer' ? 'RECEIPT' : 'DISBURSEMENT', + [party === 'customer' ? 'customerId' : 'supplierId']: partyId, + paymentDate: String(f.get('date')), methodId: String(f.get('methodId')), amount: String(f.get('amount')), + reference: f.get('reference') || null, + allocations: Object.entries(alloc).filter(([, v]) => v.trim() && Number(v) > 0).map(([documentId, amount]) => ({ + documentType: openDocs.data?.data.find((d) => d.id === documentId)?.documentType, documentId, amount: amount.trim(), + })), + }); + onDone(); + } catch (err) { setError(err); } + } + + return ( +
+

{party === 'customer' ? 'سند قبض جديد' : 'سند صرف جديد'}

+ +
+ + + + + +
+ {partyId && ( + <> +

تخصيص على الفواتير المفتوحة (اختياري — الباقي يبقى رصيدًا مقدمًا)

+ + + + {openDocs.data?.data.map((d) => ( + + + + + + ))} + {openDocs.data?.data.length === 0 && } + +
الفاتورةالتاريخالمتبقيالمبلغ المخصص
{d.number}{d.documentType === 'EXPENSE' && (مصروف)}{d.date}{formatAmount(d.remainingAmount)} setAlloc((a) => ({ ...a, [d.id]: e.target.value }))} />
لا توجد فواتير مفتوحة
+ + )} +
+ + ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/documents/PrintDocument.tsx b/alshuyukh-accounting/apps/web/src/pages/documents/PrintDocument.tsx new file mode 100644 index 000000000000..dbf5afc3e510 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/documents/PrintDocument.tsx @@ -0,0 +1,92 @@ +import { Link, useParams } from 'react-router-dom'; +import { api } from '../../api'; +import { formatAmount } from '../../money'; +import { ErrorBox, useLoad } from '../../ui'; +import type { Doc, DocConfig } from './config'; +import { loadZatcaDocument } from './EInvoicePanel'; + +interface Company { id: string; name: string; legalName: string | null; vatNumber: string | null; commercialRegistration: string | null; buildingNumber: string | null; street: string | null; district: string | null; city: string | null; postalCode: string | null; additionalNumber: string | null; phone: string | null } + +const joinAddress = (a: { buildingNumber?: string | null; street?: string | null; district?: string | null; city?: string | null; postalCode?: string | null; additionalNumber?: string | null } | null | undefined) => + a ? [a.buildingNumber && a.street ? `${a.buildingNumber} ${a.street}` : a.street, a.district, [a.city, a.postalCode].filter(Boolean).join(' '), a.additionalNumber ? `الرقم الإضافي ${a.additionalNumber}` : null].filter(Boolean).join('، ') : ''; + +/** Printable tax invoice / simplified tax invoice / credit note with its QR code. */ +export default function PrintDocument({ config }: { config: DocConfig }) { + const { id } = useParams(); + const type = config.key === 'SALES_RETURN' ? 'SALES_RETURN' : 'SALES_INVOICE'; + const data = useLoad(async () => { + const [doc, companies, z] = await Promise.all([ + api('GET', `/api/${config.api}/${id}`), + api<{ data: Company[] }>('GET', '/api/companies'), + loadZatcaDocument(type, id!), + ]); + return { doc, company: companies.data.find((c) => c.id === doc.companyId) ?? companies.data[0]!, z }; + }, [id, config.api]); + if (!data.data) return ; + const { doc: d, company: co, z } = data.data; + const standard = d.invoiceKind === 'STANDARD'; + const title = config.key === 'SALES_RETURN' ? (standard ? 'إشعار دائن ضريبي' : 'إشعار دائن ضريبي مبسط') : (standard ? 'فاتورة ضريبية' : 'فاتورة ضريبية مبسطة'); + const notCleared = standard && z.einvoice && z.einvoice.status !== 'CLEARED'; + const buyer = d.partySnapshot; + return ( +
+
+ رجوع + +
+ {d.status === 'DRAFT' &&
مسودة — ليست مستندًا ضريبيًا.
} + {notCleared &&
لم تُعتمد من هيئة الزكاة والضريبة والجمارك بعد — لا تُسلَّم للعميل.
} +
+
+
+

{title}

+
+
الرقم
{d.number ?? '—'}
+
التاريخ
{d.date}
+ {d.originalInvoiceNumber &&
الفاتورة الأصلية
{d.originalInvoiceNumber}
} + {d.reason &&
سبب الإشعار
{d.reason}
} +
+
+ {z.qrSvg && !notCleared && رمز QR للفاتورة} +
+
+
+

البائع

+ {co.legalName ?? co.name} + {co.vatNumber &&
الرقم الضريبي: {co.vatNumber}
} + {co.commercialRegistration &&
السجل التجاري: {co.commercialRegistration}
} +
{joinAddress(co)}
+
+
+

المشتري

+ {buyer?.nameAr ?? d.partyName} + {buyer?.vatNumber &&
الرقم الضريبي: {buyer.vatNumber}
} +
{joinAddress(buyer?.address)}
+
+
+ + + + {d.lines.map((l) => ( + + + + + + + + + + ))} + +
#الصنفالكميةسعر الوحدةالمبلغ الخاضعنسبة الضريبةالضريبةالإجمالي
{l.lineNo}{l.description || l.productName}{l.quantity.replace(/\.?0+$/, '')}{formatAmount(l.unitPrice)}{formatAmount(l.netAmount)}{(Number(l.vatRate) * 100).toFixed(0)}%{formatAmount(l.vatAmount)}{formatAmount(l.totalAmount)}
+
+
الإجمالي غير شامل الضريبة
{formatAmount(d.taxableAmount)}
+ {d.discountTotal !== '0.00' &&
الخصم
{formatAmount(d.discountTotal)}
} +
ضريبة القيمة المضافة
{formatAmount(d.taxAmount)}
+
الإجمالي شامل الضريبة
{formatAmount(d.total)} ر.س
+
+
+
+ ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/documents/Section.tsx b/alshuyukh-accounting/apps/web/src/pages/documents/Section.tsx new file mode 100644 index 000000000000..881fadf9c069 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/documents/Section.tsx @@ -0,0 +1,42 @@ +import { NavLink, Navigate, Route, Routes } from 'react-router-dom'; +import { useAuth } from '../../auth'; +import { PageHeader } from '../../ui'; +import { DOCS, type DocConfig } from './config'; +import DocumentDetail from './DocumentDetail'; +import DocumentForm from './DocumentForm'; +import DocumentList from './DocumentList'; +import Payments from './Payments'; +import PrintDocument from './PrintDocument'; + +/** Sales or purchases section: tabs for each document type and payments. */ +export default function Section({ party }: { party: 'customer' | 'supplier' }) { + const { can } = useAuth(); + const docs: DocConfig[] = party === 'customer' + ? [DOCS.SALES_INVOICE, DOCS.SALES_QUOTE, DOCS.SALES_RETURN] + : [DOCS.PURCHASE_INVOICE, DOCS.PURCHASE_ORDER, DOCS.PURCHASE_RETURN]; + const paymentsRoute = party === 'customer' ? 'receipts' : 'payments'; + const visible = docs.filter((d) => can(d.perm.view)); + // Absolute paths: inside a splat route, relative links resolve against the full URL. + const base = party === 'customer' ? '/sales' : '/purchases'; + return ( + <> + + + + } /> + {visible.map((d) => ( + + } /> + {!d.isReturn && } />} + } /> + {(d.key === 'SALES_INVOICE' || d.key === 'SALES_RETURN') && } />} + + ))} + } /> + + + ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/documents/Totals.tsx b/alshuyukh-accounting/apps/web/src/pages/documents/Totals.tsx new file mode 100644 index 000000000000..602860b3f43b --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/documents/Totals.tsx @@ -0,0 +1,18 @@ +import { formatAmount } from '../../money'; + +export interface TotalsData { subtotal: string; discountTotal: string; taxableAmount: string; taxAmount: string; total: string } + +export default function Totals({ t, extra }: { t: TotalsData; extra?: [string, string | undefined][] }) { + const rows: [string, string | undefined, boolean?][] = [ + ['الإجمالي قبل الخصم', t.subtotal], ['الخصم', t.discountTotal], ['المبلغ الخاضع للضريبة', t.taxableAmount], + ['ضريبة القيمة المضافة', t.taxAmount], ['الإجمالي شامل الضريبة', t.total, true], + ...(extra ?? []).map(([a, b]) => [a, b] as [string, string | undefined]), + ]; + return ( +
+ {rows.filter(([, v]) => v !== undefined).map(([label, value, strong]) => ( +
{label}
{formatAmount(value)}
+ ))} +
+ ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/documents/config.ts b/alshuyukh-accounting/apps/web/src/pages/documents/config.ts new file mode 100644 index 000000000000..b1b490fdce49 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/documents/config.ts @@ -0,0 +1,55 @@ +export interface DocConfig { + key: 'SALES_QUOTE' | 'SALES_INVOICE' | 'SALES_RETURN' | 'PURCHASE_ORDER' | 'PURCHASE_INVOICE' | 'PURCHASE_RETURN'; + api: string; // API path segment + route: string; // UI route, relative to the section + title: string; + singular: string; + newLabel: string; + party: 'customer' | 'supplier'; + legal: boolean; + isReturn: boolean; + perm: { view: string; create: string; issue: string; cancel: string }; +} + +const SALES = { view: 'invoice.view', create: 'invoice.create', issue: 'invoice.post', cancel: 'invoice.cancel' }; +const PURCHASE = { view: 'purchase.view', create: 'purchase.create', issue: 'purchase.post', cancel: 'purchase.cancel' }; + +export const DOCS: Record = { + SALES_INVOICE: { key: 'SALES_INVOICE', api: 'invoices', route: 'invoices', title: 'فواتير المبيعات', singular: 'فاتورة', newLabel: 'فاتورة جديدة', party: 'customer', legal: true, isReturn: false, perm: SALES }, + SALES_QUOTE: { key: 'SALES_QUOTE', api: 'sales-quotes', route: 'quotes', title: 'عروض الأسعار', singular: 'عرض سعر', newLabel: 'عرض سعر جديد', party: 'customer', legal: false, isReturn: false, perm: SALES }, + SALES_RETURN: { key: 'SALES_RETURN', api: 'sales-returns', route: 'returns', title: 'مرتجعات المبيعات', singular: 'إشعار دائن', newLabel: '', party: 'customer', legal: true, isReturn: true, perm: SALES }, + PURCHASE_INVOICE: { key: 'PURCHASE_INVOICE', api: 'purchase-invoices', route: 'invoices', title: 'فواتير المشتريات', singular: 'فاتورة مشتريات', newLabel: 'فاتورة مشتريات جديدة', party: 'supplier', legal: true, isReturn: false, perm: PURCHASE }, + PURCHASE_ORDER: { key: 'PURCHASE_ORDER', api: 'purchase-orders', route: 'orders', title: 'أوامر الشراء', singular: 'أمر شراء', newLabel: 'أمر شراء جديد', party: 'supplier', legal: false, isReturn: false, perm: PURCHASE }, + PURCHASE_RETURN: { key: 'PURCHASE_RETURN', api: 'purchase-returns', route: 'returns', title: 'مرتجعات المشتريات', singular: 'إشعار مدين', newLabel: '', party: 'supplier', legal: true, isReturn: true, perm: PURCHASE }, +}; + +export const STATUS_AR: Record = { + DRAFT: 'مسودة', ISSUED: 'صادرة', POSTED: 'مرحّلة', PARTIALLY_PAID: 'مدفوعة جزئيًا', PAID: 'مدفوعة', CANCELLED: 'ملغاة', + RETURNED: 'مرتجعة', SENT: 'مرسل', ACCEPTED: 'مقبول', REJECTED: 'مرفوض', CONVERTED: 'محوّل لفاتورة', APPROVED: 'معتمد', + VOIDED: 'ملغى', +}; + +export const statusClass = (s: string) => + ({ DRAFT: 'status-draft', PAID: 'status-posted', ISSUED: 'status-open', POSTED: 'status-open', PARTIALLY_PAID: 'status-partial', + CANCELLED: 'status-reversed', VOIDED: 'status-reversed', RETURNED: 'status-reversed' } as Record)[s] ?? 'status-open'; + +export interface DocLine { + id: string; lineNo: number; productId: string | null; productName: string | null; sku: string | null; accountId: string | null; + sourceItemId: string | null; description: string | null; quantity: string; unitCode: string | null; unitPrice: string; + grossAmount: string; discountBasis: string; discountAmount: string; netAmount: string; vatCategory: string; vatRate: string; + vatAmount: string; totalAmount: string; +} + +export interface Doc { + id: string; companyId: string; number: string | null; date: string; partyId: string; partyName: string; partyCode: string; status: string; + issuedAt?: string | null; + partySnapshot?: { nameAr: string; vatNumber: string | null; commercialRegistration: string | null; address: { buildingNumber: string | null; street: string | null; district: string | null; city: string | null; postalCode: string | null; additionalNumber: string | null } | null } | null; + pricesIncludeVat: boolean; subtotal: string; discountTotal: string; taxableAmount: string; taxAmount: string; total: string; + notes: string | null; journalEntryId: string | null; cancelReason: string | null; + dueDate?: string | null; paidAmount?: string; returnedAmount?: string; remainingAmount?: string; invoiceKind?: string; + supplierInvoiceNumber?: string | null; originalInvoiceId?: string; originalInvoiceNumber?: string; reason?: string; + appliedAmount?: string; refundedAmount?: string; convertedInvoiceId?: string | null; validUntil?: string | null; + lines: DocLine[]; + allocations?: { id: string; amount: string; paymentId: string; paymentNumber: string; paymentDate: string }[]; + returns?: { id: string; number: string | null; date: string; status: string; total: string }[]; +} diff --git a/alshuyukh-accounting/apps/web/src/pages/einvoicing/EInvoicing.tsx b/alshuyukh-accounting/apps/web/src/pages/einvoicing/EInvoicing.tsx new file mode 100644 index 000000000000..79912ddd823b --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/einvoicing/EInvoicing.tsx @@ -0,0 +1,253 @@ +import { Fragment, useState, type FormEvent } from 'react'; +import { Link } from 'react-router-dom'; +import { api, downloadFile } from '../../api'; +import { useAuth } from '../../auth'; +import { ErrorBox, formatDateTime, PageHeader, useLoad } from '../../ui'; + +interface Device { + id: string; name: string; branchName: string | null; environment: string; status: string; egsSerial: string; invoiceTypes: string; + csr: string; complianceChecks: Record; certificateSerial: string | null; certificateIssuer: string | null; + certificateExpiresAt: string | null; icv: string; lastHash: string; activatedAt: string | null; createdAt: string; +} +interface EInvoice { + id: string; documentType: string; documentId: string; documentNumber: string; invoiceKind: string; typeCode: string; uuid: string; icv: string; + status: string; hasWarnings: boolean; attempts: number; nextAttemptAt: string; lastError: string | null; submittedAt: string | null; + createdAt: string; reportingOverdue: boolean; +} +interface Detail extends EInvoice { + invoiceHash: string; previousHash: string; + submissions: { id: string; operation: string; httpStatus: number | null; outcome: string; createdAt: string; messages: { level: string; code: string | null; message: string }[] }[]; + documents: { kind: string }[]; +} + +const ENV_AR: Record = { DEVELOPER: 'بوابة المطورين (تجريبي)', SIMULATION: 'بيئة المحاكاة', PRODUCTION: 'الإنتاج' }; +const DEVICE_STATUS: Record = { + NEW: ['بانتظار رمز OTP', 'status-draft'], COMPLIANCE: ['فحوص الامتثال', 'status-partial'], ACTIVE: ['مفعّلة', 'status-posted'], REVOKED: ['ملغاة', 'status-reversed'], +}; +export const EINVOICE_STATUS: Record = { + PENDING: ['بانتظار الإرسال', 'status-partial'], REPORTED: ['مُبلَّغ عنها', 'status-posted'], CLEARED: ['معتمدة', 'status-posted'], REJECTED: ['مرفوضة', 'status-reversed'], +}; +const TYPE_AR: Record = { '388': 'فاتورة', '381': 'إشعار دائن', '383': 'إشعار مدين' }; +const CHECK_AR: Record = { + STANDARD_INVOICE: 'فاتورة ضريبية', STANDARD_CREDIT_NOTE: 'إشعار دائن ضريبي', STANDARD_DEBIT_NOTE: 'إشعار مدين ضريبي', + SIMPLIFIED_INVOICE: 'فاتورة مبسطة', SIMPLIFIED_CREDIT_NOTE: 'إشعار دائن مبسط', SIMPLIFIED_DEBIT_NOTE: 'إشعار مدين مبسط', +}; + +export default function EInvoicing() { + const { can } = useAuth(); + const devices = useLoad(() => api<{ data: Device[] }>('GET', '/api/zatca/devices')); + const [status, setStatus] = useState(''); + const list = useLoad(() => api<{ data: EInvoice[]; total: number; summary: { pending: number; rejected: number; overdue: number } }>( + 'GET', `/api/zatca/invoices?limit=100${status ? `&status=${status}` : ''}`), [status]); + const live = devices.data?.data.filter((d) => d.status !== 'REVOKED') ?? []; + return ( + <> + +
+ التكامل مع منصة فاتورة (ZATCA) مبني وفق المواصفات ومختبر داخليًا فقط، ولم يُختبر بعد مع بوابة الهيئة الفعلية. + ابدأ ببيئة «بوابة المطورين» ثم «المحاكاة» قبل «الإنتاج». +
+ + {devices.data && live.length === 0 && can('zatca.manage') && } + {devices.data && live.length === 0 && !can('zatca.manage') &&
لم تُربط الشركة بمنصة فاتورة بعد. الفواتير تُطبع برمز QR للمرحلة الأولى.
} + {devices.data?.data.map((d) => { devices.reload(); list.reload(); }} />)} + +
+
+

الفواتير الإلكترونية

+ {list.data && ( + + {list.data.summary.pending} بانتظار الإرسال · {list.data.summary.rejected} مرفوضة + {list.data.summary.overdue > 0 && · {list.data.summary.overdue} تجاوزت مهلة 24 ساعة للإبلاغ} + + )} +
+
+ +
+ +
+ + ); +} + +function NewDevice({ onDone }: { onDone: () => void }) { + const [error, setError] = useState(null); + async function submit(e: FormEvent) { + e.preventDefault(); + const f = Object.fromEntries(new FormData(e.currentTarget)) as Record; + setError(null); + try { await api('POST', '/api/zatca/devices', { name: f.name, environment: f.environment, businessCategory: f.businessCategory, invoiceTypes: f.invoiceTypes }); onDone(); } + catch (err) { setError(err); } + } + return ( +
+

ربط الشركة بمنصة فاتورة

+

تُنشأ وحدة توليد فواتير (EGS) بمفتاح تشفير خاص يبقى مشفرًا في الخادم، وطلب شهادة (CSR) يُرسل إلى الهيئة مع رمز OTP من بوابة فاتورة.

+ +
+ + + + +
+

يجب أن تكون بيانات الشركة مكتملة: الرقم الضريبي، السجل التجاري، والعنوان الوطني (إعدادات الشركة).

+ + + ); +} + +function DeviceCard({ d, onChange }: { d: Device; onChange: () => void }) { + const { can } = useAuth(); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const manage = can('zatca.manage') && d.status !== 'REVOKED'; + async function run(fn: () => Promise) { + setError(null); setBusy(true); + try { await fn(); onChange(); } catch (e) { setError(e); } finally { setBusy(false); } + } + const [label, cls] = DEVICE_STATUS[d.status] ?? [d.status, '']; + const steps = [ + { done: true, text: 'إنشاء الوحدة وطلب الشهادة (CSR)' }, + { done: d.status !== 'NEW', text: 'إدخال رمز OTP من بوابة فاتورة والحصول على شهادة الامتثال' }, + { done: d.status === 'ACTIVE' || (Object.keys(d.complianceChecks).length > 0 && !Object.values(d.complianceChecks).includes('FAILED')), text: 'فحوص الامتثال لكل أنواع المستندات' }, + { done: d.status === 'ACTIVE', text: 'الحصول على شهادة الإنتاج وتفعيل التوقيع' }, + ]; + return ( +
+
+

{d.name}{d.branchName ? ` — ${d.branchName}` : ''}

{ENV_AR[d.environment]} · {d.egsSerial}

+ {label} +
+ +
    {steps.map((s) =>
  1. {s.done ? '✓' : '○'}{s.text}
  2. )}
+ {Object.keys(d.complianceChecks).length > 0 && ( +
{Object.entries(d.complianceChecks).map(([k, v]) => {CHECK_AR[k] ?? k}: {v === 'FAILED' ? 'فشل' : v === 'PASSED' ? 'نجح' : 'نجح مع تحذيرات'})}
+ )} + {d.status === 'ACTIVE' && ( +
+
آخر عداد (ICV)
{d.icv}
+
الرقم التسلسلي للشهادة
{d.certificateSerial}
+
تنتهي الشهادة
{formatDateTime(d.certificateExpiresAt)}
+
الجهة المصدرة
{d.certificateIssuer}
+
+ )} + {manage && d.status === 'NEW' && ( +
{ e.preventDefault(); const otp = String(new FormData(e.currentTarget).get('otp')); void run(() => api('POST', `/api/zatca/devices/${d.id}/compliance-csid`, { otp })); }}> + + + +
+ )} + {manage && d.status === 'COMPLIANCE' && ( +
+ + +
+ )} + {manage && ( +
إلغاء الوحدة +
{ e.preventDefault(); const reason = String(new FormData(e.currentTarget).get('reason')); void run(() => api('POST', `/api/zatca/devices/${d.id}/revoke`, { reason })); }}> + + +
+
+ )} +
+ ); +} + +export function EInvoiceTable({ rows, onChange }: { rows: EInvoice[]; onChange: () => void }) { + const { can } = useAuth(); + const [open, setOpen] = useState(null); + const [detail, setDetail] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + async function toggle(id: string) { + if (open === id) { setOpen(null); return; } + setOpen(id); setDetail(null); + try { setDetail(await api('GET', `/api/zatca/invoices/${id}`)); } catch (e) { setError(e); } + } + async function submit(id: string) { + setError(null); setBusy(id); + try { const d = await api('POST', `/api/zatca/invoices/${id}/submit`); if (open === id) setDetail(d); onChange(); } catch (e) { setError(e); } finally { setBusy(null); } + } + const docLink = (r: EInvoice) => `${r.documentType === 'SALES_INVOICE' ? '/sales/invoices' : '/sales/returns'}/${r.documentId}`; + return ( + <> + +
+ + + + {rows.map((r) => { + const [label, cls] = EINVOICE_STATUS[r.status] ?? [r.status, '']; + return ( + + + + + + + + + + {open === r.id && ( + + )} + + ); + })} + {rows.length === 0 && } + +
المستندالنوعICVالإنشاءالحالة
{r.documentNumber}{TYPE_AR[r.typeCode]} {r.invoiceKind === 'STANDARD' ? 'ضريبي' : 'مبسط'}{r.icv}{formatDateTime(r.createdAt)} + {label}{r.hasWarnings ? ' (تحذيرات)' : ''} + {r.reportingOverdue && متأخرة عن 24 ساعة} + {r.status === 'PENDING' && r.attempts > 0 &&
محاولات: {r.attempts} — التالية {formatDateTime(r.nextAttemptAt)}
} +
+ {r.status === 'PENDING' && can('invoice.post') && } + +
+ {!detail ? 'جارٍ التحميل…' : ( +
+ {detail.lastError &&
{detail.lastError}
} +
+
UUID
{detail.uuid}
+
بصمة الفاتورة
{detail.invoiceHash}
+
بصمة السابقة (PIH)
{detail.previousHash}
+
+
+ {detail.documents.map((doc) => ( + + ))} +
+ {detail.submissions.length > 0 && ( + + + {detail.submissions.map((s) => ( + + + + + + ))} +
العمليةالوقتHTTPالنتيجةالرسائل
{s.operation === 'CLEARANCE' ? 'اعتماد' : 'إبلاغ'}{formatDateTime(s.createdAt)}{s.httpStatus ?? '—'}{{ SUCCESS: 'نجاح', WARNING: 'نجاح مع تحذيرات', REJECTED: 'رفض', TRANSPORT_ERROR: 'تعذر الاتصال' }[s.outcome]}{s.messages.map((m, i) =>
{m.code} {m.message}
)}
+ )} +
+ )} +
لا توجد فواتير إلكترونية
+
+ + ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/expenses/Expenses.tsx b/alshuyukh-accounting/apps/web/src/pages/expenses/Expenses.tsx new file mode 100644 index 000000000000..4e38f2063ae6 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/expenses/Expenses.tsx @@ -0,0 +1,399 @@ +import { useEffect, useRef, useState, type FormEvent } from 'react'; +import { Link, NavLink, Navigate, Route, Routes, useNavigate, useParams } from 'react-router-dom'; +import { api } from '../../api'; +import { useAuth } from '../../auth'; +import { formatAmount } from '../../money'; +import { ErrorBox, PageHeader, useLoad } from '../../ui'; +import { STATUS_AR, statusClass } from '../documents/config'; + +export interface Category { id: string; code: string; nameAr: string; accountId: string; accountCode: string; accountName: string; vatCategory: string; isActive: boolean } +interface Method { id: string; code: string; nameAr: string; isActive: boolean; methodType?: string } +interface Party { id: string; code: string; nameAr: string } +interface CostCenter { id: string; code: string; name: string; isActive: boolean } +interface ExpenseLine { id: string; categoryId: string; categoryName: string; accountCode: string; description: string | null; costCenterId: string | null; amount: string; netAmount: string; vatCategory: string; vatAmount: string; totalAmount: string } +export interface Expense { + id: string; number: string | null; date: string; paymentType: 'CASH' | 'BANK' | 'CREDIT'; methodId: string | null; methodName: string | null; + supplierId: string | null; supplierName: string | null; payeeName: string | null; reference: string | null; vendorVatNumber: string | null; + pricesIncludeVat: boolean; subtotal: string; taxAmount: string; total: string; paidAmount: string; remainingAmount: string; + notes: string | null; status: string; journalEntryId: string | null; cancelReason: string | null; lines: ExpenseLine[]; +} + +const today = () => new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Riyadh' }).format(new Date()); +export const VAT_AR: Record = { S: 'خاضع', Z: 'صفري', E: 'معفى', O: 'خارج النطاق' }; +const PAY_AR: Record = { CASH: 'نقدًا', BANK: 'تحويل بنكي', CREDIT: 'آجل على مورد' }; +const tab = ({ isActive }: { isActive: boolean }) => `tab ${isActive ? 'active' : ''}`; + +export default function Expenses() { + const { can } = useAuth(); + return ( + <> + + + + } /> + } /> + {can('expense.create') && } />} + } /> + } /> + + + ); +} + +function ExpenseList() { + const { can } = useAuth(); + const [status, setStatus] = useState(''); + const list = useLoad(() => { + const q = new URLSearchParams({ limit: '100' }); + if (status === 'OPEN') q.set('open', 'true'); else if (status) q.set('status', status); + return api<{ data: Expense[]; total: number }>('GET', `/api/expenses?${q}`); + }, [status]); + return ( +
+
+ + + {can('expense.create') && مصروف جديد} +
+ +
+ + + + {!list.data && !list.error && } + {list.data?.data.map((e) => ( + + + + + + + + + + + ))} + {list.data?.data.length === 0 && } + +
الرقمالتاريخالمستفيدالدفعالضريبةالإجماليالمتبقيالحالة
جارٍ التحميل…
{e.number ?? 'مسودة'}{e.date}{e.supplierName ?? e.payeeName ?? '—'}{PAY_AR[e.paymentType]}{e.methodName ? ` — ${e.methodName}` : ''}{formatAmount(e.taxAmount)}{formatAmount(e.total)}{['DRAFT', 'CANCELLED'].includes(e.status) ? '' : formatAmount(e.remainingAmount)}{STATUS_AR[e.status] ?? e.status}
لا توجد مصروفات
+
+
+ ); +} + +interface FormLine { categoryId: string; amount: string; description: string; vatCategory: string; costCenterId: string } +const emptyLine = (): FormLine => ({ categoryId: '', amount: '', description: '', vatCategory: '', costCenterId: '' }); +const payloadLines = (ls: FormLine[]) => ls.filter((l) => l.categoryId && l.amount.trim()).map((l) => ({ + categoryId: l.categoryId, amount: l.amount.trim(), + ...(l.vatCategory ? { vatCategory: l.vatCategory } : {}), + ...(l.description.trim() ? { description: l.description.trim() } : {}), + ...(l.costCenterId ? { costCenterId: l.costCenterId } : {}), +})); + +function ExpenseForm({ expense, onSaved }: { expense?: Expense; onSaved?: () => void }) { + const navigate = useNavigate(); + const { can } = useAuth(); + const cats = useLoad(() => api<{ data: Category[] }>('GET', '/api/expense-categories')); + const methods = useLoad(() => (can('payment.view') ? api<{ data: Method[] }>('GET', '/api/payment-methods') : Promise.resolve({ data: [] as Method[] }))); + const suppliers = useLoad(() => api<{ data: Party[] }>('GET', '/api/suppliers?limit=200').catch(() => ({ data: [] as Party[] }))); + const centers = useLoad(() => api<{ data: CostCenter[] }>('GET', '/api/cost-centers').catch(() => ({ data: [] as CostCenter[] }))); + const [date, setDate] = useState(expense?.date ?? today()); + const [paymentType, setPaymentType] = useState(expense?.paymentType ?? 'CASH'); + const [methodId, setMethodId] = useState(expense?.methodId ?? ''); + const [supplierId, setSupplierId] = useState(expense?.supplierId ?? ''); + const [includeVat, setIncludeVat] = useState(expense?.pricesIncludeVat ?? false); + const [lines, setLines] = useState(() => expense?.lines.length + ? expense.lines.map((l) => ({ categoryId: l.categoryId, amount: l.amount, description: l.description ?? '', vatCategory: l.vatCategory, costCenterId: l.costCenterId ?? '' })) + : [emptyLine()]); + const [preview, setPreview] = useState<{ lines: { netAmount: string; vatAmount: string }[]; totals: { subtotal: string; taxAmount: string; total: string } } | null>(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const timer = useRef>(undefined); + const update = (i: number, patch: Partial) => setLines((ls) => ls.map((l, j) => (j === i ? { ...l, ...patch } : l))); + + useEffect(() => { + clearTimeout(timer.current); + const body = payloadLines(lines); + if (!body.length) { setPreview(null); return; } + timer.current = setTimeout(() => { + api('POST', '/api/expenses/calculate', { expenseDate: date, pricesIncludeVat: includeVat, lines: body }) + .then(setPreview).catch(() => setPreview(null)); + }, 350); + return () => clearTimeout(timer.current); + }, [lines, date, includeVat]); + + // Cash expenses default to a cash method and bank expenses to a bank method. + const methodOptions = methods.data?.data.filter((m) => m.isActive) ?? []; + useEffect(() => { + if (paymentType === 'CREDIT' || methodId || !methodOptions.length) return; + const match = methodOptions.find((m) => m.code === paymentType) ?? methodOptions[0]; + if (match) setMethodId(match.id); + }, [paymentType, methodOptions, methodId]); + + async function submit(e: FormEvent) { + e.preventDefault(); + const f = new FormData(e.currentTarget); + const opt = (k: string) => (String(f.get(k) ?? '').trim() || null); + const post = (e.nativeEvent as SubmitEvent).submitter?.getAttribute('value') === 'post'; + const body = { + expenseDate: date, paymentType, pricesIncludeVat: includeVat, + methodId: paymentType === 'CREDIT' ? null : methodId || null, + supplierId: supplierId || null, + payeeName: opt('payeeName'), reference: opt('reference'), vendorVatNumber: opt('vendorVatNumber'), notes: opt('notes'), + lines: payloadLines(lines), + }; + setBusy(true); setError(null); + try { + if (expense) { + await api('PATCH', `/api/expenses/${expense.id}`, body); + if (post) await api('POST', `/api/expenses/${expense.id}/post`); + onSaved?.(); + } else { + const created = await api<{ id: string }>('POST', '/api/expenses', { ...body, post }); + navigate(`/expenses/list/${created.id}`); + } + } catch (err) { setError(err); } finally { setBusy(false); } + } + + const catById = new Map(cats.data?.data.map((c) => [c.id, c])); + const ready = cats.data && methods.data; + return ( +
+

{expense ? 'تعديل مسودة المصروف' : 'مصروف جديد'}

+ +
+ + + {paymentType !== 'CREDIT' && ( + + )} + + {!supplierId && } + + +
+ + +
+ + {(centers.data?.data.length ?? 0) > 0 && } + + {lines.map((l, i) => { + const pl = preview?.lines[payloadLines(lines.slice(0, i + 1)).length - 1]; + const ok = l.categoryId && l.amount.trim(); + return ( + + + + + {(centers.data?.data.length ?? 0) > 0 && ( + + )} + + + + + ); + })} + +
الفئةالمبلغالضريبةمركز التكلفةالصافيضريبة المدخلات
+ + update(i, { description: e.target.value })} /> + update(i, { amount: e.target.value })} /> + + {ok && pl ? formatAmount(pl.netAmount) : ''}{ok && pl ? formatAmount(pl.vatAmount) : ''}{lines.length > 1 && }
+
+ + +
+ +
+ {preview && ( +
+
الإجمالي قبل الضريبة
{formatAmount(preview.totals.subtotal)}
+
ضريبة المدخلات
{formatAmount(preview.totals.taxAmount)}
+
الإجمالي
{formatAmount(preview.totals.total)}
+
+ )} +

تُحسب المبالغ والضريبة في الخادم.

+
+
+
+ + {can('expense.post') && } + {expense && } +
+ + ); +} + +function ExpenseDetail() { + const { id } = useParams(); + const { can } = useAuth(); + const navigate = useNavigate(); + const exp = useLoad(() => api('GET', `/api/expenses/${id}`), [id]); + const [mode, setMode] = useState<'view' | 'edit' | 'cancel'>('view'); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + async function run(fn: () => Promise) { + setError(null); setBusy(true); + try { await fn(); setMode('view'); exp.reload(); } catch (e) { setError(e); } finally { setBusy(false); } + } + const e = exp.data; + if (!e) return ; + if (mode === 'edit') return { setMode('view'); exp.reload(); }} />; + const posted = !['DRAFT', 'CANCELLED'].includes(e.status); + + return ( +
+ +
+
+

{e.number ?? 'مسودة'}

+

سند مصروف — {e.supplierId ? {e.supplierName} : e.payeeName ?? 'بدون مستفيد'}

+
+ {STATUS_AR[e.status] ?? e.status} +
+
+
التاريخ
{e.date}
+
السداد
{PAY_AR[e.paymentType]}{e.methodName ? ` — ${e.methodName}` : ''}
+ {e.reference &&
المرجع
{e.reference}
} + {e.vendorVatNumber &&
الرقم الضريبي للمورد
{e.vendorVatNumber}
} + {e.journalEntryId &&
القيد
عرض القيد المحاسبي
} + {e.cancelReason &&
سبب الإلغاء
{e.cancelReason}
} +
المبالغ
{e.pricesIncludeVat ? 'شاملة الضريبة' : 'غير شاملة الضريبة'}
+
+
+ + + + {e.lines.map((l) => ( + + + + + ))} + +
الفئةالحسابالوصفالضريبةالصافيالضريبةالإجمالي
{l.categoryName}{l.accountCode}{l.description ?? ''}{VAT_AR[l.vatCategory]}{formatAmount(l.netAmount)}{formatAmount(l.vatAmount)}{formatAmount(l.totalAmount)}
+
+
+
الإجمالي قبل الضريبة
{formatAmount(e.subtotal)}
+
ضريبة المدخلات
{formatAmount(e.taxAmount)}
+
الإجمالي
{formatAmount(e.total)}
+ {posted &&
المدفوع
{formatAmount(e.paidAmount)}
} + {posted &&
المتبقي
{formatAmount(e.remainingAmount)}
} +
+ {e.notes &&

{e.notes}

} + {e.paymentType === 'CREDIT' && posted && Number(e.remainingAmount) > 0 && ( +

يُسدَّد المصروف الآجل من سندات الصرف بتخصيص الدفعة عليه.

+ )} + + {mode === 'cancel' && ( +
{ ev.preventDefault(); const reason = String(new FormData(ev.currentTarget).get('reason')); void run(() => api('POST', `/api/expenses/${e.id}/cancel`, { reason })); }}> + + + +
+ )} + {mode === 'view' && ( +
+ {e.status === 'DRAFT' && can('expense.create') && } + {e.status === 'DRAFT' && can('expense.post') && } + {e.status === 'DRAFT' && can('expense.create') && ( + + )} + {posted && can('expense.cancel') && } +
+ )} +
+ ); +} + +function Categories() { + const { can } = useAuth(); + const cats = useLoad(() => api<{ data: Category[] }>('GET', '/api/expense-categories')); + const manage = can('account.manage'); + const accounts = useLoad(() => (manage ? api<{ data: { id: string; code: string; nameAr: string; type: string }[] }>('GET', '/api/accounts?postableOnly=true') : Promise.resolve({ data: [] })), [manage]); + const [error, setError] = useState(null); + + async function add(e: FormEvent) { + e.preventDefault(); + const form = e.currentTarget; + const f = Object.fromEntries(new FormData(form)) as Record; + setError(null); + try { await api('POST', '/api/expense-categories', f); form.reset(); cats.reload(); } catch (err) { setError(err); } + } + async function toggle(c: Category) { + setError(null); + try { await api('PATCH', `/api/expense-categories/${c.id}`, { isActive: !c.isActive }); cats.reload(); } catch (err) { setError(err); } + } + + return ( +
+ +

كل فئة مرتبطة بحساب مصروف في دليل الحسابات وبمعاملة ضريبية افتراضية يمكن تغييرها في كل سطر.

+
+ + {manage && + + {cats.data?.data.map((c) => ( + + + + {manage && } + + ))} + +
الرمزالفئةالحسابالضريبة الافتراضيةالحالة}
{c.code}{c.nameAr}{c.accountCode} {c.accountName}{VAT_AR[c.vatCategory]}{c.isActive ? 'نشطة' : 'موقوفة'}
+
+ {manage && accounts.data && ( +
+

فئة جديدة

+
+ + + + +
+ +
+ )} +
+ ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/inventory/Adjustments.tsx b/alshuyukh-accounting/apps/web/src/pages/inventory/Adjustments.tsx new file mode 100644 index 000000000000..fb53e3ba444d --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/inventory/Adjustments.tsx @@ -0,0 +1,123 @@ +import { useState, type FormEvent } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../../api'; +import { useAuth } from '../../auth'; +import { formatAmount } from '../../money'; +import { ErrorBox, useLoad } from '../../ui'; +import type { Account } from '../accounting/ChartOfAccounts'; +import { loadStockedProducts, loadWarehouses, qty } from './shared'; + +interface Adjustment { id: string; number: string; date: string; warehouseName: string; reason: string; offsetAccountName: string; totalIncrease: string; totalDecrease: string; journalEntryId: string | null } +interface Line { productId: string; counted: string; direction: 'IN' | 'OUT'; quantity: string; unitCost: string } + +const today = () => new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Riyadh' }).format(new Date()); +const empty = (): Line => ({ productId: '', counted: '', direction: 'IN', quantity: '', unitCost: '' }); + +export default function Adjustments() { + const { can } = useAuth(); + const list = useLoad(() => api<{ data: Adjustment[] }>('GET', '/api/stock-adjustments')); + const warehouses = useLoad(loadWarehouses); + const products = useLoad(loadStockedProducts); + const accounts = useLoad(() => api<{ data: Account[] }>('GET', '/api/accounts?postableOnly=true')); + const [mode, setMode] = useState<'count' | 'manual' | null>(null); + const [lines, setLines] = useState([empty()]); + const [error, setError] = useState(null); + const update = (i: number, patch: Partial) => setLines((ls) => ls.map((l, j) => (j === i ? { ...l, ...patch } : l))); + + async function submit(e: FormEvent) { + e.preventDefault(); + const f = new FormData(e.currentTarget); + setError(null); + try { + await api('POST', '/api/stock-adjustments', { + warehouseId: f.get('warehouseId'), date: f.get('date'), reason: f.get('reason'), + ...(f.get('offsetAccountId') ? { offsetAccountId: f.get('offsetAccountId') } : {}), + lines: lines.filter((l) => l.productId).map((l) => mode === 'count' + ? { productId: l.productId, countedQuantity: l.counted.trim() } + : { productId: l.productId, direction: l.direction, quantity: l.quantity.trim(), ...(l.unitCost.trim() ? { unitCost: l.unitCost.trim() } : {}) }), + }); + setMode(null); + setLines([empty()]); + list.reload(); + } catch (err) { setError(err); } + } + + const offsetAccounts = accounts.data?.data.filter((a) => ['EXPENSE', 'COST_OF_GOODS_SOLD', 'EQUITY', 'REVENUE'].includes(a.type)) ?? []; + return ( + <> + {mode && warehouses.data && products.data && accounts.data && ( +
+

{mode === 'count' ? 'جرد المخزون' : 'تسوية مخزون'}

+ +
+ + + + +
+ + {mode === 'count' ? <> : <>} + {lines.map((l, i) => ( + + + {mode === 'count' ? ( + <> + + + + ) : ( + <> + + + + + )} + + + ))} +
الصنفالرصيد الحاليالكمية المعدودةالنوعالكميةتكلفة الوحدة (للزيادة)
{qty(products.data?.find((p) => p.id === l.productId)?.onHand)} update(i, { counted: e.target.value })} /> update(i, { quantity: e.target.value })} /> update(i, { unitCost: e.target.value })} />{lines.length > 1 && }
+ +

+ {mode === 'count' ? 'الرصيد الحالي هو مجموع كل المستودعات للعرض فقط؛ الفرق يُحسب في الخادم لرصيد المستودع المختار. ' : ''} + النقص يُقيَّم بمتوسط التكلفة. للرصيد الافتتاحي اختر حساب رأس المال كحساب مقابل. +

+
+ + )} +
+
+ + {can('inventory.adjust') && !mode && ( + <> + + + + )} +
+ + + + + {list.data?.data.map((a) => ( + + + + + + + ))} + {list.data?.data.length === 0 && } + +
الرقمالتاريخالمستودعالسببزيادةنقص
{a.number}{a.date}{a.warehouseName}{a.reason}{a.totalIncrease === '0.00' ? '' : formatAmount(a.totalIncrease)}{a.totalDecrease === '0.00' ? '' : formatAmount(a.totalDecrease)}{a.journalEntryId && القيد}
لا توجد تسويات
+
+ + ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/inventory/Balances.tsx b/alshuyukh-accounting/apps/web/src/pages/inventory/Balances.tsx new file mode 100644 index 000000000000..bc4f5992abb4 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/inventory/Balances.tsx @@ -0,0 +1,41 @@ +import { useState } from 'react'; +import { api } from '../../api'; +import { formatAmount } from '../../money'; +import { ErrorBox, useLoad } from '../../ui'; +import { loadWarehouses, qty } from './shared'; + +interface Row { productId: string; sku: string; productName: string; unitCode: string; warehouseName: string; quantity: string; value: string; averageCost: string } + +export default function Balances() { + const [warehouseId, setWarehouseId] = useState(''); + const warehouses = useLoad(loadWarehouses); + const rows = useLoad(() => api<{ data: Row[] }>('GET', `/api/inventory/balances${warehouseId ? `?warehouseId=${warehouseId}` : ''}`), [warehouseId]); + return ( +
+
+ +
+ +
+ + + + {!rows.data && !rows.error && } + {rows.data?.data.map((r) => ( + + + + + + + ))} + {rows.data?.data.length === 0 && } + +
الرمزالصنفالمستودعالكميةمتوسط التكلفةالقيمة
جارٍ التحميل…
{r.sku}{r.productName}{r.warehouseName}{qty(r.quantity)} {r.unitCode}{formatAmount(Number(r.averageCost).toFixed(2))}{formatAmount(r.value)}
لا يوجد مخزون
+
+
+ ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/inventory/StockCard.tsx b/alshuyukh-accounting/apps/web/src/pages/inventory/StockCard.tsx new file mode 100644 index 000000000000..89a92f12322a --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/inventory/StockCard.tsx @@ -0,0 +1,57 @@ +import { useState } from 'react'; +import { api } from '../../api'; +import { formatAmount } from '../../money'; +import { ErrorBox, useLoad } from '../../ui'; +import { MOVEMENT_AR, loadStockedProducts, loadWarehouses, qty } from './shared'; + +interface Movement { + id: string; date: string; type: string; direction: string; warehouseName: string; quantity: string; unitCost: string; + totalCost: string; balanceQuantity: string; balanceValue: string; referenceType: string; +} + +export default function StockCard() { + const products = useLoad(loadStockedProducts); + const warehouses = useLoad(loadWarehouses); + const [productId, setProductId] = useState(''); + const [warehouseId, setWarehouseId] = useState(''); + const moves = useLoad(() => (productId + ? api<{ data: Movement[] }>('GET', `/api/inventory/movements?productId=${productId}${warehouseId ? `&warehouseId=${warehouseId}` : ''}&limit=500`) + : Promise.resolve({ data: [] as Movement[] })), [productId, warehouseId]); + return ( +
+
+ + +
+ + {productId && ( +
+ + + + {moves.data?.data.map((m) => ( + + + + + + + + + + ))} + {moves.data?.data.length === 0 && } + +
التاريخالحركةالمستودعواردصادرتكلفة الوحدةالقيمةالرصيدقيمة الرصيد
{m.date}{MOVEMENT_AR[m.type] ?? m.type}{m.warehouseName}{m.direction === 'IN' ? qty(m.quantity) : ''}{m.direction === 'OUT' ? qty(m.quantity) : ''}{Number(m.unitCost).toFixed(4)}{formatAmount(m.totalCost)}{qty(m.balanceQuantity)}{formatAmount(m.balanceValue)}
لا توجد حركات
+
+ )} +

الأحدث أولًا. الرصيد بعد كل حركة في مستودعها.

+
+ ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/inventory/Transfers.tsx b/alshuyukh-accounting/apps/web/src/pages/inventory/Transfers.tsx new file mode 100644 index 000000000000..d1128d645937 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/inventory/Transfers.tsx @@ -0,0 +1,80 @@ +import { useState, type FormEvent } from 'react'; +import { api } from '../../api'; +import { useAuth } from '../../auth'; +import { formatAmount } from '../../money'; +import { ErrorBox, useLoad } from '../../ui'; +import { loadStockedProducts, loadWarehouses } from './shared'; + +interface Transfer { id: string; number: string; date: string; fromWarehouseName: string; toWarehouseName: string; totalCost: string; notes: string | null } +interface Line { productId: string; quantity: string } + +const today = () => new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Riyadh' }).format(new Date()); + +export default function Transfers() { + const { can } = useAuth(); + const list = useLoad(() => api<{ data: Transfer[] }>('GET', '/api/stock-transfers')); + const warehouses = useLoad(loadWarehouses); + const products = useLoad(loadStockedProducts); + const [creating, setCreating] = useState(false); + const [lines, setLines] = useState([{ productId: '', quantity: '' }]); + const [error, setError] = useState(null); + + async function submit(e: FormEvent) { + e.preventDefault(); + const f = new FormData(e.currentTarget); + setError(null); + try { + await api('POST', '/api/stock-transfers', { + fromWarehouseId: f.get('from'), toWarehouseId: f.get('to'), date: f.get('date'), notes: f.get('notes') || null, + lines: lines.filter((l) => l.productId && l.quantity.trim()).map((l) => ({ productId: l.productId, quantity: l.quantity.trim() })), + }); + setCreating(false); + setLines([{ productId: '', quantity: '' }]); + list.reload(); + } catch (err) { setError(err); } + } + + return ( + <> + {creating && warehouses.data && products.data && ( +
+

تحويل مخزون بين المستودعات

+ +
+ + + +
+ + + {lines.map((l, i) => ( + + + + + + ))} +
الصنفالكمية
setLines((ls) => ls.map((x, j) => (j === i ? { ...x, quantity: e.target.value } : x)))} />{lines.length > 1 && }
+ + +

ينتقل المخزون بمتوسط تكلفته في المستودع المصدر. لا يُنشأ قيد لأن القيمة تبقى في حساب المخزون.

+
+ + )} +
+
{can('inventory.transfer') && !creating && }
+ + + + + {list.data?.data.map((t) => )} + {list.data?.data.length === 0 && } + +
الرقمالتاريخمنإلىالتكلفة
{t.number}{t.date}{t.fromWarehouseName}{t.toWarehouseName}{formatAmount(t.totalCost)}
لا توجد تحويلات
+
+ + ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/inventory/Valuation.tsx b/alshuyukh-accounting/apps/web/src/pages/inventory/Valuation.tsx new file mode 100644 index 000000000000..815b713b3e30 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/inventory/Valuation.tsx @@ -0,0 +1,32 @@ +import { api } from '../../api'; +import { formatAmount } from '../../money'; +import { ErrorBox, useLoad } from '../../ui'; +import { qty } from './shared'; + +interface Result { data: { productId: string; sku: string; productName: string; quantity: string; value: string }[]; stockValue: string; ledgerBalance: string; difference: string; reconciled: boolean } + +export default function Valuation() { + const v = useLoad(() => api('GET', '/api/inventory/valuation')); + const d = v.data; + return ( +
+ + {d && ( + <> +
+
قيمة المخزون
{formatAmount(d.stockValue)}
+
رصيد حساب المخزون في الدفتر
{formatAmount(d.ledgerBalance)}
+
المطابقة
{d.reconciled ? مطابق : فرق {formatAmount(d.difference)}}
+
+ + + {d.data.map((r) => ( + + ))} +
الرمزالصنفالكميةالقيمة
{r.sku}{r.productName}{qty(r.quantity)}{formatAmount(r.value)}
+ + )} +

التقييم بطريقة المتوسط المرجح لكل مستودع.

+
+ ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/inventory/shared.ts b/alshuyukh-accounting/apps/web/src/pages/inventory/shared.ts new file mode 100644 index 000000000000..1f0d1e5d149b --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/inventory/shared.ts @@ -0,0 +1,28 @@ +import { api } from '../../api'; + +export interface Warehouse { id: string; code: string; name: string; isActive: boolean } +export interface StockProduct { id: string; sku: string; nameAr: string; trackInventory: boolean; onHand: string } + +/** + * Active warehouses of the (first) company, main warehouse first so it is + * the default choice everywhere (the server also defaults to it). + */ +export async function loadWarehouses(): Promise { + const companies = await api<{ data: { id: string }[] }>('GET', '/api/companies'); + const company = companies.data[0]; + if (!company) return []; + const list = (await api<{ data: Warehouse[] }>('GET', `/api/companies/${company.id}/warehouses`)).data.filter((w) => w.isActive); + return [...list.filter((w) => w.code === 'MAIN'), ...list.filter((w) => w.code !== 'MAIN')]; +} + +export async function loadStockedProducts(): Promise { + return (await api<{ data: StockProduct[] }>('GET', '/api/products?limit=200&productType=GOODS')).data.filter((p) => p.trackInventory); +} + +export const qty = (v: string | null | undefined) => (v ? v.replace(/\.?0+$/, '') || '0' : ''); + +export const MOVEMENT_AR: Record = { + PURCHASE: 'شراء', SALE: 'بيع', SALE_RETURN: 'مرتجع مبيعات', PURCHASE_RETURN: 'مرتجع مشتريات', + TRANSFER_IN: 'تحويل وارد', TRANSFER_OUT: 'تحويل صادر', ADJUSTMENT_IN: 'تسوية بالزيادة', ADJUSTMENT_OUT: 'تسوية بالنقص', + CANCELLATION_IN: 'إلغاء (وارد)', CANCELLATION_OUT: 'إلغاء (صادر)', +}; diff --git a/alshuyukh-accounting/apps/web/src/pages/parties/Parties.tsx b/alshuyukh-accounting/apps/web/src/pages/parties/Parties.tsx new file mode 100644 index 000000000000..fe3dc8e5bd55 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/parties/Parties.tsx @@ -0,0 +1,180 @@ +import { useState, type FormEvent } from 'react'; +import { api } from '../../api'; +import { useAuth } from '../../auth'; +import { formatAmount } from '../../money'; +import { ErrorBox, PageHeader, useLoad } from '../../ui'; + +interface Address { + addressType: 'BILLING' | 'SHIPPING'; isDefault?: boolean; buildingNumber: string | null; street: string | null; + district: string | null; city: string | null; postalCode: string | null; additionalNumber: string | null; country?: string; +} +export interface Party { + id: string; code: string; partyType: string; nameAr: string; nameEn: string | null; vatNumber: string | null; + commercialRegistration: string | null; nationalId: string | null; email: string | null; phone: string | null; + creditLimit: string | null; paymentTermsDays: number; notes: string | null; isActive: boolean; balance: string; + addresses?: Address[]; +} + +export interface PartyConfig { + path: 'customers' | 'suppliers'; + title: string; + singular: string; + view: string; + manage: string; + /** Customers owe us (debit balance); suppliers are owed (credit balance). */ + balanceLabel: (balance: string) => string; +} + +export const CUSTOMERS: PartyConfig = { + path: 'customers', title: 'العملاء', singular: 'عميل', view: 'customer.view', manage: 'customer.manage', + balanceLabel: (b) => (b.startsWith('-') ? `دائن ${formatAmount(b.slice(1))}` : formatAmount(b)), +}; +export const SUPPLIERS: PartyConfig = { + path: 'suppliers', title: 'الموردون', singular: 'مورد', view: 'supplier.view', manage: 'supplier.manage', + balanceLabel: (b) => (b.startsWith('-') ? formatAmount(b.slice(1)) : b === '0.00' ? '0.00' : `مدين ${formatAmount(b)}`), +}; + +const PAGE = 50; +const blank = (v: FormDataEntryValue | null) => (v === null || String(v).trim() === '' ? null : String(v).trim()); + +export default function Parties({ config }: { config: PartyConfig }) { + const { can } = useAuth(); + const [search, setSearch] = useState(''); + const [status, setStatus] = useState('active'); + const [offset, setOffset] = useState(0); + const [editing, setEditing] = useState(null); + const [error, setError] = useState(null); + const list = useLoad(() => { + const q = new URLSearchParams({ limit: String(PAGE), offset: String(offset), status }); + if (search) q.set('search', search); + return api<{ data: Party[]; total: number }>('GET', `/api/${config.path}?${q}`); + }, [config.path, search, status, offset]); + const manage = can(config.manage); + + async function open(p: Party) { + setError(null); + try { setEditing(await api('GET', `/api/${config.path}/${p.id}`)); } catch (e) { setError(e); } + } + + async function save(e: FormEvent) { + e.preventDefault(); + const f = new FormData(e.currentTarget); + const body = { + nameAr: String(f.get('nameAr')), nameEn: blank(f.get('nameEn')), partyType: String(f.get('partyType')), + vatNumber: blank(f.get('vatNumber')), commercialRegistration: blank(f.get('commercialRegistration')), + nationalId: blank(f.get('nationalId')), email: blank(f.get('email')), phone: blank(f.get('phone')), + creditLimit: blank(f.get('creditLimit')), paymentTermsDays: Number(f.get('paymentTermsDays') || 0), notes: blank(f.get('notes')), + }; + const addr: Address = { + addressType: 'BILLING', buildingNumber: blank(f.get('buildingNumber')), street: blank(f.get('street')), + district: blank(f.get('district')), city: blank(f.get('city')), postalCode: blank(f.get('postalCode')), + additionalNumber: blank(f.get('additionalNumber')), + }; + const hasAddress = Object.entries(addr).some(([k, v]) => k !== 'addressType' && v); + setError(null); + try { + if (editing === 'new') { + await api('POST', `/api/${config.path}`, { ...body, code: blank(f.get('code')) ?? undefined, addresses: hasAddress ? [addr] : [] }); + } else if (editing) { + await api('PATCH', `/api/${config.path}/${editing.id}`, body); + const others = (editing.addresses ?? []).filter((a) => a.addressType !== 'BILLING' || !a.isDefault); + await api('PUT', `/api/${config.path}/${editing.id}/addresses`, { addresses: [...(hasAddress ? [addr] : []), ...others] }); + } + setEditing(null); + list.reload(); + } catch (err) { setError(err); } + } + + async function toggle(p: Party) { + setError(null); + try { await api('PATCH', `/api/${config.path}/${p.id}`, { isActive: !p.isActive }); list.reload(); } catch (e) { setError(e); } + } + + const current = editing && editing !== 'new' ? editing : null; + const billing = current?.addresses?.find((a) => a.addressType === 'BILLING' && a.isDefault); + + return ( + <> + + {manage && } + + + + {editing && ( +
+

{current ? `تعديل: ${current.nameAr}` : `${config.singular} جديد`}

+
+
+ + + + {!current && } + + + + + + + +
+

العنوان الوطني

+
+ + + + + + +
+ +
+ {manage && } + +
+
+
+ )} + +
+
+ { if (e.key === 'Enter') { setSearch(e.currentTarget.value); setOffset(0); } }} /> + +
+
+ + + + {list.data?.data.map((p) => ( + + + + + + + + + ))} + {!list.data && !list.error && } + {list.data?.data.length === 0 && } + +
الرمزالاسمالرقم الضريبيالجوالالرصيد
{p.code}{!p.isActive && غير نشط}{p.vatNumber ?? '—'}{p.phone ?? '—'}{config.balanceLabel(p.balance)}{manage && }
جارٍ التحميل…
لا توجد نتائج
+
+ {list.data && list.data.total > PAGE && ( +
+ {offset + 1}–{Math.min(offset + PAGE, list.data.total)} من {list.data.total} + + +
+ )} +

الرصيد محسوب من القيود المرحّلة المرتبطة بال{config.singular}.

+
+ + ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/products/Products.tsx b/alshuyukh-accounting/apps/web/src/pages/products/Products.tsx new file mode 100644 index 000000000000..1054f781b2c2 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/products/Products.tsx @@ -0,0 +1,231 @@ +import { useState, type FormEvent } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { api } from '../../api'; +import { useAuth } from '../../auth'; +import { ErrorBox, PageHeader, useLoad } from '../../ui'; +import Adjustments from '../inventory/Adjustments'; +import Balances from '../inventory/Balances'; +import StockCard from '../inventory/StockCard'; +import Transfers from '../inventory/Transfers'; +import Valuation from '../inventory/Valuation'; + +interface Product { + id: string; sku: string; barcode: string | null; nameAr: string; nameEn: string | null; productType: string; + categoryId: string | null; categoryName: string | null; unitId: string; unitName: string; salePrice: string; + salePriceIncludesVat: boolean; purchasePrice: string; vatCategory: string; vatExemptionCode: string | null; vatExemptionReason: string | null; trackInventory: boolean; isActive: boolean; onHand: string; +} +interface Unit { id: string; code: string; nameAr: string; isActive: boolean } +interface Category { id: string; parentId: string | null; nameAr: string; isActive: boolean } + +const VAT_AR: Record = { S: 'خاضع للنسبة الأساسية', Z: 'نسبة صفرية', E: 'معفى', O: 'خارج النطاق' }; +const blank = (v: FormDataEntryValue | null) => (v === null || String(v).trim() === '' ? null : String(v).trim()); +/** Shows a 4-decimal unit price without trailing zeros beyond 2 places. */ +const price = (v: string) => v.replace(/(\.\d\d)00$/, '$1').replace(/(\.\d\d\d)0$/, '$1'); + +/** ZATCA exemption and zero-rating reason codes. */ +const EXEMPTION_CODES: [string, string][] = [ + ['VATEX-SA-29', 'خدمات مالية (معفى)'], ['VATEX-SA-29-7', 'تأمين على الحياة (معفى)'], ['VATEX-SA-30', 'توريد عقاري (معفى)'], + ['VATEX-SA-32', 'تصدير سلع (صفري)'], ['VATEX-SA-33', 'تصدير خدمات (صفري)'], ['VATEX-SA-34-1', 'نقل دولي للسلع (صفري)'], + ['VATEX-SA-34-2', 'نقل دولي للركاب (صفري)'], ['VATEX-SA-34-3', 'خدمات مرتبطة بالنقل الدولي (صفري)'], ['VATEX-SA-34-4', 'توريد وسائل نقل مؤهلة (صفري)'], + ['VATEX-SA-34-5', 'خدمات متعلقة بنقل السلع أو الركاب (صفري)'], ['VATEX-SA-35', 'أدوية ومعدات طبية (صفري)'], ['VATEX-SA-36', 'معادن مؤهلة (صفري)'], + ['VATEX-SA-EDU', 'تعليم خاص للمواطنين (صفري)'], ['VATEX-SA-HEA', 'رعاية صحية خاصة للمواطنين (صفري)'], ['VATEX-SA-MLTRY', 'سلع عسكرية مؤهلة (صفري)'], + ['VATEX-SA-OOS', 'خارج نطاق الضريبة'], +]; + +type Tab = 'products' | 'balances' | 'card' | 'transfers' | 'adjustments' | 'valuation' | 'setup'; + +export default function Products() { + const { can } = useAuth(); + const [params] = useSearchParams(); + const TABS: Tab[] = ['products', 'balances', 'card', 'transfers', 'adjustments', 'valuation', 'setup']; + const [tab, setTab] = useState(() => (TABS.find((x) => x === params.get('tab')) ?? 'products')); + const tabs: [Tab, string, boolean][] = [ + ['products', 'المنتجات والخدمات', can('product.view')], + ['balances', 'الأرصدة', can('inventory.view')], + ['card', 'حركة الصنف', can('inventory.view')], + ['transfers', 'التحويلات', can('inventory.view')], + ['adjustments', 'التسويات والجرد', can('inventory.view')], + ['valuation', 'تقييم المخزون', can('inventory.view')], + ['setup', 'الوحدات والتصنيفات', can('product.manage')], + ]; + return ( + <> + + + {tab === 'products' && } + {tab === 'balances' && } + {tab === 'card' && } + {tab === 'transfers' && } + {tab === 'adjustments' && } + {tab === 'valuation' && } + {tab === 'setup' && } + + ); +} + +function ProductList() { + const { can } = useAuth(); + const [search, setSearch] = useState(''); + const [editing, setEditing] = useState(null); + const [error, setError] = useState(null); + const products = useLoad(() => api<{ data: Product[]; total: number }>('GET', `/api/products?limit=200${search ? `&search=${encodeURIComponent(search)}` : ''}`), [search]); + const units = useLoad(() => api<{ data: Unit[] }>('GET', '/api/units')); + const categories = useLoad(() => api<{ data: Category[] }>('GET', '/api/product-categories')); + const manage = can('product.manage'); + + async function save(e: FormEvent) { + e.preventDefault(); + const f = new FormData(e.currentTarget); + const type = String(f.get('productType')); + const body = { + nameAr: String(f.get('nameAr')), nameEn: blank(f.get('nameEn')), barcode: blank(f.get('barcode')), + productType: type, unitId: String(f.get('unitId')), categoryId: blank(f.get('categoryId')), + salePrice: String(f.get('salePrice') || '0'), purchasePrice: String(f.get('purchasePrice') || '0'), + salePriceIncludesVat: f.get('salePriceIncludesVat') === 'on', vatCategory: String(f.get('vatCategory')), + trackInventory: type === 'GOODS' && f.get('trackInventory') === 'on', + vatExemptionCode: String(f.get('vatCategory')) === 'S' ? null : blank(f.get('vatExemptionCode')), + vatExemptionReason: String(f.get('vatCategory')) === 'S' ? null : blank(f.get('vatExemptionReason')), + }; + setError(null); + try { + if (editing === 'new') await api('POST', '/api/products', { ...body, sku: blank(f.get('sku')) ?? undefined }); + else if (editing) await api('PATCH', `/api/products/${editing.id}`, body); + setEditing(null); + products.reload(); + } catch (err) { setError(err); } + } + + const current = editing && editing !== 'new' ? editing : null; + return ( + <> + + {editing && units.data && categories.data && ( +
+

{current ? `تعديل: ${current.nameAr}` : 'منتج أو خدمة جديدة'}

+
+ + + + {!current && } + + + + + + + + +
+ + +
+ + +
+
+ )} +
+
+ { if (e.key === 'Enter') setSearch(e.currentTarget.value); }} /> + {manage && } +
+
+ + + + {products.data?.data.map((p) => ( + + + + + + + + + + + ))} + {!products.data && !products.error && } + {products.data?.data.length === 0 && } + +
الرمزالاسمالنوعالتصنيفالوحدةسعر البيعسعر الشراءالكمية المتاحة
{p.sku}{manage ? : p.nameAr}{p.productType === 'GOODS' ? 'سلعة' : 'خدمة'}{p.categoryName ?? '—'}{p.unitName}{price(p.salePrice)}{p.salePriceIncludesVat && شامل}{price(p.purchasePrice)}{p.trackInventory ? p.onHand.replace(/\.?0+$/, '') || '0' : '—'}
جارٍ التحميل…
لا توجد منتجات
+
+
+ + ); +} + +function Setup() { + const units = useLoad(() => api<{ data: Unit[] }>('GET', '/api/units')); + const categories = useLoad(() => api<{ data: Category[] }>('GET', '/api/product-categories')); + const [error, setError] = useState(null); + + async function submit(e: FormEvent, fn: (f: FormData) => Promise, reload: () => void) { + e.preventDefault(); + const form = e.currentTarget; + setError(null); + try { await fn(new FormData(form)); form.reset(); reload(); } catch (err) { setError(err); } + } + + return ( + <> + +
+
+

وحدات القياس

+ + {units.data?.data.map((u) => )} +
{u.code}{u.nameAr}
+
submit(e, (f) => api('POST', '/api/units', { code: f.get('code'), nameAr: f.get('nameAr') }), units.reload)}> + + + +
+
+
+

التصنيفات

+ + {categories.data?.data.map((c) => ( + + ))} +
{c.parentId ? `${categories.data!.data.find((p) => p.id === c.parentId)?.nameAr ?? ''} ← ` : ''}{c.nameAr}
+
submit(e, (f) => api('POST', '/api/product-categories', { nameAr: f.get('nameAr'), parentId: f.get('parentId') || null }), categories.reload)}> + + + +
+
+
+ + ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/reports/Commercial.tsx b/alshuyukh-accounting/apps/web/src/pages/reports/Commercial.tsx new file mode 100644 index 000000000000..836012d1d07c --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/reports/Commercial.tsx @@ -0,0 +1,113 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../../api'; +import { ErrorBox, useLoad } from '../../ui'; +import { Amount, RangeBar, ReportFrame, useRange } from './shared'; + +interface Row { key: string; label: string; code: string | null; quantity: string; netAmount: string; vatAmount: string; totalAmount: string; cost: string; grossProfit?: string; documents: number; returns: number } + +export function SalesPurchases({ kind }: { kind: 'sales' | 'purchases' }) { + const { range, setRange, query } = useRange('year'); + const isSales = kind === 'sales'; + const [groupBy, setGroupBy] = useState('party'); + const r = useLoad(() => api<{ data: Row[]; totals: Record }>('GET', `/api/reports/${kind}?${query}&groupBy=${groupBy}`), [kind, query, groupBy]); + const d = r.data; + const groups: [string, string][] = [['party', isSales ? 'حسب العميل' : 'حسب المورد'], ['product', 'حسب الصنف'], ['month', 'حسب الشهر'], ['document', 'حسب المستند']]; + const showQty = groupBy === 'product'; + const title = isSales ? 'تقرير المبيعات' : 'تقرير المشتريات'; + return ( + [ + ['البند', 'الرمز', 'الكمية', 'الصافي', 'الضريبة', 'الإجمالي', ...(isSales ? ['التكلفة', 'مجمل الربح'] : []), 'المستندات', 'المرتجعات'], + ...d.data.map((x) => [x.label, x.code, x.quantity, x.netAmount, x.vatAmount, x.totalAmount, ...(isSales ? [x.cost, x.grossProfit] : []), x.documents, x.returns]), + ['الإجمالي', '', '', d.totals.netAmount, d.totals.vatAmount, d.totals.totalAmount, ...(isSales ? [d.totals.cost, d.totals.grossProfit] : [])], + ] : undefined}> +
+ + + + + {d && ( +
+ + + + + {showQty && } + + {isSales && <>} + + + + + {d.data.map((x) => ( + + + {showQty && } + + {isSales && <>} + + + ))} + {d.data.length === 0 && } + + {showQty && + +
{groups.find(([k]) => k === groupBy)![1].replace('حسب ', '')}الكميةالصافيالضريبةالإجماليالتكلفةمجمل الربحمستنداتمرتجعات
{x.code && groupBy !== 'document' && {x.code} }{x.label}{groupBy === 'document' && {x.code}}{x.quantity.replace(/\.?0+$/, '')}{x.documents}{x.returns || ''}
لا توجد مستندات في الفترة
الإجمالي} + + {isSales && <>} + +
+
+ )} +

+ من المستندات المُصدرة؛ المرتجعات تُطرح والملغاة مستبعدة. + {isSales ? ' التكلفة من حركات المخزون الفعلية.' : ''} الأرقام المالية الرسمية في قائمة الدخل. +

+
+
+ ); +} + +export function ExpenseReport() { + const { range, setRange, query } = useRange('year'); + const [groupBy, setGroupBy] = useState('account'); + const r = useLoad(() => api<{ data: { key: string; label: string; code: string | null; amount: string; entries: number }[]; total: string }>( + 'GET', `/api/reports/expenses?${query}&groupBy=${groupBy}`), [query, groupBy]); + const d = r.data; + return ( + [ + ['البند', 'الرمز', 'المبلغ', 'القيود'], ...d.data.map((x) => [x.label, x.code, x.amount, x.entries]), ['الإجمالي', '', d.total], + ] : undefined}> +
+ + + + + {d && ( +
+ + + + {d.data.map((x) => ( + + + + + + + ))} + {d.data.length === 0 && } + + +
البندالمبلغالنسبةالقيود
{x.code && {x.code} }{x.label}{d.total !== '0.00' ? `${((Number(x.amount) / Number(d.total)) * 100).toFixed(1)}%` : ''}{x.entries}
لا توجد مصروفات في الفترة
الإجمالي
+
+ )} +

من حسابات المصروفات في دفتر الأستاذ: سندات المصروفات، وبنود المصروفات في فواتير المشتريات، والقيود اليدوية.

+
+
+ ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/reports/Financial.tsx b/alshuyukh-accounting/apps/web/src/pages/reports/Financial.tsx new file mode 100644 index 000000000000..5a68a71bcdff --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/reports/Financial.tsx @@ -0,0 +1,157 @@ +import { Fragment, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../../api'; +import { ErrorBox, useLoad } from '../../ui'; +import { Amount, RangeBar, ReportFrame, today, useRange } from './shared'; + +interface AccountRow { accountId: string; code: string; nameAr: string; groupName: string | null; amount: string } +interface Section { type: string; title: string; accounts: AccountRow[]; total: string } + +const glLink = (accountId: string, query: string) => `/reports/general-ledger?accountId=${accountId}&${query}`; + +function SectionRows({ s, query, emphasis }: { s: Section; query: string; emphasis?: boolean }) { + return ( + <> + {s.title} + {s.accounts.map((a) => ( + + {a.code} + {a.nameAr} + + + ))} + {s.accounts.length === 0 && لا توجد حركات} + إجمالي {s.title} + + ); +} + +export function ProfitLoss() { + const { range, setRange, query } = useRange('year'); + const [costCenterId, setCostCenterId] = useState(''); + const centers = useLoad(() => api<{ data: { id: string; code: string; name: string }[] }>('GET', '/api/cost-centers').catch(() => ({ data: [] }))); + const r = useLoad(() => api<{ sections: Section[]; revenue: string; costOfSales: string; grossProfit: string; expenses: string; netProfit: string }>( + 'GET', `/api/reports/profit-loss?${query}${costCenterId ? `&costCenterId=${costCenterId}` : ''}`), [query, costCenterId]); + const d = r.data; + return ( + [ + ['الرمز', 'الحساب', 'المبلغ'], + ...d.sections.flatMap((s) => [[s.title], ...s.accounts.map((a) => [a.code, a.nameAr, a.amount]), ['', `إجمالي ${s.title}`, s.total]]), + ['', 'مجمل الربح', d.grossProfit], ['', 'صافي الربح', d.netProfit], + ] : undefined}> +
+ + {(centers.data?.data.length ?? 0) > 0 && ( + + )} + + + {d && ( +
+ + + + + + + + +
مجمل الربح
{d.netProfit.startsWith('-') ? 'صافي الخسارة' : 'صافي الربح'}
+
+ )} +

من القيود المرحّلة فقط، دون قيود إقفال السنة.

+
+
+ ); +} + +export function BalanceSheet() { + const [asOf, setAsOf] = useState(today); + const r = useLoad(() => api<{ + assets: Section; liabilities: Section; equity: Section & { currentEarnings: string }; + totalAssets: string; totalLiabilitiesAndEquity: string; balanced: boolean; + }>('GET', `/api/reports/balance-sheet?asOf=${asOf}`), [asOf]); + const d = r.data; + const query = `dateFrom=${asOf.slice(0, 4)}-01-01&dateTo=${asOf}`; + return ( + [ + ['الرمز', 'الحساب', 'المبلغ'], + ...[d.assets, d.liabilities, d.equity].flatMap((s) => [[s.title], ...s.accounts.map((a) => [a.code, a.nameAr, a.amount]), ['', `إجمالي ${s.title}`, s.total]]), + ['', 'أرباح الفترة غير المقفلة', d.equity.currentEarnings], ['', 'إجمالي الالتزامات وحقوق الملكية', d.totalLiabilitiesAndEquity], + ] : undefined}> +
+
+ + {d && ( + <> + {!d.balanced &&
الميزانية غير متوازنة — راجع القيود.
} +
+ + + + + + {d.equity.accounts.map((a) => ( + + ))} + + + + +
{d.equity.title}
{a.code}{a.nameAr}
أرباح الفترة غير المقفلة
إجمالي حقوق الملكية
إجمالي الالتزامات وحقوق الملكية
+
+

رصيد ضريبة المدخلات يظهر سالبًا ضمن الالتزامات لأنه يُخصم من ضريبة المخرجات.

+ + )} +
+
+ ); +} + +const CF_AR: Record = { OPERATING: 'الأنشطة التشغيلية', INVESTING: 'الأنشطة الاستثمارية', FINANCING: 'الأنشطة التمويلية' }; + +export function CashFlow() { + const { range, setRange, query } = useRange('year'); + const r = useLoad(() => api<{ + sections: { category: string; total: string; lines: { accountId: string; code: string; nameAr: string; inflow: string; outflow: string; net: string }[] }[]; + openingCash: string; netChange: string; closingCash: string; reconciled: boolean; + }>('GET', `/api/reports/cash-flow?${query}`), [query]); + const d = r.data; + return ( + [ + ['النشاط', 'الرمز', 'الحساب', 'داخل', 'خارج', 'الصافي'], + ...d.sections.flatMap((s) => s.lines.map((l) => [CF_AR[s.category], l.code, l.nameAr, l.inflow, l.outflow, l.net])), + ['', '', 'النقدية أول الفترة', '', '', d.openingCash], ['', '', 'صافي التغير', '', '', d.netChange], ['', '', 'النقدية آخر الفترة', '', '', d.closingCash], + ] : undefined}> +
+ + + {d && ( +
+ + + + {d.sections.map((s) => ( + + + {s.lines.map((l) => ( + + ))} + + + ))} + + + + +
الحساب المقابلداخلخارجالصافي
{CF_AR[s.category]}
{l.code} {l.nameAr}
صافي {CF_AR[s.category]}
النقدية وما في حكمها أول الفترة
صافي التغير في النقدية
النقدية وما في حكمها آخر الفترة
+
+ )} +

الطريقة المباشرة: كل حركة على الصندوق أو البنك أو حسابات طرق الدفع تُنسب إلى الحسابات المقابلة في القيد.

+
+
+ ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/reports/Ledger.tsx b/alshuyukh-accounting/apps/web/src/pages/reports/Ledger.tsx new file mode 100644 index 000000000000..d4b74188ce11 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/reports/Ledger.tsx @@ -0,0 +1,143 @@ +import { useState } from 'react'; +import { Link, useSearchParams } from 'react-router-dom'; +import { api } from '../../api'; +import { formatAmount } from '../../money'; +import { ErrorBox, useLoad } from '../../ui'; +import { Amount, docLink, RangeBar, REF_AR, ReportFrame, preset } from './shared'; + +interface Line { entryId: string; entryNumber: string; date: string; description: string; referenceType: string; referenceId?: string; documentNumber: string | null; debit: string; credit: string; balance: string } +export interface LedgerData { openingBalance: string; lines: Line[]; totals: { debit: string; credit: string }; closingBalance: string } + +/** Range state kept in the URL so links from other reports (and reloads) keep their filters. */ +export function useUrlRange() { + const [params, setParams] = useSearchParams(); + const year = preset('year'); + const range = { from: params.get('dateFrom') ?? year.from, to: params.get('dateTo') ?? year.to }; + const set = (patch: Record) => setParams((p) => { for (const [k, v] of Object.entries(patch)) { if (v) p.set(k, v); else p.delete(k); } return p; }, { replace: true }); + return { params, range, setRange: (r: { from: string; to: string }) => set({ dateFrom: r.from, dateTo: r.to }), set, query: `dateFrom=${range.from}&dateTo=${range.to}` }; +} + +/** Ledger lines with opening, running and closing balance; shared by the general ledger and party statements. */ +export function LedgerTable({ d }: { d: LedgerData }) { + return ( +
+ + + + + {d.lines.map((l, i) => { + const link = docLink(l.referenceType, l.referenceId); + return ( + + + + + + + + + + ); + })} + {d.lines.length === 0 && } + + +
التاريخالقيدالمستندالبيانمديندائنالرصيد
الرصيد الافتتاحي
{l.date}{l.entryNumber}{REF_AR[l.referenceType] ?? l.referenceType} {l.documentNumber && (link ? {l.documentNumber} : {l.documentNumber})}{l.description}{l.debit === '0.00' ? '' : formatAmount(l.debit)}{l.credit === '0.00' ? '' : formatAmount(l.credit)}
لا توجد حركات في الفترة
الإجمالي والرصيد الختامي
+
+ ); +} + +export const ledgerCsv = (d: LedgerData) => [ + ['التاريخ', 'القيد', 'النوع', 'المستند', 'البيان', 'مدين', 'دائن', 'الرصيد'], + ['', '', '', '', 'الرصيد الافتتاحي', '', '', d.openingBalance], + ...d.lines.map((l) => [l.date, l.entryNumber, REF_AR[l.referenceType] ?? l.referenceType, l.documentNumber, l.description, l.debit, l.credit, l.balance]), + ['', '', '', '', 'الإجمالي', d.totals.debit, d.totals.credit, d.closingBalance], +]; + +export function GeneralLedger() { + const { params, range, setRange, set, query } = useUrlRange(); + const accountId = params.get('accountId') ?? ''; + const accounts = useLoad(() => api<{ data: { id: string; code: string; nameAr: string; isPostable: boolean }[] }>('GET', '/api/accounts?postableOnly=true')); + const r = useLoad(() => (accountId + ? api('GET', `/api/reports/general-ledger?${query}&accountId=${accountId}`) + : Promise.resolve(null)), [accountId, query]); + const d = r.data; + return ( + ledgerCsv(d) : undefined}> +
+ + + + + {!accountId &&

اختر حسابًا لعرض حركاته.

} + {d && <> + {d.truncated &&
عُرضت أول 5000 حركة فقط؛ ضيّق الفترة.
} + + } +
+
+ ); +} + +interface Entry { id: string; entryNumber: string; date: string; description: string; referenceType: string; status: string; totalDebit: string; lines: { lineNo: number; accountCode: string; accountName: string; description: string | null; debit: string; credit: string }[] } + +export function JournalReport() { + const { range, setRange, query } = useUrlRange(); + const [type, setType] = useState(''); + const [offset, setOffset] = useState(0); + const PAGE = 100; + const r = useLoad(() => api<{ data: Entry[]; total: number; totals: { debit: string; credit: string } }>( + 'GET', `/api/reports/journal?${query}&limit=${PAGE}&offset=${offset}${type ? `&referenceType=${type}` : ''}`), [query, type, offset]); + const d = r.data; + return ( + [ + ['القيد', 'التاريخ', 'النوع', 'البيان', 'الحساب', 'مدين', 'دائن'], + ...d.data.flatMap((e) => e.lines.map((l) => [e.entryNumber, e.date, REF_AR[e.referenceType] ?? e.referenceType, l.description ?? e.description, `${l.accountCode} ${l.accountName}`, l.debit, l.credit])), + ] : undefined}> +
+ { setOffset(0); setRange(x); }}> + + + + {d && ( + <> +

{d.total} قيدًا — إجمالي المدين {formatAmount(d.totals.debit)} = إجمالي الدائن {formatAmount(d.totals.credit)}

+
+ + + + {d.data.map((e) => [ + + + , + ...e.lines.map((l) => ( + + + + + )), + ])} + {d.data.length === 0 && } + +
القيد / الحسابالبيانمديندائن
{e.entryNumber} {e.date}{REF_AR[e.referenceType] ?? e.referenceType} — {e.description}{e.status === 'REVERSED' && (معكوس)} +
{l.accountCode} {l.accountName}{l.description ?? ''}{l.debit === '0.00' ? '' : formatAmount(l.debit)}{l.credit === '0.00' ? '' : formatAmount(l.credit)}
لا توجد قيود في الفترة
+
+ {d.total > PAGE && ( +
+ {offset + 1}–{Math.min(offset + PAGE, d.total)} من {d.total} + + +
+ )} + + )} +
+
+ ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/reports/Parties.tsx b/alshuyukh-accounting/apps/web/src/pages/reports/Parties.tsx new file mode 100644 index 000000000000..df09cef33f6c --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/reports/Parties.tsx @@ -0,0 +1,99 @@ +import { Fragment, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../../api'; +import { formatAmount } from '../../money'; +import { ErrorBox, useLoad } from '../../ui'; +import { LedgerTable, ledgerCsv, useUrlRange, type LedgerData } from './Ledger'; +import { Amount, docLink, RangeBar, ReportFrame } from './shared'; + +type PartyType = 'customer' | 'supplier'; +const BUCKETS: [string, string][] = [['current', 'غير مستحق'], ['days1to30', '1–30 يومًا'], ['days31to60', '31–60'], ['days61to90', '61–90'], ['over90', 'أكثر من 90']]; +interface AgingRow extends Record { + partyId: string; code: string; nameAr: string; unapplied: string; balance: string; + documents: { type: string; id: string; number: string; date: string; dueDate: string; total: string; remaining: string; daysOverdue: number }[]; +} + +export function Aging({ party }: { party: PartyType }) { + const r = useLoad(() => api<{ asOf: string; data: AgingRow[]; totals: Record }>('GET', `/api/reports/${party === 'customer' ? 'receivables' : 'payables'}-aging`), [party]); + const [open, setOpen] = useState(null); + const d = r.data; + const title = party === 'customer' ? 'أعمار الذمم المدينة (العملاء)' : 'أعمار الذمم الدائنة (الموردون)'; + return ( + [ + ['الرمز', 'الاسم', ...BUCKETS.map(([, l]) => l), 'أرصدة غير مخصصة', 'الرصيد'], + ...d.data.map((p) => [p.code, p.nameAr, ...BUCKETS.map(([k]) => p[k] as string), p.unapplied, p.balance]), + ['', 'الإجمالي', ...BUCKETS.map(([k]) => d.totals[k]), d.totals.unapplied, d.totals.balance], + ] : undefined}> +
+ + {d && ( + <> +

كما في {d.asOf}، حسب تاريخ الاستحقاق. الرصيد من دفتر الأستاذ؛ «غير مخصص» يشمل الدفعات المقدمة والإشعارات غير المطبقة.

+
+ + {BUCKETS.map(([k, l]) => )} + + {d.data.map((p) => ( + + + + {BUCKETS.map(([k]) => )} + + + {open === p.partyId && p.documents.map((doc) => ( + + + + + + ))} + + ))} + {d.data.length === 0 && } + {BUCKETS.map(([k]) => )} + +
{party === 'customer' ? 'العميل' : 'المورد'}{l}غير مخصصالرصيد
+ {p.documents.length > 0 && } + {p.code} — {p.nameAr} +
{doc.number} استحقاق {doc.dueDate}{doc.daysOverdue > 0 ? ` — متأخر ${doc.daysOverdue} يومًا` : ''}الإجمالي {formatAmount(doc.total)}
لا توجد أرصدة
الإجمالي
+
+ + )} +
+
+ ); +} + +export function Statement({ party }: { party: PartyType }) { + const { params, range, setRange, set, query } = useUrlRange(); + const partyId = params.get('partyId') ?? ''; + const parties = useLoad(() => api<{ data: { id: string; code: string; nameAr: string }[] }>('GET', `/api/${party}s?limit=200`), [party]); + const r = useLoad(() => (partyId + ? api('GET', `/api/reports/${party}-statement?${query}&partyId=${partyId}`) + : Promise.resolve(null)), [party, partyId, query]); + const d = r.data; + const label = party === 'customer' ? 'العميل' : 'المورد'; + return ( + ledgerCsv(d) : undefined}> +
+ + + + + {!partyId &&

اختر {label} لعرض كشف الحساب.

} + {d && <> +
+
{label}
{d.party.code} — {d.party.nameAr}
+ {d.party.vatNumber &&
الرقم الضريبي
{d.party.vatNumber}
} +
الرصيد الختامي
{formatAmount(d.closingBalance)}
+
+ +

{party === 'customer' ? 'الرصيد الموجب مستحق على العميل.' : 'الرصيد الموجب مستحق للمورد.'}

+ } +
+
+ ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/reports/Reports.tsx b/alshuyukh-accounting/apps/web/src/pages/reports/Reports.tsx new file mode 100644 index 000000000000..ff85029c3a4e --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/reports/Reports.tsx @@ -0,0 +1,83 @@ +import { Link, Navigate, Route, Routes } from 'react-router-dom'; +import { useAuth } from '../../auth'; +import { PageHeader } from '../../ui'; +import { ExpenseReport, SalesPurchases } from './Commercial'; +import { BalanceSheet, CashFlow, ProfitLoss } from './Financial'; +import { GeneralLedger, JournalReport } from './Ledger'; +import { Aging, Statement } from './Parties'; +import VatReturn from './VatReturn'; + +interface Item { to: string; title: string; hint: string; perm: string } +const GROUPS: { title: string; items: Item[] }[] = [ + { title: 'القوائم المالية', items: [ + { to: 'profit-loss', title: 'قائمة الدخل', hint: 'الإيرادات والتكاليف وصافي الربح', perm: 'financial_report.view' }, + { to: 'balance-sheet', title: 'الميزانية العمومية', hint: 'الأصول والالتزامات وحقوق الملكية في تاريخ', perm: 'financial_report.view' }, + { to: 'cash-flow', title: 'التدفقات النقدية', hint: 'مصادر النقد واستخداماته', perm: 'financial_report.view' }, + { to: '/accounting/trial-balance', title: 'ميزان المراجعة', hint: 'أرصدة كل الحسابات', perm: 'financial_report.view' }, + ] }, + { title: 'الدفاتر', items: [ + { to: 'general-ledger', title: 'دفتر الأستاذ العام', hint: 'حركات حساب برصيد متحرك', perm: 'financial_report.view' }, + { to: 'journal', title: 'تقرير اليومية', hint: 'القيود المرحّلة بسطورها', perm: 'financial_report.view' }, + { to: 'expenses', title: 'تقرير المصروفات', hint: 'حسب الحساب أو مركز التكلفة أو الشهر', perm: 'financial_report.view' }, + ] }, + { title: 'العملاء والموردون', items: [ + { to: 'receivables', title: 'أعمار الذمم المدينة', hint: 'المستحق على العملاء حسب التأخير', perm: 'report.view' }, + { to: 'payables', title: 'أعمار الذمم الدائنة', hint: 'المستحق للموردين حسب التأخير', perm: 'report.view' }, + { to: 'customer-statement', title: 'كشف حساب عميل', hint: 'الحركات والرصيد المتحرك', perm: 'report.view' }, + { to: 'supplier-statement', title: 'كشف حساب مورد', hint: 'الحركات والرصيد المتحرك', perm: 'report.view' }, + ] }, + { title: 'المبيعات والمشتريات', items: [ + { to: 'sales', title: 'تقرير المبيعات', hint: 'حسب العميل أو الصنف أو الشهر، مع مجمل الربح', perm: 'report.view' }, + { to: 'purchases', title: 'تقرير المشتريات', hint: 'حسب المورد أو الصنف أو الشهر', perm: 'report.view' }, + ] }, + { title: 'الضريبة والمخزون', items: [ + { to: 'vat', title: 'إقرار ضريبة القيمة المضافة', hint: 'بنود نموذج الهيئة مع مطابقة الدفتر', perm: 'financial_report.view' }, + { to: '/inventory?tab=valuation', title: 'تقييم المخزون', hint: 'الكمية والتكلفة مع مطابقة الدفتر', perm: 'inventory.view' }, + { to: '/inventory?tab=card', title: 'حركة الصنف', hint: 'كل حركات صنف برصيد متحرك', perm: 'inventory.view' }, + ] }, +]; + +function Index() { + const { can } = useAuth(); + const groups = GROUPS.map((g) => ({ ...g, items: g.items.filter((i) => can(i.perm)) })).filter((g) => g.items.length); + return ( + <> + + {groups.length === 0 &&
لا تملك صلاحية عرض أي تقرير.
} + {groups.map((g) => ( +
+

{g.title}

+
+ {g.items.map((i) => ( + + {i.title}{i.hint} + + ))} +
+
+ ))} + + ); +} + +export default function Reports() { + return ( + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/reports/VatReturn.tsx b/alshuyukh-accounting/apps/web/src/pages/reports/VatReturn.tsx new file mode 100644 index 000000000000..87ab54c1dd04 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/reports/VatReturn.tsx @@ -0,0 +1,131 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../../api'; +import { formatAmount } from '../../money'; +import { ErrorBox, useLoad } from '../../ui'; +import { ReportFrame } from './shared'; + +interface Box { amount: string; adjustment: string; vat: string } +interface VatReturnData { + dateFrom: string; dateTo: string; + sales: Record; purchases: Record; + '13_totalVatDue': string; '14_previousPeriodCorrections': string; '15_creditCarriedForward': string; '16_netVatDue': string; + notSupported: string[]; + ledger: { vatOutput: string; vatInput: string; outputDifference: string; inputDifference: string; reconciled: boolean }; +} +interface TaxTx { id: string; direction: string; sourceType: string; sourceId: string; sourceNumber: string | null; date: string; vatCategory: string; taxableAmount: string; taxAmount: string; isAdjustment: boolean; reversesId: string | null; partyName: string | null; partyVatNumber: string | null } + +const LABELS: Record = { + '1_standardRated': 'المبيعات الخاضعة للنسبة الأساسية', '2_citizenHealthEducation': 'مبيعات المواطنين (خدمات صحية خاصة وتعليم أهلي)', + '3_zeroRatedDomestic': 'المبيعات المحلية الخاضعة للنسبة الصفرية', '4_exports': 'الصادرات', '5_exempt': 'المبيعات المعفاة', '6_total': 'إجمالي المبيعات', + '7_standardRatedDomestic': 'المشتريات الخاضعة للنسبة الأساسية', '8_importsPaidAtCustoms': 'الاستيرادات الخاضعة للضريبة المدفوعة في الجمارك', + '9_importsReverseCharge': 'الاستيرادات الخاضعة للتحويل العكسي', '10_zeroRated': 'المشتريات الخاضعة للنسبة الصفرية', '11_exempt': 'المشتريات المعفاة', '12_total': 'إجمالي المشتريات', +}; +const SOURCE_AR: Record = { + SALES_INVOICE: 'فاتورة مبيعات', SALES_RETURN: 'مرتجع مبيعات', PURCHASE_INVOICE: 'فاتورة مشتريات', PURCHASE_RETURN: 'مرتجع مشتريات', EXPENSE: 'مصروف', +}; +const SOURCE_LINK: Record = { + SALES_INVOICE: '/sales/invoices', SALES_RETURN: '/sales/returns', PURCHASE_INVOICE: '/purchases/invoices', PURCHASE_RETURN: '/purchases/returns', EXPENSE: '/expenses/list', +}; + +/** First and last day of the previous calendar quarter, in Riyadh time. */ +function lastQuarter() { + const [y, m] = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Riyadh' }).format(new Date()).split('-').map(Number) as [number, number]; + const q = Math.floor((m - 1) / 3); + const [year, startMonth] = q === 0 ? [y - 1, 10] : [y, q * 3 - 2]; + const end = new Date(Date.UTC(year, startMonth + 2, 0)).toISOString().slice(0, 10); + return { from: `${year}-${String(startMonth).padStart(2, '0')}-01`, to: end }; +} + +export default function VatReturn() { + const [range, setRange] = useState(lastQuarter); + const [showTx, setShowTx] = useState(false); + const report = useLoad(() => api('GET', `/api/reports/vat-return?dateFrom=${range.from}&dateTo=${range.to}`), [range.from, range.to]); + const tx = useLoad(() => (showTx ? api<{ data: TaxTx[] }>('GET', `/api/reports/vat-transactions?dateFrom=${range.from}&dateTo=${range.to}`) : Promise.resolve(null)), [showTx, range.from, range.to]); + const r = report.data; + + const section = (title: string, boxes: Record) => ( + <> + {title} + {Object.entries(boxes).map(([key, b]) => { + const [n] = key.split('_'); + const unsupported = r?.notSupported.includes(key); + const total = key.endsWith('_total'); + return ( + + {n} + {LABELS[key]}{unsupported && — غير مدعوم بعد} + {formatAmount(b.amount)} + {formatAmount(b.adjustment)} + {formatAmount(b.vat)} + + ); + })} + + ); + + return ( + +
+
+ + +
+ + {r && ( + <> +
+ {r.ledger.reconciled + ? 'الإقرار مطابق لأرصدة حسابات ضريبة المخرجات والمدخلات في دفتر الأستاذ.' + : `يوجد فرق بين الإقرار ودفتر الأستاذ: المخرجات ${formatAmount(r.ledger.outputDifference)}، المدخلات ${formatAmount(r.ledger.inputDifference)}. تحقق من القيود اليدوية على حسابات الضريبة.`} +
+
+ + + + {section('ضريبة القيمة المضافة على المبيعات', r.sales)} + {section('ضريبة القيمة المضافة على المشتريات', r.purchases)} + + + + + + +
البندالوصفالمبلغ (ريال)التعديلاتمبلغ الضريبة
صافي الضريبة
13إجمالي ضريبة القيمة المضافة المستحقة عن الفترة الحالية{formatAmount(r['13_totalVatDue'])}
14تصحيحات من الفترات السابقة — غير مدعوم بعد{formatAmount(r['14_previousPeriodCorrections'])}
15ضريبة القيمة المضافة المرحلة من الفترات السابقة — غير مدعوم بعد{formatAmount(r['15_creditCarriedForward'])}
16صافي الضريبة المستحقة {Number(r['16_netVatDue']) < 0 ? '(رصيد لصالح المنشأة)' : ''}{formatAmount(r['16_netVatDue'])}
+
+
+
رصيد ضريبة المخرجات في الدفتر
{formatAmount(r.ledger.vatOutput)}
+
رصيد ضريبة المدخلات في الدفتر
{formatAmount(r.ledger.vatInput)}
+
+

هذا تقرير مساعد لإعداد الإقرار وليس تقديمًا إلى هيئة الزكاة والضريبة والجمارك. البنود الموسومة بـ«غير مدعوم بعد» تظهر صفرًا لأن النظام لا يفصلها حاليًا.

+ + + )} +
+ {showTx && ( +
+ +
+ + + + {tx.data?.data.map((t) => ( + + + + + + + + + + ))} + {tx.data?.data.length === 0 && } + +
التاريخالنوعالمستندالطرفالفئةالمبلغ الخاضعالضريبة
{t.date}{t.direction === 'OUTPUT' ? 'مخرجات' : 'مدخلات'} — {SOURCE_AR[t.sourceType] ?? t.sourceType}{t.reversesId ? ' (إلغاء)' : ''}{t.sourceNumber ?? '—'}{t.partyName ?? ''}{t.partyVatNumber &&
{t.partyVatNumber}
}
{t.vatCategory}{formatAmount(t.taxableAmount)}{formatAmount(t.taxAmount)}
لا توجد حركات في الفترة
+
+
+ )} +
+ ); +} diff --git a/alshuyukh-accounting/apps/web/src/pages/reports/shared.tsx b/alshuyukh-accounting/apps/web/src/pages/reports/shared.tsx new file mode 100644 index 000000000000..2299bf65f1cb --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/reports/shared.tsx @@ -0,0 +1,99 @@ +import { useState, type ReactNode } from 'react'; +import { Link } from 'react-router-dom'; +import { formatAmount } from '../../money'; +import { PageHeader } from '../../ui'; + +export const today = () => new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Riyadh' }).format(new Date()); +const pad = (n: number) => String(n).padStart(2, '0'); +const lastDay = (y: number, m: number) => new Date(Date.UTC(y, m, 0)).getUTCDate(); + +/** Date-range presets in Riyadh time. Months are 1-based. */ +export function preset(kind: 'month' | 'lastMonth' | 'quarter' | 'year'): { from: string; to: string } { + const [y, m] = today().split('-').map(Number) as [number, number]; + if (kind === 'month') return { from: `${y}-${pad(m)}-01`, to: `${y}-${pad(m)}-${lastDay(y, m)}` }; + if (kind === 'lastMonth') { + const [py, pm] = m === 1 ? [y - 1, 12] : [y, m - 1]; + return { from: `${py}-${pad(pm)}-01`, to: `${py}-${pad(pm)}-${lastDay(py, pm)}` }; + } + if (kind === 'quarter') { + const qs = Math.floor((m - 1) / 3) * 3 + 1; + return { from: `${y}-${pad(qs)}-01`, to: `${y}-${pad(qs + 2)}-${lastDay(y, qs + 2)}` }; + } + return { from: `${y}-01-01`, to: `${y}-12-31` }; +} + +export function useRange(initial: Parameters[0] = 'year') { + const [range, setRange] = useState(() => preset(initial)); + return { range, setRange, query: `dateFrom=${range.from}&dateTo=${range.to}` }; +} + +export function RangeBar({ range, setRange, children }: { range: { from: string; to: string }; setRange: (r: { from: string; to: string }) => void; children?: ReactNode }) { + const presets: [Parameters[0], string][] = [['month', 'هذا الشهر'], ['lastMonth', 'الشهر السابق'], ['quarter', 'هذا الربع'], ['year', 'هذه السنة']]; + return ( +
+ + +
+ {presets.map(([k, label]) => )} +
+ {children} +
+ ); +} + +/** Report page frame: title, back link, print and CSV export. */ +export function ReportFrame({ title, subtitle, csv, children }: { title: string; subtitle?: string; csv?: () => (string | number | null | undefined)[][]; children: ReactNode }) { + return ( + <> + + كل التقارير + {csv && } + + + {subtitle &&

{subtitle}

} + {children} + + ); +} + +/** + * Downloads rows as CSV with a BOM so Excel opens Arabic text correctly. Values are exported as the server sent them. + * File names stay ASCII: some browsers drop non-ASCII download names. + */ +export function downloadCsv(name: string, rows: (string | number | null | undefined)[][]) { + const esc = (v: string | number | null | undefined) => { + let s = v === null || v === undefined ? '' : String(v); + // Spreadsheet formula injection: a cell starting with = + - @ (or tab/CR) would + // be evaluated by Excel. Prefix text with ' — numbers such as -150.00 stay numbers. + if (/^[=+\-@\t\r]/.test(s) && !/^-?\d+(\.\d+)?$/.test(s)) s = `'${s}`; + return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + }; + const blob = new Blob(['' + rows.map((r) => r.map(esc).join(',')).join('\r\n')], { type: 'text/csv;charset=utf-8' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `${name}.csv`; + document.body.append(a); + a.click(); + a.remove(); + // Revoking immediately can cancel the download in some browsers. + setTimeout(() => URL.revokeObjectURL(a.href), 1000); +} + +export const Amount = ({ v, strong }: { v: string | null | undefined; strong?: boolean }) => + {formatAmount(v)}; + +export const REF_AR: Record = { + MANUAL: 'قيد يدوي', REVERSAL: 'قيد عكسي', YEAR_CLOSING: 'إقفال سنة', SALES_INVOICE: 'فاتورة مبيعات', SALES_RETURN: 'مرتجع مبيعات', + PURCHASE_INVOICE: 'فاتورة مشتريات', PURCHASE_RETURN: 'مرتجع مشتريات', PAYMENT_RECEIPT: 'سند قبض', PAYMENT_DISBURSEMENT: 'سند صرف', + EXPENSE: 'مصروف', STOCK_ADJUSTMENT: 'تسوية مخزون', INVENTORY_RESIDUAL: 'فرق تقييم مخزون', +}; + +/** Link to the document behind a ledger reference, when the app has a page for it. */ +export function docLink(type: string, id: string | null | undefined): string | null { + if (!id) return null; + const base: Record = { + SALES_INVOICE: '/sales/invoices', SALES_RETURN: '/sales/returns', PURCHASE_INVOICE: '/purchases/invoices', + PURCHASE_RETURN: '/purchases/returns', EXPENSE: '/expenses/list', + }; + return base[type] ? `${base[type]}/${id}` : null; +} diff --git a/alshuyukh-accounting/apps/web/src/pages/subscription/Subscription.tsx b/alshuyukh-accounting/apps/web/src/pages/subscription/Subscription.tsx new file mode 100644 index 000000000000..be3702d2a715 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/pages/subscription/Subscription.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react'; +import { api } from '../../api'; +import { useAuth } from '../../auth'; +import { formatAmount } from '../../money'; +import { ErrorBox, formatDateTime, PageHeader, useLoad } from '../../ui'; + +export const LIMIT_AR: Record = { + max_users: 'المستخدمون', max_companies: 'الشركات', max_branches: 'الفروع', max_warehouses: 'المستودعات', max_products: 'المنتجات والخدمات', + max_invoices_per_month: 'فواتير المبيعات هذا الشهر', max_storage_mb: 'التخزين (MB)', max_api_calls_per_month: 'طلبات API هذا الشهر', +}; +export const STATE_AR: Record = { + TRIALING: ['فترة تجريبية', 'status-partial'], ACTIVE: ['نشط', 'status-posted'], GRACE: ['فترة سماح', 'status-partial'], + EXPIRED: ['منتهٍ — للاطلاع فقط', 'status-reversed'], CANCELLED: ['ملغى', 'status-reversed'], NONE: ['بلا اشتراك', 'status-reversed'], +}; +const EVENT_AR: Record = { + TRIAL_STARTED: 'بدء الفترة التجريبية', PLAN_CHANGED: 'تغيير الباقة', PERIOD_EXTENDED: 'تمديد الفترة', PAYMENT_RECORDED: 'تسجيل دفعة', + LIMITS_CHANGED: 'تعديل الحدود', PLAN_CHANGE_REQUESTED: 'طلب تغيير الباقة', SUBSCRIPTION_CANCELLED: 'إلغاء الاشتراك', + TENANT_SUSPENDED: 'إيقاف المنشأة', TENANT_ACTIVATED: 'تفعيل المنشأة', +}; +export { EVENT_AR }; + +interface Sub { + planName: string | null; state: string; billingCycle: string | null; periodEnd: string | null; graceEnd: string | null; + limits: Record; usage: Record; items: { description: string; unitPrice: string; currency: string; quantity: number }[]; +} +interface Plan { id: string; nameAr: string; description: string | null; priceMonthly: string; priceYearly: string; currency: string; [k: string]: unknown } + +/** Usage against a limit; a bar only when the limit is finite. */ +export function UsageRow({ label, used, max }: { label: string; used: number; max: number | null }) { + const pct = max ? Math.min(100, Math.round((used / max) * 100)) : 0; + return ( +
+
{label}{used}{max === null ? ' / ∞' : ` / ${max}`}
+ {max !== null &&
= 90 ? 'high' : ''} />
} +
+ ); +} + +export default function Subscription() { + const { can } = useAuth(); + const sub = useLoad(() => api('GET', '/api/subscription')); + const plans = useLoad(() => api<{ data: Plan[] }>('GET', '/api/subscription/plans')); + const events = useLoad(() => (can('subscription.manage') ? api<{ data: { id: string; eventType: string; amount: string | null; currency: string | null; reference: string | null; createdAt: string }[] }>('GET', '/api/subscription/billing-events') : Promise.resolve({ data: [] }))); + const [cycle, setCycle] = useState<'MONTHLY' | 'YEARLY'>('MONTHLY'); + const [sent, setSent] = useState(null); + const [error, setError] = useState(null); + async function request(planId: string) { + setError(null); + try { await api('POST', '/api/subscription/request-change', { planId, billingCycle: cycle }); setSent(planId); events.reload(); } catch (e) { setError(e); } + } + const s = sub.data; + const [label, cls] = s ? STATE_AR[s.state] ?? [s.state, ''] : ['', '']; + return ( + <> + + + {s && ( +
+
+

{s.planName ?? 'بلا باقة'}

{label}
+
+
{s.state === 'TRIALING' ? 'تنتهي التجربة' : 'نهاية الفترة'}
{formatDateTime(s.periodEnd)}
+ {s.billingCycle &&
دورة الفوترة
{s.billingCycle === 'YEARLY' ? 'سنوية' : 'شهرية'}
} + {s.items.map((i) =>
{i.description}
{formatAmount(i.unitPrice)} {i.currency}
)} +
+
+
+

الاستخدام والحدود

+ {Object.keys(LIMIT_AR).filter((k) => k !== 'max_storage_mb').map((k) => )} +
+
+ )} + {can('subscription.manage') && plans.data && ( +
+
+

الباقات المتاحة

+ +
+

الدفع الإلكتروني غير مفعّل بعد: اطلب الباقة وسيتواصل معك فريق المنصة لإتمام الدفع وتفعيلها.

+
+ {plans.data.data.map((p) => ( +
+

{p.nameAr}

+
{formatAmount(cycle === 'YEARLY' ? p.priceYearly : p.priceMonthly)} {p.currency} / {cycle === 'YEARLY' ? 'سنة' : 'شهر'}
+ {p.description &&

{p.description}

} +
    + {Object.keys(LIMIT_AR).map((k) =>
  • {LIMIT_AR[k]}: {p[k] === null ? 'غير محدود' : String(p[k])}
  • )} +
+ {sent === p.id ?
أُرسل الطلب
: } +
+ ))} + {plans.data.data.length === 0 &&

لا توجد باقات معروضة حاليًا.

} +
+
+ )} + {(events.data?.data.length ?? 0) > 0 && ( +
+

سجل الفوترة

+ + + {events.data!.data.map((e) => ( + + + ))} +
التاريخالحدثالمبلغالمرجع
{formatDateTime(e.createdAt)}{EVENT_AR[e.eventType] ?? e.eventType}{e.amount ? `${formatAmount(e.amount)} ${e.currency}` : ''}{e.reference ?? ''}
+
+ )} + + ); +} diff --git a/alshuyukh-accounting/apps/web/src/styles.css b/alshuyukh-accounting/apps/web/src/styles.css new file mode 100644 index 000000000000..dc80918f6c40 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/styles.css @@ -0,0 +1,333 @@ +:root { + --bg: #f4f6f8; + --surface: #ffffff; + --text: #17212b; + --muted: #5f6b7a; + --border: #dde3ea; + --primary: #0f6e5a; + --primary-ink: #ffffff; + --primary-soft: #e3f2ee; + --danger: #b42318; + --warn-bg: #fff6e0; + --warn-ink: #7a4b00; + --ok-bg: #e7f6ec; + --ok-ink: #17643a; + --err-bg: #fdecea; + --sidebar: #0e2a2f; + --sidebar-ink: #cfe0dd; + --radius: 10px; + --shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 8%); + color-scheme: light; +} + +* { box-sizing: border-box; } +html, body, #root { height: 100%; } +body { + margin: 0; + font-family: 'IBM Plex Sans Arabic', system-ui, -apple-system, 'Segoe UI', Tahoma, sans-serif; + background: var(--bg); + color: var(--text); + font-size: 15px; + line-height: 1.6; +} +a { color: var(--primary); } +h1 { font-size: 1.4rem; margin: 0; } +h2 { font-size: 1.05rem; margin: 0 0 12px; } +code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85em; } +pre { white-space: pre-wrap; word-break: break-word; margin: 4px 0 0; background: var(--bg); padding: 8px; border-radius: 6px; } +.muted { color: var(--muted); } +.small { font-size: 0.82rem; } +.center-screen { display: grid; place-items: center; height: 100%; color: var(--muted); } + +/* Layout ------------------------------------------------------------------ */ +.shell { display: flex; min-height: 100%; } +.sidebar { + width: 248px; flex-shrink: 0; background: var(--sidebar); color: var(--sidebar-ink); + padding: 16px 12px; position: sticky; top: 0; height: 100vh; overflow-y: auto; +} +.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; color: #fff; padding: 4px 8px 18px; } +.brand-lg { color: var(--text); justify-content: center; font-size: 1.15rem; padding-bottom: 8px; } +.brand-mark { + display: inline-grid; place-items: center; width: 34px; height: 34px; border-radius: 9px; + background: var(--primary); color: #fff; font-weight: 700; +} +.nav-item { + display: flex; align-items: center; gap: 10px; padding: 9px 10px; border-radius: 8px; + color: var(--sidebar-ink); text-decoration: none; margin-bottom: 2px; +} +.nav-item:hover { background: rgb(255 255 255 / 6%); } +.nav-item.active { background: rgb(255 255 255 / 12%); color: #fff; font-weight: 600; } +.nav-icon { width: 20px; text-align: center; opacity: 0.85; } +.badge-soon { margin-inline-start: auto; font-size: 0.7rem; padding: 1px 7px; border-radius: 99px; background: rgb(255 255 255 / 10%); } +.main { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.topbar { + display: flex; align-items: center; gap: 16px; padding: 10px 24px; background: var(--surface); + border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 5; +} +.topbar .tenant { flex: 1; } +.user { display: flex; flex-direction: column; text-align: end; line-height: 1.3; } +.user-name { font-weight: 600; } +.content { padding: 24px; max-width: 1200px; width: 100%; } +.page-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 18px; flex-wrap: wrap; } +.page-header .actions { display: flex; gap: 8px; } +.menu-btn, .scrim { display: none; } + +/* Components --------------------------------------------------------------- */ +.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; box-shadow: var(--shadow); margin-bottom: 18px; } +.grid-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 18px; } +.grid-cards .card { margin-bottom: 0; } +.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 16px; } +.empty { text-align: center; padding: 40px 20px; } + +.btn { + font: inherit; border: 1px solid var(--border); background: var(--surface); color: var(--text); + padding: 8px 16px; border-radius: 8px; cursor: pointer; font-weight: 500; +} +.btn:hover { border-color: var(--primary); } +.btn:disabled { opacity: 0.6; cursor: default; } +.btn-primary { background: var(--primary); border-color: var(--primary); color: var(--primary-ink); } +.btn-primary:hover { filter: brightness(1.08); } +.btn-ghost { background: transparent; border-color: transparent; } +.btn-danger { color: var(--danger); border-color: var(--danger); } +.btn-small { padding: 3px 10px; font-size: 0.85rem; } +.icon-btn { font: inherit; background: none; border: none; font-size: 1.3rem; cursor: pointer; } + +label { display: flex; flex-direction: column; gap: 4px; font-weight: 500; margin-bottom: 14px; font-size: 0.92rem; } +input, select { + font: inherit; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; + background: var(--surface); color: var(--text); min-width: 0; +} +input:focus, select:focus, .btn:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; } +input:user-invalid { border-color: var(--danger); } +fieldset { border: none; padding: 0; margin: 0; min-width: 0; } +.hint { font-weight: 400; font-size: 0.8rem; color: var(--muted); } +.check { flex-direction: row; align-items: center; gap: 8px; font-weight: 400; margin-bottom: 6px; } +.form .btn-primary { min-width: 120px; } + +.alert { padding: 10px 14px; border-radius: 8px; margin-bottom: 14px; } +.alert ul { margin: 6px 0 0; padding-inline-start: 18px; } +.alert-error { background: var(--err-bg); color: var(--danger); } +.alert-warn { background: var(--warn-bg); color: var(--warn-ink); } +.invite-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.alert-ok { background: var(--ok-bg); color: var(--ok-ink); } + +.table-wrap { overflow-x: auto; } +.table { width: 100%; border-collapse: collapse; font-size: 0.92rem; } +.table th, .table td { text-align: start; padding: 10px 8px; border-bottom: 1px solid var(--border); vertical-align: top; } +.table th { color: var(--muted); font-weight: 600; font-size: 0.82rem; } +.table tr.clickable { cursor: pointer; } +.table tr.clickable:hover { background: var(--bg); } +.row-actions { display: flex; gap: 6px; white-space: nowrap; } +.tag { display: inline-block; padding: 1px 8px; border-radius: 99px; font-size: 0.78rem; background: var(--bg); border: 1px solid var(--border); } +.tag-ok { background: var(--ok-bg); color: var(--ok-ink); border-color: transparent; } +.tag-off { background: var(--err-bg); color: var(--danger); border-color: transparent; } +.inline-form { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 14px; } +.inline-form input, .inline-form select { flex: 1 1 120px; } +.pager { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; } +.diff { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } + +.checklist { list-style: none; padding: 0; margin: 0; } +.checklist li { display: flex; gap: 10px; padding: 6px 0; } +.checklist li.done { color: var(--muted); } +.checklist li.done span { color: var(--ok-ink); } +.settings-links { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; margin-bottom: 18px; } +.link-card { margin: 0; text-decoration: none; color: var(--text); font-weight: 600; } +.link-card:hover { border-color: var(--primary); } + +.role-row { border-bottom: 1px solid var(--border); } +.role-row:last-child { border-bottom: none; } +.role-head { all: unset; box-sizing: border-box; width: 100%; display: flex; gap: 14px; align-items: center; padding: 12px 4px; cursor: pointer; } +.role-head > span:first-child { font-weight: 600; flex: 1; } +.role-body { padding: 0 4px 14px; } +.perm-list { columns: 3 220px; margin: 0 0 10px; padding-inline-start: 18px; font-size: 0.88rem; } +.perm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; margin-bottom: 14px; } +.perm-grid fieldset { border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px; } +.perm-grid legend { font-size: 0.8rem; color: var(--muted); padding: 0 4px; } +.role-picker { display: flex; flex-direction: column; gap: 2px; } + +.auth-page { min-height: 100%; display: grid; place-items: center; padding: 24px 16px; background: linear-gradient(160deg, #e3f2ee, var(--bg)); } +.auth-card { width: 100%; max-width: 460px; } +.auth-card h1 { text-align: center; margin-bottom: 18px; font-size: 1.2rem; } +.auth-card .btn-primary { width: 100%; padding: 10px; margin-top: 4px; } +.auth-card p { text-align: center; margin-bottom: 0; } + +/* Responsive --------------------------------------------------------------- */ +@media (max-width: 900px) { + .sidebar { + position: fixed; inset-block: 0; inset-inline-start: 0; z-index: 20; + transform: translateX(100%); transition: transform 0.2s ease; + } + .nav-open .sidebar { transform: none; } + .nav-open .scrim { display: block; position: fixed; inset: 0; background: rgb(0 0 0 / 35%); z-index: 15; } + .menu-btn { display: inline-block; } + .content { padding: 16px; } + .topbar { padding: 10px 16px; gap: 10px; } +} +@media (max-width: 600px) { + .grid-2, .diff { grid-template-columns: 1fr; } + .user { display: none; } + .grid-cards { grid-template-columns: 1fr; } +} + +/* Accounting ---------------------------------------------------------------- */ +.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 18px; overflow-x: auto; } +.tab { padding: 8px 14px; text-decoration: none; color: var(--muted); border-bottom: 2px solid transparent; white-space: nowrap; font-weight: 500; } +.tab.active { color: var(--primary); border-bottom-color: var(--primary); } +.toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; } +.toolbar .check, .toolbar label { margin-bottom: 0; } +label.inline { flex-direction: row; align-items: center; gap: 6px; } +.spacer { flex: 1; } +.num { text-align: left !important; font-variant-numeric: tabular-nums; white-space: nowrap; } +.code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.88em; } +.row-header td { font-weight: 600; background: #f8fafb; } +.row-muted td { color: var(--muted); } +.tag-soft { margin-inline-start: 6px; background: var(--primary-soft); border-color: transparent; color: var(--primary); } +.tag.tag-off, .tag.tag-soft { margin-inline-start: 6px; } +.status-draft { background: var(--warn-bg); color: var(--warn-ink); border-color: transparent; } +.status-posted { background: var(--ok-bg); color: var(--ok-ink); border-color: transparent; } +.status-reversed { background: var(--err-bg); color: var(--danger); border-color: transparent; } +.plain-link { color: inherit; text-decoration: none; } +.plain-link:hover { color: var(--primary); } +.empty-row { text-align: center !important; padding: 24px !important; } +.entry-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; } +.entry-head h2 { margin: 0; } +.entry-head p { margin: 4px 0 0; } +.meta { display: flex; flex-wrap: wrap; gap: 8px 28px; margin: 16px 0; } +.meta div { display: flex; flex-direction: column; } +.meta dt { font-size: 0.78rem; color: var(--muted); } +.meta dd { margin: 0; } +.form-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 14px; } +.lines-table { min-width: 680px; } +.lines-table select, .lines-table input { width: 100%; } +.lines-table td { padding: 6px 4px; vertical-align: middle; } +.lines-table td:nth-child(1) { min-width: 220px; } +.lines-table td:nth-child(2), .lines-table td:nth-child(3) { width: 130px; } +.lines-table tfoot td { border-bottom: none; } +input[aria-invalid="true"] { border-color: var(--danger); } +.reverse-box { margin-top: 16px; padding: 14px; border: 1px solid var(--danger); border-radius: 8px; background: #fffafa; } +.reverse-box p { margin-top: 0; } +.tb-table th { text-align: center; } +.tb-table th:nth-child(1), .tb-table th:nth-child(2) { text-align: start; } + +/* Parties & products ------------------------------------------------------------ */ +.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0 16px; } +h3 { font-size: 0.95rem; margin: 6px 0 10px; color: var(--muted); } +.grow { flex: 1 1 240px; } +.link-btn { all: unset; cursor: pointer; color: var(--primary); } +.link-btn:hover { text-decoration: underline; } +button.tab { font: inherit; background: none; border: none; border-bottom: 2px solid transparent; cursor: pointer; } +@media (max-width: 900px) { .grid-3 { grid-template-columns: 1fr 1fr; } } +@media (max-width: 600px) { .grid-3 { grid-template-columns: 1fr; } } +.party-select { margin-top: 4px; } + +/* Commercial documents ----------------------------------------------------------- */ +.doc-lines td:nth-child(1) { min-width: 240px; } +.doc-lines td:nth-child(2), .doc-lines td:nth-child(4) { width: 90px; } +.doc-lines td:nth-child(3) { width: 120px; } +.sub-row { margin-top: 4px; display: flex; gap: 4px; width: 100%; } +.sub-row select { flex: 1; } +.doc-footer { display: flex; gap: 24px; align-items: flex-start; flex-wrap: wrap; margin-top: 16px; } +.totals { margin: 0; min-width: 300px; border: 1px solid var(--border); border-radius: 8px; padding: 6px 14px; } +.totals div { display: flex; justify-content: space-between; gap: 24px; padding: 4px 0; } +.totals dt { color: var(--muted); } +.totals dd { margin: 0; font-variant-numeric: tabular-nums; } +.totals .strong { border-top: 1px solid var(--border); font-weight: 700; font-size: 1.05rem; } +.totals .strong dt { color: var(--text); } +.reverse-box.neutral { border-color: var(--primary); background: #f6fbfa; } +.related { margin-top: 18px; } +.related h3 { margin-bottom: 4px; } +.status-open { background: #e8f0fe; color: #1d4ed8; border-color: transparent; } +.status-partial { background: var(--warn-bg); color: var(--warn-ink); border-color: transparent; } +.doc-lines { min-width: 760px; } + +/* Report tables */ +.table .group-row th { background: var(--bg); text-align: start; font-weight: 600; } +.table .total-row td { font-weight: 700; border-top: 2px solid var(--border); } +.table tr.muted td { color: var(--muted); } + +/* Reports */ +:root { --series-1: #2a78d6; --series-2: #eb6834; } +.toolbar .chips { display: flex; gap: 6px; flex-wrap: wrap; } +.report-group h2 { font-size: 1rem; margin: 20px 0 10px; color: var(--muted); } +.report-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; } +.report-card { display: flex; flex-direction: column; gap: 4px; } +.report-table td.strong, .report-table .strong { font-weight: 700; } +.table .total-row.emphasis td { background: var(--primary-soft); } +.table tr.sub-detail td { background: var(--bg); font-size: 0.9em; } +.negative { color: var(--danger); } +.print-only { display: none; } + +/* Dashboard */ +.kpi-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; margin-bottom: 16px; } +@media (max-width: 600px) { .kpi { padding: 12px; } .kpi strong { font-size: 1.05rem; } } +.kpi { display: flex; flex-direction: column; gap: 6px; text-decoration: none; color: inherit; margin: 0; } +.kpi strong { font-size: 1.25rem; font-variant-numeric: tabular-nums; text-align: end; } +.kpi-main { border-inline-start: 4px solid var(--primary); } +.grid-cards.charts { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); } +.card-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; } +.chart { position: relative; } +.chart-plot { position: relative; } +.chart svg { width: 100%; height: auto; display: block; overflow: visible; } +.chart-grid { stroke: var(--border); stroke-width: 1; } +.chart-baseline { stroke: var(--muted); stroke-width: 1; } +.chart-crosshair { stroke: var(--muted); stroke-dasharray: 3 3; } +.chart-tick { fill: var(--muted); font-size: 13px; } +.chart svg rect[tabindex]:focus { outline: none; stroke: var(--primary); stroke-width: 1; } +.chart-legend { display: flex; gap: 16px; font-size: 0.85rem; color: var(--muted); margin-bottom: 4px; } +.chart-legend i, .chart-tooltip i { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-inline-end: 6px; vertical-align: middle; } +.chart-tooltip { position: absolute; top: 0; transform: translateX(-50%); background: var(--surface); border: 1px solid var(--border); border-radius: 8px; + box-shadow: var(--shadow); padding: 8px 10px; font-size: 0.85rem; pointer-events: none; min-width: 180px; } +.chart-tooltip div { display: flex; align-items: center; gap: 4px; } +.chart-tooltip div span { margin-inline-start: auto; font-variant-numeric: tabular-nums; } + +@media print { + .sidebar, .topbar, .no-print, .scrim { display: none !important; } + .shell, .main, .content { display: block; padding: 0; margin: 0; } + .card { box-shadow: none; border: none; padding: 0; } + .print-only { display: block; } + body { background: #fff; } +} + +/* E-invoicing */ +.einvoice-panel { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 12px; margin: 8px 0 12px; background: var(--bg); border-radius: var(--radius); } +.einvoice-panel .alert { flex-basis: 100%; margin: 0; } +.einvoice-panel p { flex-basis: 100%; margin: 0; } +.steps { list-style: none; padding: 0; margin: 8px 0; display: grid; gap: 6px; } +.steps li { display: flex; gap: 8px; color: var(--muted); } +.steps li.done { color: var(--ok-ink); } +.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0; } +.actions-cell { white-space: nowrap; display: flex; gap: 6px; } +.einvoice-detail .meta dd { word-break: break-all; } + +/* Printed invoice */ +.invoice-sheet { background: var(--surface); padding: 24px; border-radius: var(--radius); box-shadow: var(--shadow); max-width: 900px; } +.invoice-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; border-bottom: 2px solid var(--border); padding-bottom: 12px; } +.invoice-head h1 { margin: 0 0 8px; font-size: 1.4rem; } +.invoice-qr { width: 140px; height: 140px; image-rendering: pixelated; } +.invoice-parties { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 16px 0; } +.invoice-parties h2 { font-size: 0.9rem; color: var(--muted); margin: 0 0 4px; } +.invoice-lines td, .invoice-lines th { font-size: 0.9rem; } +@media (max-width: 600px) { .invoice-parties { grid-template-columns: 1fr; } .invoice-sheet { padding: 12px; } .invoice-qr { width: 110px; height: 110px; } } +@media print { .invoice-sheet { box-shadow: none; padding: 0; max-width: none; } .print-page .alert-error { border: 2px solid #000; } } +@media print { body:has(.print-page) .tabs, body:has(.print-page) .page-header { display: none !important; } } + +/* Subscriptions and platform admin */ +.usage-row { margin: 10px 0; } +.usage-label { display: flex; justify-content: space-between; font-size: 0.9rem; } +.usage-bar { height: 6px; background: var(--bg); border-radius: 3px; overflow: hidden; margin-top: 4px; } +.usage-bar i { display: block; height: 100%; background: var(--primary); border-radius: 3px; } +.usage-bar i.high { background: var(--danger); } +.plan-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; } +.plan-card { border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; background: var(--surface); display: flex; flex-direction: column; gap: 8px; } +.plan-card h3 { margin: 0; } +.plan-price { font-size: 1.4rem; font-weight: 700; font-variant-numeric: tabular-nums; } +.plan-price small { font-size: 0.8rem; font-weight: 400; color: var(--muted); } +.plan-limits { list-style: none; padding: 0; margin: 0; font-size: 0.85rem; display: grid; gap: 2px; } +.tabs-scroll { overflow-x: auto; flex-wrap: nowrap; } +.tabs-scroll .tab { white-space: nowrap; } +.nav-admin { margin-top: 12px; border-top: 1px solid rgb(255 255 255 / 12%); padding-top: 12px; } +.sub-form { border: 1px dashed var(--border); border-radius: var(--radius); padding: 12px; margin-bottom: 12px; } +.inline-form { flex-wrap: wrap; } +.limits-form summary { cursor: pointer; margin-top: 8px; } +.stack { white-space: pre-wrap; font-size: 0.75rem; max-height: 260px; overflow: auto; margin: 0; } +tr.clickable { cursor: pointer; } diff --git a/alshuyukh-accounting/apps/web/src/ui.tsx b/alshuyukh-accounting/apps/web/src/ui.tsx new file mode 100644 index 000000000000..6ef378ab1034 --- /dev/null +++ b/alshuyukh-accounting/apps/web/src/ui.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState, type ReactNode } from 'react'; +import { ApiError } from './api'; + +export function useLoad(load: () => Promise, deps: unknown[] = []) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [version, setVersion] = useState(0); + useEffect(() => { + let alive = true; + setError(null); + load().then((d) => alive && setData(d)).catch((e: Error) => alive && setError(e.message)); + return () => { alive = false; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...deps, version]); + return { data, error, reload: () => setVersion((v) => v + 1) }; +} + +export function ErrorBox({ error }: { error: unknown }) { + if (!error) return null; + const msg = error instanceof Error ? error.message : String(error); + // Only field-validation errors carry a list; other details are structured data. + const details = error instanceof ApiError && Array.isArray(error.details) ? error.details : undefined; + return ( +
+ {msg} + {details &&
    {details.map((d) =>
  • {d.path}: {d.message}
  • )}
} +
+ ); +} + +export function PageHeader({ title, children }: { title: string; children?: ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +export const formatDateTime = (iso: string | null) => + iso ? new Intl.DateTimeFormat('ar-SA-u-nu-latn', { dateStyle: 'medium', timeStyle: 'short', timeZone: 'Asia/Riyadh' }).format(new Date(iso)) : '—'; diff --git a/alshuyukh-accounting/apps/web/tsconfig.json b/alshuyukh-accounting/apps/web/tsconfig.json new file mode 100644 index 000000000000..ea31880993ac --- /dev/null +++ b/alshuyukh-accounting/apps/web/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/alshuyukh-accounting/apps/web/vite.config.ts b/alshuyukh-accounting/apps/web/vite.config.ts new file mode 100644 index 000000000000..1466b85634ec --- /dev/null +++ b/alshuyukh-accounting/apps/web/vite.config.ts @@ -0,0 +1,10 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { '/api': 'http://localhost:3000' }, + }, +}); diff --git a/alshuyukh-accounting/deploy/.env.example b/alshuyukh-accounting/deploy/.env.example new file mode 100644 index 000000000000..3f3d85c8ce44 --- /dev/null +++ b/alshuyukh-accounting/deploy/.env.example @@ -0,0 +1,17 @@ +# Production settings for docker-compose.prod.yml. Copy to deploy/.env and fill in. +# Use hex secrets (openssl rand -hex 32) for passwords: they go into connection URLs. + +# Public address users open, e.g. https://app.example.com +PUBLIC_ORIGIN=https://app.example.com +# Host port nginx listens on (put the TLS proxy / load balancer in front of it) +HTTP_PORT=8080 + +POSTGRES_PASSWORD= +OWNER_DB_PASSWORD= +APP_DB_PASSWORD= + +# openssl rand -base64 48 +JWT_SECRET= +# openssl rand -base64 32 — keep a copy: it decrypts ZATCA keys, and losing it means re-onboarding every unit +ZATCA_ENCRYPTION_KEY= +ZATCA_WORKER=on diff --git a/alshuyukh-accounting/deploy/api.Dockerfile b/alshuyukh-accounting/deploy/api.Dockerfile new file mode 100644 index 000000000000..07f2fe455d2a --- /dev/null +++ b/alshuyukh-accounting/deploy/api.Dockerfile @@ -0,0 +1,35 @@ +# API image: compiled TypeScript on Node 22, production dependencies only, non-root. +# Behind a TLS-inspecting proxy, pass its CA: --secret id=npm_ca,src=ca.crt (and the +# standard HTTPS_PROXY build argument). +# Build from the project root: docker build -f deploy/api.Dockerfile . + +FROM node:22-bookworm-slim AS build +WORKDIR /app +COPY package.json package-lock.json ./ +COPY apps/api/package.json apps/api/ +COPY apps/web/package.json apps/web/ +RUN --mount=type=secret,id=npm_ca,required=false \ + if [ -f /run/secrets/npm_ca ]; then export npm_config_cafile=/run/secrets/npm_ca; fi; npm ci -w apps/api --include-workspace-root=false --no-audit --no-fund +COPY apps/api apps/api +RUN npm run build -w apps/api + +FROM node:22-bookworm-slim AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +COPY apps/api/package.json apps/api/ +COPY apps/web/package.json apps/web/ +RUN --mount=type=secret,id=npm_ca,required=false \ + if [ -f /run/secrets/npm_ca ]; then export npm_config_cafile=/run/secrets/npm_ca; fi; npm ci -w apps/api --include-workspace-root=false --omit=dev --no-audit --no-fund + +FROM node:22-bookworm-slim +ENV NODE_ENV=production +WORKDIR /app +COPY --from=deps /app/node_modules node_modules +COPY --from=build /app/apps/api/package.json apps/api/package.json +COPY --from=build /app/apps/api/dist apps/api/dist +WORKDIR /app/apps/api +USER node +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \ + CMD node -e "fetch('http://127.0.0.1:3000/api/health').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))" +CMD ["node", "dist/server.js"] diff --git a/alshuyukh-accounting/deploy/nginx.conf b/alshuyukh-accounting/deploy/nginx.conf new file mode 100644 index 000000000000..548f893e8c4c --- /dev/null +++ b/alshuyukh-accounting/deploy/nginx.conf @@ -0,0 +1,44 @@ +# Serves the single-page app and proxies /api to the API container. +# TLS is expected to end at a load balancer or a TLS proxy in front of this +# container; once it does, enable the Strict-Transport-Security line below. +# The image serves on 8080 as a non-root user. + +map $uri $cache_control { + ~^/assets/ "public, max-age=31536000, immutable"; + default "no-cache"; +} + +server { + listen 8080; + server_name _; + server_tokens off; + root /usr/share/nginx/html; + client_max_body_size 2m; + + gzip on; + gzip_types text/css application/javascript application/json image/svg+xml; + + location /api/ { + proxy_pass http://api:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + # Replace (not append) the client's header: the API trusts exactly one hop. + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + } + + # Security headers for the app (the API sets its own, stricter ones). nginx + # drops inherited add_header lines in a location that has its own, so they live here. + location / { + # add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Cache-Control $cache_control always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always; + add_header Cross-Origin-Opener-Policy "same-origin" always; + try_files $uri /index.html; + } +} diff --git a/alshuyukh-accounting/deploy/postgres-init.sh b/alshuyukh-accounting/deploy/postgres-init.sh new file mode 100755 index 000000000000..5d24671373b9 --- /dev/null +++ b/alshuyukh-accounting/deploy/postgres-init.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Runs once, when the PostgreSQL volume is first created. +# Creates the schema owner (migrations) and the restricted application role +# (NOBYPASSRLS, so row-level security isolates tenants). +set -eu +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname postgres \ + -v owner_pw="$OWNER_DB_PASSWORD" -v app_pw="$APP_DB_PASSWORD" <<'SQL' +CREATE ROLE alshuyukh_owner LOGIN PASSWORD :'owner_pw' NOSUPERUSER NOCREATEROLE NOCREATEDB; +CREATE ROLE alshuyukh_app LOGIN PASSWORD :'app_pw' NOSUPERUSER NOCREATEROLE NOCREATEDB NOBYPASSRLS; +CREATE DATABASE alshuyukh OWNER alshuyukh_owner; +REVOKE ALL ON DATABASE alshuyukh FROM PUBLIC; +GRANT CONNECT ON DATABASE alshuyukh TO alshuyukh_app; +SQL diff --git a/alshuyukh-accounting/deploy/web.Dockerfile b/alshuyukh-accounting/deploy/web.Dockerfile new file mode 100644 index 000000000000..dcd977fe9738 --- /dev/null +++ b/alshuyukh-accounting/deploy/web.Dockerfile @@ -0,0 +1,19 @@ +# Web image: the React build served by an unprivileged nginx that also proxies /api. +# Behind a TLS-inspecting proxy, pass its CA: --secret id=npm_ca,src=ca.crt (and the +# standard HTTPS_PROXY build argument). +# Build from the project root: docker build -f deploy/web.Dockerfile . + +FROM node:22-bookworm-slim AS build +WORKDIR /app +COPY package.json package-lock.json ./ +COPY apps/api/package.json apps/api/ +COPY apps/web/package.json apps/web/ +RUN --mount=type=secret,id=npm_ca,required=false \ + if [ -f /run/secrets/npm_ca ]; then export npm_config_cafile=/run/secrets/npm_ca; fi; npm ci -w apps/web --include-workspace-root=false --no-audit --no-fund +COPY apps/web apps/web +RUN npm run build -w apps/web + +FROM nginxinc/nginx-unprivileged:1.27-alpine +COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/apps/web/dist /usr/share/nginx/html +EXPOSE 8080 diff --git a/alshuyukh-accounting/docker-compose.prod.yml b/alshuyukh-accounting/docker-compose.prod.yml new file mode 100644 index 000000000000..9dfe23eb84ee --- /dev/null +++ b/alshuyukh-accounting/docker-compose.prod.yml @@ -0,0 +1,65 @@ +# Production stack: PostgreSQL, a one-shot migration job, the API and nginx. +# cp deploy/.env.example deploy/.env # then fill in the secrets +# docker compose -f docker-compose.prod.yml --env-file deploy/.env up -d --build +# Only nginx publishes a port. Put TLS in front of it (load balancer, Caddy, certbot…). + +x-api-env: &api-env + NODE_ENV: production + DATABASE_URL: postgres://alshuyukh_app:${APP_DB_PASSWORD:?}@postgres:5432/alshuyukh + JWT_SECRET: ${JWT_SECRET:?} + ZATCA_ENCRYPTION_KEY: ${ZATCA_ENCRYPTION_KEY:?} + CORS_ORIGINS: ${PUBLIC_ORIGIN:?} + TRUST_PROXY: "1" + ZATCA_WORKER: ${ZATCA_WORKER:-on} + +services: + postgres: + image: postgres:16 + restart: unless-stopped + environment: + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?} + OWNER_DB_PASSWORD: ${OWNER_DB_PASSWORD:?} + APP_DB_PASSWORD: ${APP_DB_PASSWORD:?} + TZ: UTC + volumes: + - pgdata:/var/lib/postgresql/data + - ./deploy/postgres-init.sh:/docker-entrypoint-initdb.d/01-init.sh:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d alshuyukh"] + interval: 5s + timeout: 5s + retries: 20 + + migrate: + build: { context: ., dockerfile: deploy/api.Dockerfile } + image: alshuyukh-api + command: ["node", "dist/db/migrate-cli.js"] + environment: + <<: *api-env + DATABASE_URL_MIGRATE: postgres://alshuyukh_owner:${OWNER_DB_PASSWORD:?}@postgres:5432/alshuyukh + depends_on: + postgres: { condition: service_healthy } + restart: "no" + + api: + image: alshuyukh-api + restart: unless-stopped + environment: *api-env + depends_on: + migrate: { condition: service_completed_successfully } + read_only: true + tmpfs: [/tmp] + security_opt: [no-new-privileges:true] + + web: + build: { context: ., dockerfile: deploy/web.Dockerfile } + image: alshuyukh-web + restart: unless-stopped + ports: + - "${HTTP_PORT:-8080}:8080" + depends_on: + api: { condition: service_healthy } + security_opt: [no-new-privileges:true] + +volumes: + pgdata: diff --git a/alshuyukh-accounting/docker-compose.yml b/alshuyukh-accounting/docker-compose.yml new file mode 100644 index 000000000000..5aaeadb64d3e --- /dev/null +++ b/alshuyukh-accounting/docker-compose.yml @@ -0,0 +1,13 @@ +services: + postgres: + image: postgres:16 + environment: + POSTGRES_PASSWORD: postgres + TZ: UTC + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + - ./scripts/db-setup.sql:/docker-entrypoint-initdb.d/01-setup.sql:ro +volumes: + pgdata: diff --git a/alshuyukh-accounting/docs/DEPLOYMENT.md b/alshuyukh-accounting/docs/DEPLOYMENT.md new file mode 100644 index 000000000000..27891b0d7b81 --- /dev/null +++ b/alshuyukh-accounting/docs/DEPLOYMENT.md @@ -0,0 +1,105 @@ +# دليل النشر — الشيوخ للمحاسبة + +هذا الدليل يشرح تشغيل النظام على خادم واحد باستخدام Docker Compose. جُرِّبت هذه الخطوات كاملة: بناء الصور، والترحيلات، والتسجيل، واختبار المتصفح، والنسخ الاحتياطي والاستعادة. + +## المكونات + +| الخدمة | الصورة | الدور | +|---|---|---| +| `postgres` | `postgres:16` | قاعدة البيانات. لا تنشر منفذًا خارج الشبكة الداخلية | +| `migrate` | `alshuyukh-api` | مهمة تعمل مرة واحدة: تطبق الترحيلات بدور مالك المخطط ثم تنتهي | +| `api` | `alshuyukh-api` | واجهة API بدور التطبيق المقيد (RLS مفعّل). تعمل بمستخدم غير root ونظام ملفات للقراءة فقط | +| `web` | `alshuyukh-web` | nginx بمستخدم غير root: يخدم الواجهة ويمرر `/api` إلى الـ API، مع ترويسات الأمان و CSP | + +لا تبدأ `api` إلا بعد نجاح `migrate`، ولا تبدأ `web` إلا بعد أن تصبح `api` سليمة. + +## المتطلبات + +- خادم Linux عليه Docker Engine و Docker Compose v2. +- اسم نطاق وشهادة TLS. يُنهى TLS قبل حاوية `web` (موازن أحمال، أو Caddy، أو nginx مع certbot)، ثم يُفعَّل سطر `Strict-Transport-Security` في `deploy/nginx.conf`. +- نسخ احتياطي خارج الخادم (تخزين كائنات أو خادم آخر). + +## التشغيل لأول مرة + +```sh +cp deploy/.env.example deploy/.env +# املأ الأسرار: +# openssl rand -hex 32 لكل من POSTGRES_PASSWORD و OWNER_DB_PASSWORD و APP_DB_PASSWORD +# openssl rand -base64 48 لـ JWT_SECRET +# openssl rand -base64 32 لـ ZATCA_ENCRYPTION_KEY +chmod 600 deploy/.env +docker compose -f docker-compose.prod.yml --env-file deploy/.env up -d --build +``` + +تحقق من الجاهزية: + +```sh +curl -s http://localhost:8080/api/ready # {"status":"ready"} +``` + +- `/api/health`: العملية تعمل وتصل لقاعدة البيانات (فحص الحياة). +- `/api/ready`: قاعدة البيانات متاحة ولا ترحيلات معلقة (فحص الجاهزية). يرد 503 قبل ذلك. + +ثم امنح حسابك صلاحية مدير المنصة بعد التسجيل من الواجهة: + +```sh +docker compose -f docker-compose.prod.yml --env-file deploy/.env run --rm migrate node dist/admin-cli.js grant you@example.com +``` + +يعمل الأمر عبر خدمة `migrate` لأنها تتصل بدور مالك المخطط. دور التطبيق لا يستطيع منح صلاحية المنصة، حتى لو اختُرق الـ API. + +## إعدادات مهمة + +| المتغير | الملاحظة | +|---|---| +| `JWT_SECRET` | يرفض التشغيل في الإنتاج إن كان أقصر من 32 حرفًا أو القيمة الافتراضية | +| `ZATCA_ENCRYPTION_KEY` | إلزامي في الإنتاج. يشفّر مفاتيح ZATCA الخاصة وأسرار CSID. **احتفظ بنسخة منه خارج الخادم**: فقده يعني إعادة ربط كل وحدات الفوترة | +| `TRUST_PROXY` | `1` في ملف Compose لأن nginx أمام الـ API. إن أضفت موازن أحمال أمام nginx فارفعه إلى `2` أو اكتب عناوين الوكلاء. لا تضبطه إن كان الـ API مكشوفًا مباشرة، فيمكن حينها تزوير عنوان العميل وتجاوز حد المحاولات | +| `PUBLIC_ORIGIN` | العنوان الذي يفتحه المستخدمون؛ يُستخدم لـ CORS | +| `DB_STATEMENT_TIMEOUT_MS` | يلغي الاستعلام الأطول من ذلك (افتراضيًا 15 ثانية) ويرد 503 | +| `DB_POOL_MAX` | حجم مجمع الاتصالات (افتراضيًا 20) | + +## التحديث + +```sh +git pull +docker compose -f docker-compose.prod.yml --env-file deploy/.env up -d --build +``` + +تُطبَّق الترحيلات الجديدة تلقائيًا قبل بدء الـ API. الترحيل الذي عُدّل بعد تطبيقه يوقف التشغيل عمدًا: اكتب ترحيلًا جديدًا بدل تعديل القديم. + +## النسخ الاحتياطي والاستعادة + +```sh +scripts/backup.sh /var/backups/alshuyukh # نسخة مضغوطة، ويحذف ما مضى عليه 14 يومًا +scripts/restore.sh /var/backups/alshuyukh/alshuyukh-20260101T021500Z.dump +``` + +- النسخة بصيغة `pg_dump --format=custom` وتحفظ الملكية والصلاحيات، فتبقى سياسات RLS سارية على دور التطبيق بعد الاستعادة. يتحقق السكربت من إمكانية قراءة النسخة قبل اعتمادها. +- جدولة يومية عبر cron مثلًا: `15 2 * * * cd /srv/alshuyukh && scripts/backup.sh /var/backups/alshuyukh`. +- الاستعادة تطلب كتابة `restore` للتأكيد، وتوقف الـ API أثناءها ثم تطبق أي ترحيلات أحدث من النسخة. +- انسخ الملفات خارج الخادم، وجرّب الاستعادة دوريًا على خادم تجريبي. + +## البناء خلف وكيل يفحص TLS + +إن كان الخادم خلف وكيل شركة يعيد توقيع اتصالات TLS، مرّر شهادته أثناء البناء: + +```sh +docker build -f deploy/api.Dockerfile -t alshuyukh-api --secret id=npm_ca,src=/path/to/proxy-ca.crt --build-arg HTTPS_PROXY . +docker build -f deploy/web.Dockerfile -t alshuyukh-web --secret id=npm_ca,src=/path/to/proxy-ca.crt --build-arg HTTPS_PROXY . +``` + +## اختبار ما بعد النشر + +```sh +npm ci +E2E_BASE_URL=https://staging.example.com npm run e2e +``` + +يفتح متصفحًا حقيقيًا، وينشئ منشأة جديدة، وعميلًا وخدمة، ويصدر فاتورة بقيمة 1000 ريال، ثم يتحقق من القيد (مدين 1150 / دائن 1000 / دائن 150) والتحصيل وتوازن ميزان المراجعة. يفشل الاختبار عند أي خطأ في المتصفح، ومنه مخالفات CSP. شغّله على بيئة تجريبية لأنه ينشئ بيانات. لتحديد مسار المتصفح استخدم `CHROMIUM_PATH`. + +## حدود هذا الإعداد + +- خادم واحد. مع أكثر من نسخة من الـ API تحتاج حدود المحاولات وعدادات الاستخدام إلى Redis، ويُفضَّل فصل مُرسل ZATCA في عملية مستقلة. +- لا مراقبة أو تنبيهات مدمجة: اربط `/api/ready` بأداة مراقبة، وراجع لوحة «صحة النظام» في `/admin`. +- السجلات تخرج على stdout بصيغة JSON؛ اجمعها بأداة السجلات المعتمدة لديك. diff --git a/alshuyukh-accounting/docs/screenshots/03-home.png b/alshuyukh-accounting/docs/screenshots/03-home.png new file mode 100644 index 000000000000..1bcaf77bb802 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/03-home.png differ diff --git a/alshuyukh-accounting/docs/screenshots/04-companies.png b/alshuyukh-accounting/docs/screenshots/04-companies.png new file mode 100644 index 000000000000..8ff14bc60fb0 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/04-companies.png differ diff --git a/alshuyukh-accounting/docs/screenshots/05-users.png b/alshuyukh-accounting/docs/screenshots/05-users.png new file mode 100644 index 000000000000..65a9d7c94f96 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/05-users.png differ diff --git a/alshuyukh-accounting/docs/screenshots/06-audit.png b/alshuyukh-accounting/docs/screenshots/06-audit.png new file mode 100644 index 000000000000..5458e161931a Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/06-audit.png differ diff --git a/alshuyukh-accounting/docs/screenshots/07-mobile-nav.png b/alshuyukh-accounting/docs/screenshots/07-mobile-nav.png new file mode 100644 index 000000000000..4c29049386d7 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/07-mobile-nav.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p10-invitation.png b/alshuyukh-accounting/docs/screenshots/p10-invitation.png new file mode 100644 index 000000000000..5a280b1bb65a Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p10-invitation.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p10-users-invited.png b/alshuyukh-accounting/docs/screenshots/p10-users-invited.png new file mode 100644 index 000000000000..ed41403c2370 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p10-users-invited.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p2-01-chart.png b/alshuyukh-accounting/docs/screenshots/p2-01-chart.png new file mode 100644 index 000000000000..3713a8a9b9a9 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p2-01-chart.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p2-03-posted.png b/alshuyukh-accounting/docs/screenshots/p2-03-posted.png new file mode 100644 index 000000000000..95ceccf53762 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p2-03-posted.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p2-04-reversal.png b/alshuyukh-accounting/docs/screenshots/p2-04-reversal.png new file mode 100644 index 000000000000..789462f8904a Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p2-04-reversal.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p2-06-trial-balance.png b/alshuyukh-accounting/docs/screenshots/p2-06-trial-balance.png new file mode 100644 index 000000000000..feade9ac58ff Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p2-06-trial-balance.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p2-07-fiscal.png b/alshuyukh-accounting/docs/screenshots/p2-07-fiscal.png new file mode 100644 index 000000000000..975b84562aad Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p2-07-fiscal.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p3-01-customer-form.png b/alshuyukh-accounting/docs/screenshots/p3-01-customer-form.png new file mode 100644 index 000000000000..5e232d09d7a6 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p3-01-customer-form.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p3-02-products.png b/alshuyukh-accounting/docs/screenshots/p3-02-products.png new file mode 100644 index 000000000000..d7d293cd9952 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p3-02-products.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p3-03-tagged-entry.png b/alshuyukh-accounting/docs/screenshots/p3-03-tagged-entry.png new file mode 100644 index 000000000000..3adad4e2e4ec Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p3-03-tagged-entry.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p3-04-customer-balance.png b/alshuyukh-accounting/docs/screenshots/p3-04-customer-balance.png new file mode 100644 index 000000000000..7bcd8dc41457 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p3-04-customer-balance.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p4-01-invoice-form.png b/alshuyukh-accounting/docs/screenshots/p4-01-invoice-form.png new file mode 100644 index 000000000000..fc53c77bbadf Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p4-01-invoice-form.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p4-02-invoice-issued.png b/alshuyukh-accounting/docs/screenshots/p4-02-invoice-issued.png new file mode 100644 index 000000000000..d6644bf20feb Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p4-02-invoice-issued.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p4-03-invoice-journal.png b/alshuyukh-accounting/docs/screenshots/p4-03-invoice-journal.png new file mode 100644 index 000000000000..d369cc282923 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p4-03-invoice-journal.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p4-04-invoice-paid.png b/alshuyukh-accounting/docs/screenshots/p4-04-invoice-paid.png new file mode 100644 index 000000000000..829fe65b9069 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p4-04-invoice-paid.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p4-05-credit-note.png b/alshuyukh-accounting/docs/screenshots/p4-05-credit-note.png new file mode 100644 index 000000000000..0e534b417b49 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p4-05-credit-note.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p4-06-supplier-payment.png b/alshuyukh-accounting/docs/screenshots/p4-06-supplier-payment.png new file mode 100644 index 000000000000..ce8f0fea8a43 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p4-06-supplier-payment.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p5-01-sale-journal.png b/alshuyukh-accounting/docs/screenshots/p5-01-sale-journal.png new file mode 100644 index 000000000000..59f1d80dfdc0 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p5-01-sale-journal.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p5-02-adjustments.png b/alshuyukh-accounting/docs/screenshots/p5-02-adjustments.png new file mode 100644 index 000000000000..24459c7e1f32 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p5-02-adjustments.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p5-03-balances.png b/alshuyukh-accounting/docs/screenshots/p5-03-balances.png new file mode 100644 index 000000000000..eeacbea558e4 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p5-03-balances.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p5-04-stock-card.png b/alshuyukh-accounting/docs/screenshots/p5-04-stock-card.png new file mode 100644 index 000000000000..79f4cbf40f20 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p5-04-stock-card.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p5-05-valuation.png b/alshuyukh-accounting/docs/screenshots/p5-05-valuation.png new file mode 100644 index 000000000000..0b42714e8d85 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p5-05-valuation.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p6-01-expense-form.png b/alshuyukh-accounting/docs/screenshots/p6-01-expense-form.png new file mode 100644 index 000000000000..001ff7e9eb74 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p6-01-expense-form.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p6-02-expense-posted.png b/alshuyukh-accounting/docs/screenshots/p6-02-expense-posted.png new file mode 100644 index 000000000000..a257b5637315 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p6-02-expense-posted.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p6-03-expense-journal.png b/alshuyukh-accounting/docs/screenshots/p6-03-expense-journal.png new file mode 100644 index 000000000000..721b968a7170 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p6-03-expense-journal.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p6-04-supplier-payment-expense.png b/alshuyukh-accounting/docs/screenshots/p6-04-supplier-payment-expense.png new file mode 100644 index 000000000000..723e080fce95 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p6-04-supplier-payment-expense.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p6-06-vat-return.png b/alshuyukh-accounting/docs/screenshots/p6-06-vat-return.png new file mode 100644 index 000000000000..514aa6559740 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p6-06-vat-return.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p6-08-mobile-expense.png b/alshuyukh-accounting/docs/screenshots/p6-08-mobile-expense.png new file mode 100644 index 000000000000..d70c3eebcb54 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p6-08-mobile-expense.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p7-01-dashboard.png b/alshuyukh-accounting/docs/screenshots/p7-01-dashboard.png new file mode 100644 index 000000000000..0324b95bb0d7 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p7-01-dashboard.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p7-02-reports-index.png b/alshuyukh-accounting/docs/screenshots/p7-02-reports-index.png new file mode 100644 index 000000000000..38a7c4df9c60 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p7-02-reports-index.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p7-03-profit-loss.png b/alshuyukh-accounting/docs/screenshots/p7-03-profit-loss.png new file mode 100644 index 000000000000..a924f3768919 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p7-03-profit-loss.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p7-04-general-ledger.png b/alshuyukh-accounting/docs/screenshots/p7-04-general-ledger.png new file mode 100644 index 000000000000..1eeb08113c4a Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p7-04-general-ledger.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p7-05-balance-sheet.png b/alshuyukh-accounting/docs/screenshots/p7-05-balance-sheet.png new file mode 100644 index 000000000000..bde5e94aee7e Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p7-05-balance-sheet.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p7-06-cash-flow.png b/alshuyukh-accounting/docs/screenshots/p7-06-cash-flow.png new file mode 100644 index 000000000000..81b8be8cf69c Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p7-06-cash-flow.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p7-07-aging.png b/alshuyukh-accounting/docs/screenshots/p7-07-aging.png new file mode 100644 index 000000000000..f7559faa3c1d Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p7-07-aging.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p7-08-statement.png b/alshuyukh-accounting/docs/screenshots/p7-08-statement.png new file mode 100644 index 000000000000..cc79bf8fc68b Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p7-08-statement.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p7-09-sales.png b/alshuyukh-accounting/docs/screenshots/p7-09-sales.png new file mode 100644 index 000000000000..114f73d6a766 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p7-09-sales.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p7-10-mobile-dashboard.png b/alshuyukh-accounting/docs/screenshots/p7-10-mobile-dashboard.png new file mode 100644 index 000000000000..9efe56dd8e22 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p7-10-mobile-dashboard.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p7-11-mobile-pl.png b/alshuyukh-accounting/docs/screenshots/p7-11-mobile-pl.png new file mode 100644 index 000000000000..527efea2e0b6 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p7-11-mobile-pl.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p8-01-missing-data.png b/alshuyukh-accounting/docs/screenshots/p8-01-missing-data.png new file mode 100644 index 000000000000..3720887da81c Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p8-01-missing-data.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p8-02-compliance.png b/alshuyukh-accounting/docs/screenshots/p8-02-compliance.png new file mode 100644 index 000000000000..a7e9d8b3144b Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p8-02-compliance.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p8-03-active.png b/alshuyukh-accounting/docs/screenshots/p8-03-active.png new file mode 100644 index 000000000000..147671b6190c Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p8-03-active.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p8-04-invoice-reported.png b/alshuyukh-accounting/docs/screenshots/p8-04-invoice-reported.png new file mode 100644 index 000000000000..c77a5dd4af8d Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p8-04-invoice-reported.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p8-05-print.png b/alshuyukh-accounting/docs/screenshots/p8-05-print.png new file mode 100644 index 000000000000..93440ca46dfb Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p8-05-print.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p8-06-log.png b/alshuyukh-accounting/docs/screenshots/p8-06-log.png new file mode 100644 index 000000000000..05be9fb13a5a Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p8-06-log.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p8-07-mobile-print.png b/alshuyukh-accounting/docs/screenshots/p8-07-mobile-print.png new file mode 100644 index 000000000000..395043a4afb5 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p8-07-mobile-print.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p9-01-tenant-trial.png b/alshuyukh-accounting/docs/screenshots/p9-01-tenant-trial.png new file mode 100644 index 000000000000..01abfef90ec1 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p9-01-tenant-trial.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p9-02-admin-overview.png b/alshuyukh-accounting/docs/screenshots/p9-02-admin-overview.png new file mode 100644 index 000000000000..8078c16c0d8e Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p9-02-admin-overview.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p9-03-plans.png b/alshuyukh-accounting/docs/screenshots/p9-03-plans.png new file mode 100644 index 000000000000..201feffdb02b Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p9-03-plans.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p9-04-tenant-detail.png b/alshuyukh-accounting/docs/screenshots/p9-04-tenant-detail.png new file mode 100644 index 000000000000..103a90821155 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p9-04-tenant-detail.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p9-05-limit.png b/alshuyukh-accounting/docs/screenshots/p9-05-limit.png new file mode 100644 index 000000000000..052f4497b1fc Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p9-05-limit.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p9-05b-suspended.png b/alshuyukh-accounting/docs/screenshots/p9-05b-suspended.png new file mode 100644 index 000000000000..fae8008e8614 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p9-05b-suspended.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p9-06-expired.png b/alshuyukh-accounting/docs/screenshots/p9-06-expired.png new file mode 100644 index 000000000000..a890f75e67e6 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p9-06-expired.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p9-07-logs.png b/alshuyukh-accounting/docs/screenshots/p9-07-logs.png new file mode 100644 index 000000000000..d6950c2c28dc Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p9-07-logs.png differ diff --git a/alshuyukh-accounting/docs/screenshots/p9-08-mobile-revenue.png b/alshuyukh-accounting/docs/screenshots/p9-08-mobile-revenue.png new file mode 100644 index 000000000000..21b0ea525180 Binary files /dev/null and b/alshuyukh-accounting/docs/screenshots/p9-08-mobile-revenue.png differ diff --git a/alshuyukh-accounting/e2e/smoke.mjs b/alshuyukh-accounting/e2e/smoke.mjs new file mode 100644 index 000000000000..159bffac122e --- /dev/null +++ b/alshuyukh-accounting/e2e/smoke.mjs @@ -0,0 +1,94 @@ +// End-to-end smoke test in a real browser against a running deployment. +// docker compose -f docker-compose.prod.yml --env-file deploy/.env up -d +// E2E_BASE_URL=http://localhost:8080 npm run e2e +// It registers a new organization, so point it at a staging or local stack. +// CHROMIUM_PATH selects the browser binary (defaults to Playwright's own lookup). +import { chromium } from 'playwright-core'; + +const BASE = (process.env.E2E_BASE_URL ?? 'http://localhost:8080').replace(/\/$/, ''); +const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || undefined }); +const problems = []; +const page = await (await browser.newContext({ viewport: { width: 1280, height: 900 }, locale: 'ar-SA' })).newPage(); +page.on('pageerror', (e) => problems.push(`page error: ${e.message}`)); +page.on('console', (m) => { + // Expected 4xx answers show up as console errors; anything else (CSP violations, crashes) fails the run. + if (m.type() === 'error' && !/status of 4\d\d/.test(m.text())) problems.push(`console: ${m.text()}`); +}); + +let current = ''; +const step = (name) => { current = name; console.log(`• ${name}`); }; +const run = Date.now().toString(36); + +try { + step('register a new organization'); + await page.goto(`${BASE}/register`); + await page.fill('input[name=fullName]', 'مستخدم اختبار'); + await page.fill('input[name=email]', `smoke-${run}@example.test`); + await page.fill('input[name=password]', 'Str0ng-Passw0rd!'); + await page.fill('input[name=companyName]', `شركة الاختبار ${run}`); + await page.click('button:has-text("إنشاء الحساب")'); + await page.waitForSelector('text=مرحبًا'); + + step('customer and product'); + await page.goto(`${BASE}/customers`); + await page.click('button:has-text("عميل جديد")'); + await page.fill('input[name=nameAr]', 'شركة النخبة للتجارة'); + await page.fill('input[name=vatNumber]', '300000000000003'); + await page.click('button:has-text("حفظ")'); + await page.waitForSelector('td:has-text("CUS-00001")'); + await page.goto(`${BASE}/inventory`); + await page.click('button:has-text("منتج جديد")'); + await page.selectOption('select[name=productType]', 'SERVICE'); + await page.fill('input[name=nameAr]', 'خدمة استشارية'); + await page.fill('input[name=salePrice]', '1000'); + await page.fill('input[name=purchasePrice]', '600'); + await page.click('button:has-text("حفظ")'); + await page.waitForSelector('td:has-text("PRD-00001")'); + + step('sales invoice: 1000 SAR + 15% VAT'); + await page.goto(`${BASE}/sales`); + await page.click('a:has-text("فاتورة جديدة")'); + await page.locator('form select').first().selectOption({ label: 'CUS-00001 — شركة النخبة للتجارة' }); + await page.locator('[aria-label="صنف السطر 1"]').selectOption({ label: 'PRD-00001 — خدمة استشارية' }); + await page.waitForSelector('.totals .strong dd:has-text("1,150.00")'); + await page.click('button:has-text("حفظ كمسودة")'); + await page.waitForSelector('h2:has-text("مسودة")'); + await page.click('button:has-text("إصدار")'); + await page.waitForSelector('h2:has-text("INV-000001")'); + + step('journal entry: Dr receivables 1150 / Cr sales 1000 / Cr VAT 150'); + await page.click('a:has-text("عرض القيد المحاسبي")'); + await page.waitForSelector('text=فاتورة مبيعات INV-000001'); + const body = await page.textContent('main'); + for (const amount of ['1,150.00', '1,000.00', '150.00']) { + if (!body.includes(amount)) throw new Error(`journal entry is missing ${amount}`); + } + + step('customer payment'); + await page.goBack(); + await page.waitForSelector('h2:has-text("INV-000001")'); + await page.click('button:has-text("تسجيل دفعة")'); + await page.fill('input[name=amount]', '1150.00'); + await page.click('.reverse-box button:has-text("حفظ")'); + await page.waitForSelector('.tag:has-text("مدفوعة")'); + + step('trial balance is balanced'); + await page.goto(`${BASE}/accounting/trial-balance`); + await page.waitForSelector('text=الميزان متوازن'); + + step('reports and dashboard load'); + await page.goto(`${BASE}/reports`); + await page.waitForSelector('main h2, main h1'); + await page.goto(`${BASE}/`); + await page.waitForSelector('text=مرحبًا'); + + if (problems.length) throw new Error(`browser reported problems:\n ${problems.join('\n ')}`); + console.log('smoke test passed'); +} catch (err) { + const shot = `e2e-failure-${run}.png`; + await page.screenshot({ path: shot, fullPage: true }).catch(() => undefined); + console.error(`failed at "${current}": ${err.message}\nscreenshot: ${shot}`); + process.exitCode = 1; +} finally { + await browser.close(); +} diff --git a/alshuyukh-accounting/package-lock.json b/alshuyukh-accounting/package-lock.json new file mode 100644 index 000000000000..9083ea1e0748 --- /dev/null +++ b/alshuyukh-accounting/package-lock.json @@ -0,0 +1,3466 @@ +{ + "name": "alshuyukh-accounting", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "alshuyukh-accounting", + "version": "0.1.0", + "workspaces": [ + "apps/api", + "apps/web" + ], + "devDependencies": { + "playwright-core": "^1.56.1" + }, + "engines": { + "node": ">=22" + } + }, + "apps/api": { + "name": "@alshuyukh/api", + "version": "0.1.0", + "dependencies": { + "@fastify/cookie": "^11.1.2", + "@fastify/cors": "^11.3.0", + "@fastify/helmet": "^13.1.1", + "@fastify/rate-limit": "^11.2.0", + "@xmldom/xmldom": "^0.9.12", + "argon2": "^0.45.1", + "decimal.js": "^10.6.0", + "fastify": "^5.12.5", + "fastify-plugin": "^5.1.0", + "jose": "^6.2.12", + "pg": "^8.23.1", + "qrcode": "^1.5.4", + "xml-crypto": "^6.3.2", + "zod": "^4.6.5" + }, + "devDependencies": { + "@types/node": "^22", + "@types/pg": "^8.23.1", + "@types/qrcode": "^1.5.6", + "@vitest/coverage-v8": "^5.0.3", + "tsx": "^4.23.15", + "typescript": "^5.9", + "vitest": "^5.0.3" + } + }, + "apps/api/node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "apps/web": { + "name": "@alshuyukh/web", + "version": "0.1.0", + "dependencies": { + "@fontsource/ibm-plex-sans-arabic": "^5.3.0", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "react-router-dom": "^7.18.4" + }, + "devDependencies": { + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "typescript": "^5.9", + "vite": "^8.3.2", + "vitest": "^5.0.3" + } + }, + "node_modules/@alshuyukh/api": { + "resolved": "apps/api", + "link": true + }, + "node_modules/@alshuyukh/web": { + "resolved": "apps/web", + "link": true + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.6.tgz", + "integrity": "sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0" + } + }, + "node_modules/@fastify/cookie": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.2.tgz", + "integrity": "sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "cookie": "^2.0.0", + "fastify-plugin": "^6.0.0" + } + }, + "node_modules/@fastify/cors": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.3.0.tgz", + "integrity": "sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^6.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.2.tgz", + "integrity": "sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/helmet": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/@fastify/helmet/-/helmet-13.1.1.tgz", + "integrity": "sha512-bSat5DTq8geASv8G6P0KW1UbltZ+xGD/zyd9S72pT7ogAHehcsWL85GdjMRCjDsExJvaEvgEZ52qU/2HXirVCw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^6.0.0", + "helmet": "^8.0.0" + } + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.1.tgz", + "integrity": "sha512-zv07Y9GEuDsJPegZoDFd4SDWaZOW8N2pa0GSrYmKpId/tjt1Hgo3BjZBVjdVpfVrHaA+Qv5jawtS2O50J5xM9g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/rate-limit": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-11.2.0.tgz", + "integrity": "sha512-X7osJd4XSvMoejYrnJkSZYYjY1eNYoBqhjlzf1RakC2204qExFqZFTKj5+T7VuzA/iUI9Z3UoSqQRkB2HpG0oQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "fastify-plugin": "^6.0.0", + "ip-address": "^10.2.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fontsource/ibm-plex-sans-arabic": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans-arabic/-/ibm-plex-sans-arabic-5.3.0.tgz", + "integrity": "sha512-ZZ4g+JDgQY0NqSh4o0MNWU1sHQmtcee1g++aKAwyJvZxnT5y51JXsN1cIXnaQaMd40dcbK6QFYa+Z1LbaiNuqA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.3.tgz", + "integrity": "sha512-+klsyz7BvT1vCU28Zkfzms1Ia78XD0V41U3FUtRaa3S+vOr/EXvK1O6BvVD7l1RzEjm6SONR2aybOvDDf9u0mQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/istanbul-lib-coverage": "^1.0.0", + "@vitest/istanbul-lib-report": "^1.0.0", + "ast-v8-to-istanbul": "^1.0.5", + "magicast": "^0.5.4", + "obug": "^2.1.4", + "std-env": "^4.2.0", + "tinyrainbow": "^3.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "5.0.3", + "vitest": "5.0.3" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/istanbul-lib-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.2.tgz", + "integrity": "sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@vitest/istanbul-lib-report": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.2.tgz", + "integrity": "sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/istanbul-lib-coverage": "1.0.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xmldom/is-dom-node": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@xmldom/is-dom-node/-/is-dom-node-1.0.1.tgz", + "integrity": "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==", + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz", + "integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==", + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argon2": { + "version": "0.45.1", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.45.1.tgz", + "integrity": "sha512-skm+/WCjkGqCQxF7FG1LuZXM5yvbFjgbfiCGsud2oLgaDhh6b6dbH0b1EkghbM+xx4Bj8Ape+KKgixoIlWZicQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@phc/format": "^1.0.0", + "cross-env": "^10.1.0", + "node-addon-api": "^8.9.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", + "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz", + "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.1.tgz", + "integrity": "sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.12.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.5.tgz", + "integrity": "sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.1.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-my-way": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.9.0.tgz", + "integrity": "sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, + "node_modules/ip-address": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz", + "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/magicast": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.1", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.1", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.4.0.tgz", + "integrity": "sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^1.0.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/playwright-core": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-router/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" + } + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tinybench": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", + "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-crypto": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-6.3.2.tgz", + "integrity": "sha512-XoUdpErePlpyFdNYVzG2Sfw5ghvqUCQMT3dQ0tAxs0JKzk9gLzwALFkvHZ/D0OyfD/OyxoXsCcYZzbDzgY18RA==", + "license": "MIT", + "dependencies": { + "@xmldom/is-dom-node": "^1.0.1", + "@xmldom/xmldom": "^0.8.15", + "xpath": "^0.0.33" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/xml-crypto/node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/xpath": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.33.tgz", + "integrity": "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==", + "license": "MIT", + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/alshuyukh-accounting/package.json b/alshuyukh-accounting/package.json new file mode 100644 index 000000000000..695ddf8c10f6 --- /dev/null +++ b/alshuyukh-accounting/package.json @@ -0,0 +1,25 @@ +{ + "name": "alshuyukh-accounting", + "private": true, + "version": "0.1.0", + "description": "ALSHUYUKH ACCOUNTING — Saudi multi-tenant cloud accounting SaaS", + "workspaces": [ + "apps/api", + "apps/web" + ], + "scripts": { + "dev:api": "npm run dev -w apps/api", + "dev:web": "npm run dev -w apps/web", + "db:migrate": "npm run db:migrate -w apps/api", + "test": "npm test -w apps/api", + "typecheck": "npm run typecheck -w apps/api && npm run typecheck -w apps/web", + "build": "npm run build -w apps/api && npm run build -w apps/web", + "e2e": "node e2e/smoke.mjs" + }, + "engines": { + "node": ">=22" + }, + "devDependencies": { + "playwright-core": "^1.56.1" + } +} diff --git a/alshuyukh-accounting/scripts/backup.sh b/alshuyukh-accounting/scripts/backup.sh new file mode 100755 index 000000000000..d2a8f11420cf --- /dev/null +++ b/alshuyukh-accounting/scripts/backup.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# Daily database backup for the docker-compose.prod.yml stack. +# scripts/backup.sh [backup-dir] (default: ./backups, keeps 14 days) +# Schedule it with cron, e.g. 15 2 * * * cd /srv/alshuyukh && scripts/backup.sh /var/backups/alshuyukh +# Copy the files off the server too: a backup on the same disk is not a backup. +set -eu +DIR=${1:-./backups} +KEEP_DAYS=${KEEP_DAYS:-14} +COMPOSE="docker compose -f docker-compose.prod.yml --env-file ${ENV_FILE:-deploy/.env}" +mkdir -p "$DIR" +FILE="$DIR/alshuyukh-$(date -u +%Y%m%dT%H%M%SZ).dump" +# Custom format: compressed, keeps owners and grants, restorable with pg_restore. +$COMPOSE exec -T postgres pg_dump -U postgres -d alshuyukh --format=custom > "$FILE.partial" +mv "$FILE.partial" "$FILE" +# A dump that pg_restore cannot list is not a backup. +$COMPOSE exec -T postgres pg_restore --list < "$FILE" > /dev/null +find "$DIR" -name 'alshuyukh-*.dump' -mtime +"$KEEP_DAYS" -delete +echo "backup written: $FILE ($(du -h "$FILE" | cut -f1))" diff --git a/alshuyukh-accounting/scripts/db-setup.sql b/alshuyukh-accounting/scripts/db-setup.sql new file mode 100644 index 000000000000..b9b0b8f091ea --- /dev/null +++ b/alshuyukh-accounting/scripts/db-setup.sql @@ -0,0 +1,28 @@ +-- Run once as a PostgreSQL superuser: +-- psql -U postgres -f scripts/db-setup.sql +-- +-- Two roles: +-- alshuyukh_owner : owns the schema, runs migrations. +-- alshuyukh_app : used by the API at runtime. NOT a superuser and has +-- NOBYPASSRLS, so PostgreSQL Row-Level Security applies. +-- Change the passwords for any non-local environment. + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'alshuyukh_owner') THEN + CREATE ROLE alshuyukh_owner LOGIN PASSWORD 'owner_dev_password' NOSUPERUSER NOCREATEROLE; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'alshuyukh_app') THEN + CREATE ROLE alshuyukh_app LOGIN PASSWORD 'app_dev_password' NOSUPERUSER NOCREATEROLE NOBYPASSRLS; + END IF; +END +$$; + +SELECT 'CREATE DATABASE alshuyukh OWNER alshuyukh_owner' +WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'alshuyukh')\gexec + +SELECT 'CREATE DATABASE alshuyukh_test OWNER alshuyukh_owner' +WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'alshuyukh_test')\gexec + +GRANT CONNECT ON DATABASE alshuyukh TO alshuyukh_app; +GRANT CONNECT ON DATABASE alshuyukh_test TO alshuyukh_app; diff --git a/alshuyukh-accounting/scripts/restore.sh b/alshuyukh-accounting/scripts/restore.sh new file mode 100755 index 000000000000..74cea74263ba --- /dev/null +++ b/alshuyukh-accounting/scripts/restore.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# Restores a backup made by scripts/backup.sh into the docker-compose.prod.yml stack. +# scripts/restore.sh backups/alshuyukh-20260101T021500Z.dump +# This REPLACES the current database. The API is stopped during the restore. +set -eu +FILE=${1:?usage: scripts/restore.sh } +COMPOSE="docker compose -f docker-compose.prod.yml --env-file ${ENV_FILE:-deploy/.env}" +printf 'Replace the database with %s? Type "restore" to continue: ' "$FILE" +read -r answer +[ "$answer" = "restore" ] || { echo "cancelled"; exit 1; } +$COMPOSE stop api +$COMPOSE exec -T postgres psql -U postgres -v ON_ERROR_STOP=1 -d postgres <<'SQL' +SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'alshuyukh' AND pid <> pg_backend_pid(); +DROP DATABASE alshuyukh; +CREATE DATABASE alshuyukh OWNER alshuyukh_owner; +REVOKE ALL ON DATABASE alshuyukh FROM PUBLIC; +GRANT CONNECT ON DATABASE alshuyukh TO alshuyukh_app; +SQL +# Ownership and grants come from the dump: tables stay owned by alshuyukh_owner, +# so row-level security still applies to the app role. +$COMPOSE exec -T postgres pg_restore -U postgres -d alshuyukh --exit-on-error < "$FILE" +# Applies any migrations newer than the backup. +$COMPOSE run --rm migrate +$COMPOSE start api +echo "restored from $FILE"