🚀 Initialize Musadaq SaaS: Full Backend + AI + React Dashboard + Docker Setup
This commit is contained in:
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user