🚀 Initialize Musadaq SaaS: Full Backend + AI + React Dashboard + Docker Setup

This commit is contained in:
Hamza-Ayed
2026-04-16 23:26:32 +03:00
commit d66891ba0f
221 changed files with 13079 additions and 0 deletions

51
.gitignore vendored Normal file
View File

@@ -0,0 +1,51 @@
# ──────────────────────────────────────────────────────────
# مُصادَق (Musadaq) — Git Exclusions
# ──────────────────────────────────────────────────────────
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS Files
.DS_Store
Thumbs.db
# Dependencies
node_modules/
bower_components/
.npm/
# IDEs
.vscode/
.idea/
*.swp
*.swo
# Project specific
dist/
build/
.env
.env.local
.env.production
.npm_cache/
# Flutter specific
.dart_tool/
.packages
.pub-cache/
.pub/
build/
ios/Flutter/Generated.xcconfig
ios/Flutter/flutter_export_environment.sh
macos/Flutter/ephemeral/
android/app/local.properties
android/app/GeneratedPluginRegistrant.java
linux/flutter/ephemeral/
windows/flutter/ephemeral/
*.lock
# Docker
docker-compose.override.yml

40
README.md Normal file
View File

@@ -0,0 +1,40 @@
# مُصادَق (Musadaq) — Financial Automation SaaS
منصة برمجية متكاملة (SaaS) لأتمتة دورة حياة الفواتير الضريبية في الأردن، مدمجة بذكاء اصطناعي لاستخراج البيانات وربطها مباشرة ببوابة "جو فوترة" (JoFotara).
---
## 🏛️ المجلدات الرئيسية
| المجلد | الوظيفة | التقنيات |
|---------|---------|----------|
| `backend/` | محرك المعالجة، التحقق الضريبي، AI، والربط الحكومي | NestJS, TypeORM, PostgreSQL, Bull, Gemini AI |
| `frontend/` | لوحة تحكم مكاتب المحاسبة والشركات | React, Vite, Tailwind CSS, React Query |
| `musadaq/` | تطبيق الموبايل للمسح الضوئي (Scanner) | Flutter, GetX |
---
## 🚀 التشغيل السريع (Docker)
لتشغيل البيئة بالكامل:
```bash
docker compose up -d --build
```
---
## 🔄 المزامنة والنشر (Sync to Server)
تم إعداد ملف `sync-to-server.sh` لأتمتة عملية الرفع والبناء على خادم الإنتاج (Contabo) بضغطة واحدة:
```bash
sh sync-to-server.sh "وصف التعديلات هنا"
```
---
## 🔒 الميزات الأمنية والقانونية
- **التحقق الضريبي**: تطبيق قواعد المحاسبة الأردنية (Rules 001-004).
- **جو فوترة**: توليد ملفات XML بمعيار **UBL 2.1**.
- **حماية البيانات**: تشفير AES-256 للمفاتيح الحساسة.

12
backend/.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
node_modules
dist
.env
.git
.gitignore
uploads
coverage
*.log
*.md
test
.DS_Store
docker-compose*.yml

51
backend/.env.example Normal file
View File

@@ -0,0 +1,51 @@
# ════════════════════════════════════════════════════════════
# مُصادَق (Musadaq) — Environment Variables Template
# ════════════════════════════════════════════════════════════
# Copy this file to .env and fill in real values.
# NEVER commit .env to version control.
# ════════════════════════════════════════════════════════════
# ── Application ────────────────────────────────────────────
NODE_ENV=
PORT=
APP_URL=
ALLOWED_ORIGINS=
# ── Database (PostgreSQL) ─────────────────────────────────
DB_HOST=
DB_PORT=
DB_USER=
DB_PASS=
DB_NAME=
# ── Redis ──────────────────────────────────────────────────
REDIS_HOST=
REDIS_PORT=
# ── JWT Authentication ─────────────────────────────────────
JWT_SECRET=
JWT_EXPIRY=
JWT_REFRESH_SECRET=
JWT_REFRESH_EXPIRY=
# ── Encryption (AES-256-GCM) — 32 bytes = 64 hex chars ───
ENCRYPTION_KEY=
# ── JoFotara ───────────────────────────────────────────────
JOFOTARA_SANDBOX_URL=
JOFOTARA_PROD_URL=
JOFOTARA_ENV=
# ── Gemini AI ──────────────────────────────────────────────
GEMINI_API_KEY=
GEMINI_MODEL=
# ── File Storage ───────────────────────────────────────────
STORAGE_PATH=
# ── Email ──────────────────────────────────────────────────
RESEND_API_KEY=
EMAIL_FROM=
# ── Domain ─────────────────────────────────────────────────
DOMAIN=

7
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules
dist
.env
uploads
*.log
coverage
.DS_Store

39
backend/Dockerfile Normal file
View File

@@ -0,0 +1,39 @@
# ═══════════════════════════════════════════════
# مُصادَق — Multi-stage Docker Build
# ═══════════════════════════════════════════════
# ── Stage 1: Builder ──────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ── Stage 2: Production ──────────────────────
FROM node:20-alpine AS production
# Security: non-root user
RUN addgroup -g 1001 -S musadaq && \
adduser -S musadaq -u 1001 -G musadaq
WORKDIR /app
# Copy only production deps
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
# Copy built application
COPY --from=builder /app/dist ./dist
# Create uploads directory
RUN mkdir -p /app/uploads && chown -R musadaq:musadaq /app
USER musadaq
EXPOSE 3300
CMD ["node", "dist/main"]

24
backend/data-source.ts Normal file
View File

@@ -0,0 +1,24 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق — TypeORM CLI Data Source
* ════════════════════════════════════════════════════════════
*/
import { DataSource } from 'typeorm';
import * as dotenv from 'dotenv';
dotenv.config();
export const AppDataSource = new DataSource({
type: 'postgres',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '5300'),
username: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
synchronize: false,
logging: true,
entities: ['src/**/entities/*.entity.ts'],
migrations: ['src/migrations/*.ts'],
subscribers: [],
});

111
backend/docker-compose.yml Normal file
View File

@@ -0,0 +1,111 @@
# ════════════════════════════════════════════════════════════
# مُصادَق (Musadaq) — Docker Compose
# ════════════════════════════════════════════════════════════
# Services: NestJS API (3300) + PostgreSQL (5300) + Redis (6400)
# Usage: docker compose up -d
# ════════════════════════════════════════════════════════════
services:
# ── NestJS API ─────────────────────────────────────────
api:
build:
context: .
dockerfile: Dockerfile
container_name: musadaq-api
restart: unless-stopped
ports:
- "${PORT:-3300}:${PORT:-3300}"
environment:
NODE_ENV: ${NODE_ENV:-development}
PORT: ${PORT:-3300}
DB_HOST: db
DB_PORT: 5432
DB_USER: ${DB_USER}
DB_PASS: ${DB_PASS}
DB_NAME: ${DB_NAME}
REDIS_HOST: redis
REDIS_PORT: 6379
JWT_SECRET: ${JWT_SECRET}
JWT_EXPIRY: ${JWT_EXPIRY}
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET}
JWT_REFRESH_EXPIRY: ${JWT_REFRESH_EXPIRY}
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
JOFOTARA_SANDBOX_URL: ${JOFOTARA_SANDBOX_URL}
JOFOTARA_PROD_URL: ${JOFOTARA_PROD_URL}
JOFOTARA_ENV: ${JOFOTARA_ENV}
GEMINI_API_KEY: ${GEMINI_API_KEY}
GEMINI_MODEL: ${GEMINI_MODEL}
STORAGE_PATH: /app/uploads
RESEND_API_KEY: ${RESEND_API_KEY}
EMAIL_FROM: ${EMAIL_FROM}
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS}
DOMAIN: ${DOMAIN}
volumes:
- invoice_uploads:/app/uploads
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- musadaq-network
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:${PORT:-3300}/api/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# ── PostgreSQL 16 ──────────────────────────────────────
db:
image: postgres:16-alpine
container_name: musadaq-db
restart: unless-stopped
ports:
- "${DB_PORT:-5300}:5432"
environment:
POSTGRES_USER: ${DB_USER:-musadaq_user}
POSTGRES_PASSWORD: ${DB_PASS:-dev_password_min_32_chars_long_here!}
POSTGRES_DB: ${DB_NAME:-musadaq_db}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- musadaq-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-musadaq_user} -d ${DB_NAME:-musadaq_db}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
# ── Redis 7 ────────────────────────────────────────────
redis:
image: redis:7-alpine
container_name: musadaq-redis
restart: unless-stopped
ports:
- "${REDIS_PORT:-6400}:6379"
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
networks:
- musadaq-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# ── Volumes ────────────────────────────────────────────────
volumes:
postgres_data:
driver: local
redis_data:
driver: local
invoice_uploads:
driver: local
# ── Network ────────────────────────────────────────────────
networks:
musadaq-network:
driver: bridge

8
backend/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

90
backend/package.json Normal file
View File

@@ -0,0 +1,90 @@
{
"name": "musadaq-backend",
"version": "1.0.0",
"description": "مُصادَق — منصة أتمتة الفوترة الضريبية الأردنية",
"author": "Musadaq Team",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
"migration:run": "npm run typeorm -- migration:run -d data-source.ts",
"migration:revert": "npm run typeorm -- migration:revert -d data-source.ts",
"migration:generate": "npm run typeorm -- migration:generate -d data-source.ts"
},
"dependencies": {
"@google/generative-ai": "^0.11.1",
"@nestjs/bull": "^10.1.1",
"@nestjs/common": "^10.3.8",
"@nestjs/config": "^3.2.2",
"@nestjs/core": "^10.3.8",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.8",
"@nestjs/platform-socket.io": "^10.3.8",
"@nestjs/throttler": "^5.1.2",
"@nestjs/typeorm": "^10.0.2",
"@nestjs/websockets": "^10.3.8",
"axios": "^1.6.8",
"bcrypt": "^5.1.1",
"bull": "^4.12.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cookie-parser": "^1.4.6",
"helmet": "^7.1.0",
"ioredis": "^5.3.2",
"joi": "^17.12.3",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.11.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20",
"xmlbuilder2": "^3.1.1"
},
"devDependencies": {
"@nestjs/cli": "^10.3.2",
"@nestjs/schematics": "^10.1.1",
"@nestjs/testing": "^10.3.8",
"@types/bcrypt": "^5.0.2",
"@types/bull": "^4.10.0",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/multer": "^1.4.11",
"@types/node": "^20.12.7",
"@types/passport-jwt": "^4.0.1",
"@typescript-eslint/eslint-plugin": "^7.7.1",
"@typescript-eslint/parser": "^7.7.1",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"jest": "^29.7.0",
"prettier": "^3.2.5",
"source-map-support": "^0.5.21",
"ts-jest": "^29.1.2",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.4.5"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
"collectCoverageFrom": ["**/*.(t|j)s"],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

83
backend/src/app.module.ts Normal file
View File

@@ -0,0 +1,83 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Root Application Module
* ════════════════════════════════════════════════════════════
*/
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';
import { BullModule } from '@nestjs/bull';
import { envValidationSchema } from './config/env.validation';
import { databaseConfig } from './config/database.config';
import { HealthController } from './modules/health/health.controller';
import { AuthModule } from './modules/auth/auth.module';
import { TenantsModule } from './modules/tenants/tenant.module';
import { UsersModule } from './modules/users/user.module';
import { CompaniesModule } from './modules/companies/company.module';
import { SubscriptionsModule } from './modules/subscriptions/subscription.module';
import { InvoicesModule } from './modules/invoices/invoice.module';
import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor';
@Module({
imports: [
// ── Configuration ─────────────────────────────────────────
ConfigModule.forRoot({
isGlobal: true,
validationSchema: envValidationSchema,
cache: true,
}),
// ── Database (TypeORM) ────────────────────────────────────
TypeOrmModule.forRootAsync(databaseConfig),
// ── Queue (Bull/Redis) ────────────────────────────────────
BullModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
redis: {
host: configService.getOrThrow<string>('REDIS_HOST'),
port: configService.getOrThrow<number>('REDIS_PORT'),
},
}),
}),
// ── Rate Limiting (Throttler) ─────────────────────────────
ThrottlerModule.forRoot([{
name: 'short',
ttl: 1000, // 1 second
limit: 3, // 3 requests
}, {
name: 'long',
ttl: 60000, // 1 minute
limit: 100, // 100 requests
}]),
// ── Functional Modules ────────────────────────────────────
AuthModule,
TenantsModule,
UsersModule,
CompaniesModule,
SubscriptionsModule,
InvoicesModule,
],
providers: [
// Global Rate Limiting Guard
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
// Global Audit Log Interceptor
{
provide: APP_INTERCEPTOR,
useClass: AuditLogInterceptor,
},
],
controllers: [HealthController],
})
export class AppModule {}

View File

@@ -0,0 +1,14 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — CurrentUser Decorator
* ════════════════════════════════════════════════════════════
*/
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);

View File

@@ -0,0 +1,11 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Roles Decorator
* ════════════════════════════════════════════════════════════
*/
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '../../modules/users/enums/role.enum';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -0,0 +1,54 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Global Exception Filter
* ════════════════════════════════════════════════════════════
*/
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message =
exception instanceof HttpException
? exception.getResponse()
: 'Internal Server Error';
// Log the error
if (status >= 500) {
this.logger.error(
`${request.method} ${request.url} - ${status} - ${JSON.stringify(message)}`,
exception instanceof Error ? exception.stack : '',
);
} else {
this.logger.warn(`${request.method} ${request.url} - ${status} - ${JSON.stringify(message)}`);
}
response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
message: typeof message === 'string' ? message : (message as any).message || message,
error: typeof message === 'string' ? null : (message as any).error || null,
});
}
}

View File

@@ -0,0 +1,30 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — JWT Auth Guard
* ════════════════════════════════════════════════════════════
*/
import {
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Observable } from 'rxjs';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
// Add custom authentication logic here if needed
return super.canActivate(context);
}
handleRequest(err: any, user: any, info: any) {
if (err || !user) {
throw err || new UnauthorizedException('Authentication Required');
}
return user;
}
}

View File

@@ -0,0 +1,45 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Roles Guard
* ════════════════════════════════════════════════════════════
*/
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { UserRole } from '../../modules/users/enums/role.enum';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) {
return true;
}
const { user } = context.switchToHttp().getRequest();
if (!user) {
return false;
}
const hasRole = requiredRoles.includes(user.role);
if (!hasRole) {
throw new ForbiddenException('Required Role Missing');
}
return true;
}
}

View File

@@ -0,0 +1,32 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Tenant Guard
* ════════════════════════════════════════════════════════════
* يضمن أن المستخدم الحالي يحمل نفس tenant_id الكيان المطلوب.
* يمنع الوصول العشوائي للبيانات عبر المستأجرين.
* ════════════════════════════════════════════════════════════
*/
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
@Injectable()
export class TenantGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const { user, params } = request;
if (!user || !user.tenantId) {
throw new ForbiddenException('Invalid User Context');
}
// This is a stub: real implementation will check the target entity's tenant_id
// against the user's tenant_id if applicable.
return true;
}
}

View File

@@ -0,0 +1,37 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Audit Log Interceptor
* ════════════════════════════════════════════════════════════
*/
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class AuditLogInterceptor implements NestInterceptor {
private readonly logger = new Logger(AuditLogInterceptor.name);
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const { user, method, url } = request;
return next.handle().pipe(
tap(() => {
// Only log non-GET requests (mutative operations)
if (method !== 'GET') {
this.logger.log(
`Audit: User ${user?.id || 'Anonymous'} - ${method} ${url}`,
);
// Phase 2: Save to Database
}
}),
);
}
}

