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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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