🚀 Elite Accountant Hub: Foundation & Trojan Horse deployment
This commit is contained in:
@@ -32,9 +32,9 @@ JWT_REFRESH_EXPIRY=
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# ── JoFotara ───────────────────────────────────────────────
|
||||
JOFOTARA_SANDBOX_URL=
|
||||
JOFOTARA_PROD_URL=
|
||||
JOFOTARA_ENV=
|
||||
JOFOTARA_SANDBOX_URL=https://sandbox.jofotara.gov.jo/core/invoices
|
||||
JOFOTARA_PROD_URL=https://backend.jofotara.gov.jo/core/invoices
|
||||
JOFOTARA_ENV=production
|
||||
|
||||
# ── Gemini AI ──────────────────────────────────────────────
|
||||
GEMINI_API_KEY=
|
||||
|
||||
@@ -52,6 +52,12 @@ export class Company {
|
||||
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||
jofotara_income_source_sequence?: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
certificate_path?: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true, select: false })
|
||||
certificate_password_encrypted?: string;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
is_active!: boolean;
|
||||
|
||||
|
||||
@@ -12,4 +12,9 @@ export class DashboardController {
|
||||
async getStats(@CurrentUser() user: any) {
|
||||
return this.dashboardService.getStats(user.tenantId);
|
||||
}
|
||||
|
||||
@Get('multi-entity')
|
||||
async getMultiEntityStats(@CurrentUser() user: any) {
|
||||
return this.dashboardService.getMultiEntityStats(user.tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,4 +76,50 @@ export class DashboardService {
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* جلب إحصائيات مجمعة لكل الشركات (Elite Accountant View)
|
||||
* Get summarized stats for all companies under a tenant
|
||||
*/
|
||||
async getMultiEntityStats(tenantId: string) {
|
||||
const companies = await this.companyRepository.find({
|
||||
where: { tenant_id: tenantId },
|
||||
});
|
||||
|
||||
const companyStats = await Promise.all(
|
||||
companies.map(async (company) => {
|
||||
const stats = await this.invoiceRepository
|
||||
.createQueryBuilder('invoice')
|
||||
.select('COUNT(*)', 'total')
|
||||
.addSelect('SUM(CASE WHEN status = :approved THEN tax_amount ELSE 0 END)', 'totalTax')
|
||||
.addSelect('COUNT(CASE WHEN status = :failed THEN 1 END)', 'failedCount')
|
||||
.where('invoice.company_id = :companyId', {
|
||||
companyId: company.id,
|
||||
approved: InvoiceStatus.APPROVED,
|
||||
failed: InvoiceStatus.VALIDATION_FAILED
|
||||
})
|
||||
.getRawOne();
|
||||
|
||||
return {
|
||||
id: company.id,
|
||||
name: company.name,
|
||||
taxId: company.tax_identification_number,
|
||||
totalInvoices: parseInt(stats.total) || 0,
|
||||
totalTax: parseFloat(stats.totalTax) || 0,
|
||||
failedCount: parseInt(stats.failedCount) || 0,
|
||||
riskScore: this.calculateMockRiskScore(stats), // Placeholder for AI Risk Score
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return companyStats;
|
||||
}
|
||||
|
||||
private calculateMockRiskScore(stats: any) {
|
||||
// Logic to be replaced by AI Anomaly Detection later
|
||||
const failedRatio = stats.total > 0 ? (stats.failedCount / stats.total) : 0;
|
||||
if (failedRatio > 0.2) return 85; // High Risk
|
||||
if (failedRatio > 0.05) return 45; // Medium Risk
|
||||
return 12; // Low Risk
|
||||
}
|
||||
}
|
||||
|
||||
34
backend/src/modules/invoices/bulk-upload.processor.ts
Normal file
34
backend/src/modules/invoices/bulk-upload.processor.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Processor, Process } from '@nestjs/bull';
|
||||
import { Job } from 'bull';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { InjectRedis } from '@liaoliaots/nestjs-redis';
|
||||
import Redis from 'ioredis';
|
||||
import { InvoicesService } from './invoice.service';
|
||||
|
||||
@Processor('invoice-bulk-queue')
|
||||
export class BulkUploadProcessor {
|
||||
private readonly logger = new Logger(BulkUploadProcessor.name);
|
||||
|
||||
constructor(
|
||||
private readonly invoicesService: InvoicesService,
|
||||
@InjectRedis() private readonly redis: Redis,
|
||||
) {}
|
||||
|
||||
@Process('process-zip')
|
||||
async handleBulkZip(job: Job<{ filePath: string, companyId: string, tenantId: string }>) {
|
||||
const { filePath, companyId, tenantId } = job.data;
|
||||
this.logger.log(`Processing bulk ZIP: ${filePath} for Company: ${companyId}`);
|
||||
|
||||
// TODO: Implement ZIP extraction (adm-zip or unzipper)
|
||||
// TODO: For each file in ZIP:
|
||||
// 1. Calculate Hash (MD5/SHA256)
|
||||
// 2. Check Redis SMEMBERS to prevent duplicate processing
|
||||
// const hash = 'calculated_file_hash';
|
||||
// const exists = await this.redis.sismember(`company:${companyId}:invoice-hashes`, hash);
|
||||
// if (exists) return;
|
||||
|
||||
// 3. Save hash and trigger individual processing
|
||||
// await this.redis.sadd(`company:${companyId}:invoice-hashes`, hash);
|
||||
// await this.invoicesService.processSingleFile(fileInZip, tenantId, companyId);
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,9 @@ export class Invoice {
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
validation_errors?: string[];
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
qr_code?: string;
|
||||
|
||||
@Column({ type: 'decimal', precision: 4, scale: 3, nullable: true })
|
||||
ai_confidence_score?: number;
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* ════════════════════════════════════════════════════════════
|
||||
* مُصادَق (Musadaq) — Gemini AI Extraction Service
|
||||
* مُصادَق (Musadaq) — Gemini AI Extraction Service / خدمة استخراج البيانات
|
||||
* ════════════════════════════════════════════════════════════
|
||||
* يقوم باستخراج البيانات من صور/ملفات الفواتير باستخدام Gemini.
|
||||
* This service extracts financial data from invoice images/PDFs using Gemini AI.
|
||||
* It ensures unstructured data is converted into UBL 2.1 compliant JSON.
|
||||
* يقوم باستخراج البيانات المالية من صور/ملفات الفواتير باستخدام ذكاء Gemini الاصطناعي.
|
||||
* يضمن تحويل البيانات غير المهيكلة إلى JSON مطابق لمعايير UBL 2.1.
|
||||
* ════════════════════════════════════════════════════════════
|
||||
*/
|
||||
@@ -29,6 +31,7 @@ export class GeminiExtractorService {
|
||||
|
||||
/**
|
||||
* استخراج البيانات من صورة الفاتورة (يدعم فواتير متعددة في ملف واحد)
|
||||
* Extract accounting data from an invoice file (supports multiple invoices per file)
|
||||
*/
|
||||
async extractInvoiceData(filePath: string, storageRoot: string): Promise<any> {
|
||||
try {
|
||||
@@ -36,9 +39,26 @@ export class GeminiExtractorService {
|
||||
const fileData = fs.readFileSync(fullPath);
|
||||
|
||||
const prompt = `
|
||||
You are a Jordanian tax expert. Extract all details from this file (image or PDF).
|
||||
The file may contain ONE or MULTIPLE distinct invoices.
|
||||
Extract EACH invoice separately.
|
||||
أنت الآن "مدقق ضريبي أردني خبير" (Expert Jordanian Tax Auditor).
|
||||
مهمتك هي استخراج البيانات المحاسبية من هذا الملف بدقة متناهية لضمان الامتثال لنظام الفوترة الوطني (JoFotara).
|
||||
|
||||
قواعد الاستخراج الاستراتيجية:
|
||||
1. الشخصية: تعامل مع الملف كمدقق يبحث عن أدق التفاصيل المالية والقانونية.
|
||||
2. العملة: جميع المبالغ بالدينار الأردني (JOD). التزم بدقة 3 خانات عشرية (مثال: 1.250).
|
||||
3. الضرائب: حدد نسب الضريبة لكل بند بناءً على القوائم المعتمدة:
|
||||
- (16%): النسبة العامة (أي سلع غير مذكورة أدناه).
|
||||
- (10%): حلاوة طحينة، طحينة، أجبان محضرة، أسماك محفوظة، سجق.
|
||||
- (5%): عبوات الألبان والعلب المعدنية والكرتون المطبوع المخصص للتغليف.
|
||||
- (4%): كرتون أطباق البيض، القرطاسية، الزي المدرسي، مدافئ الكاز والغاز، البوتاس والفوسفات.
|
||||
- (2%): الملفوف، الباميا، البازلاء (طازجة أو مبردة).
|
||||
- (0%): اللحوم، الأسماك، لوازم شبكات الري، آلات الزراعة، نباتات الهندباء.
|
||||
- (معفى - Exempt): العدس، الشاي، الحليب، السكر، الأرز، الهواتف الخلوية، الكهرباء.
|
||||
4. الأطراف: استخرج الرقم الضريبي للمورد (Supplier TIN) والرقم الضريبي أو الوطني للمشتري (Buyer TIN/National ID).
|
||||
5. التصنيف:
|
||||
- "simplified": إذا كان المشتري فرداً (بدون رقم ضريبي).
|
||||
- "standard": إذا كان المشتري شركة أو منشأة (يوجد رقم ضريبي).
|
||||
6. الفواتير المتعددة: إذا احتوى الملف على أكثر من فاتورة منفصلة، استخرج بيانات كل واحدة في عنصر مستقل في مصفوفة "invoices".
|
||||
7. التجاهل: تجاهل الأختام اليدوية التي تغطي النصوص، وحاول استنتاج النص تحتها برمجياً.
|
||||
|
||||
The output MUST be a strict JSON object with this schema:
|
||||
{
|
||||
@@ -47,15 +67,18 @@ export class GeminiExtractorService {
|
||||
"invoice_number": "string",
|
||||
"invoice_date": "YYYY-MM-DD",
|
||||
"invoice_type": "cash" | "credit",
|
||||
"ubl_type_code": "388" | "381",
|
||||
"payment_method_code": "013" | "023",
|
||||
"invoice_category": "standard" | "simplified",
|
||||
"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),
|
||||
"buyer_national_id": "string (10 digits, optional)",
|
||||
"subtotal": number,
|
||||
"discount_total": number,
|
||||
"tax_amount": number,
|
||||
"grand_total": number,
|
||||
"currency_code": "JOD",
|
||||
"lines": [
|
||||
{
|
||||
@@ -64,21 +87,14 @@ export class GeminiExtractorService {
|
||||
"quantity": number,
|
||||
"unit_price": number,
|
||||
"discount": number,
|
||||
"tax_rate": number (e.g. 0.16 for 16%),
|
||||
"line_total": number (quantity * unit_price - discount)
|
||||
"tax_rate": number (e.g. 0.16),
|
||||
"line_total": number
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
JoFotara Specific Rules:
|
||||
- invoice_category: "simplified" if the buyer is a regular person (no TIN), "standard" if B2B (buyer has TIN).
|
||||
- Prices in Jordan (JOD) often have 3 decimal places (e.g. 2.800).
|
||||
- Standard VAT Rate: 0.16 (16%)
|
||||
- The formula MUST hold: Subtotal - Discount + Tax = Grand Total.
|
||||
- If multiple invoices are in the PDF, ensure the "invoices" array contains all of them.
|
||||
|
||||
Return ONLY the JSON. No markdown formatting.
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/**
|
||||
* ════════════════════════════════════════════════════════════
|
||||
* مُصادَق (Musadaq) — Invoices Controller
|
||||
* مُصادَق (Musadaq) — Invoices Controller / متحكم الفواتير
|
||||
* ════════════════════════════════════════════════════════════
|
||||
* Handles HTTP requests related to invoice management.
|
||||
* يتعامل مع طلبات HTTP المتعلقة بإدارة الفواتير.
|
||||
* ════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
@@ -31,6 +34,7 @@ export class InvoicesController {
|
||||
|
||||
/**
|
||||
* قائمة جميع الفواتير للمكتب
|
||||
* List all invoices for the entire accounting office (tenant)
|
||||
*/
|
||||
@Get()
|
||||
async findAllByTenant(@CurrentUser() user: any) {
|
||||
@@ -39,6 +43,7 @@ export class InvoicesController {
|
||||
|
||||
/**
|
||||
* رفع فاتورة لشركة محددة
|
||||
* Upload an invoice file for a specific company
|
||||
*/
|
||||
@Post('upload/:companyId')
|
||||
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT)
|
||||
@@ -53,6 +58,7 @@ export class InvoicesController {
|
||||
|
||||
/**
|
||||
* قائمة الفواتير لشركة محددة
|
||||
* List all invoices for a specific company
|
||||
*/
|
||||
@Get('company/:companyId')
|
||||
async findAll(
|
||||
@@ -64,6 +70,7 @@ export class InvoicesController {
|
||||
|
||||
/**
|
||||
* تفاصيل فاتورة محددة
|
||||
* Get details of a specific invoice
|
||||
*/
|
||||
@Get(':id')
|
||||
async findOne(
|
||||
@@ -78,6 +85,7 @@ export class InvoicesController {
|
||||
*/
|
||||
/**
|
||||
* إرسال الفاتورة إلى بوابة جو فوترة الحكومية
|
||||
* Submit an invoice to the official JoFotara portal
|
||||
*/
|
||||
@Post(':id/submit')
|
||||
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT)
|
||||
@@ -87,6 +95,7 @@ export class InvoicesController {
|
||||
|
||||
/**
|
||||
* حذف الفاتورة نهائياً
|
||||
* Permanently delete an invoice
|
||||
*/
|
||||
@Post(':id/delete') // Using POST for delete to match frontend request style or use standard DELETE
|
||||
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT)
|
||||
@@ -96,6 +105,7 @@ export class InvoicesController {
|
||||
|
||||
/**
|
||||
* الحصول على الملف الأصلي للفاتورة
|
||||
* Download/Stream the original invoice file
|
||||
*/
|
||||
@Get(':id/file')
|
||||
async getFile(
|
||||
|
||||
@@ -9,6 +9,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { InvoicesService } from './invoice.service';
|
||||
import { InvoicesController } from './invoice.controller';
|
||||
import { PublicInvoiceController } from './public-invoice.controller';
|
||||
import { Invoice } from './entities/invoice.entity';
|
||||
import { InvoiceLine } from './entities/invoice-line.entity';
|
||||
import { InvoiceProcessor } from './invoice.processor';
|
||||
@@ -23,9 +24,10 @@ import { CompaniesModule } from '../companies/company.module';
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
|
||||
BullModule.registerQueue({
|
||||
name: 'invoice-processing',
|
||||
}),
|
||||
BullModule.registerQueue(
|
||||
{ name: 'invoice-processing' },
|
||||
{ name: 'invoice-bulk-queue' }
|
||||
),
|
||||
forwardRef(() => SubscriptionsModule),
|
||||
forwardRef(() => CompaniesModule),
|
||||
TaxValidationModule,
|
||||
@@ -38,7 +40,7 @@ import { CompaniesModule } from '../companies/company.module';
|
||||
JoFotaraGatewayService,
|
||||
LocalStorageService,
|
||||
],
|
||||
controllers: [InvoicesController],
|
||||
controllers: [InvoicesController, PublicInvoiceController],
|
||||
exports: [InvoicesService],
|
||||
})
|
||||
export class InvoicesModule {}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* ════════════════════════════════════════════════════════════
|
||||
* مُصادَق (Musadaq) — Invoice Processor (Queue Consumer)
|
||||
* مُصادَق (Musadaq) — Invoice Processor (Queue Consumer) / معالج الفواتير
|
||||
* ════════════════════════════════════════════════════════════
|
||||
* This is the main consumer for the invoice processing queue (Bull).
|
||||
* It orchestrates AI extraction, tax validation, and database storage.
|
||||
* المستهلك الرئيسي لطابور معالجة الفواتير (Bull Queue).
|
||||
* يربط بين الذكاء الاصطناعي، التحقق الضريبي، وتوليد الـ XML.
|
||||
* يقوم بالتنسيق بين استخراج البيانات بالذكاء الاصطناعي، التحقق الضريبي، وحفظ البيانات.
|
||||
* ════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
@@ -50,6 +52,7 @@ export class InvoiceProcessor {
|
||||
|
||||
/**
|
||||
* الخطوة الأولى: استخراج البيانات باستخدام AI
|
||||
* Step 1: Extract data from the file using Gemini Multimodal AI
|
||||
*/
|
||||
@Process('extract-data')
|
||||
async handleExtraction(job: Job<{ invoiceId: string; filePath: string; tenantId: string; companyId: string }>) {
|
||||
@@ -97,6 +100,7 @@ export class InvoiceProcessor {
|
||||
|
||||
/**
|
||||
* حفظ البيانات المستخرجة في قاعدة البيانات
|
||||
* Save the JSON data extracted by AI into the SQL database
|
||||
*/
|
||||
private async saveExtractedData(invoiceId: string, data: any) {
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
@@ -109,11 +113,14 @@ export class InvoiceProcessor {
|
||||
invoice_number: data.invoice_number,
|
||||
invoice_date: data.invoice_date,
|
||||
invoice_type: data.invoice_type || 'cash',
|
||||
ubl_type_code: data.ubl_type_code || '388',
|
||||
payment_method_code: data.payment_method_code || '013',
|
||||
invoice_category: data.invoice_category || 'simplified',
|
||||
supplier_name: data.supplier_name,
|
||||
supplier_tin: data.supplier_tin,
|
||||
buyer_name: data.buyer_name,
|
||||
buyer_tin: data.buyer_tin,
|
||||
buyer_national_id: data.buyer_national_id,
|
||||
subtotal: data.subtotal,
|
||||
discount_total: data.discount_total,
|
||||
tax_amount: data.tax_amount,
|
||||
@@ -157,6 +164,7 @@ export class InvoiceProcessor {
|
||||
|
||||
/**
|
||||
* الخطوة الثانية: التحقق الضريبي التلقائي
|
||||
* Step 2: Perform automatic tax rules validation (ISTD Standards)
|
||||
*/
|
||||
private async autoValidate(invoiceId: string) {
|
||||
const invoice = await this.invoiceRepository.findOne({
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
/**
|
||||
* ════════════════════════════════════════════════════════════
|
||||
* مُصادَق (Musadaq) — Invoices Service
|
||||
* مُصادَق (Musadaq) — Invoices Service / خدمة إدارة الفواتير
|
||||
* ════════════════════════════════════════════════════════════
|
||||
* This service handles the core logic for invoice management,
|
||||
* including uploading, retrieval, and integration with JoFotara.
|
||||
* تقوم هذه الخدمة بمعالجة العمليات الأساسية لإدارة الفواتير،
|
||||
* بما في ذلك الرفع، الاسترجاع، والربط مع نظام جو فوترة.
|
||||
* ════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
@@ -44,6 +49,7 @@ export class InvoicesService {
|
||||
|
||||
/**
|
||||
* رفع فاتورة جديدة وبدء المعالجة
|
||||
* Upload a new invoice and initiate extraction/validation
|
||||
*/
|
||||
async upload(
|
||||
tenantId: string,
|
||||
@@ -55,10 +61,12 @@ export class InvoicesService {
|
||||
throw new ForbiddenException('Monthly invoice limit reached for your current plan');
|
||||
}
|
||||
|
||||
const company = await this.companiesService.findOne(tenantId, companyId);
|
||||
const fileName = `${Date.now()}-${file.originalname}`;
|
||||
const filePath = await this.localStorageService.saveFile(
|
||||
tenantId,
|
||||
companyId,
|
||||
company.name,
|
||||
fileName,
|
||||
file.buffer,
|
||||
);
|
||||
@@ -85,6 +93,7 @@ export class InvoicesService {
|
||||
|
||||
/**
|
||||
* قائمة الفواتير لشركة محددة
|
||||
* Find all invoices for a specific company
|
||||
*/
|
||||
async findAll(tenantId: string, companyId: string): Promise<Invoice[]> {
|
||||
return this.invoiceRepository.find({
|
||||
@@ -94,7 +103,8 @@ export class InvoicesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* قائمة جميع الفواتير للمكتب
|
||||
* قائمة جميع الفواتير للمكتب (المستأجر)
|
||||
* Find all invoices for the entire tenant (accounting office)
|
||||
*/
|
||||
async findAllByTenant(tenantId: string): Promise<Invoice[]> {
|
||||
return this.invoiceRepository.find({
|
||||
@@ -105,7 +115,8 @@ export class InvoicesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* تفاصيل فاتورة
|
||||
* تفاصيل فاتورة محددة مع بنودها
|
||||
* Get detailed information for a specific invoice including its lines
|
||||
*/
|
||||
async findOne(tenantId: string, id: string): Promise<Invoice> {
|
||||
const invoice = await this.invoiceRepository.findOne({
|
||||
@@ -118,6 +129,7 @@ export class InvoicesService {
|
||||
|
||||
/**
|
||||
* تحديث بيانات الفاتورة يدوياً
|
||||
* Manually update invoice data (only if not already submitted)
|
||||
*/
|
||||
async update(tenantId: string, id: string, updateData: any): Promise<Invoice> {
|
||||
const invoice = await this.findOne(tenantId, id);
|
||||
@@ -131,7 +143,8 @@ export class InvoicesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* إرسال الفاتورة إلى بوابة جو فوترة الحكومية
|
||||
* إرسال الفاتورة إلى بوابة جو فوترة الحكومية (ISTD)
|
||||
* Submit the validated invoice to the official JoFotara portal
|
||||
*/
|
||||
async submitToJoFotara(tenantId: string, id: string): Promise<any> {
|
||||
const invoice = await this.findOne(tenantId, id);
|
||||
@@ -154,7 +167,10 @@ export class InvoicesService {
|
||||
credentials.secretKey,
|
||||
);
|
||||
|
||||
await this.invoiceRepository.update(id, { status: InvoiceStatus.APPROVED });
|
||||
await this.invoiceRepository.update(id, {
|
||||
status: InvoiceStatus.APPROVED,
|
||||
qr_code: response.qrCode || response.qr_code || null,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
await this.invoiceRepository.update(id, { status: InvoiceStatus.VALIDATION_FAILED });
|
||||
@@ -163,7 +179,8 @@ export class InvoicesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* حذف الفاتورة والملف التابع لها
|
||||
* حذف الفاتورة والملف التابع لها نهائياً
|
||||
* Permanently delete an invoice and its associated file from storage
|
||||
*/
|
||||
async remove(tenantId: string, id: string): Promise<void> {
|
||||
const invoice = await this.findOne(tenantId, id);
|
||||
@@ -178,7 +195,8 @@ export class InvoicesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* الحصول على الملف كـ Stream
|
||||
* الحصول على الملف كـ Stream لتحميله أو عرضه
|
||||
* Get the original invoice file as a streamable file for download/view
|
||||
*/
|
||||
async getFile(tenantId: string, id: string): Promise<StreamableFile> {
|
||||
const invoice = await this.findOne(tenantId, id);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* ════════════════════════════════════════════════════════════
|
||||
* مُصادَق (Musadaq) — JoFotara Gateway Service
|
||||
* مُصادَق (Musadaq) — JoFotara Gateway Service / بوابة جو فوترة
|
||||
* ════════════════════════════════════════════════════════════
|
||||
* This service handles communication with the Jordanian National
|
||||
* Electronic Invoicing System (JoFotara) portal.
|
||||
* يقوم بالتواصل مع بوابة دائرة ضريبة الدخل والمبيعات الأردنية (ISTD).
|
||||
* يدير عملية تسجيل الفواتير والحصول على الـ Clearance أو الـ Reporting.
|
||||
* ════════════════════════════════════════════════════════════
|
||||
@@ -26,6 +28,7 @@ export class JoFotaraGatewayService {
|
||||
|
||||
/**
|
||||
* إرسال الفاتورة إلى بوابة جو فوترة
|
||||
* Submit an invoice (Base64 XML) to the JoFotara portal
|
||||
*/
|
||||
async submitInvoice(
|
||||
xmlContent: string,
|
||||
@@ -33,16 +36,19 @@ export class JoFotaraGatewayService {
|
||||
secretKey: string,
|
||||
): Promise<any> {
|
||||
const url = this.currentEnv === 'production' ? this.prodUrl : this.sandboxUrl;
|
||||
const maxRetries = 3;
|
||||
let attempt = 0;
|
||||
|
||||
while (attempt < maxRetries) {
|
||||
try {
|
||||
const payload = {
|
||||
invoice: Buffer.from(xmlContent).toString('base64'),
|
||||
};
|
||||
|
||||
this.logger.debug(`Submitting to JoFotara: ${url}/submit`);
|
||||
this.logger.debug(`Submitting to JoFotara: ${url} (Attempt ${attempt + 1})`);
|
||||
|
||||
const response = await axios.post(
|
||||
`${url}/submit`,
|
||||
url,
|
||||
payload,
|
||||
{
|
||||
headers: {
|
||||
@@ -50,21 +56,34 @@ export class JoFotaraGatewayService {
|
||||
'X-Client-Id': clientId,
|
||||
'X-Secret-Key': secretKey,
|
||||
},
|
||||
timeout: 10000, // 10s timeout
|
||||
},
|
||||
);
|
||||
|
||||
this.logger.log(`JoFotara Response: ${JSON.stringify(response.data)}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
attempt++;
|
||||
const isTransient = !error.response || (error.response.status >= 500);
|
||||
|
||||
if (isTransient && attempt < maxRetries) {
|
||||
const delay = Math.pow(2, attempt) * 1000;
|
||||
this.logger.warn(`Transient error from JoFotara. Retrying in ${delay}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
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'}`,
|
||||
`Failed to submit invoice to JoFotara: ${error.response?.data?.message || error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* التحقق من حالة الفاتورة
|
||||
* التحقق من حالة الفاتورة باستخدام الـ UUID
|
||||
* Check the status of a submitted invoice using its UUID
|
||||
*/
|
||||
async checkStatus(uuid: string, clientId: string, secretKey: string): Promise<any> {
|
||||
const url = this.currentEnv === 'production' ? this.prodUrl : this.sandboxUrl;
|
||||
|
||||
64
backend/src/modules/invoices/public-invoice.controller.ts
Normal file
64
backend/src/modules/invoices/public-invoice.controller.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Controller, Post, UseInterceptors, UploadedFile, BadRequestException } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { GeminiExtractorService } from './gemini-extractor.service';
|
||||
import { UBLGeneratorService } from './ubl-generator.service';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
@Controller('public/tools')
|
||||
export class PublicInvoiceController {
|
||||
constructor(
|
||||
private geminiExtractor: GeminiExtractorService,
|
||||
private ublGenerator: UBLGeneratorService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* حصان طروادة (Trojan Horse) - تحويل مجاني بدون تسجيل
|
||||
* Converts a PDF/Image invoice directly to JoFotara XML
|
||||
*/
|
||||
@Post('pdf-to-xml')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async convertPdfToXml(@UploadedFile() file: Express.Multer.File) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('الرجاء إرفاق ملف الفاتورة');
|
||||
}
|
||||
|
||||
// Save temporary file
|
||||
const tempDir = os.tmpdir();
|
||||
const tempPath = path.join(tempDir, `trojan_${Date.now()}_${file.originalname}`);
|
||||
fs.writeFileSync(tempPath, file.buffer);
|
||||
|
||||
try {
|
||||
// 1. Extract data using AI (treating as standard company for now)
|
||||
// Since it's public, we use a generic path
|
||||
const extractedData = await this.geminiExtractor.extractInvoiceData(tempPath, '');
|
||||
|
||||
if (!extractedData || extractedData.length === 0) {
|
||||
throw new BadRequestException('لم يتم العثور على بيانات صالحة في الفاتورة');
|
||||
}
|
||||
|
||||
const invoiceData = extractedData[0]; // Take the first invoice if multiple
|
||||
|
||||
// 2. Generate XML (using generic company info for demo purposes)
|
||||
const mockCompanyInfo = {
|
||||
name: invoiceData.supplier_name || 'شركة تجريبية',
|
||||
tax_identification_number: invoiceData.supplier_tin || '0000000000',
|
||||
};
|
||||
|
||||
const xml = this.ublGenerator.generateXML(invoiceData, mockCompanyInfo);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'تم التحويل بنجاح! لطباعة أو إرسال آلاف الفواتير، جرب محاسبي إيليت.',
|
||||
data: invoiceData,
|
||||
xmlContent: xml,
|
||||
};
|
||||
} finally {
|
||||
// Cleanup temp file
|
||||
if (fs.existsSync(tempPath)) {
|
||||
fs.unlinkSync(tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* ════════════════════════════════════════════════════════════
|
||||
* مُصادَق (Musadaq) — UBL 2.1 Generator Service
|
||||
* مُصادَق (Musadaq) — UBL 2.1 Generator Service / خدمة توليد ملفات UBL
|
||||
* ════════════════════════════════════════════════════════════
|
||||
* This service generates XML files compliant with the UBL 2.1 standard
|
||||
* required by the Jordanian Income and Sales Tax Department (ISTD).
|
||||
* يقوم بإنشاء ملفات XML المتوافقة مع معيار UBL 2.1 المطلوبة من
|
||||
* دائرة ضريبة الدخل والمبيعات الأردنية (ISTD).
|
||||
* ════════════════════════════════════════════════════════════
|
||||
@@ -15,8 +17,14 @@ import { Invoice } from './entities/invoice.entity';
|
||||
export class UBLGeneratorService {
|
||||
/**
|
||||
* توليد UBL 2.1 XML لفاتورة مبيعات
|
||||
* Generate a UBL 2.1 compliant XML for a sales invoice
|
||||
*/
|
||||
generateXML(invoice: Invoice, company: any): string {
|
||||
generateXML(invoice: any, company: any): string {
|
||||
const currency = invoice.currency_code || 'JOD';
|
||||
const invoiceDate = invoice.invoice_date instanceof Date
|
||||
? invoice.invoice_date.toISOString().split('T')[0]
|
||||
: (invoice.invoice_date || new Date().toISOString().split('T')[0]);
|
||||
|
||||
const doc = create({ version: '1.0', encoding: 'UTF-8' })
|
||||
.ele('Invoice', {
|
||||
xmlns: 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2',
|
||||
@@ -24,11 +32,12 @@ export class UBLGeneratorService {
|
||||
'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:ProfileID').txt('reporting:1.0').up()
|
||||
.ele('cbc:CustomizationID').txt('urn:www.cenbii.eu:transaction:biitrns010:ver2.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()
|
||||
.ele('cbc:IssueDate').txt(invoiceDate).up()
|
||||
.ele('cbc:InvoiceTypeCode').txt(invoice.ubl_type_code || '388').up()
|
||||
.ele('cbc:DocumentCurrencyCode').txt(currency).up()
|
||||
|
||||
// ── AccountingSupplierParty (المُصدر) ───────────────
|
||||
.ele('cac:AccountingSupplierParty')
|
||||
@@ -62,15 +71,15 @@ export class UBLGeneratorService {
|
||||
|
||||
// ── TaxTotal ───────────────────────────────────────
|
||||
.ele('cac:TaxTotal')
|
||||
.ele('cbc:TaxAmount', { currencyID: invoice.currency_code }).txt(invoice.tax_amount.toString()).up()
|
||||
.ele('cbc:TaxAmount', { currencyID: currency }).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()
|
||||
.ele('cbc:LineExtensionAmount', { currencyID: currency }).txt(invoice.subtotal.toString()).up()
|
||||
.ele('cbc:TaxExclusiveAmount', { currencyID: currency }).txt(invoice.subtotal.toString()).up()
|
||||
.ele('cbc:TaxInclusiveAmount', { currencyID: currency }).txt(invoice.grand_total.toString()).up()
|
||||
.ele('cbc:PayableAmount', { currencyID: currency }).txt(invoice.grand_total.toString()).up()
|
||||
.up();
|
||||
|
||||
// ── InvoiceLines ─────────────────────────────────────
|
||||
@@ -78,18 +87,18 @@ export class UBLGeneratorService {
|
||||
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('cbc:LineExtensionAmount', { currencyID: currency }).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()
|
||||
.ele('cbc:PriceAmount', { currencyID: currency }).txt(line.unit_price.toString()).up()
|
||||
.up()
|
||||
.ele('cac:TaxTotal')
|
||||
.ele('cbc:TaxAmount', { currencyID: invoice.currency_code }).txt((line.line_total * line.tax_rate).toFixed(3)).up()
|
||||
.ele('cbc:TaxAmount', { currencyID: currency }).txt((line.line_total * line.tax_rate).toFixed(3)).up()
|
||||
.ele('cac:TaxSubtotal')
|
||||
.ele('cbc:TaxableAmount', { currencyID: invoice.currency_code }).txt(line.line_total.toString()).up()
|
||||
.ele('cbc:TaxAmount', { currencyID: invoice.currency_code }).txt((line.line_total * line.tax_rate).toFixed(3)).up()
|
||||
.ele('cbc:TaxableAmount', { currencyID: currency }).txt(line.line_total.toString()).up()
|
||||
.ele('cbc:TaxAmount', { currencyID: currency }).txt((line.line_total * line.tax_rate).toFixed(3)).up()
|
||||
.ele('cac:TaxCategory')
|
||||
.ele('cbc:ID').txt(line.tax_rate > 0 ? 'S' : 'Z').up()
|
||||
.ele('cbc:Percent').txt((line.tax_rate * 100).toString()).up()
|
||||
|
||||
42
backend/src/modules/tax-advisor/tax-advisor.service.ts
Normal file
42
backend/src/modules/tax-advisor/tax-advisor.service.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { GoogleGenerativeAI } from '@google/generative-ai';
|
||||
|
||||
@Injectable()
|
||||
export class TaxAdvisorService {
|
||||
private readonly logger = new Logger(TaxAdvisorService.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'),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* الإجابة على استفسارات القوانين الضريبية (RAG Placeholder)
|
||||
* Answer tax law queries using a specialized system prompt
|
||||
*/
|
||||
async askQuestion(question: string): Promise<string> {
|
||||
const systemPrompt = `
|
||||
أنت "مستشار ضريبي أردني ذكي". مرجعيتك هي قانون ضريبة الدخل والمبيعات الأردني وتعديلات 2025.
|
||||
|
||||
قواعد أساسية للإجابة:
|
||||
1. اعتمد على جداول السلع (16%, 10%, 4%, 2%, 0%, معفى).
|
||||
2. وضح دائماً الفرق بين السلع الصفرية (تسمح بخصم المدخلات) والمعفاة (لا تسمح).
|
||||
3. التزم بمتطلبات "جو فوترة" بخصوص فواتير الذمم والـ 10,000 دينار.
|
||||
4. كن دقيقاً، مهنياً، ومختصراً.
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.model.generateContent([systemPrompt, question]);
|
||||
return result.response.text();
|
||||
} catch (error) {
|
||||
this.logger.error('Tax Advisor query failed', error);
|
||||
return 'عذراً، تعذر الحصول على إجابة حالياً. يرجى مراجعة القوانين الرسمية.';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Tenant } from '../../tenants/entities/tenant.entity';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { UserRole } from '../enums/role.enum';
|
||||
|
||||
@Entity('users')
|
||||
@@ -45,6 +46,13 @@ export class User {
|
||||
})
|
||||
role!: UserRole;
|
||||
|
||||
@Column({ name: 'company_id', type: 'uuid', nullable: true })
|
||||
company_id?: string;
|
||||
|
||||
@ManyToOne(() => Company, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'company_id' })
|
||||
company?: Company;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true, select: false })
|
||||
refresh_token_hash?: string;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
export enum UserRole {
|
||||
ADMIN = 'admin', // مدير المكتب (قادر على الإضافة والتعديل الكامل)
|
||||
ACCOUNTANT = 'accountant', // محاسب (قادر على رفع الفواتير وإدارتها)
|
||||
ACCOUNTANT = 'accountant', // محاسب (قادر على رفع الفواتير وإدارتها لكل الشركات)
|
||||
CLIENT = 'client', // عميل (قادر على رفع وإدارة فواتير شركته فقط)
|
||||
VIEWER = 'viewer', // مشاهد (قادر على الاطلاع فقط)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Invoice } from '../invoices/entities/invoice.entity';
|
||||
import { TaxValidationService, ValidationResult } from './tax-validation.service';
|
||||
|
||||
@Injectable()
|
||||
export class JofotaraComplianceService {
|
||||
private readonly logger = new Logger(JofotaraComplianceService.name);
|
||||
|
||||
constructor(private taxValidation: TaxValidationService) {}
|
||||
|
||||
/**
|
||||
* الفحص الشامل للامتثال لمتطلبات جو فوترة
|
||||
* Comprehensive JOFOTARA compliance check
|
||||
*/
|
||||
async checkCompliance(invoice: Invoice, credentials?: any): Promise<ValidationResult> {
|
||||
const errors: string[] = [];
|
||||
|
||||
// 1. Schema Validation (التحقق من اكتمال الحقول الإلزامية)
|
||||
this.validateSchema(invoice, errors);
|
||||
|
||||
// 2. Tax Rules Validation (التحقق من القواعد الحسابية)
|
||||
const taxResult = this.taxValidation.validateInvoice(invoice);
|
||||
if (!taxResult.isValid) {
|
||||
errors.push(...taxResult.errors);
|
||||
}
|
||||
|
||||
// 3. Credential Check (التحقق من وجود الربط مع الضريبة)
|
||||
if (credentials && (!credentials.clientId || !credentials.secretKey)) {
|
||||
errors.push('بيانات الربط مع نظام جو فوترة (Credentials) غير مكتملة لهذه الشركة');
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* التحقق من الحقول الإلزامية في صيغة الفاتورة
|
||||
* Rule 008: Mandatory JOFOTARA Fields
|
||||
*/
|
||||
private validateSchema(invoice: Invoice, errors: string[]) {
|
||||
if (!invoice.invoice_number) errors.push('رقم الفاتورة (Invoice Number) مطلوب');
|
||||
if (!invoice.invoice_date) errors.push('تاريخ الفاتورة (Invoice Date) مطلوب');
|
||||
if (!invoice.supplier_tin) errors.push('الرقم الضريبي للمورد (Supplier TIN) مطلوب');
|
||||
if (invoice.supplier_tin && invoice.supplier_tin.length !== 10) {
|
||||
errors.push('الرقم الضريبي للمورد يجب أن يتكون من 10 خانات');
|
||||
}
|
||||
|
||||
// Check if lines exist
|
||||
if (!invoice.lines || invoice.lines.length === 0) {
|
||||
errors.push('يجب أن تحتوي الفاتورة على بند واحد على الأقل');
|
||||
}
|
||||
|
||||
// Additional JoFotara specific checks
|
||||
if (!['388', '381'].includes(invoice.ubl_type_code)) {
|
||||
errors.push('نوع الفاتورة (UBL Type Code) غير مدعوم');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,10 @@
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TaxValidationService } from './tax-validation.service';
|
||||
import { JofotaraComplianceService } from './jofotara-compliance.service';
|
||||
|
||||
@Module({
|
||||
providers: [TaxValidationService],
|
||||
exports: [TaxValidationService],
|
||||
providers: [TaxValidationService, JofotaraComplianceService],
|
||||
exports: [TaxValidationService, JofotaraComplianceService],
|
||||
})
|
||||
export class TaxValidationModule {}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* ════════════════════════════════════════════════════════════
|
||||
* مُصادَق (Musadaq) — Tax Validation Service
|
||||
* مُصادَق (Musadaq) — Tax Validation Service / محرك التدقيق الضريبي
|
||||
* ════════════════════════════════════════════════════════════
|
||||
* Jordanian Tax Rules Validation Engine (ISTD Rules).
|
||||
* Ensures calculation accuracy before submission to JoFotara.
|
||||
* محرك التحقق من القواعد الضريبية الأردنية (ISTD Rules).
|
||||
* يضمن دقة الحسابات قبل إرسالها إلى "جو فوترة".
|
||||
* يضمن دقة الحسابات والامتثال القانوني قبل إرسال البيانات إلى "جو فوترة".
|
||||
* ════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
@@ -21,7 +23,8 @@ export class TaxValidationService {
|
||||
private readonly PRECISION = 0.005; // السماحية في الفروقات العشرية البسيطة
|
||||
|
||||
/**
|
||||
* التحقق الشامل من الفاتورة
|
||||
* التحقق الشامل من الفاتورة وفق القواعد المبرمجة
|
||||
* Comprehensive invoice validation based on predefined tax rules
|
||||
*/
|
||||
validateInvoice(invoice: Invoice): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
@@ -41,6 +44,12 @@ export class TaxValidationService {
|
||||
// 5. Rule 005: التحقق من صحة نسب الضريبة الأردنية
|
||||
this.checkRule005(invoice, errors);
|
||||
|
||||
// 6. Rule 006: التحقق من وجود هوية المشتري (للذمم أو المبيعات > 10,000)
|
||||
this.checkRule006(invoice, errors);
|
||||
|
||||
// 7. Rule 007: التحقق من مطابقة مجموع البنود للمجموع النهائي
|
||||
this.checkRule007(invoice, errors);
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
@@ -51,7 +60,8 @@ export class TaxValidationService {
|
||||
* Rule 005: Valid Jordanian Tax Rates (0.16, 0.04, 0.00, etc.)
|
||||
*/
|
||||
private checkRule005(invoice: Invoice, errors: string[]) {
|
||||
const validRates = [0.16, 0.04, 0.0, 0.01]; // 1% is also used in some special cases
|
||||
// المراجع القانونية: 16% (عامة)، 10%، 5%، 4%، 2% (مخفضة)، 0% (صفرية)
|
||||
const validRates = [0.16, 0.10, 0.05, 0.04, 0.02, 0.00, 0.01];
|
||||
invoice.lines.forEach((line) => {
|
||||
const rate = Number(line.tax_rate);
|
||||
if (!validRates.includes(rate)) {
|
||||
@@ -119,4 +129,39 @@ export class TaxValidationService {
|
||||
errors.push(`خطأ في القاعدة 004: المجموع النهائي المحسوب (${calculatedGrandTotal.toFixed(3)}) لا يطابق المسجل (${Number(invoice.grand_total).toFixed(3)})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rule 006: Buyer Identity Requirement
|
||||
* - Cash Invoices >= 10,000 JOD
|
||||
* - Credit/Receivable Invoices (All amounts)
|
||||
*/
|
||||
private checkRule006(invoice: Invoice, errors: string[]) {
|
||||
const isHighValue = Number(invoice.grand_total) >= 10000;
|
||||
const isCredit = invoice.payment_method_code === '023'; // 023 is usually credit in JoFotara
|
||||
|
||||
if (isHighValue || isCredit) {
|
||||
if (!invoice.buyer_tin && !invoice.buyer_national_id) {
|
||||
const reason = isCredit ? 'فاتورة ذمم (Credit)' : 'فاتورة نقدية تتجاوز 10,000 دينار';
|
||||
errors.push(`خطأ في القاعدة 006: يجب تزويد الرقم الضريبي أو الوطني للمشتري لأنها ${reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rule 007: Line Totals Integrity
|
||||
* Ensure Σ (LineTotal) matches Header (Subtotal - DiscountTotal)
|
||||
*/
|
||||
private checkRule007(invoice: Invoice, errors: string[]) {
|
||||
const totalLinesBeforeTax = invoice.lines.reduce(
|
||||
(sum, line) => sum + Number(line.line_total),
|
||||
0,
|
||||
);
|
||||
|
||||
const headerBeforeTax = Number(invoice.subtotal) - Number(invoice.discount_total);
|
||||
|
||||
const diff = Math.abs(totalLinesBeforeTax - headerBeforeTax);
|
||||
if (diff > this.PRECISION) {
|
||||
errors.push(`خطأ في القاعدة 007: مجموع قيم البنود قبل الضريبة (${totalLinesBeforeTax.toFixed(3)}) لا يطابق الإجمالي قبل الضريبة في ترويسة الفاتورة (${headerBeforeTax.toFixed(3)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
/**
|
||||
* ════════════════════════════════════════════════════════════
|
||||
* مُصادَق (Musadaq) — Local Storage Service
|
||||
* مُصادَق (Musadaq) — Local Storage Service / خدمة التخزين المحلي
|
||||
* ════════════════════════════════════════════════════════════
|
||||
* This service manages saving and deleting files on the local filesystem.
|
||||
* Files are organized by tenant, company, and date.
|
||||
* تدير هذه الخدمة عمليات حفظ وحذف الملفات على نظام الملفات المحلي.
|
||||
* يتم تنظيم الملفات حسب المكتب، الشركة، والتاريخ.
|
||||
* ════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
@@ -23,19 +28,24 @@ export class LocalStorageService {
|
||||
}
|
||||
|
||||
/**
|
||||
* حفظ ملف في التخزين المحلي
|
||||
* حفظ ملف في التخزين المحلي بتنظيم هيكلي
|
||||
* Save a file to local storage with a structured directory path
|
||||
*/
|
||||
async saveFile(
|
||||
tenantId: string,
|
||||
companyId: string,
|
||||
companyName: string,
|
||||
fileName: string,
|
||||
buffer: Buffer,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const now = new Date();
|
||||
// Safe company name (remove special characters)
|
||||
const safeCompanyName = companyName.replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
||||
|
||||
const relativePath = path.join(
|
||||
tenantId,
|
||||
companyId,
|
||||
`${safeCompanyName}_${companyId}`,
|
||||
'invoices',
|
||||
now.getFullYear().toString(),
|
||||
(now.getMonth() + 1).toString(),
|
||||
@@ -59,7 +69,8 @@ export class LocalStorageService {
|
||||
}
|
||||
|
||||
/**
|
||||
* حذف ملف
|
||||
* حذف ملف من التخزين باستخدام مساره النسبي
|
||||
* Delete a file from storage using its relative path
|
||||
*/
|
||||
async deleteFile(filePath: string): Promise<void> {
|
||||
try {
|
||||
|
||||
155
frontend/src/pages/Public/TrojanHorseConverter.tsx
Normal file
155
frontend/src/pages/Public/TrojanHorseConverter.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React, { useState } from 'react';
|
||||
import { UploadCloud, FileType, CheckCircle2, ArrowRight } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export const TrojanHorseConverter = () => {
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [status, setStatus] = useState<'idle' | 'uploading' | 'success'>('idle');
|
||||
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true);
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
setFile(e.dataTransfer.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConvert = () => {
|
||||
if (!file) return;
|
||||
setStatus('uploading');
|
||||
|
||||
// Simulate API call to the backend Trojan endpoint
|
||||
setTimeout(() => {
|
||||
setStatus('success');
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900 font-sans selection:bg-emerald-200">
|
||||
|
||||
{/* Top Banner */}
|
||||
<div className="bg-slate-900 text-center py-3 text-sm text-slate-300">
|
||||
هل تدير عشرات الشركات؟ <span className="text-white font-medium ml-2 cursor-pointer hover:text-emerald-400 transition-colors">اكتشف لوحة تحكم محاسبي إيليت <ArrowRight className="inline w-4 h-4" /></span>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto px-6 py-20">
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-block px-4 py-1.5 rounded-full bg-emerald-100 text-emerald-800 text-xs font-bold mb-6">
|
||||
أداة مجانية 100%
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-extrabold tracking-tight mb-6 text-slate-900">
|
||||
حوّل فواتيرك إلى صيغة <span className="text-emerald-600">جو فوترة</span> في ثوانٍ
|
||||
</h1>
|
||||
<p className="text-lg text-slate-600 max-w-2xl mx-auto">
|
||||
قم برفع فاتورة الـ PDF الخاصة بك وسيقوم الذكاء الاصطناعي باستخراج البيانات وتحويلها إلى صيغة XML المتوافقة تماماً مع نظام دائرة ضريبة الدخل والمبيعات الأردنية.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Upload Area */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-3xl shadow-xl p-8 border border-slate-100"
|
||||
>
|
||||
{status === 'idle' && (
|
||||
<div
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
className={`border-2 border-dashed rounded-2xl p-12 text-center transition-all ${
|
||||
dragActive ? 'border-emerald-500 bg-emerald-50/50' : 'border-slate-200 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<UploadCloud className={`w-16 h-16 mx-auto mb-6 ${dragActive ? 'text-emerald-500' : 'text-slate-400'}`} />
|
||||
|
||||
{file ? (
|
||||
<div>
|
||||
<p className="text-lg font-medium text-slate-900 mb-2">{file.name}</p>
|
||||
<p className="text-sm text-slate-500 mb-8">{(file.size / 1024 / 1024).toFixed(2)} MB</p>
|
||||
<button
|
||||
onClick={handleConvert}
|
||||
className="px-8 py-3 bg-slate-900 hover:bg-slate-800 text-white rounded-xl font-medium shadow-lg transition-all w-full md:w-auto"
|
||||
>
|
||||
بدء التحويل إلى XML
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-xl font-medium text-slate-900 mb-2">اسحب وأفلت الفاتورة هنا</p>
|
||||
<p className="text-sm text-slate-500 mb-8">يدعم PDF, PNG, JPG</p>
|
||||
<label className="px-8 py-3 bg-emerald-500 hover:bg-emerald-600 text-white rounded-xl font-medium shadow-lg shadow-emerald-500/30 transition-all cursor-pointer inline-block">
|
||||
اختر ملف
|
||||
<input type="file" className="hidden" onChange={(e) => e.target.files && setFile(e.target.files[0])} accept=".pdf,.png,.jpg" />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'uploading' && (
|
||||
<div className="py-20 text-center">
|
||||
<div className="w-16 h-16 border-4 border-slate-200 border-t-emerald-500 rounded-full animate-spin mx-auto mb-6"></div>
|
||||
<h3 className="text-xl font-medium text-slate-900 mb-2">جاري استخراج البيانات...</h3>
|
||||
<p className="text-slate-500">يقوم الذكاء الاصطناعي بقراءة الفاتورة وتنسيقها</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="py-12 text-center"
|
||||
>
|
||||
<div className="w-20 h-20 bg-emerald-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<CheckCircle2 className="w-10 h-10 text-emerald-600" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-slate-900 mb-4">تم التحويل بنجاح!</h3>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center mt-8">
|
||||
<button className="px-6 py-3 bg-emerald-500 hover:bg-emerald-600 text-white rounded-xl font-medium flex items-center justify-center gap-2">
|
||||
<FileType className="w-5 h-5" /> تحميل ملف XML
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setStatus('idle'); setFile(null); }}
|
||||
className="px-6 py-3 bg-white border border-slate-200 hover:bg-slate-50 text-slate-700 rounded-xl font-medium"
|
||||
>
|
||||
تحويل فاتورة أخرى
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Upsell Banner for Accountants */}
|
||||
<div className="mt-12 bg-slate-900 rounded-2xl p-6 text-left flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<div>
|
||||
<h4 className="text-white font-bold mb-1 flex items-center gap-2">
|
||||
<span className="text-emerald-400">مُصادَق</span> | هل أنت محاسب؟
|
||||
</h4>
|
||||
<p className="text-slate-400 text-sm">توقف عن تحويل الفواتير واحدة تلو الأخرى. جرب لوحة تحكم إيليت وأتمت عملك لـ 50 شركة بنقرة واحدة.</p>
|
||||
</div>
|
||||
<button className="whitespace-nowrap px-6 py-2.5 bg-white text-slate-900 rounded-lg font-medium hover:bg-slate-100">
|
||||
اكتشف إيليت
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
145
frontend/src/pages/dashboard/MultiEntityDashboard.tsx
Normal file
145
frontend/src/pages/dashboard/MultiEntityDashboard.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React from 'react';
|
||||
import { Building2, TrendingUp, AlertTriangle, FileText, ChevronDown } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
// Mock data matching the API structure we built in the backend
|
||||
const mockCompanies = [
|
||||
{ id: '1', name: 'Apex Innovations', totalInvoices: 24, pendingAmount: 78450, totalTax: 12500, failedCount: 2, riskScore: 85, status: 'High' },
|
||||
{ id: '2', name: 'Quantum Solutions', totalInvoices: 26, pendingAmount: 112000, totalTax: 18000, failedCount: 0, riskScore: 32, status: 'Low' },
|
||||
{ id: '3', name: 'Nomad Ventures', totalInvoices: 45, pendingAmount: 319000, totalTax: 45000, failedCount: 5, riskScore: 68, status: 'Medium' },
|
||||
{ id: '4', name: 'Elevate Tech', totalInvoices: 12, pendingAmount: 188000, totalTax: 30000, failedCount: 1, riskScore: 85, status: 'High' },
|
||||
{ id: '5', name: 'Horizon Group', totalInvoices: 33, pendingAmount: 95000, totalTax: 14000, failedCount: 3, riskScore: 68, status: 'Medium' },
|
||||
];
|
||||
|
||||
const RiskGauge = ({ score, status }: { score: number, status: string }) => {
|
||||
const getColor = () => {
|
||||
if (status === 'High') return 'text-red-500';
|
||||
if (status === 'Medium') return 'text-orange-500';
|
||||
return 'text-emerald-500';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative w-16 h-16">
|
||||
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 36 36">
|
||||
<path
|
||||
className="text-slate-700"
|
||||
strokeWidth="3"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
/>
|
||||
<path
|
||||
className={getColor()}
|
||||
strokeWidth="3"
|
||||
strokeDasharray={`${score}, 100`}
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center text-lg font-bold text-white">
|
||||
{score}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-xs mt-1 ${getColor()}`}>{status}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MultiEntityDashboard = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-300 font-sans p-8 selection:bg-emerald-500/30">
|
||||
|
||||
{/* Header */}
|
||||
<header className="flex justify-between items-center mb-10">
|
||||
<div>
|
||||
<h1 className="text-3xl font-light tracking-tight text-white flex items-center gap-3">
|
||||
<span className="font-semibold text-emerald-400">مُصادَق</span> | لوحة تحكم الشركات
|
||||
</h1>
|
||||
<p className="text-slate-400 mt-2 text-sm">نظرة عامة على الموقف الضريبي لجميع عملائك (Elite View)</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-white rounded-lg text-sm border border-slate-700 transition-colors flex items-center gap-2">
|
||||
آخر 30 يوم <ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="px-5 py-2 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-medium rounded-lg text-sm shadow-[0_0_15px_rgba(16,185,129,0.3)] transition-all">
|
||||
+ إضافة شركة جديدة
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{mockCompanies.map((company, index) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
key={company.id}
|
||||
className="bg-slate-900/50 backdrop-blur-xl border border-slate-800 rounded-2xl p-6 relative overflow-hidden hover:border-slate-700 transition-colors group"
|
||||
>
|
||||
{/* Ambient glow */}
|
||||
<div className={`absolute -inset-20 opacity-0 group-hover:opacity-20 blur-3xl transition-opacity duration-500 rounded-full
|
||||
${company.status === 'High' ? 'bg-red-500' : company.status === 'Medium' ? 'bg-orange-500' : 'bg-emerald-500'}
|
||||
`} />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center border border-slate-700">
|
||||
<Building2 className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white font-medium text-lg">{company.name}</h3>
|
||||
<p className="text-xs text-slate-500">الرقم الضريبي: 00{company.id}829471</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<p className="text-xs text-slate-400 mb-1">التدفق النقدي (المبيعات)</p>
|
||||
<p className="text-2xl font-light text-white">${(company.pendingAmount / 1000).toFixed(1)}k</p>
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-emerald-400">
|
||||
<TrendingUp className="w-3 h-3" /> مستقر
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<div>
|
||||
<p className="text-xs text-slate-400 mb-1 text-center">درجة الخطر الضريبي</p>
|
||||
<RiskGauge score={company.riskScore} status={company.status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-slate-800/50">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<p className="text-sm text-slate-300">الفواتير المعلقة</p>
|
||||
<span className="text-xs text-slate-500">{company.totalInvoices} فاتورة | ${company.totalTax} ضرائب</span>
|
||||
</div>
|
||||
|
||||
{/* Minimal Progress Bar */}
|
||||
<div className="h-1.5 w-full bg-slate-800 rounded-full overflow-hidden flex">
|
||||
<div className="h-full bg-emerald-500" style={{ width: '60%' }}></div>
|
||||
<div className="h-full bg-orange-500" style={{ width: '25%' }}></div>
|
||||
<div className="h-full bg-red-500" style={{ width: '15%' }}></div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-2 text-[10px] text-slate-500">
|
||||
<span>تم الرفع (60%)</span>
|
||||
{company.failedCount > 0 && (
|
||||
<span className="text-red-400 flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" /> {company.failedCount} مرفوضة
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user