View File

@@ -0,0 +1,32 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Tenant Middleware
* ════════════════════════════════════════════════════════════
* يستخرج tenantId من JWT ويضعه في request.
* يستخدم في تصفية البيانات (Query filtering).
* ════════════════════════════════════════════════════════════
*/
import {
Injectable,
NestMiddleware,
UnauthorizedException,
} from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class TenantMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const { user } = req as any;
if (!user || !user.tenantId) {
// Phase 1: Not always required (registration, login)
return next();
}
// Set tenant context for the request
(req as any).tenantId = user.tenantId;
next();
}
}

View File

@@ -0,0 +1,47 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق — TypeORM Database Configuration
* ════════════════════════════════════════════════════════════
* تهيئة اتصال PostgreSQL من متغيرات البيئة.
* synchronize: false دائماً — نستخدم migrations فقط.
* ════════════════════════════════════════════════════════════
*/
import { TypeOrmModuleAsyncOptions } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
export const databaseConfig: TypeOrmModuleAsyncOptions = {
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
type: 'postgres' as const,
host: configService.getOrThrow<string>('DB_HOST'),
port: configService.getOrThrow<number>('DB_PORT'),
username: configService.getOrThrow<string>('DB_USER'),
password: configService.getOrThrow<string>('DB_PASS'),
database: configService.getOrThrow<string>('DB_NAME'),
// Entity auto-discovery
autoLoadEntities: true,
// NEVER synchronize — use migrations only
synchronize: false,
// SSL in production
ssl: configService.get<string>('NODE_ENV') === 'production'
? { rejectUnauthorized: false }
: false,
// Logging based on environment
logging: configService.get<string>('NODE_ENV') === 'development'
? ['query', 'error', 'warn']
: ['error'],
// Connection pool
extra: {
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
},
}),
};

View File

@@ -0,0 +1,136 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق — Environment Validation Schema (Joi)
* ════════════════════════════════════════════════════════════
* يتحقق من وجود وصحة جميع متغيرات البيئة عند بدء التطبيق.
* أي متغير مفقود أو غير صالح = فشل فوري في التشغيل.
* ════════════════════════════════════════════════════════════
*/
import * as Joi from 'joi';
export const envValidationSchema = Joi.object({
// ── Application ────────────────────────────────────────
NODE_ENV: Joi.string()
.valid('development', 'production', 'test')
.required()
.description('بيئة التشغيل'),
PORT: Joi.number()
.port()
.default(3300)
.description('منفذ الخادم'),
APP_URL: Joi.string()
.uri()
.required()
.description('رابط التطبيق الكامل'),
ALLOWED_ORIGINS: Joi.string()
.required()
.description('النطاقات المسموح بها للـ CORS (مفصولة بفاصلة)'),
// ── Database ───────────────────────────────────────────
DB_HOST: Joi.string()
.required()
.description('مضيف قاعدة البيانات'),
DB_PORT: Joi.number()
.port()
.default(5300)
.description('منفذ قاعدة البيانات'),
DB_USER: Joi.string()
.required()
.description('مستخدم قاعدة البيانات'),
DB_PASS: Joi.string()
.min(32)
.required()
.description('كلمة مرور قاعدة البيانات (32 حرف كحد أدنى)'),
DB_NAME: Joi.string()
.required()
.description('اسم قاعدة البيانات'),
// ── Redis ──────────────────────────────────────────────
REDIS_HOST: Joi.string()
.required()
.description('مضيف Redis'),
REDIS_PORT: Joi.number()
.port()
.default(6400)
.description('منفذ Redis'),
// ── JWT ────────────────────────────────────────────────
JWT_SECRET: Joi.string()
.min(64)
.required()
.description('مفتاح JWT الرئيسي (64 حرف كحد أدنى)'),
JWT_EXPIRY: Joi.string()
.default('15m')
.description('مدة صلاحية Access Token'),
JWT_REFRESH_SECRET: Joi.string()
.min(64)
.required()
.description('مفتاح JWT للتجديد (مختلف عن الرئيسي)'),
JWT_REFRESH_EXPIRY: Joi.string()
.default('7d')
.description('مدة صلاحية Refresh Token'),
// ── Encryption ─────────────────────────────────────────
ENCRYPTION_KEY: Joi.string()
.length(64)
.hex()
.required()
.description('مفتاح التشفير AES-256 (32 بايت = 64 حرف hex)'),
// ── JoFotara ───────────────────────────────────────────
JOFOTARA_SANDBOX_URL: Joi.string()
.uri()
.required()
.description('رابط بيئة الاختبار لجو فوترة'),
JOFOTARA_PROD_URL: Joi.string()
.uri()
.required()
.description('رابط بيئة الإنتاج لجو فوترة'),
JOFOTARA_ENV: Joi.string()
.valid('sandbox', 'production')
.default('sandbox')
.description('بيئة جو فوترة المستخدمة'),
// ── Gemini AI ──────────────────────────────────────────
GEMINI_API_KEY: Joi.string()
.required()
.description('مفتاح Google Gemini API'),
GEMINI_MODEL: Joi.string()
.default('gemini-2.0-flash-lite')
.description('نموذج Gemini المستخدم'),
// ── File Storage ───────────────────────────────────────
STORAGE_PATH: Joi.string()
.default('./uploads')
.description('مسار تخزين الملفات'),
// ── Email ──────────────────────────────────────────────
RESEND_API_KEY: Joi.string()
.required()
.description('مفتاح Resend API للبريد الإلكتروني'),
EMAIL_FROM: Joi.string()
.email()
.required()
.description('عنوان المرسل للبريد الإلكتروني'),
// ── Domain ─────────────────────────────────────────────
DOMAIN: Joi.string()
.required()
.description('اسم النطاق الرئيسي'),
});

View File

@@ -0,0 +1,27 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق — JWT Configuration
* ════════════════════════════════════════════════════════════
*/
import { JwtModuleAsyncOptions } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
export const jwtConfig: JwtModuleAsyncOptions = {
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.getOrThrow<string>('JWT_SECRET'),
signOptions: {
expiresIn: configService.get<string>('JWT_EXPIRY', '15m'),
},
}),
};
/**
* Refresh token config — used separately in AuthService
*/
export const getRefreshTokenConfig = (configService: ConfigService) => ({
secret: configService.getOrThrow<string>('JWT_REFRESH_SECRET'),
expiresIn: configService.get<string>('JWT_REFRESH_EXPIRY', '7d'),
});

78
backend/src/main.ts Normal file
View File

@@ -0,0 +1,78 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Application Bootstrap
* ════════════════════════════════════════════════════════════
* نقطة الدخول الرئيسية مع طبقات الأمان الكاملة:
* - Helmet (HTTP Security Headers)
* - CORS (مقيد بنطاقات محددة)
* - Global Validation Pipe (تنقية المدخلات)
* - Global Exception Filter (أخطاء موحدة)
* ════════════════════════════════════════════════════════════
*/
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import helmet from 'helmet';
import { AppModule } from './app.module';
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
async function bootstrap(): Promise<void> {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule, {
logger: ['error', 'warn', 'log', 'debug'],
});
const configService = app.get(ConfigService);
const port = configService.get<number>('PORT', 3300);
const allowedOrigins = configService.get<string>('ALLOWED_ORIGINS', '');
// ── LAYER 1: HTTP Security Headers (Helmet) ──────────
app.use(helmet({
contentSecurityPolicy: false, // API only — no CSP needed
crossOriginEmbedderPolicy: false,
}));
// ── LAYER 2: CORS — مقيد بنطاقات محددة فقط ───────────
app.enableCors({
origin: allowedOrigins.split(',').map((o) => o.trim()),
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],
});
// ── LAYER 3: Global Validation Pipe ───────────────────
// whitelist: يحذف أي حقل غير معرّف في الـ DTO
// forbidNonWhitelisted: يرفض الطلب إذا وُجد حقل زائد
// transform: يحوّل الأنواع تلقائياً (string → number, etc.)
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);
// ── LAYER 4: Global Exception Filter ──────────────────
app.useGlobalFilters(new GlobalExceptionFilter());
// ── API Prefix ────────────────────────────────────────
app.setGlobalPrefix('api', {
exclude: ['/'],
});
// ── Start Server ──────────────────────────────────────
await app.listen(port, '0.0.0.0');
logger.log(`══════════════════════════════════════════════`);
logger.log(`🟢 مُصادَق (Musadaq) running on port ${port}`);
logger.log(`🌍 Environment: ${configService.get('NODE_ENV')}`);
logger.log(`🔒 CORS: ${allowedOrigins}`);
logger.log(`══════════════════════════════════════════════`);
}
bootstrap();

View File

