🚀 Initialize Musadaq SaaS: Full Backend + AI + React Dashboard + Docker Setup
This commit is contained in:
83
backend/src/app.module.ts
Normal file
83
backend/src/app.module.ts
Normal 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 {}
|
||||
14
backend/src/common/decorators/current-user.decorator.ts
Normal file
14
backend/src/common/decorators/current-user.decorator.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
11
backend/src/common/decorators/roles.decorator.ts
Normal file
11
backend/src/common/decorators/roles.decorator.ts
Normal 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);
|
||||
54
backend/src/common/filters/global-exception.filter.ts
Normal file
54
backend/src/common/filters/global-exception.filter.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
30
backend/src/common/guards/jwt-auth.guard.ts
Normal file
30
backend/src/common/guards/jwt-auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
45
backend/src/common/guards/roles.guard.ts
Normal file
45
backend/src/common/guards/roles.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
32
backend/src/common/guards/tenant.guard.ts
Normal file
32
backend/src/common/guards/tenant.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
37
backend/src/common/interceptors/audit-log.interceptor.ts
Normal file
37
backend/src/common/interceptors/audit-log.interceptor.ts
Normal 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
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
32
backend/src/common/middleware/tenant.middleware.ts
Normal file
32
backend/src/common/middleware/tenant.middleware.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
47
backend/src/config/database.config.ts
Normal file
47
backend/src/config/database.config.ts
Normal 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,
|
||||
},
|
||||
}),
|
||||
};
|
||||
136
backend/src/config/env.validation.ts
Normal file
136
backend/src/config/env.validation.ts
Normal 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('اسم النطاق الرئيسي'),
|
||||
});
|
||||
27
backend/src/config/jwt.config.ts
Normal file
27
backend/src/config/jwt.config.ts
Normal 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
78
backend/src/main.ts
Normal 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();
|
||||
51
backend/src/migrations/1713304298000-InitialSchema.ts
Normal file
51
backend/src/migrations/1713304298000-InitialSchema.ts
Normal 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"`);
|
||||
}
|
||||
|
||||
}
|
||||
74
backend/src/modules/auth/auth.controller.ts
Normal file
74
backend/src/modules/auth/auth.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
30
backend/src/modules/auth/auth.module.ts
Normal file
30
backend/src/modules/auth/auth.module.ts
Normal 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 {}
|
||||
204
backend/src/modules/auth/auth.service.ts
Normal file
204
backend/src/modules/auth/auth.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
16
backend/src/modules/auth/dto/login.dto.ts
Normal file
16
backend/src/modules/auth/dto/login.dto.ts
Normal 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;
|
||||
}
|
||||
31
backend/src/modules/auth/dto/register.dto.ts
Normal file
31
backend/src/modules/auth/dto/register.dto.ts
Normal 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;
|
||||
}
|
||||
43
backend/src/modules/auth/strategies/jwt-refresh.strategy.ts
Normal file
43
backend/src/modules/auth/strategies/jwt-refresh.strategy.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
43
backend/src/modules/auth/strategies/jwt.strategy.ts
Normal file
43
backend/src/modules/auth/strategies/jwt.strategy.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
72
backend/src/modules/companies/company.controller.ts
Normal file
72
backend/src/modules/companies/company.controller.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
24
backend/src/modules/companies/company.module.ts
Normal file
24
backend/src/modules/companies/company.module.ts
Normal 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 {}
|
||||
113
backend/src/modules/companies/company.service.ts
Normal file
113
backend/src/modules/companies/company.service.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
63
backend/src/modules/companies/entities/company.entity.ts
Normal file
63
backend/src/modules/companies/entities/company.entity.ts
Normal 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;
|
||||
}
|
||||
25
backend/src/modules/health/health.controller.ts
Normal file
25
backend/src/modules/health/health.controller.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
48
backend/src/modules/invoices/entities/invoice-line.entity.ts
Normal file
48
backend/src/modules/invoices/entities/invoice-line.entity.ts
Normal 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;
|
||||
}
|
||||
122
backend/src/modules/invoices/entities/invoice.entity.ts
Normal file
122
backend/src/modules/invoices/entities/invoice.entity.ts
Normal 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[];
|
||||
}
|
||||
90
backend/src/modules/invoices/gemini-extractor.service.ts
Normal file
90
backend/src/modules/invoices/gemini-extractor.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
79
backend/src/modules/invoices/invoice.controller.ts
Normal file
79
backend/src/modules/invoices/invoice.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
44
backend/src/modules/invoices/invoice.module.ts
Normal file
44
backend/src/modules/invoices/invoice.module.ts
Normal 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 {}
|
||||
160
backend/src/modules/invoices/invoice.processor.ts
Normal file
160
backend/src/modules/invoices/invoice.processor.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
149
backend/src/modules/invoices/invoice.service.ts
Normal file
149
backend/src/modules/invoices/invoice.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
79
backend/src/modules/invoices/jofotara-gateway.service.ts
Normal file
79
backend/src/modules/invoices/jofotara-gateway.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
93
backend/src/modules/invoices/ubl-generator.service.ts
Normal file
93
backend/src/modules/invoices/ubl-generator.service.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
21
backend/src/modules/subscriptions/subscription.module.ts
Normal file
21
backend/src/modules/subscriptions/subscription.module.ts
Normal 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 {}
|
||||
64
backend/src/modules/subscriptions/subscription.service.ts
Normal file
64
backend/src/modules/subscriptions/subscription.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
64
backend/src/modules/tenants/entities/tenant.entity.ts
Normal file
64
backend/src/modules/tenants/entities/tenant.entity.ts
Normal 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[];
|
||||
}
|
||||
30
backend/src/modules/tenants/tenant.controller.ts
Normal file
30
backend/src/modules/tenants/tenant.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
19
backend/src/modules/tenants/tenant.module.ts
Normal file
19
backend/src/modules/tenants/tenant.module.ts
Normal 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 {}
|
||||
29
backend/src/modules/tenants/tenant.service.ts
Normal file
29
backend/src/modules/tenants/tenant.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
62
backend/src/modules/users/entities/user.entity.ts
Normal file
62
backend/src/modules/users/entities/user.entity.ts
Normal 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;
|
||||
}
|
||||
11
backend/src/modules/users/enums/role.enum.ts
Normal file
11
backend/src/modules/users/enums/role.enum.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* ════════════════════════════════════════════════════════════
|
||||
* مُصادَق (Musadaq) — User Roles
|
||||
* ════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
export enum UserRole {
|
||||
ADMIN = 'admin', // مدير المكتب (قادر على الإضافة والتعديل الكامل)
|
||||
ACCOUNTANT = 'accountant', // محاسب (قادر على رفع الفواتير وإدارتها)
|
||||
VIEWER = 'viewer', // مشاهد (قادر على الاطلاع فقط)
|
||||
}
|
||||
49
backend/src/modules/users/user.controller.ts
Normal file
49
backend/src/modules/users/user.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
19
backend/src/modules/users/user.module.ts
Normal file
19
backend/src/modules/users/user.module.ts
Normal 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 {}
|
||||
71
backend/src/modules/users/user.service.ts
Normal file
71
backend/src/modules/users/user.service.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
14
backend/src/modules/validation/tax-validation.module.ts
Normal file
14
backend/src/modules/validation/tax-validation.module.ts
Normal 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 {}
|
||||
103
backend/src/modules/validation/tax-validation.service.ts
Normal file
103
backend/src/modules/validation/tax-validation.service.ts
Normal 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})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
79
backend/src/services/encryption/encryption.service.ts
Normal file
79
backend/src/services/encryption/encryption.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
74
backend/src/services/storage/local-storage.service.ts
Normal file
74
backend/src/services/storage/local-storage.service.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user