@@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class InitialSchema1713304298000 implements MigrationInterface {
name = 'InitialSchema1713304298000'
public async up(queryRunner: QueryRunner): Promise<void> {
// ── Create Enums ──────────────────────────────────────
await queryRunner.query(`CREATE TYPE "public"."tenants_status_enum" AS ENUM('active', 'suspended', 'trial')`);
await queryRunner.query(`CREATE TYPE "public"."users_role_enum" AS ENUM('admin', 'accountant', 'viewer')`);
await queryRunner.query(`CREATE TYPE "public"."subscriptions_plan_enum" AS ENUM('basic', 'office', 'pro', 'enterprise')`);
await queryRunner.query(`CREATE TYPE "public"."subscriptions_status_enum" AS ENUM('active', 'past_due', 'cancelled')`);
// ── Tenants ───────────────────────────────────────────
await queryRunner.query(`CREATE TABLE "tenants" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "name" character varying(255) NOT NULL, "email" character varying(255) NOT NULL, "phone" character varying(20), "status" "public"."tenants_status_enum" NOT NULL DEFAULT 'trial', "trial_ends_at" TIMESTAMP, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_71458e0a4f5c5b5f5f5f5f5f5f5" UNIQUE ("email"), CONSTRAINT "PK_71458e0a4f5c5b5f5f5f5f5f5f5" PRIMARY KEY ("id"))`);
// ── Users ─────────────────────────────────────────────
await queryRunner.query(`CREATE TABLE "users" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "tenant_id" uuid NOT NULL, "name" character varying(255) NOT NULL, "email" character varying(255) NOT NULL, "password_hash" character varying(255) NOT NULL, "role" "public"."users_role_enum" NOT NULL, "refresh_token_hash" character varying(255), "is_active" boolean NOT NULL DEFAULT true, "last_login_at" TIMESTAMP, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_tenant_email_unique" ON "users" ("tenant_id", "email")`);
// ── Companies ─────────────────────────────────────────
await queryRunner.query(`CREATE TABLE "companies" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "tenant_id" uuid NOT NULL, "name" character varying(255) NOT NULL, "name_en" character varying(255), "tax_identification_number" character varying(20) NOT NULL, "address" text, "jofotara_client_id_encrypted" text, "jofotara_secret_key_encrypted" text, "jofotara_income_source_sequence" character varying(50), "is_active" boolean NOT NULL DEFAULT true, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_d4c35295f3d3b66e0d3ca60938a" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_companies_name" ON "companies" ("name")`);
await queryRunner.query(`CREATE INDEX "IDX_companies_tin" ON "companies" ("tax_identification_number")`);
// ── Subscriptions ─────────────────────────────────────
await queryRunner.query(`CREATE TABLE "subscriptions" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "tenant_id" uuid NOT NULL, "plan" "public"."subscriptions_plan_enum" NOT NULL, "max_companies" integer NOT NULL, "max_invoices_per_month" integer NOT NULL, "price_jod" numeric(10,2) NOT NULL, "billing_cycle" "public"."subscriptions_status_enum" NOT NULL DEFAULT 'active', "current_period_start" TIMESTAMP, "current_period_end" TIMESTAMP, "invoices_used_this_month" integer NOT NULL DEFAULT 0, "status" "public"."subscriptions_status_enum" NOT NULL DEFAULT 'active', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_subscriptions_id" PRIMARY KEY ("id"))`);
// ── Foreign Keys ──────────────────────────────────────
await queryRunner.query(`ALTER TABLE "users" ADD CONSTRAINT "FK_users_tenant" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "companies" ADD CONSTRAINT "FK_companies_tenant" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "subscriptions" ADD CONSTRAINT "FK_subscriptions_tenant" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "subscriptions" DROP CONSTRAINT "FK_subscriptions_tenant"`);
await queryRunner.query(`ALTER TABLE "companies" DROP CONSTRAINT "FK_companies_tenant"`);
await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "FK_users_tenant"`);
await queryRunner.query(`DROP INDEX "IDX_companies_tin"`);
await queryRunner.query(`DROP INDEX "IDX_companies_name"`);
await queryRunner.query(`DROP TABLE "companies"`);
await queryRunner.query(`DROP INDEX "IDX_tenant_email_unique"`);
await queryRunner.query(`DROP TABLE "users"`);
await queryRunner.query(`DROP TABLE "tenants"`);
await queryRunner.query(`DROP TABLE "subscriptions"`);
await queryRunner.query(`DROP TYPE "public"."subscriptions_status_enum"`);
await queryRunner.query(`DROP TYPE "public"."subscriptions_plan_enum"`);
await queryRunner.query(`DROP TYPE "public"."users_role_enum"`);
await queryRunner.query(`DROP TYPE "public"."tenants_status_enum"`);
}
}

View File

@@ -0,0 +1,74 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Authentication Controller
* ════════════════════════════════════════════════════════════
*/
import {
Controller,
Post,
Body,
UseGuards,
Req,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { AuthGuard } from '@nestjs/passport';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
/**
* تسجيل مكتب جديد + مدير مسؤول
*/
@Post('register')
@HttpCode(HttpStatus.CREATED)
async register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
/**
* تسجيل الدخول
*/
@Post('login')
@HttpCode(HttpStatus.OK)
async login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
/**
* تجديد التوكن
*/
@UseGuards(AuthGuard('jwt-refresh'))
@Post('refresh')
@HttpCode(HttpStatus.OK)
async refresh(@CurrentUser() user: any) {
return this.authService.refresh(user.id, user.refreshToken);
}
/**
* تسجيل الخروج
*/
@UseGuards(JwtAuthGuard)
@Post('logout')
@HttpCode(HttpStatus.OK)
async logout(@CurrentUser() user: any) {
return this.authService.logout(user.id);
}
/**
* الملف الشخصي
*/
@UseGuards(JwtAuthGuard)
@Post('profile')
@HttpCode(HttpStatus.OK)
async profile(@CurrentUser() user: any) {
return user;
}
}

View File

@@ -0,0 +1,30 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Auth Module
* ════════════════════════════════════════════════════════════
*/
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './strategies/jwt.strategy';
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy';
import { jwtConfig } from '../../config/jwt.config';
import { User } from '../users/entities/user.entity';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync(jwtConfig),
TypeOrmModule.forFeature([User]),
ConfigModule,
],
providers: [AuthService, JwtStrategy, JwtRefreshStrategy],
controllers: [AuthController],
exports: [AuthService, JwtStrategy, PassportModule],
})
export class AuthModule {}

View File

@@ -0,0 +1,204 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Authentication Service
* ════════════════════════════════════════════════════════════
*/
import {
Injectable,
UnauthorizedException,
ConflictException,
InternalServerErrorException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User } from '../../users/entities/user.entity';
import { Tenant, TenantStatus } from '../../tenants/entities/tenant.entity';
import { UserRole } from '../../users/enums/role.enum';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { Subscription, SubscriptionPlan, SubscriptionStatus } from '../../subscriptions/entities/subscription.entity';
@Injectable()
export class AuthService {
constructor(
private jwtService: JwtService,
private configService: ConfigService,
private dataSource: DataSource,
) {}
/**
* تسجيل مستخدم جديد (مدير مكتب)
*/
async register(dto: RegisterDto) {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 1. Check if user exists
const existingUser = await queryRunner.manager.findOne(User, {
where: { email: dto.email },
});
if (existingUser) {
throw new ConflictException('Email already registered');
}
// 2. Create Tenant (Accounting Office)
const tenant = queryRunner.manager.create(Tenant, {
name: dto.tenantName,
email: dto.email, // Use same email for office contact initially
phone: dto.phone,
status: TenantStatus.TRIAL,
trial_ends_at: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14 days trial
});
const savedTenant = await queryRunner.manager.save(tenant);
// 3. Create Default Subscription (Trial)
const subscription = queryRunner.manager.create(Subscription, {
tenant_id: savedTenant.id,
plan: SubscriptionPlan.BASIC,
max_companies: 1,
max_invoices_per_month: 200,
price_jod: 15, // Basic price
status: SubscriptionStatus.ACTIVE,
current_period_start: new Date(),
current_period_end: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
});
await queryRunner.manager.save(subscription);
// 4. Create Admin User
const passwordHash = await this.hashPassword(dto.password);
const user = queryRunner.manager.create(User, {
tenant_id: savedTenant.id,
name: dto.name,
email: dto.email,
password_hash: passwordHash,
role: UserRole.ADMIN,
});
const savedUser = await queryRunner.manager.save(user);
await queryRunner.commitTransaction();
return {
userId: savedUser.id,
tenantId: savedTenant.id,
message: 'Registration successful',
};
} catch (error) {
await queryRunner.rollbackTransaction();
if (error instanceof ConflictException) throw error;
throw new InternalServerErrorException('Registration failed');
} finally {
await queryRunner.release();
}
}
/**
* تسجيل دخول
*/
async login(dto: LoginDto) {
const user = await this.dataSource.getRepository(User).findOne({
where: { email: dto.email, is_active: true },
});
if (!user || !(await this.comparePassword(dto.password, user.password_hash))) {
throw new UnauthorizedException('Invalid credentials');
}
const payload = {
sub: user.id,
tenantId: user.tenant_id,
role: user.role
};
const accessToken = await this.jwtService.signAsync(payload);
const refreshToken = await this.generateRefreshToken(payload);
// Save refresh token hash
await this.updateRefreshToken(user.id, refreshToken);
return {
user: {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
tenantId: user.tenant_id,
},
accessToken,
refreshToken,
};
}
/**
* تجديد التوكن
*/
async refresh(userId: string, refreshToken: string) {
const user = await this.dataSource.getRepository(User).findOne({
where: { id: userId, is_active: true },
select: ['id', 'tenant_id', 'role', 'refresh_token_hash'],
});
if (!user || !user.refresh_token_hash) {
throw new UnauthorizedException('Access Denied');
}
const isMatch = await bcrypt.compare(refreshToken, user.refresh_token_hash);
if (!isMatch) {
throw new UnauthorizedException('Access Denied');
}
const payload = {
sub: user.id,
tenantId: user.tenant_id,
role: user.role
};
const accessToken = await this.jwtService.signAsync(payload);
const newRefreshToken = await this.generateRefreshToken(payload);
await this.updateRefreshToken(user.id, newRefreshToken);
return {
accessToken,
refreshToken: newRefreshToken,
};
}
/**
* تسجيل خروج
*/
async logout(userId: string) {
await this.dataSource.getRepository(User).update(userId, {
refresh_token_hash: undefined,
});
return { message: 'Logged out' };
}
// ── Helpers ─────────────────────────────────────────────
private async hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12);
}
private async comparePassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
private async generateRefreshToken(payload: any): Promise<string> {
return this.jwtService.signAsync(payload, {
secret: this.configService.getOrThrow<string>('JWT_REFRESH_SECRET'),
expiresIn: this.configService.get<string>('JWT_REFRESH_EXPIRY', '7d'),
});
}
private async updateRefreshToken(userId: string, refreshToken: string) {
const hash = await bcrypt.hash(refreshToken, 10);
await this.dataSource.getRepository(User).update(userId, {
refresh_token_hash: hash,
});
}
}

View File

@@ -0,0 +1,16 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Login DTO
* ════════════════════════════════════════════════════════════
*/
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
export class LoginDto {
@IsEmail()
email!: string;
@IsString()
@IsNotEmpty()
password!: string;
}

View File

@@ -0,0 +1,31 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Register DTO
* ════════════════════════════════════════════════════════════
*/
import { IsEmail, IsNotEmpty, IsString, MinLength, IsOptional, Matches } from 'class-validator';
export class RegisterDto {
@IsString()
@IsNotEmpty()
tenantName!: string;
@IsString()
@IsNotEmpty()
name!: string;
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
@Matches(/((?=.*\d)|(?=.*\W+))(?![.\n])(?=.*[A-Z])(?=.*[a-z]).*$/, {
message: 'Password is too weak (Must have uppercase, lowercase, and number/special char)',
})
password!: string;
@IsString()
@IsOptional()
phone?: string;
}

View File

@@ -0,0 +1,43 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — JWT Refresh Strategy
* ════════════════════════════════════════════════════════════
*/
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
constructor(private configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(request: Request) => {
// Extract from HttpOnly Cookie (prod) or Header (dev)
return request?.cookies?.refreshToken || request?.headers?.['x-refresh-token'];
},
]),
ignoreExpiration: false,
passReqToCallback: true,
secretOrKey: configService.getOrThrow<string>('JWT_REFRESH_SECRET'),
});
}
async validate(req: Request, payload: any) {
const refreshToken = req?.cookies?.refreshToken || req?.headers?.['x-refresh-token'];
if (!refreshToken) {
throw new UnauthorizedException('Refresh token missing');
}
return {
id: payload.sub,
tenantId: payload.tenantId,
role: payload.role,
refreshToken,
};
}
}

View File

@@ -0,0 +1,43 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — JWT Strategy
* ════════════════════════════════════════════════════════════
*/
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
import { User } from '../../users/entities/user.entity';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private configService: ConfigService,
private dataSource: DataSource,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.getOrThrow<string>('JWT_SECRET'),
});
}
async validate(payload: any) {
const user = await this.dataSource.getRepository(User).findOne({
where: { id: payload.sub, is_active: true },
select: ['id', 'tenant_id', 'role'], // Add only necessary fields to context
});
if (!user) {
throw new UnauthorizedException('User not found or inactive');
}
return {
id: user.id,
tenantId: user.tenant_id,
role: user.role,
};
}
}

View File

@@ -0,0 +1,72 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Companies Controller
* ════════════════════════════════════════════════════════════
*/
import {
Controller,
Get,
Post,
Patch,
Body,
Param,
UseGuards,
Put,
} from '@nestjs/common';
import { CompaniesService } from './company.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RolesGuard } from '../../common/guards/roles.guard';
import { Roles } from '../../common/decorators/roles.decorator';
import { UserRole } from '../users/enums/role.enum';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@Controller('companies')
@UseGuards(JwtAuthGuard, RolesGuard)
export class CompaniesController {
constructor(private companiesService: CompaniesService) {}
@Post()
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT)
async create(@CurrentUser() user: any, @Body() dto: any) {
return this.companiesService.create(user.tenantId, dto);
}
@Get()
async findAll(@CurrentUser() user: any) {
return this.companiesService.findAll(user.tenantId);
}
@Get(':id')
async findOne(@CurrentUser() user: any, @Param('id') id: string) {
return this.companiesService.findOne(user.tenantId, id);
}
@Patch(':id')
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT)
async update(
@CurrentUser() user: any,
@Param('id') id: string,
@Body() dto: any,
) {
return this.companiesService.update(user.tenantId, id, dto);
}
/**
* ربط بيانات جو فوترة المشفرة
*/
@Put(':id/jofotara')
@Roles(UserRole.ADMIN)
async setJoFotara(
@CurrentUser() user: any,
@Param('id') id: string,
@Body() dto: { clientId: string; secretKey: string },
) {
return this.companiesService.setJoFotaraCredentials(
user.tenantId,
id,
dto.clientId,
dto.secretKey,
);
}
}

View File

@@ -0,0 +1,24 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Companies Module
* ════════════════════════════════════════════════════════════
*/
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CompaniesService } from './company.service';
import { CompaniesController } from './company.controller';
import { Company } from './entities/company.entity';
import { EncryptionService } from '../../services/encryption/encryption.service';
import { SubscriptionsModule } from '../subscriptions/subscription.module';
@Module({
imports: [
TypeOrmModule.forFeature([Company]),
forwardRef(() => SubscriptionsModule),
],
providers: [CompaniesService, EncryptionService],
controllers: [CompaniesController],
exports: [CompaniesService],
})
export class CompaniesModule {}

View File

@@ -0,0 +1,113 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Companies Service
* ════════════════════════════════════════════════════════════
*/
import {
Injectable,
NotFoundException,
ForbiddenException,
Inject,
forwardRef,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Company } from './entities/company.entity';
import { EncryptionService } from '../../services/encryption/encryption.service';
import { SubscriptionsService } from '../subscriptions/subscription.service';
@Injectable()
export class CompaniesService {
constructor(
@InjectRepository(Company)
private companyRepository: Repository<Company>,
private encryptionService: EncryptionService,
@Inject(forwardRef(() => SubscriptionsService))
private subscriptionsService: SubscriptionsService,
) {}
/**
* إنشاء شركة جديدة مع التحقق من حدود الاشتراك
*/
async create(tenantId: string, dto: any): Promise<Company> {
// 1. Check subscription limits
const canCreate = await this.subscriptionsService.checkCompanyLimit(tenantId);
if (!canCreate) {
throw new ForbiddenException('Company limit reached for your current plan');
}
const company = this.companyRepository.create({
...dto,
tenant_id: tenantId,
});
return this.companyRepository.save(company);
}
/**
* قائمة الشركات التابعة للمكتب
*/
async findAll(tenantId: string): Promise<Company[]> {
return this.companyRepository.find({
where: { tenant_id: tenantId, is_active: true },
order: { created_at: 'DESC' },
});
}
/**
* تفاصيل شركة محددة
*/
async findOne(tenantId: string, id: string): Promise<Company> {
const company = await this.companyRepository.findOne({
where: { id, tenant_id: tenantId },
});
if (!company) throw new NotFoundException('Company not found');
return company;
}
/**
* تحديث بيانات شركة
*/
async update(tenantId: string, id: string, dto: any): Promise<Company> {
const company = await this.findOne(tenantId, id);
Object.assign(company, dto);
return this.companyRepository.save(company);
}
/**
* حفظ مفاتيح جو فوترة (مُشفرة)
*/
async setJoFotaraCredentials(
tenantId: string,
id: string,
clientId: string,
secretKey: string,
): Promise<void> {
const company = await this.findOne(tenantId, id);
company.jofotara_client_id_encrypted = this.encryptionService.encrypt(clientId);
company.jofotara_secret_key_encrypted = this.encryptionService.encrypt(secretKey);
await this.companyRepository.save(company);
}
/**
* الحصول على المفاتيح (مفكوك تشفيرها) — للاستخدام الداخلي فقط
*/
async getDecryptedCredentials(tenantId: string, id: string) {
const company = await this.companyRepository.findOne({
where: { id, tenant_id: tenantId },
select: ['jofotara_client_id_encrypted', 'jofotara_secret_key_encrypted'],
});
if (!company || !company.jofotara_client_id_encrypted || !company.jofotara_secret_key_encrypted) {
throw new NotFoundException('JoFotara credentials not set for this company');
}
return {
clientId: this.encryptionService.decrypt(company.jofotara_client_id_encrypted),
secretKey: this.encryptionService.decrypt(company.jofotara_secret_key_encrypted),
};
}
}

View File

@@ -0,0 +1,63 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Company Entity (Client of Accounting Office)
* ════════════════════════════════════════════════════════════
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Tenant } from '../../tenants/entities/tenant.entity';
@Entity('companies')
export class Company {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenant_id!: string;
@ManyToOne(() => Tenant, (tenant) => tenant.companies, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'tenant_id' })
tenant!: Tenant;
@Column({ type: 'varchar', length: 255 })
@Index()
name!: string;
@Column({ type: 'varchar', length: 255, nullable: true })
name_en?: string;
@Column({ type: 'varchar', length: 20 })
@Index()
tax_identification_number!: string; // الرقم الضريبي
@Column({ type: 'text', nullable: true })
address?: string;
// ── JoFotara Credentials (Encrypted) ───────────────────
@Column({ type: 'text', nullable: true, select: false })
jofotara_client_id_encrypted?: string;
@Column({ type: 'text', nullable: true, select: false })
jofotara_secret_key_encrypted?: string;
@Column({ type: 'varchar', length: 50, nullable: true })
jofotara_income_source_sequence?: string;
@Column({ type: 'boolean', default: true })
is_active!: boolean;
@CreateDateColumn({ type: 'timestamp' })
created_at!: Date;
@UpdateDateColumn({ type: 'timestamp' })
updated_at!: Date;
}

View File

@@ -0,0 +1,25 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Health Check Controller
* ════════════════════════════════════════════════════════════
*/
import { Controller, Get } from '@nestjs/common';
import { DataSource } from 'typeorm';
@Controller('health')
export class HealthController {
constructor(private dataSource: DataSource) {}
@Get()
async check() {
const dbConnected = this.dataSource.isInitialized;
return {
status: 'ok',
uptime: process.uptime(),
timestamp: new Date().toISOString(),
database: dbConnected ? 'connected' : 'disconnected',
};
}
}

View File

@@ -0,0 +1,48 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Invoice Line Entity
* ════════════════════════════════════════════════════════════
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Invoice } from './invoice.entity';
@Entity('invoice_lines')
export class InvoiceLine {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'invoice_id', type: 'uuid' })
invoice_id!: string;
@ManyToOne(() => Invoice, (invoice) => invoice.lines, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'invoice_id' })
invoice!: Invoice;
@Column({ type: 'int' })
line_number!: number;
@Column({ type: 'text' })
description!: string;
@Column({ type: 'decimal', precision: 15, scale: 3 })
quantity!: number;
@Column({ type: 'decimal', precision: 15, scale: 3 })
unit_price!: number;
@Column({ type: 'decimal', precision: 15, scale: 3, default: 0 })
discount!: number;
@Column({ type: 'decimal', precision: 5, scale: 4 }) // e.g., 0.1600
tax_rate!: number;
@Column({ type: 'decimal', precision: 15, scale: 3 })
line_total!: number;
}

View File

@@ -0,0 +1,122 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Invoice Entity
* ════════════════════════════════════════════════════════════
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
Index,
} from 'typeorm';
import { Tenant } from '../../tenants/entities/tenant.entity';
import { Company } from '../../companies/entities/company.entity';
import { InvoiceLine } from './invoice-line.entity';
export enum InvoiceStatus {
UPLOADED = 'uploaded',
EXTRACTING = 'extracting',
EXTRACTED = 'extracted',
VALIDATED = 'validated',
VALIDATION_FAILED = 'validation_failed',
SUBMITTING = 'submitting',
APPROVED = 'approved',
REJECTED = 'rejected',
}
@Entity('invoices')
export class Invoice {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenant_id!: string;
@ManyToOne(() => Tenant, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'tenant_id' })
tenant!: Tenant;
@Column({ name: 'company_id', type: 'uuid' })
company_id!: string;
@ManyToOne(() => Company, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'company_id' })
company!: Company;
// ── Invoice Identity ─────────────────────────────────────
@Column({ type: 'varchar', length: 100, nullable: true })
invoice_number?: string;
@Column({ type: 'date', nullable: true })
invoice_date?: Date;
@Column({ type: 'enum', enum: ['cash', 'credit'], default: 'cash' })
invoice_type!: 'cash' | 'credit';
@Column({ type: 'varchar', length: 3, default: '388' }) // 388: Sales, 381: Credit Note
ubl_type_code!: string;
@Column({ type: 'varchar', length: 3, default: '013' }) // 013: Cash, 023: Credit
payment_method_code!: string;
// ── Parties ──────────────────────────────────────────────
@Column({ type: 'varchar', length: 20, nullable: true })
supplier_tin?: string;
@Column({ type: 'varchar', length: 255, nullable: true })
supplier_name?: string;
@Column({ type: 'text', nullable: true })
supplier_address?: string;
@Column({ type: 'varchar', length: 20, nullable: true })
buyer_tin?: string;
@Column({ type: 'varchar', length: 20, nullable: true })
buyer_national_id?: string;
@Column({ type: 'varchar', length: 255, nullable: true })
buyer_name?: string;
// ── Totals (decimal 15,3) ────────────────────────────────
@Column({ type: 'decimal', precision: 15, scale: 3, default: 0 })
subtotal!: number;
@Column({ type: 'decimal', precision: 15, scale: 3, default: 0 })
discount_total!: number;
@Column({ type: 'decimal', precision: 15, scale: 3, default: 0 })
tax_amount!: number;
@Column({ type: 'decimal', precision: 15, scale: 3, default: 0 })
grand_total!: number;
@Column({ type: 'char', length: 3, default: 'JOD' })
currency_code!: string;
// ── Processing Status ────────────────────────────────────
@Column({ type: 'enum', enum: InvoiceStatus, default: InvoiceStatus.UPLOADED })
status!: InvoiceStatus;
@Column({ type: 'text', nullable: true })
original_file_path?: string;
@Column({ type: 'decimal', precision: 4, scale: 3, nullable: true })
ai_confidence_score?: number;
@CreateDateColumn({ type: 'timestamp' })
created_at!: Date;
@UpdateDateColumn({ type: 'timestamp' })
updated_at!: Date;
// ── One-to-Many Relationship ─────────────────────────────
@OneToMany(() => InvoiceLine, (line) => line.invoice, { cascade: true })
lines!: InvoiceLine[];
}

View File

@@ -0,0 +1,90 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Gemini AI Extraction Service
* ════════════════════════════════════════════════════════════
* يقوم باستخراج البيانات من صور/ملفات الفواتير باستخدام Gemini.
* يضمن تحويل البيانات غير المهيكلة إلى JSON مطابق لمعايير UBL 2.1.
* ════════════════════════════════════════════════════════════
*/
import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GoogleGenerativeAI } from '@google/generative-ai';
import * as fs from 'fs';
import * as path from 'path';
@Injectable()
export class GeminiExtractorService {
private readonly logger = new Logger(GeminiExtractorService.name);
private genAI: GoogleGenerativeAI;
private model: any;
constructor(private configService: ConfigService) {
const apiKey = this.configService.getOrThrow<string>('GEMINI_API_KEY');
this.genAI = new GoogleGenerativeAI(apiKey);
this.model = this.genAI.getGenerativeModel({
model: this.configService.get<string>('GEMINI_MODEL', 'gemini-1.5-flash'),
});
}
/**
* استخراج البيانات من صورة الفاتورة
*/
async extractInvoiceData(filePath: string, storageRoot: string): Promise<any> {
try {
const fullPath = path.join(storageRoot, filePath);
const fileData = fs.readFileSync(fullPath);
const prompt = `
You are a Jordanian tax expert. Extract all details from this invoice image for the Jordan National Invoicing System (JoFotara / JoInvoice).
The output MUST be a strict JSON object following this schema:
{
"invoice_number": "string",
"invoice_date": "YYYY-MM-DD",
"invoice_type": "cash" | "credit",
"supplier_name": "string",
"supplier_tin": "string (10 digits)",
"buyer_name": "string (optional)",
"buyer_tin": "string (optional)",
"subtotal": number (before discount and tax),
"discount_total": number (total discount),
"tax_amount": number (total tax),
"grand_total": number (final amount),
"currency_code": "JOD",
"lines": [
{
"line_number": number,
"description": "string",
"quantity": number,
"unit_price": number,
"discount": number,
"tax_rate": number (e.g. 0.16 for 16%),
"line_total": number (quantity * unit_price - discount)
}
]
}
Return ONLY the JSON. No markdown formatting. If a field is missing, return null.
Pay close attention to Jordanian Tax Rules (subtotal - discount + tax = grand_total).
`;
const result = await this.model.generateContent([
prompt,
{
inlineData: {
data: fileData.toString('base64'),
mimeType: 'image/jpeg', // Adjusted based on file extension in prod
},
},
]);
const responseText = result.response.text();
// Clean up markdown if any
const cleanedJson = responseText.replace(/```json|```/g, '').trim();
return JSON.parse(cleanedJson);
} catch (error) {
this.logger.error(`AI Extraction failed: ${error.message}`);
throw new InternalServerErrorException('AI Extraction failed');
}
}
}

View File

@@ -0,0 +1,79 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Invoices Controller
* ════════════════════════════════════════════════════════════
*/
import {
Controller,
Get,
Post,
Patch,
Body,
Param,
UseGuards,
UploadedFile,
UseInterceptors,
ParseUUIDPipe,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { InvoicesService } from './invoice.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RolesGuard } from '../../common/guards/roles.guard';
import { Roles } from '../../common/decorators/roles.decorator';
import { UserRole } from '../users/enums/role.enum';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@Controller('invoices')
@UseGuards(JwtAuthGuard, RolesGuard)
export class InvoicesController {
constructor(private invoicesService: InvoicesService) {}
/**
* رفع فاتورة لشركة محددة
*/
@Post('upload/:companyId')
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT)
@UseInterceptors(FileInterceptor('file'))
async upload(
@CurrentUser() user: any,
@Param('companyId', ParseUUIDPipe) companyId: string,
@UploadedFile() file: Express.Multer.File,
) {
return this.invoicesService.upload(user.tenantId, companyId, file);
}
/**
* قائمة الفواتير لشركة محددة
*/
@Get('company/:companyId')
async findAll(
@CurrentUser() user: any,
@Param('companyId', ParseUUIDPipe) companyId: string,
) {
return this.invoicesService.findAll(user.tenantId, companyId);
}
/**
* تفاصيل فاتورة محددة
*/
@Get(':id')
async findOne(
@CurrentUser() user: any,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.invoicesService.findOne(user.tenantId, id);
}
/**
* تحديث بيانات الفاتورة يدوياً
*/
/**
* إرسال الفاتورة إلى بوابة جو فوترة الحكومية
*/
@Post(':id/submit')
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT)
async submit(@CurrentUser() user: any, @Param('id', ParseUUIDPipe) id: string) {
return this.invoicesService.submitToJoFotara(user.tenantId, id);
}
}

View File

@@ -0,0 +1,44 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Invoices Module (Finalized)
* ════════════════════════════════════════════════════════════
*/
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bull';
import { InvoicesService } from './invoice.service';
import { InvoicesController } from './invoice.controller';
import { Invoice } from './entities/invoice.entity';
import { InvoiceLine } from './entities/invoice-line.entity';
import { InvoiceProcessor } from './invoice.processor';
import { GeminiExtractorService } from './gemini-extractor.service';
import { UBLGeneratorService } from './ubl-generator.service';
import { JoFotaraGatewayService } from './jofotara-gateway.service';
import { LocalStorageService } from '../../services/storage/local-storage.service';
import { SubscriptionsModule } from '../subscriptions/subscription.module';
import { TaxValidationModule } from '../validation/tax-validation.module';
import { CompaniesModule } from '../companies/company.module';
@Module({
imports: [
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
BullModule.registerQueue({
name: 'invoice-processing',
}),
forwardRef(() => SubscriptionsModule),
forwardRef(() => CompaniesModule),
TaxValidationModule,
],
providers: [
InvoicesService,
InvoiceProcessor,
GeminiExtractorService,
UBLGeneratorService,
JoFotaraGatewayService,
LocalStorageService,
],
controllers: [InvoicesController],
exports: [InvoicesService],
})
export class InvoicesModule {}

View File

@@ -0,0 +1,160 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Invoice Processor (Queue Consumer)
* ════════════════════════════════════════════════════════════
* المستهلك الرئيسي لطابور معالجة الفواتير (Bull Queue).
* يربط بين الذكاء الاصطناعي، التحقق الضريبي، وتوليد الـ XML.
* ════════════════════════════════════════════════════════════
*/
import { Process, Processor, OnQueueActive, OnQueueCompleted, OnQueueFailed } from '@nestjs/bull';
import { Logger } from '@nestjs/common';
import { Job } from 'bull';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { Invoice, InvoiceStatus } from './entities/invoice.entity';
import { InvoiceLine } from './entities/invoice-line.entity';
import { GeminiExtractorService } from './gemini-extractor.service';
import { TaxValidationService } from '../validation/tax-validation.service';
import { ConfigService } from '@nestjs/config';
@Processor('invoice-processing')
export class InvoiceProcessor {
private readonly logger = new Logger(InvoiceProcessor.name);
constructor(
@InjectRepository(Invoice)
private invoiceRepository: Repository<Invoice>,
@InjectRepository(InvoiceLine)
private lineRepository: Repository<InvoiceLine>,
private geminiExtractor: GeminiExtractorService,
private taxValidation: TaxValidationService,
private configService: ConfigService,
private dataSource: DataSource,
) {}
@OnQueueActive()
onActive(job: Job) {
this.logger.log(`Processing job ${job.id} of type ${job.name}...`);
}
@OnQueueCompleted()
onComplete(job: Job, result: any) {
this.logger.log(`Completed job ${job.id} for invoice ${job.data.invoiceId}`);
}
@OnQueueFailed()
onError(job: Job, error: Error) {
this.logger.error(`Job ${job.id} failed: ${error.message}`);
}
/**
* الخطوة الأولى: استخراج البيانات باستخدام AI
*/
@Process('extract-data')
async handleExtraction(job: Job<{ invoiceId: string; filePath: string; tenantId: string }>) {
const { invoiceId, filePath } = job.data;
const storageRoot = this.configService.get<string>('STORAGE_PATH', './uploads');
try {
// 1. Update status to EXTRACTING
await this.invoiceRepository.update(invoiceId, { status: InvoiceStatus.EXTRACTING });
// 2. Extract data via Gemini
const data = await this.geminiExtractor.extractInvoiceData(filePath, storageRoot);
// 3. Save extracted data in a transaction
await this.saveExtractedData(invoiceId, data);
this.logger.log(`Extraction successful for invoice ${invoiceId}`);
} catch (error) {
await this.invoiceRepository.update(invoiceId, {
status: InvoiceStatus.VALIDATION_FAILED,
// Optional: Save error message in a notes column
});
throw error;
}
}
/**
* حفظ البيانات المستخرجة في قاعدة البيانات
*/
private async saveExtractedData(invoiceId: string, data: any) {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 1. Update Invoice Header
await queryRunner.manager.update(Invoice, invoiceId, {
invoice_number: data.invoice_number,
invoice_date: data.invoice_date,
invoice_type: data.invoice_type || 'cash',
supplier_name: data.supplier_name,
supplier_tin: data.supplier_tin,
buyer_name: data.buyer_name,
buyer_tin: data.buyer_tin,
subtotal: data.subtotal,
discount_total: data.discount_total,
tax_amount: data.tax_amount,
grand_total: data.grand_total,
currency_code: data.currency_code || 'JOD',
status: InvoiceStatus.EXTRACTED,
});
// 2. Clear old lines if any (shouldn't happen on first extract)
await queryRunner.manager.delete(InvoiceLine, { invoice_id: invoiceId });
// 3. Create new lines
if (data.lines && Array.isArray(data.lines)) {
const lines = data.lines.map((l: any) =>
queryRunner.manager.create(InvoiceLine, {
invoice_id: invoiceId,
line_number: l.line_number,
description: l.description,
quantity: l.quantity,
unit_price: l.unit_price,
discount: l.discount || 0,
tax_rate: l.tax_rate,
line_total: l.line_total,
})
);
await queryRunner.manager.save(InvoiceLine, lines);
}
await queryRunner.commitTransaction();
// 4. Trigger Auto-Validation (Internal Step)
await this.autoValidate(invoiceId);
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
/**
* الخطوة الثانية: التحقق الضريبي التلقائي
*/
private async autoValidate(invoiceId: string) {
const invoice = await this.invoiceRepository.findOne({
where: { id: invoiceId },
relations: ['lines'],
});
if (!invoice) return;
const result = this.taxValidation.validateInvoice(invoice);
if (result.isValid) {
await this.invoiceRepository.update(invoiceId, { status: InvoiceStatus.VALIDATED });
} else {
await this.invoiceRepository.update(invoiceId, {
status: InvoiceStatus.VALIDATION_FAILED,
// Optional: Save detailed error list
});
}
}
}

View File

@@ -0,0 +1,149 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Invoices Service
* ════════════════════════════════════════════════════════════
*/
import {
Injectable,
NotFoundException,
ForbiddenException,
Inject,
forwardRef,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull';
import { Invoice, InvoiceStatus } from './entities/invoice.entity';
import { LocalStorageService } from '../../services/storage/local-storage.service';
import { SubscriptionsService } from '../subscriptions/subscription.service';
import { UBLGeneratorService } from './ubl-generator.service';
import { JoFotaraGatewayService } from './jofotara-gateway.service';
import { CompaniesService } from '../companies/company.service';
@Injectable()
export class InvoicesService {
constructor(
@InjectRepository(Invoice)
private invoiceRepository: Repository<Invoice>,
private localStorageService: LocalStorageService,
@Inject(forwardRef(() => SubscriptionsService))
private subscriptionsService: SubscriptionsService,
@InjectQueue('invoice-processing')
private invoiceQueue: Queue,
private ublGenerator: UBLGeneratorService,
private jofotaraGateway: JoFotaraGatewayService,
@Inject(forwardRef(() => CompaniesService))
private companiesService: CompaniesService,
) {}
/**
* رفع فاتورة جديدة وبدء المعالجة
*/
async upload(
tenantId: string,
companyId: string,
file: Express.Multer.File,
): Promise<Invoice> {
const canUpload = await this.subscriptionsService.checkInvoiceLimit(tenantId);
if (!canUpload) {
throw new ForbiddenException('Monthly invoice limit reached for your current plan');
}
const fileName = `${Date.now()}-${file.originalname}`;
const filePath = await this.localStorageService.saveFile(
tenantId,
companyId,
fileName,
file.buffer,
);
const invoice = this.invoiceRepository.create({
tenant_id: tenantId,
company_id: companyId,
original_file_path: filePath,
status: InvoiceStatus.UPLOADED,
});
const savedInvoice = await this.invoiceRepository.save(invoice);
await this.invoiceQueue.add('extract-data', {
invoiceId: savedInvoice.id,
tenantId,
companyId,
filePath,
});
await this.subscriptionsService.incrementInvoiceCount(tenantId);
return savedInvoice;
}
/**
* قائمة الفواتير لشركة محددة
*/
async findAll(tenantId: string, companyId: string): Promise<Invoice[]> {
return this.invoiceRepository.find({
where: { tenant_id: tenantId, company_id: companyId },
order: { created_at: 'DESC' },
});
}
/**
* تفاصيل فاتورة
*/
async findOne(tenantId: string, id: string): Promise<Invoice> {
const invoice = await this.invoiceRepository.findOne({
where: { id, tenant_id: tenantId },
relations: ['lines'],
});
if (!invoice) throw new NotFoundException('Invoice not found');
return invoice;
}
/**
* تحديث بيانات الفاتورة يدوياً
*/
async update(tenantId: string, id: string, updateData: any): Promise<Invoice> {
const invoice = await this.findOne(tenantId, id);
if (invoice.status === InvoiceStatus.APPROVED || invoice.status === InvoiceStatus.SUBMITTING) {
throw new ForbiddenException('Cannot edit already approved or submitted invoice');
}
Object.assign(invoice, updateData);
return this.invoiceRepository.save(invoice);
}
/**
* إرسال الفاتورة إلى بوابة جو فوترة الحكومية
*/
async submitToJoFotara(tenantId: string, id: string): Promise<any> {
const invoice = await this.findOne(tenantId, id);
if (invoice.status !== InvoiceStatus.VALIDATED && invoice.status !== InvoiceStatus.VALIDATION_FAILED && invoice.status !== InvoiceStatus.EXTRACTED) {
throw new ForbiddenException('Invoice must be validated or extracted before submission');
}
const company = await this.companiesService.findOne(tenantId, invoice.company_id);
const credentials = await this.companiesService.getDecryptedCredentials(tenantId, invoice.company_id);
const xml = this.ublGenerator.generateXML(invoice, company);
await this.invoiceRepository.update(id, { status: InvoiceStatus.SUBMITTING });
try {
const response = await this.jofotaraGateway.submitInvoice(
xml,
credentials.clientId,
credentials.secretKey,
);
await this.invoiceRepository.update(id, { status: InvoiceStatus.APPROVED });
return response;
} catch (error) {
await this.invoiceRepository.update(id, { status: InvoiceStatus.VALIDATION_FAILED });
throw error;
}
}
}

View File

@@ -0,0 +1,79 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — JoFotara Gateway Service
* ════════════════════════════════════════════════════════════
* يقوم بالتواصل مع بوابة دائرة ضريبة الدخل والمبيعات الأردنية (ISTD).
* يدير عملية تسجيل الفواتير والحصول على الـ Clearance أو الـ Reporting.
* ════════════════════════════════════════════════════════════
*/
import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
@Injectable()
export class JoFotaraGatewayService {
private readonly logger = new Logger(JoFotaraGatewayService.name);
private readonly sandboxUrl: string;
private readonly prodUrl: string;
private readonly currentEnv: string;
constructor(private configService: ConfigService) {
this.sandboxUrl = this.configService.getOrThrow<string>('JOFOTARA_SANDBOX_URL');
this.prodUrl = this.configService.getOrThrow<string>('JOFOTARA_PROD_URL');
this.currentEnv = this.configService.get<string>('JOFOTARA_ENV', 'sandbox');
}
/**
* إرسال الفاتورة إلى بوابة جو فوترة
*/
async submitInvoice(
xmlContent: string,
clientId: string,
secretKey: string,
): Promise<any> {
const url = this.currentEnv === 'production' ? this.prodUrl : this.sandboxUrl;
try {
const response = await axios.post(
`${url}/submit`,
{
invoice: Buffer.from(xmlContent).toString('base64'),
},
{
headers: {
'Content-Type': 'application/json',
'X-Client-Id': clientId,
'X-Secret-Key': secretKey,
},
},
);
return response.data;
} catch (error) {
this.logger.error(`JoFotara API Error: ${error.response?.data || error.message}`);
throw new InternalServerErrorException(
`Failed to submit invoice to JoFotara: ${error.response?.data?.message || 'Unknown Error'}`,
);
}
}
/**
* التحقق من حالة الفاتورة
*/
async checkStatus(uuid: string, clientId: string, secretKey: string): Promise<any> {
const url = this.currentEnv === 'production' ? this.prodUrl : this.sandboxUrl;
try {
const response = await axios.get(`${url}/status/${uuid}`, {
headers: {
'X-Client-Id': clientId,
'X-Secret-Key': secretKey,
},
});
return response.data;
} catch (error) {
throw new InternalServerErrorException('Failed to check invoice status');
}
}
}

View File

@@ -0,0 +1,93 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — UBL 2.1 Generator Service
* ════════════════════════════════════════════════════════════
* يقوم بإنشاء ملفات XML المتوافقة مع معيار UBL 2.1 المطلوبة من
* دائرة ضريبة الدخل والمبيعات الأردنية (ISTD).
* ════════════════════════════════════════════════════════════
*/
import { Injectable } from '@nestjs/common';
import { create } from 'xmlbuilder2';
import { Invoice } from './entities/invoice.entity';
@Injectable()
export class UBLGeneratorService {
/**
* توليد UBL 2.1 XML لفاتورة مبيعات
*/
generateXML(invoice: Invoice, company: any): string {
const doc = create({ version: '1.0', encoding: 'UTF-8' })
.ele('Invoice', {
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',
})
.ele('cbc:UBLVersionID').txt('2.1').up()
.ele('cbc:CustomizationID').txt('TRX-1.0').up()
.ele('cbc:ID').txt(invoice.invoice_number || 'N/A').up()
.ele('cbc:IssueDate').txt(invoice.invoice_date?.toISOString().split('T')[0] || '').up()
.ele('cbc:InvoiceTypeCode').txt(invoice.ubl_type_code).up()
.ele('cbc:DocumentCurrencyCode').txt(invoice.currency_code).up()
// ── AccountingSupplierParty (المُصدر) ───────────────
.ele('cac:AccountingSupplierParty')
.ele('cac:Party')
.ele('cac:PartyIdentification')
.ele('cbc:ID').txt(company.tax_identification_number).up()
.up()
.ele('cac:PartyName')
.ele('cbc:Name').txt(company.name).up()
.up()
.ele('cac:PostalAddress')
.ele('cbc:StreetName').txt(company.address || '').up()
.ele('cac:Country')
.ele('cbc:IdentificationCode').txt('JO').up()
.up()
.up()
.up()
.up()
// ── AccountingCustomerParty (المشتري) ───────────────
.ele('cac:AccountingCustomerParty')
.ele('cac:Party')
.ele('cac:PartyIdentification')
.ele('cbc:ID').txt(invoice.buyer_tin || invoice.buyer_national_id || '').up()
.up()
.ele('cac:PartyName')
.ele('cbc:Name').txt(invoice.buyer_name || '').up()
.up()
.up()
.up()
// ── TaxTotal ───────────────────────────────────────
.ele('cac:TaxTotal')
.ele('cbc:TaxAmount', { currencyID: invoice.currency_code }).txt(invoice.tax_amount.toString()).up()
.up()
// ── LegalMonetaryTotal ─────────────────────────────
.ele('cac:LegalMonetaryTotal')
.ele('cbc:LineExtensionAmount', { currencyID: invoice.currency_code }).txt(invoice.subtotal.toString()).up()
.ele('cbc:TaxExclusiveAmount', { currencyID: invoice.currency_code }).txt(invoice.subtotal.toString()).up()
.ele('cbc:TaxInclusiveAmount', { currencyID: invoice.currency_code }).txt(invoice.grand_total.toString()).up()
.ele('cbc:PayableAmount', { currencyID: invoice.currency_code }).txt(invoice.grand_total.toString()).up()
.up();
// ── InvoiceLines ─────────────────────────────────────
invoice.lines.forEach((line) => {
doc.ele('cac:InvoiceLine')
.ele('cbc:ID').txt(line.line_number.toString()).up()
.ele('cbc:InvoicedQuantity', { unitCode: 'PCE' }).txt(line.quantity.toString()).up()
.ele('cbc:LineExtensionAmount', { currencyID: invoice.currency_code }).txt(line.line_total.toString()).up()
.ele('cac:Item')
.ele('cbc:Description').txt(line.description).up()
.up()
.ele('cac:Price')
.ele('cbc:PriceAmount', { currencyID: invoice.currency_code }).txt(line.unit_price.toString()).up()
.up()
.up();
});
return doc.end({ prettyPrint: true });
}
}

View File

@@ -0,0 +1,86 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Subscription Entity
* ════════════════════════════════════════════════════════════
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Tenant } from '../../tenants/entities/tenant.entity';
export enum SubscriptionPlan {
BASIC = 'basic',
OFFICE = 'office',
PRO = 'pro',
ENTERPRISE = 'enterprise',
}
export enum SubscriptionStatus {
ACTIVE = 'active',
PAST_DUE = 'past_due',
CANCELLED = 'cancelled',
}
@Entity('subscriptions')
export class Subscription {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenant_id!: string;
@ManyToOne(() => Tenant, (tenant) => tenant.subscriptions, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'tenant_id' })
tenant!: Tenant;
@Column({
type: 'enum',
enum: SubscriptionPlan,
})
plan!: SubscriptionPlan;
@Column({ type: 'int' })
max_companies!: number;
@Column({ type: 'int' })
max_invoices_per_month!: number;
@Column({ type: 'decimal', precision: 10, scale: 2 })
price_jod!: number;
@Column({
type: 'enum',
enum: ['monthly', 'annual'],
default: 'monthly',
})
billing_cycle!: string;
@Column({ type: 'timestamp', nullable: true })
current_period_start?: Date;
@Column({ type: 'timestamp', nullable: true })
current_period_end?: Date;
@Column({ type: 'int', default: 0 })
invoices_used_this_month!: number;
@Column({
type: 'enum',
enum: SubscriptionStatus,
default: SubscriptionStatus.ACTIVE,
})
status!: SubscriptionStatus;
@CreateDateColumn({ type: 'timestamp' })
created_at!: Date;
@UpdateDateColumn({ type: 'timestamp' })
updated_at!: Date;
}

View File

@@ -0,0 +1,21 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Subscriptions Module
* ════════════════════════════════════════════════════════════
*/
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SubscriptionsService } from './subscription.service';
import { Subscription } from './entities/subscription.entity';
import { CompaniesModule } from '../companies/company.module';
@Module({
imports: [
TypeOrmModule.forFeature([Subscription]),
forwardRef(() => CompaniesModule),
],
providers: [SubscriptionsService],
exports: [SubscriptionsService],
})
export class SubscriptionsModule {}

View File

@@ -0,0 +1,64 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Subscriptions Service
* ════════════════════════════════════════════════════════════
* يدير خطص الاشتراك والحدود المسموح بها للمكاتب.
* ════════════════════════════════════════════════════════════
*/
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Subscription, SubscriptionStatus } from './entities/subscription.entity';
@Injectable()
export class SubscriptionsService {
constructor(
@InjectRepository(Subscription)
private subscriptionRepository: Repository<Subscription>,
) {}
/**
* الحصول على اشتراك المكتب الحالي
*/
async findActive(tenantId: string): Promise<Subscription> {
const subscription = await this.subscriptionRepository.findOne({
where: { tenant_id: tenantId, status: SubscriptionStatus.ACTIVE },
});
if (!subscription) throw new NotFoundException('Active subscription not found');
return subscription;
}
/**
* هل يُسمح بإضافة شركة جديدة؟
*/
async checkCompanyLimit(tenantId: string): Promise<boolean> {
const sub = await this.findActive(tenantId);
// Check current company count
const count = await this.subscriptionRepository.manager.count('companies', {
where: { tenant_id: tenantId, is_active: true },
});
return count < sub.max_companies || sub.max_companies === -1; // -1 for unlimited
}
/**
* هل يُسمح برفع فاتورة جديدة؟
*/
async checkInvoiceLimit(tenantId: string): Promise<boolean> {
const sub = await this.findActive(tenantId);
return sub.invoices_used_this_month < sub.max_invoices_per_month || sub.max_invoices_per_month === -1;
}
/**
* زيادة عداد الفواتير بعد الرفع الناجح
*/
async incrementInvoiceCount(tenantId: string): Promise<void> {
const sub = await this.findActive(tenantId);
await this.subscriptionRepository.update(sub.id, {
invoices_used_this_month: sub.invoices_used_this_month + 1,
});
}
}

View File

@@ -0,0 +1,64 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Tenant Entity (Accounting Office)
* ════════════════════════════════════════════════════════════
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { Company } from '../../companies/entities/company.entity';
import { Subscription } from '../../subscriptions/entities/subscription.entity';
export enum TenantStatus {
ACTIVE = 'active',
SUSPENDED = 'suspended',
TRIAL = 'trial',
}
@Entity('tenants')
export class Tenant {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 255 })
name!: string;
@Column({ type: 'varchar', length: 255, unique: true })
email!: string;
@Column({ type: 'varchar', length: 20, nullable: true })
phone?: string;
@Column({
type: 'enum',
enum: TenantStatus,
default: TenantStatus.TRIAL,
})
status!: TenantStatus;
@Column({ type: 'timestamp', nullable: true })
trial_ends_at?: Date;
@CreateDateColumn({ type: 'timestamp' })
created_at!: Date;
@UpdateDateColumn({ type: 'timestamp' })
updated_at!: Date;
// ── Relationships ───────────────────────────────────────
@OneToMany(() => User, (user) => user.tenant)
users!: User[];
@OneToMany(() => Company, (company) => company.tenant)
companies!: Company[];
@OneToMany(() => Subscription, (subscription) => subscription.tenant)
subscriptions!: Subscription[];
}

View File

@@ -0,0 +1,30 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Tenants Controller
* ════════════════════════════════════════════════════════════
*/
import { Controller, Get, Patch, Body, UseGuards } from '@nestjs/common';
import { TenantsService } from './tenant.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RolesGuard } from '../../common/guards/roles.guard';
import { Roles } from '../../common/decorators/roles.decorator';
import { UserRole } from '../users/enums/role.enum';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@Controller('tenants')
@UseGuards(JwtAuthGuard, RolesGuard)
export class TenantsController {
constructor(private tenantsService: TenantsService) {}
@Get('me')
async getProfile(@CurrentUser() user: any) {
return this.tenantsService.findOne(user.tenantId);
}
@Patch('me')
@Roles(UserRole.ADMIN)
async updateProfile(@CurrentUser() user: any, @Body() updateData: any) {
return this.tenantsService.update(user.tenantId, updateData);
}
}

View File

@@ -0,0 +1,19 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Tenants Module
* ════════════════════════════════════════════════════════════
*/
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TenantsService } from './tenant.service';
import { TenantsController } from './tenant.controller';
import { Tenant } from './entities/tenant.entity';
@Module({
imports: [TypeOrmModule.forFeature([Tenant])],
providers: [TenantsService],
controllers: [TenantsController],
exports: [TenantsService],
})
export class TenantsModule {}

View File

@@ -0,0 +1,29 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Tenants Service
* ════════════════════════════════════════════════════════════
*/
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Tenant } from './entities/tenant.entity';
@Injectable()
export class TenantsService {
constructor(
@InjectRepository(Tenant)
private tenantRepository: Repository<Tenant>,
) {}
async findOne(id: string): Promise<Tenant> {
const tenant = await this.tenantRepository.findOne({ where: { id } });
if (!tenant) throw new NotFoundException('Tenant not found');
return tenant;
}
async update(id: string, updateData: Partial<Tenant>): Promise<Tenant> {
await this.tenantRepository.update(id, updateData);
return this.findOne(id);
}
}

View File

@@ -0,0 +1,62 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — User Entity
* ════════════════════════════════════════════════════════════
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Tenant } from '../../tenants/entities/tenant.entity';
import { UserRole } from '../enums/role.enum';
@Entity('users')
@Index(['tenant_id', 'email'], { unique: true })
export class User {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenant_id!: string;
@ManyToOne(() => Tenant, (tenant) => tenant.users, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'tenant_id' })
tenant!: Tenant;
@Column({ type: 'varchar', length: 255 })
name!: string;
@Column({ type: 'varchar', length: 255 })
email!: string;
@Column({ type: 'varchar', length: 255, select: false }) // Hide password by default
password_hash!: string;
@Column({
type: 'enum',
enum: UserRole,
})
role!: UserRole;
@Column({ type: 'varchar', length: 255, nullable: true, select: false })
refresh_token_hash?: string;
@Column({ type: 'boolean', default: true })
is_active!: boolean;
@Column({ type: 'timestamp', nullable: true })
last_login_at?: Date;
@CreateDateColumn({ type: 'timestamp' })
created_at!: Date;
@UpdateDateColumn({ type: 'timestamp' })
updated_at!: Date;
}

View File

@@ -0,0 +1,11 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — User Roles
* ════════════════════════════════════════════════════════════
*/
export enum UserRole {
ADMIN = 'admin', // مدير المكتب (قادر على الإضافة والتعديل الكامل)
ACCOUNTANT = 'accountant', // محاسب (قادر على رفع الفواتير وإدارتها)
VIEWER = 'viewer', // مشاهد (قادر على الاطلاع فقط)
}

View File

@@ -0,0 +1,49 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Users Controller
* ════════════════════════════════════════════════════════════
*/
import {
Controller,
Get,
Post,
Body,
Param,
Delete,
UseGuards,
} from '@nestjs/common';
import { UsersService } from './user.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RolesGuard } from '../../common/guards/roles.guard';
import { Roles } from '../../common/decorators/roles.decorator';
import { UserRole } from './enums/role.enum';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@Controller('users')
@UseGuards(JwtAuthGuard, RolesGuard)
export class UsersController {
constructor(private usersService: UsersService) {}
@Post()
@Roles(UserRole.ADMIN)
async create(@CurrentUser() user: any, @Body() dto: any) {
return this.usersService.create(user.tenantId, dto);
}
@Get()
async findAll(@CurrentUser() user: any) {
return this.usersService.findAll(user.tenantId);
}
@Get(':id')
async findOne(@CurrentUser() user: any, @Param('id') id: string) {
return this.usersService.findOne(user.tenantId, id);
}
@Delete(':id')
@Roles(UserRole.ADMIN)
async remove(@CurrentUser() user: any, @Param('id') id: string) {
return this.usersService.remove(user.tenantId, id);
}
}

View File

@@ -0,0 +1,19 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Users Module
* ════════════════════════════════════════════════════════════
*/
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersService } from './user.service';
import { UsersController } from './user.controller';
import { User } from './entities/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UsersService],
controllers: [UsersController],
exports: [UsersService],
})
export class UsersModule {}

View File

@@ -0,0 +1,71 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Users Service
* ════════════════════════════════════════════════════════════
*/
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User } from './entities/user.entity';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
) {}
/**
* إضافة مستخدم لمكتب محاسبة
*/
async create(tenantId: string, dto: any): Promise<User> {
const existing = await this.userRepository.findOne({
where: { email: dto.email, tenant_id: tenantId },
});
if (existing) {
throw new ConflictException('User with this email already exists in this office');
}
const passwordHash = await bcrypt.hash(dto.password, 12);
const user = this.userRepository.create({
...dto,
password_hash: passwordHash,
tenant_id: tenantId,
});
return this.userRepository.save(user);
}
/**
* قائمة مستخدمي المكتب
*/
async findAll(tenantId: string): Promise<User[]> {
return this.userRepository.find({
where: { tenant_id: tenantId, is_active: true },
order: { created_at: 'ASC' },
});
}
/**
* تفاصيل مستخدم
*/
async findOne(tenantId: string, id: string): Promise<User> {
const user = await this.userRepository.findOne({
where: { id, tenant_id: tenantId },
});
if (!user) throw new NotFoundException('User not found');
return user;
}
/**
* تعطيل مستخدم
*/
async remove(tenantId: string, id: string): Promise<void> {
const user = await this.findOne(tenantId, id);
await this.userRepository.update(id, { is_active: false });
}
}

View File

@@ -0,0 +1,14 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Tax Validation Module
* ════════════════════════════════════════════════════════════
*/
import { Module } from '@nestjs/common';
import { TaxValidationService } from './tax-validation.service';
@Module({
providers: [TaxValidationService],
exports: [TaxValidationService],
})
export class TaxValidationModule {}

View File

@@ -0,0 +1,103 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Tax Validation Service
* ════════════════════════════════════════════════════════════
* محرك التحقق من القواعد الضريبية الأردنية (ISTD Rules).
* يضمن دقة الحسابات قبل إرسالها إلى "جو فوترة".
* ════════════════════════════════════════════════════════════
*/
import { Injectable, Logger } from '@nestjs/common';
import { Invoice } from '../invoices/entities/invoice.entity';
export interface ValidationResult {
isValid: boolean;
errors: string[];
}
@Injectable()
export class TaxValidationService {
private readonly logger = new Logger(TaxValidationService.name);
private readonly PRECISION = 0.005; // السماحية في الفروقات العشرية البسيطة
/**
* التحقق الشامل من الفاتورة
*/
validateInvoice(invoice: Invoice): ValidationResult {
const errors: string[] = [];
// 1. Rule 001: التحقق من مجموع بنود الفاتورة (Subtotal)
this.checkRule001(invoice, errors);
// 2. Rule 002: التحقق من قيمة الضريبة (Tax Amount)
this.checkRule002(invoice, errors);
// 3. Rule 003: التحقق من الخصومات (Discounts)
this.checkRule003(invoice, errors);
// 4. Rule 004: التحقق من المجموع النهائي (Grand Total)
this.checkRule004(invoice, errors);
return {
isValid: errors.length === 0,
errors,
};
}
/**
* Rule 001: Σ (Quantity * UnitPrice) = Subtotal (Before Tax/Discount)
*/
private checkRule001(invoice: Invoice, errors: string[]) {
const calculatedSubtotal = invoice.lines.reduce(
(sum, line) => sum + Number(line.quantity) * Number(line.unit_price),
0,
);
if (Math.abs(calculatedSubtotal - Number(invoice.subtotal)) > this.PRECISION) {
errors.push(`خطأ في القاعدة 001: مجموع البنود (${calculatedSubtotal}) لا يطابق المجموع الفرعي المسجل (${invoice.subtotal})`);
}
}
/**
* Rule 002: TaxAmount = (Subtotal - Discount) * TaxRate
* ملاحظة: يجب التحقق لكل بند بشكل منفصل أو للمجموع حسب نوع الفاتورة
*/
private checkRule002(invoice: Invoice, errors: string[]) {
const calculatedTax = invoice.lines.reduce(
(sum, line) => {
const lineBeforeTax = (Number(line.quantity) * Number(line.unit_price)) - Number(line.discount);
return sum + (lineBeforeTax * Number(line.tax_rate));
},
0,
);
if (Math.abs(calculatedTax - Number(invoice.tax_amount)) > this.PRECISION) {
errors.push(`خطأ في القاعدة 002: قيمة الضريبة المحسوبة (${calculatedTax.toFixed(3)}) لا تطابق القيمة المسجلة (${invoice.tax_amount})`);
}
}
/**
* Rule 003: Σ Line Discounts = Total Discount
*/
private checkRule003(invoice: Invoice, errors: string[]) {
const totalLineDiscounts = invoice.lines.reduce(
(sum, line) => sum + Number(line.discount),
0,
);
if (Math.abs(totalLineDiscounts - Number(invoice.discount_total)) > this.PRECISION) {
errors.push(`خطأ في القاعدة 003: مجموع خصومات البنود (${totalLineDiscounts}) لا يطابق إجمالي الخصم (${invoice.discount_total})`);
}
}
/**
* Rule 004: GrandTotal = Subtotal - Discount + Tax
*/
private checkRule004(invoice: Invoice, errors: string[]) {
const calculatedGrandTotal = Number(invoice.subtotal) - Number(invoice.discount_total) + Number(invoice.tax_amount);
if (Math.abs(calculatedGrandTotal - Number(invoice.grand_total)) > this.PRECISION) {
errors.push(`خطأ في القاعدة 004: المجموع النهائي المحسوب (${calculatedGrandTotal.toFixed(3)}) لا يطابق المسجل (${invoice.grand_total})`);
}
}
}

View File

@@ -0,0 +1,79 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Encryption Service
* ════════════════════════════════════════════════════════════
* يستخدم خوارزمية AES-256-GCM لتشفير البيانات الحساسة.
* يُستخدم بشكل أساسي لمفاتيح جو فوترة (API Keys).
* ════════════════════════════════════════════════════════════
*/
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from 'crypto';
@Injectable()
export class EncryptionService {
private readonly algorithm = 'aes-256-gcm';
private readonly key: Buffer;
private readonly ivLength = 16;
private readonly tagLength = 16;
constructor(private configService: ConfigService) {
const k = this.configService.getOrThrow<string>('ENCRYPTION_KEY');
this.key = Buffer.from(k, 'hex');
if (this.key.length !== 32) {
throw new Error('Encryption key must be 32 bytes (64 hex characters)');
}
}
/**
* تشفير النص
* التنسيق الناتج: iv:tag:encryptedData (hex)
*/
encrypt(text: string): string {
try {
const iv = crypto.randomBytes(this.ivLength);
const cipher = crypto.createCipheriv(this.algorithm, this.key, iv);
const encrypted = Buffer.concat([
cipher.update(text, 'utf8'),
cipher.final(),
]);
const tag = cipher.getAuthTag();
return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`;
} catch (error) {
throw new InternalServerErrorException('Encryption failed');
}
}
/**
* فك تشفير النص
*/
decrypt(encryptedText: string): string {
try {
const parts = encryptedText.split(':');
if (parts.length !== 3) {
throw new Error('Invalid encrypted text format');
}
const iv = Buffer.from(parts[0], 'hex');
const tag = Buffer.from(parts[1], 'hex');
const encryptedData = Buffer.from(parts[2], 'hex');
const decipher = crypto.createDecipheriv(this.algorithm, this.key, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([
decipher.update(encryptedData),
decipher.final(),
]);
return decrypted.toString('utf8');
} catch (error) {
throw new InternalServerErrorException('Decryption failed');
}
}
}

View File

@@ -0,0 +1,74 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Local Storage Service
* ════════════════════════════════════════════════════════════
*/
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import * as path from 'path';
@Injectable()
export class LocalStorageService {
private readonly storageRoot: string;
constructor(private configService: ConfigService) {
this.storageRoot = this.configService.get<string>('STORAGE_PATH', './uploads');
// Ensure the root storage directory exists
if (!fs.existsSync(this.storageRoot)) {
fs.mkdirSync(this.storageRoot, { recursive: true });
}
}
/**
* حفظ ملف في التخزين المحلي
*/
async saveFile(
tenantId: string,
companyId: string,
fileName: string,
buffer: Buffer,
): Promise<string> {
try {
const now = new Date();
const relativePath = path.join(
tenantId,
companyId,
'invoices',
now.getFullYear().toString(),
(now.getMonth() + 1).toString(),
);
const fullPath = path.join(this.storageRoot, relativePath);
// Create directories if they don't exist
if (!fs.existsSync(fullPath)) {
fs.mkdirSync(fullPath, { recursive: true });
}
const filePath = path.join(fullPath, fileName);
fs.writeFileSync(filePath, buffer);
// Return the relative path to be stored in the database
return path.join(relativePath, fileName);
} catch (error) {
throw new InternalServerErrorException('File storage failed');
}
}
/**
* حذف ملف
*/
async deleteFile(filePath: string): Promise<void> {
try {
const fullPath = path.join(this.storageRoot, filePath);
if (fs.existsSync(fullPath)) {
fs.unlinkSync(fullPath);
}
} catch (error) {
// Log error but don't fail the request
}
}
}

View File

@@ -0,0 +1,7 @@
{
"compilerOptions": {
"outDir": "./dist",
"baseUrl": "./"
},
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

28
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strict": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@config/*": ["src/config/*"],
"@common/*": ["src/common/*"],
"@modules/*": ["src/modules/*"],
"@services/*": ["src/services/*"]
}
}
}

103
docker-compose.yml Normal file
View File

@@ -0,0 +1,103 @@
# ════════════════════════════════════════════════════════════
# مُصادَق (Musadaq) — Full Stack Docker Compose
# ════════════════════════════════════════════════════════════
# Services:
# - Frontend: React/Vite (80 -> Proxy to API)
# - API: NestJS (3300)
# - DB: PostgreSQL (5300 internally)
# - Redis: Cache & Queue (6400 internally)
# ════════════════════════════════════════════════════════════
services:
# ── Frontend (Ingress) ──────────────────────────────────
frontend:
build:
context: ./musadaq-frontend
dockerfile: Dockerfile
container_name: musadaq-frontend
restart: unless-stopped
ports:
- "80:80"
depends_on:
- api
networks:
- musadaq-network
# ── NestJS API ─────────────────────────────────────────
api:
build:
context: ./musadaq-backend
dockerfile: Dockerfile
container_name: musadaq-api
restart: unless-stopped
ports:
- "3300:3300"
environment:
- NODE_ENV=production
- DB_HOST=db
- DB_PORT=5432
- REDIS_HOST=redis
- REDIS_PORT=6379
- DB_USER=${DB_USER}
- DB_PASS=${DB_PASS}
- DB_NAME=${DB_NAME}
- JWT_SECRET=${JWT_SECRET}
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- GEMINI_API_KEY=${GEMINI_API_KEY}
- JOFOTARA_SANDBOX_URL=${JOFOTARA_SANDBOX_URL}
- JOFOTARA_PROD_URL=${JOFOTARA_PROD_URL}
- JOFOTARA_ENV=${JOFOTARA_ENV}
- STORAGE_PATH=/app/uploads
volumes:
- invoice_uploads:/app/uploads
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- musadaq-network
# ── PostgreSQL ────────────────────────────────────────
db:
image: postgres:16-alpine
container_name: musadaq-db
restart: unless-stopped
environment:
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASS}
- POSTGRES_DB=${DB_NAME}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- musadaq-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 10s
timeout: 5s
retries: 5
# ── Redis ─────────────────────────────────────────────
redis:
image: redis:7-alpine
container_name: musadaq-redis
restart: unless-stopped
volumes:
- redis_data:/data
networks:
- musadaq-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:
redis_data:
invoice_uploads:
networks:
musadaq-network:
driver: bridge

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

29
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
# ════════════════════════════════════════════════════════════
# مُصادَق (Musadaq) — Frontend Dockerfile (Vite)
# ════════════════════════════════════════════════════════════
# ── Build Stage ───────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm install
# Copy source and build
COPY . .
RUN npm run build
# ── Production Stage ──────────────────────────────────────
FROM nginx:stable-alpine
# Copy built files to nginx
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

73
frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>musadaq-frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

47
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,47 @@
# ════════════════════════════════════════════════════════════
# مُصادَق (Musadaq) — Frontend Nginx Config
# ════════════════════════════════════════════════════════════
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# ── Security Headers ──────────────────────────────────────
add_header X-Frame-Options "SAMEORIGIN";
add_header X-XSS-Protection "1; mode=block";
add_header X-Content-Type-Options "nosniff";
# Gzip Compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# ── Root Path (SPA Support) ────────────────────────────────
location / {
try_files $uri $uri/ /index.html;
}
# ── Proxy /api requests to the backend ──────────────────────
location /api {
proxy_pass http://api:3300/api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# Static Assets Caching
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg)$ {
expires 6M;
access_log off;
add_header Cache-Control "public";
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

3323
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
frontend/package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "musadaq-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@tanstack/react-query": "^5.99.0",
"@tanstack/react-query-devtools": "^5.99.0",
"axios": "^1.15.0",
"clsx": "^2.1.1",
"framer-motion": "^12.38.0",
"lucide-react": "^1.8.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.72.1",
"react-router-dom": "^7.14.1",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"postcss": "^8.5.10",
"tailwindcss": "^4.2.2",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

38
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,38 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from './store/authStore';
import { MainLayout } from './components/layout/MainLayout';
import LoginPage from './pages/auth/LoginPage';
import RegisterPage from './pages/auth/RegisterPage';
import { DashboardPage } from './pages/dashboard/DashboardPage';
import { InvoicesPage } from './pages/invoices/InvoicesPage';
// ── Protected Route Guard ─────────────────────────────────
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return isAuthenticated ? <>{children}</> : <Navigate to="/login" />;
};
export default function App() {
return (
<BrowserRouter>
<Routes>
{/* Public Routes */}
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
{/* Protected Dashboard Routes */}
<Route path="/" element={<ProtectedRoute><MainLayout /></ProtectedRoute>}>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="invoices" element={<InvoicesPage />} />
<Route path="companies" element={<div className="text-3xl font-bold">إدارة الشركات</div>} />
<Route path="staff" element={<div className="text-3xl font-bold">إدارة الموظفين</div>} />
<Route path="settings" element={<div className="text-3xl font-bold">الإعدادات</div>} />
</Route>
{/* Fallback */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,65 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — API Client
* ════════════════════════════════════════════════════════════
*/
import axios from 'axios';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3300/api';
const apiClient = axios.create({
baseURL: API_URL,
withCredentials: true, // Required for HttpOnly refresh cookies
headers: {
'Content-Type': 'application/json',
},
});
// ── Request Interceptor (JWT) ──────────────────────────────
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error),
);
// ── Response Interceptor (Token Rotation) ──────────────────
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// If 401 and not already retrying
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// Attempt to refresh tokens
const { data } = await axios.post(
`${API_URL}/auth/refresh`,
{},
{ withCredentials: true },
);
localStorage.setItem('access_token', data.accessToken);
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
return apiClient(originalRequest);
} catch (refreshError) {
// If refresh fails, clear and redirect to login
localStorage.removeItem('access_token');
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
},
);
export default apiClient;

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,59 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Main Layout Shell
* ════════════════════════════════════════════════════════════
*/
import { Outlet } from 'react-router-dom';
import { Sidebar } from './Sidebar';
import { Bell, Search, User } from 'lucide-react';
import { useAuthStore } from '../../store/authStore';
export const MainLayout = () => {
const user = useAuthStore((state) => state.user);
return (
<div className="flex bg-slate-50 min-h-screen rtl overflow-hidden">
{/* ── Desktop Sidebar ───────────────────────────────────── */}
<Sidebar />
<div className="flex-1 flex flex-col h-screen overflow-y-auto">
{/* ── Top Navigation ──────────────────────────────────── */}
<header className="h-16 bg-white/80 backdrop-blur-md sticky top-0 z-30 border-b border-slate-100 px-8 flex items-center justify-between shadow-sm">
<div className="flex items-center gap-4 bg-slate-50 px-4 py-2 rounded-xl group focus-within:ring-2 focus-within:ring-primary-100 transition-all border border-transparent focus-within:border-primary-200">
<Search className="w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="بحث سريع..."
className="bg-transparent border-none outline-none text-sm w-64 text-slate-900"
/>
</div>
<div className="flex items-center gap-6">
<button className="p-2 text-slate-400 hover:bg-slate-50 hover:text-primary-600 rounded-xl transition-all relative">
<Bell className="w-5 h-5" />
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full border-2 border-white"></span>
</button>
<div className="flex items-center gap-3 pl-2 border-r border-slate-100">
<div className="text-left">
<p className="text-sm font-semibold text-slate-900">{user?.name}</p>
<p className="text-[12px] text-slate-500 uppercase tracking-wider font-medium">
{user?.role}
</p>
</div>
<div className="w-10 h-10 bg-slate-100 rounded-full flex items-center justify-center border-2 border-white shadow-sm ring-1 ring-slate-100">
<User className="text-slate-400 w-5 h-5" />
</div>
</div>
</div>
</header>
{/* ── Main Content Area ───────────────────────────────── */}
<main className="p-8 pb-16 flex-1">
<Outlet />
</main>
</div>
</div>
);
};

View File

@@ -0,0 +1,70 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Premium Sidebar
* ════════════════════════════════════════════════════════════
*/
import { NavLink } from 'react-router-dom';
import {
LayoutDashboard,
FileText,
Building2,
Users,
Settings,
LogOut
} from 'lucide-react';
import { useAuthStore } from '../../store/authStore';
const menuItems = [
{ icon: LayoutDashboard, label: 'الرئيسية', path: '/dashboard' },
{ icon: FileText, label: 'الفواتير', path: '/invoices' },
{ icon: Building2, label: 'الشركات', path: '/companies' },
{ icon: Users, label: 'الموظفون', path: '/staff' },
{ icon: Settings, label: 'الإعدادات', path: '/settings' },
];
export const Sidebar = () => {
const clearAuth = useAuthStore((state) => state.clearAuth);
return (
<aside className="w-64 h-screen glass border-l border-slate-200 sticky top-0 flex flex-col p-4">
<div className="flex items-center gap-3 px-2 py-6">
<div className="w-10 h-10 bg-primary-600 rounded-xl flex items-center justify-center shadow-lg shadow-primary-500/30">
<FileText className="text-white w-6 h-6" />
</div>
<h1 className="text-xl font-bold bg-gradient-to-br from-slate-900 to-slate-500 bg-clip-text text-transparent">
مُصادَق
</h1>
</div>
<nav className="flex-1 mt-4 space-y-1">
{menuItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 group ${
isActive
? 'bg-primary-50 text-primary-600 shadow-sm border border-primary-100'
: 'text-slate-500 hover:bg-slate-50 hover:text-slate-900'
}`
}
>
<item.icon className="w-5 h-5" />
<span className="font-medium">{item.label}</span>
</NavLink>
))}
</nav>
<div className="pt-4 border-t border-slate-100">
<button
onClick={clearAuth}
className="flex items-center gap-3 px-4 py-3 w-full rounded-xl text-red-500 hover:bg-red-50 transition-all group"
>
<LogOut className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
<span className="font-medium">تسجيل الخروج</span>
</button>
</div>
</aside>
);
};

43
frontend/src/index.css Normal file
View File

@@ -0,0 +1,43 @@
@import "tailwindcss";
@theme {
--color-primary-50: oklch(0.97 0.01 240);
--color-primary-100: oklch(0.93 0.03 240);
--color-primary-200: oklch(0.87 0.06 240);
--color-primary-300: oklch(0.78 0.12 240);
--color-primary-400: oklch(0.66 0.18 240);
--color-primary-500: oklch(0.55 0.22 240);
--color-primary-600: oklch(0.48 0.23 240);
--color-primary-700: oklch(0.40 0.20 240);
--color-primary-800: oklch(0.33 0.16 240);
--color-primary-900: oklch(0.28 0.12 240);
--color-primary-950: oklch(0.18 0.08 240);
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono: "Fira Code", ui-monospace, SFMono-Regular, monospace;
}
@layer base {
body {
@apply bg-slate-50 text-slate-900 antialiased;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
}
h1, h2, h3, h4, h5, h6 {
@apply font-semibold tracking-tight text-slate-900;
}
}
@layer components {
.glass {
@apply bg-white/70 backdrop-blur-md border border-white/20 shadow-xl;
}
.card-premium {
@apply bg-white border border-slate-200 shadow-sm hover:shadow-md transition-all duration-200 rounded-xl overflow-hidden;
}
.btn-primary {
@apply bg-primary-600 hover:bg-primary-700 text-white font-medium py-2 px-4 rounded-lg shadow-sm transition-all active:scale-95;
}
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,130 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Premium Login Page
* ════════════════════════════════════════════════════════════
*/
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { motion, AnimatePresence } from 'framer-motion';
import { LogIn, Mail, Lock, AlertCircle, Loader2 } from 'lucide-react';
import apiClient from '../../api/client';
import { useAuthStore } from '../../store/authStore';
const loginSchema = z.object({
email: z.string().email('بريد إلكتروني غير صالح'),
password: z.string().min(8, 'كلمة المرور يجب أن لا تقل عن 8 أحرف'),
});
type LoginForm = z.infer<typeof loginSchema>;
export default function LoginPage() {
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const setAuth = useAuthStore((state) => state.setAuth);
const navigate = useNavigate();
const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginForm) => {
setIsLoading(true);
setError(null);
try {
const response = await apiClient.post('/auth/login', data);
const { user, accessToken } = response.data;
setAuth(user, accessToken);
navigate('/dashboard');
} catch (err: any) {
setError(err.response?.data?.message || 'فشل تسجيل الدخول. يرجى التحقق من البيانات.');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-6 relative overflow-hidden rtl">
{/* ── Background Aesthetics ────────────────────────────────── */}
<div className="absolute top-0 left-0 w-full h-full opacity-10 pointer-events-none">
<div className="absolute top-[-20%] left-[-10%] w-[600px] h-[600px] bg-primary-300 rounded-full blur-[120px]" />
<div className="absolute bottom-[-20%] right-[-10%] w-[600px] h-[600px] bg-blue-300 rounded-full blur-[120px]" />
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="w-full max-w-md glass p-10 rounded-3xl shadow-2xl relative z-10"
>
<div className="text-center mb-10">
<div className="w-16 h-16 bg-primary-600 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-xl shadow-primary-500/20">
<LogIn className="text-white w-8 h-8" />
</div>
<h1 className="text-3xl font-bold text-slate-900 mb-2">أهلاً بك في مُصادَق</h1>
<p className="text-slate-500">منصة أتمتة الفواتير الضريبية الأردنية</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<AnimatePresence mode="wait">
{error && (
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
className="bg-red-50 border border-red-100 p-4 rounded-xl flex items-center gap-3 text-red-600 text-sm font-medium"
>
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<span>{error}</span>
</motion.div>
)}
</AnimatePresence>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2 mr-1">البريد الإلكتروني</label>
<div className="relative group">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400 group-focus-within:text-primary-500 transition-colors" />
<input
{...register('email')}
className="w-full bg-slate-50/50 border border-slate-200 rounded-xl py-3 pl-4 pr-11 focus:ring-4 focus:ring-primary-500/10 focus:border-primary-500 outline-none transition-all"
placeholder="name@company.com"
/>
</div>
{errors.email && <p className="text-red-500 text-[12px] mt-1 mr-1">{errors.email.message}</p>}
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2 mr-1">كلمة المرور</label>
<div className="relative group">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400 group-focus-within:text-primary-500 transition-colors" />
<input
{...register('password')}
type="password"
className="w-full bg-slate-50/50 border border-slate-200 rounded-xl py-3 pl-4 pr-11 focus:ring-4 focus:ring-primary-500/10 focus:border-primary-500 outline-none transition-all"
placeholder="••••••••"
/>
</div>
{errors.password && <p className="text-red-500 text-[12px] mt-1 mr-1">{errors.password.message}</p>}
</div>
<button
type="submit"
disabled={isLoading}
className="w-full btn-primary h-14 text-lg mt-4 flex items-center justify-center gap-3 shadow-lg shadow-primary-500/25"
>
{isLoading ? <Loader2 className="w-6 h-6 animate-spin" /> : 'تسجيل الدخول'}
</button>
</form>
<div className="mt-8 text-center text-slate-500 text-sm">
ليس لديك حساب؟{' '}
<Link to="/register" className="text-primary-600 font-bold hover:underline">
أنشئ حساباً جديداً
</Link>
</div>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,206 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Premium Register Page
* ════════════════════════════════════════════════════════════
*/
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { motion, AnimatePresence } from 'framer-motion';
import { UserPlus, Mail, Lock, Building, Phone, ArrowLeft, ArrowRight, Loader2 } from 'lucide-react';
import apiClient from '../../api/client';
const registerSchema = z.object({
tenantName: z.string().min(3, 'اسم المكتب يجب أن لا يقل عن 3 أحرف'),
name: z.string().min(3, 'الاسم يجب أن لا يقل عن 3 أحرف'),
email: z.string().email('بريد إلكتروني غير صالح'),
password: z.string().min(8, 'كلمة المرور يجب أن لا تقل عن 8 أحرف'),
phone: z.string().optional(),
});
type RegisterForm = z.infer<typeof registerSchema>;
export default function RegisterPage() {
const [step, setStep] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const { register, handleSubmit, trigger, formState: { errors } } = useForm<RegisterForm>({
resolver: zodResolver(registerSchema),
});
const nextStep = async () => {
const fields = step === 1 ? ['tenantName', 'phone'] : ['name', 'email', 'password'];
const isValid = await trigger(fields as any);
if (isValid) setStep(step + 1);
};
const onSubmit = async (data: RegisterForm) => {
setIsLoading(true);
try {
await apiClient.post('/auth/register', data);
navigate('/login', { state: { message: 'تم إنشاء الحساب بنجاح! يرجى تسجيل الدخول.' } });
} catch (err) {
alert('فشل إنشاء الحساب. تأكد من أن البريد الإلكتروني لم يُستخدم من قبل.');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-6 relative overflow-hidden rtl font-sans">
{/* ── Background Aesthetics ────────────────────────────────── */}
<div className="absolute top-0 left-0 w-full h-full opacity-10 pointer-events-none">
<div className="absolute top-[-20%] right-[-10%] w-[600px] h-[600px] bg-primary-300 rounded-full blur-[120px]" />
<div className="absolute bottom-[-20%] left-[-10%] w-[600px] h-[600px] bg-blue-300 rounded-full blur-[120px]" />
</div>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="w-full max-w-lg glass p-10 rounded-3xl shadow-2xl relative z-10"
>
<div className="text-center mb-8">
<div className="w-16 h-16 bg-primary-600 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-xl shadow-primary-500/20">
<UserPlus className="text-white w-8 h-8" />
</div>
<h1 className="text-3xl font-bold text-slate-900 mb-2">إنشاء حساب جديد</h1>
<p className="text-slate-500">ابدأ رحلتك في أتمتة الفواتير الضريبية</p>
</div>
{/* ── Progress Indicator ─────────────────────────────────── */}
<div className="flex gap-2 mb-8 items-center justify-center">
{[1, 2].map((i) => (
<div
key={i}
className={`h-1.5 rounded-full transition-all duration-300 ${step >= i ? 'w-12 bg-primary-600' : 'w-4 bg-slate-200'}`}
/>
))}
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<AnimatePresence mode="wait">
{step === 1 ? (
<motion.div
key="step1"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-6"
>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2 mr-1">اسم مكتب المحاسبة</label>
<div className="relative group">
<Building className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400 group-focus-within:text-primary-500 transition-colors" />
<input
{...register('tenantName')}
className="w-full bg-slate-50/50 border border-slate-200 rounded-xl py-3 pl-4 pr-11 focus:ring-4 focus:ring-primary-500/10 focus:border-primary-500 outline-none transition-all"
placeholder="شركة الفوترة للمحاسبة"
/>
</div>
{errors.tenantName && <p className="text-red-500 text-[12px] mt-1 mr-1">{errors.tenantName.message}</p>}
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2 mr-1">رقم الهاتف (اختياري)</label>
<div className="relative group">
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400 group-focus-within:text-primary-500 transition-colors" />
<input
{...register('phone')}
className="w-full bg-slate-50/50 border border-slate-200 rounded-xl py-3 pl-4 pr-11 focus:ring-4 focus:ring-primary-500/10 focus:border-primary-500 outline-none transition-all"
placeholder="079 XXXXXXX"
/>
</div>
</div>
<button
type="button"
onClick={nextStep}
className="w-full btn-primary h-14 text-lg flex items-center justify-center gap-3"
>
التالي
<ArrowLeft className="w-5 h-5" />
</button>
</motion.div>
) : (
<motion.div
key="step2"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-6"
>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2 mr-1">الاسم الكامل (للمدير)</label>
<div className="relative group">
<input
{...register('name')}
className="w-full bg-slate-50/50 border border-slate-200 rounded-xl py-3 px-4 focus:ring-4 focus:ring-primary-500/10 focus:border-primary-500 outline-none transition-all"
placeholder="أحمد محمد"
/>
</div>
{errors.name && <p className="text-red-500 text-[12px] mt-1 mr-1">{errors.name.message}</p>}
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2 mr-1">البريد الإلكتروني</label>
<div className="relative group">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400 group-focus-within:text-primary-500 transition-colors" />
<input
{...register('email')}
className="w-full bg-slate-50/50 border border-slate-200 rounded-xl py-3 pl-4 pr-11 focus:ring-4 focus:ring-primary-500/10 focus:border-primary-500 outline-none transition-all"
placeholder="admin@office.com"
/>
</div>
{errors.email && <p className="text-red-500 text-[12px] mt-1 mr-1">{errors.email.message}</p>}
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2 mr-1">كلمة المرور</label>
<div className="relative group">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400 group-focus-within:text-primary-500 transition-colors" />
<input
{...register('password')}
type="password"
className="w-full bg-slate-50/50 border border-slate-200 rounded-xl py-3 pl-4 pr-11 focus:ring-4 focus:ring-primary-500/10 focus:border-primary-500 outline-none transition-all"
placeholder="••••••••"
/>
</div>
{errors.password && <p className="text-red-500 text-[12px] mt-1 mr-1">{errors.password.message}</p>}
</div>
<div className="flex gap-4">
<button
type="button"
onClick={() => setStep(1)}
className="flex-1 bg-slate-100 hover:bg-slate-200 text-slate-600 font-bold py-4 rounded-xl transition-all flex items-center justify-center gap-2"
>
<ArrowRight className="w-5 h-5" />
السابق
</button>
<button
type="submit"
disabled={isLoading}
className="flex-[2] btn-primary py-4 flex items-center justify-center gap-2"
>
{isLoading ? <Loader2 className="w-6 h-6 animate-spin" /> : 'إنشاء الحساب'}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</form>
<div className="mt-8 text-center text-slate-500 text-sm">
لديك حساب بالفعل؟{' '}
<Link to="/login" className="text-primary-600 font-bold hover:underline">
سجل دخولك من هنا
</Link>
</div>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Dashboard Statistics Components
* ════════════════════════════════════════════════════════════
*/
import { motion } from 'framer-motion';
import {
FileText,
CheckCircle2,
AlertCircle,
TrendingUp,
Wallet,
ArrowUpRight
} from 'lucide-react';
const stats = [
{ label: 'إجمالي الفواتير', value: '1,280', icon: FileText, color: 'text-primary-600', bg: 'bg-primary-50', change: '+12%' },
{ label: 'تمت مصادقتها', value: '1,150', icon: CheckCircle2, color: 'text-emerald-600', bg: 'bg-emerald-50', change: '+18%' },
{ label: 'قيد المراجعة', value: '42', icon: AlertCircle, color: 'text-amber-600', bg: 'bg-amber-50', change: '-5%' },
{ label: 'مجموع الضريبة (JOD)', value: '14,250.000', icon: Wallet, color: 'text-blue-600', bg: 'bg-blue-50', change: '+8%' },
];
export const DashboardPage = () => {
return (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
<header className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold text-slate-900">لوحة التحكم</h2>
<p className="text-slate-500 mt-1">نظرة عامة على نشاطك الضريبي هذا الشهر.</p>
</div>
<div className="flex gap-3">
<button className="bg-white border border-slate-200 text-slate-700 font-semibold py-2.5 px-6 rounded-xl shadow-sm hover:bg-slate-50 transition-all flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-primary-500" />
تصدير التقارير
</button>
<button className="btn-primary py-2.5 px-6 rounded-xl flex items-center gap-2 shadow-lg shadow-primary-500/25">
<FileText className="w-5 h-5" />
فاتورة جديدة
</button>
</div>
</header>
{/* ── Stats Grid ────────────────────────────────────────── */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{stats.map((stat, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="card-premium p-6 group cursor-pointer"
>
<div className="flex items-start justify-between mb-4">
<div className={`p-3 rounded-2xl ${stat.bg} ${stat.color} transition-transform group-hover:scale-110 duration-300`}>
<stat.icon className="w-6 h-6" />
</div>
<div className={`flex items-center gap-1 text-[12px] font-bold px-2 py-1 rounded-full ${stat.change.startsWith('+') ? 'bg-emerald-50 text-emerald-600' : 'bg-red-50 text-red-600'}`}>
<ArrowUpRight className="w-3 h-3" />
{stat.change}
</div>
</div>
<p className="text-slate-500 text-sm font-medium">{stat.label}</p>
<h3 className="text-2xl font-bold text-slate-900 mt-1">{stat.value}</h3>
</motion.div>
))}
</div>
{/* ── Main Dashboard Content (Placeholder for Charts/Lists) ── */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-6">
<div className="card-premium h-[400px] p-6 flex flex-col">
<div className="flex items-center justify-between mb-8">
<h4 className="font-bold text-lg">تحليلات الفوترة الأسبوعية</h4>
<select className="bg-slate-50 border border-slate-100 rounded-lg py-1.5 px-3 text-sm font-medium outline-none">
<option>آخر 7 أيام</option>
<option>آخر 30 يوم</option>
</select>
</div>
<div className="flex-1 bg-slate-50 rounded-2xl border border-dashed border-slate-200 flex items-center justify-center">
<p className="text-slate-400 text-sm font-medium italic">رسم بياني توضيحي (Chart integration goes here)</p>
</div>
</div>
</div>
<div className="space-y-6">
<div className="card-premium p-6 bg-primary-600 text-white shadow-xl shadow-primary-500/30">
<h4 className="font-bold text-lg mb-2">استهلاك الاشتراك الحالي</h4>
<p className="text-primary-100 text-sm mb-6">لقد استهلكت 65% من حصتك الشهرية من الفواتير.</p>
<div className="w-full h-3 bg-white/20 rounded-full overflow-hidden mb-6">
<div className="w-2/3 h-full bg-white rounded-full shadow-lg" />
</div>
<button className="w-full bg-white text-primary-600 font-bold py-3 rounded-xl hover:bg-primary-50 transition-all">
ترقية الباقة الآن
</button>
</div>
<div className="card-premium p-6">
<h4 className="font-bold text-lg mb-4">آخر النشاطات</h4>
<div className="space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="flex items-center gap-3 p-2 hover:bg-slate-50 rounded-xl transition-all cursor-pointer">
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center">
<FileText className="w-5 h-5 text-slate-500" />
</div>
<div className="flex-1">
<p className="text-sm font-bold text-slate-800">فاتورة مبيعات #A-2024-001</p>
<p className="text-[12px] text-slate-500">منذ 10 دقائق · تمت المصادقة</p>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,159 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Invoices Management Page
* ════════════════════════════════════════════════════════════
*/
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Upload,
Search,
Filter,
Eye,
CheckCircle2,
Clock,
AlertCircle,
MoreVertical,
FileImage,
ChevronLeft,
ChevronRight
} from 'lucide-react';
const invoices = [
{ id: '1', number: 'INV-2024-001', company: 'شركة الأمل', date: '2024-04-15', total: '150.000', status: 'approved', type: 'cash' },
{ id: '2', number: 'INV-2024-002', company: 'سوبرماركت المدينة', date: '2024-04-16', total: '2,400.000', status: 'validated', type: 'credit' },
{ id: '3', number: 'OCR_PENDING', company: 'مخبز السلام', date: '2024-04-16', total: '0.000', status: 'extracting', type: 'cash' },
{ id: '4', number: 'INV-2024-003', company: 'مكتبة النجاح', date: '2024-04-14', total: '85.250', status: 'validation_failed', type: 'cash' },
];
const StatusBadge = ({ status }: { status: string }) => {
const config: any = {
approved: { color: 'text-emerald-700 bg-emerald-50 border-emerald-100', icon: CheckCircle2, label: 'تم التصديق' },
validated: { color: 'text-blue-700 bg-blue-50 border-blue-100', icon: Clock, label: 'جاهز للإرسال' },
extracting: { color: 'text-amber-700 bg-amber-50 border-amber-100', icon: Clock, label: 'قيد الاستخراج AI' },
validation_failed: { color: 'text-red-700 bg-red-50 border-red-100', icon: AlertCircle, label: 'خطأ في التحقق' },
};
const { color, icon: Icon, label } = config[status] || config.extracting;
return (
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold border ${color}`}>
<Icon className="w-3.5 h-3.5" />
{label}
</span>
);
};
export const InvoicesPage = () => {
const [searchTerm, setSearchTerm] = useState('');
return (
<div className="space-y-8 h-full flex flex-col">
<header className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold text-slate-900">إدارة الفواتير</h2>
<p className="text-slate-500 mt-1">عرض، معالجة، وإرسال الفواتير الضريبية لبوابة الضريبة.</p>
</div>
<button className="btn-primary py-3 px-8 rounded-2xl flex items-center gap-2 shadow-xl shadow-primary-500/25 active:scale-95 transition-all">
<Upload className="w-5 h-5" />
رفع فاتورة جديدة
</button>
</header>
{/* ── Filter & Search Bar ──────────────────────────────── */}
<div className="flex gap-4">
<div className="flex-1 glass border-slate-200 rounded-2xl px-4 py-3 flex items-center gap-3">
<Search className="w-5 h-5 text-slate-400" />
<input
type="text"
placeholder="ابحث برقم الفاتورة، اسم الشركة، أو التاريخ..."
className="bg-transparent border-none outline-none flex-1 text-slate-800 text-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<button className="glass border-slate-200 px-6 rounded-2xl flex items-center gap-2 text-slate-600 hover:bg-slate-50 transition-all font-semibold">
<Filter className="w-4 h-4" />
فلترة متقدمة
</button>
</div>
{/* ── Invoices Table ───────────────────────────────────── */}
<div className="flex-1 card-premium overflow-hidden flex flex-col bg-white">
<div className="overflow-x-auto">
<table className="w-full text-right border-collapse">
<thead className="bg-slate-50/80 border-b border-slate-100">
<tr>
<th className="px-6 py-4 text-sm font-bold text-slate-500">رقم الفاتورة</th>
<th className="px-6 py-4 text-sm font-bold text-slate-500">الشركة المصدرة</th>
<th className="px-6 py-4 text-sm font-bold text-slate-500">التاريخ</th>
<th className="px-6 py-4 text-sm font-bold text-slate-500">النوع</th>
<th className="px-6 py-4 text-sm font-bold text-slate-500">المجموع (JOD)</th>
<th className="px-6 py-4 text-sm font-bold text-slate-500">الحالة</th>
<th className="px-6 py-4 text-sm font-bold text-slate-500 w-20">إجراءات</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{invoices.map((inv, idx) => (
<motion.tr
key={inv.id}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.05 }}
className="hover:bg-slate-50/50 transition-colors group cursor-pointer"
>
<td className="px-6 py-4 font-bold text-slate-900">{inv.number}</td>
<td className="px-6 py-4 text-slate-600 font-medium">{inv.company}</td>
<td className="px-6 py-4 text-slate-500 text-sm">{inv.date}</td>
<td className="px-6 py-4">
<span className={`text-[11px] font-bold px-2 py-0.5 rounded uppercase tracking-wider ${inv.type === 'cash' ? 'bg-indigo-50 text-indigo-600' : 'bg-orange-50 text-orange-600'}`}>
{inv.type === 'cash' ? 'نقدي' : 'ذمم'}
</span>
</td>
<td className="px-6 py-4 font-mono font-bold text-slate-800">{inv.total}</td>
<td className="px-6 py-4"><StatusBadge status={inv.status} /></td>
<td className="px-6 py-4 text-center">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button className="p-2 text-slate-400 hover:text-primary-600 hover:bg-primary-50 rounded-lg transition-all">
<Eye className="w-4 h-4" />
</button>
<button className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-all">
<MoreVertical className="w-4 h-4" />
</button>
</div>
</td>
</motion.tr>
))}
</tbody>
</table>
</div>
{/* ── Empty State Mock (Hidden if data exists) ───────────── */}
{invoices.length === 0 && (
<div className="flex-1 flex flex-col items-center justify-center p-20 text-center">
<div className="w-24 h-24 bg-slate-50 rounded-full flex items-center justify-center mb-6 border border-slate-100">
<Upload className="w-10 h-10 text-slate-300" />
</div>
<h3 className="text-xl font-bold text-slate-900 mb-2">لا توجد فواتير بعد</h3>
<p className="text-slate-500 max-w-sm mb-8">ابدأ برفع أول فاتورة ليقوم محرك الذكاء الاصطناعي باستخراج بياناتها ومصادقتها ضريبياً.</p>
<button className="btn-primary py-3 px-8 rounded-2xl flex items-center gap-2">
ارفع فاتورتك الأولى
</button>
</div>
)}
{/* ── Pagination ───────────────────────────────────────── */}
<footer className="px-6 py-4 bg-slate-50/50 border-t border-slate-100 flex items-center justify-between">
<p className="text-sm text-slate-500">عرض 1-10 من أصل 1,280 فاتورة</p>
<div className="flex gap-2">
<button className="p-2 text-slate-400 hover:text-slate-600 disabled:opacity-30 border border-slate-200 rounded-xl bg-white shadow-sm">
<ChevronRight className="w-5 h-5" />
</button>
<button className="p-2 text-slate-400 hover:text-slate-600 disabled:opacity-30 border border-slate-200 rounded-xl bg-white shadow-sm">
<ChevronLeft className="w-5 h-5" />
</button>
</div>
</footer>
</div>
</div>
);
};

View File

@@ -0,0 +1,43 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Auth Store (Zustand)
* ════════════════════════════════════════════════════════════
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface User {
id: string;
email: string;
name: string;
role: string;
tenantId: string;
}
interface AuthState {
user: User | null;
isAuthenticated: boolean;
setAuth: (user: User, token: string) => void;
clearAuth: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
isAuthenticated: false,
setAuth: (user, token) => {
localStorage.setItem('access_token', token);
set({ user, isAuthenticated: true });
},
clearAuth: () => {
localStorage.removeItem('access_token');
set({ user: null, isAuthenticated: false });
},
}),
{
name: 'musadaq-auth-storage',
},
),
);

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

7
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})

45
musadaq/.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

45
musadaq/.metadata Normal file
View File

@@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "90673a4eef275d1a6692c26ac80d6d746d41a73a"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
- platform: android
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
- platform: ios
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
- platform: linux
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
- platform: macos
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
- platform: web
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
- platform: windows
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

17
musadaq/README.md Normal file
View File

@@ -0,0 +1,17 @@
# musadaq_mobile
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

View File

@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

14
musadaq/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.musadaq.app.musadaq_mobile"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.musadaq.app.musadaq_mobile"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="musadaq_mobile"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,5 @@
package com.musadaq.app.musadaq_mobile
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

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