diff --git a/backend/src/modules/invoices/gemini-extractor.service.ts b/backend/src/modules/invoices/gemini-extractor.service.ts index 25b8189..485e68e 100644 --- a/backend/src/modules/invoices/gemini-extractor.service.ts +++ b/backend/src/modules/invoices/gemini-extractor.service.ts @@ -63,8 +63,13 @@ export class GeminiExtractorService { } ] } + Pay close attention to Jordanian Tax Rules: + - Standard Rate: 0.16 (16%) + - Reduced Rate: 0.04 (4%) + - Zero Rate: 0.00 (0%) + - Exempt: null or 0 with explanation. + - The formula MUST hold: Subtotal - Discount + Tax = Grand Total. 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). `; // Detect MIME type based on extension diff --git a/backend/src/modules/invoices/invoice.controller.ts b/backend/src/modules/invoices/invoice.controller.ts index af64f6f..0bb8af2 100644 --- a/backend/src/modules/invoices/invoice.controller.ts +++ b/backend/src/modules/invoices/invoice.controller.ts @@ -84,4 +84,24 @@ export class InvoicesController { async submit(@CurrentUser() user: any, @Param('id', ParseUUIDPipe) id: string) { return this.invoicesService.submitToJoFotara(user.tenantId, id); } + + /** + * حذف الفاتورة نهائياً + */ + @Post(':id/delete') // Using POST for delete to match frontend request style or use standard DELETE + @Roles(UserRole.ADMIN, UserRole.ACCOUNTANT) + async remove(@CurrentUser() user: any, @Param('id', ParseUUIDPipe) id: string) { + return this.invoicesService.remove(user.tenantId, id); + } + + /** + * الحصول على الملف الأصلي للفاتورة + */ + @Get(':id/file') + async getFile( + @CurrentUser() user: any, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.invoicesService.getFile(user.tenantId, id); + } } diff --git a/backend/src/modules/invoices/invoice.service.ts b/backend/src/modules/invoices/invoice.service.ts index 207ede5..0d2ee7f 100644 --- a/backend/src/modules/invoices/invoice.service.ts +++ b/backend/src/modules/invoices/invoice.service.ts @@ -10,9 +10,13 @@ import { ForbiddenException, Inject, forwardRef, + InternalServerErrorException, + StreamableFile, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import * as fs from 'fs'; +import * as path from 'path'; import { InjectQueue } from '@nestjs/bull'; import { Queue } from 'bull'; import { Invoice, InvoiceStatus } from './entities/invoice.entity'; @@ -157,4 +161,39 @@ export class InvoicesService { throw error; } } + + /** + * حذف الفاتورة والملف التابع لها + */ + async remove(tenantId: string, id: string): Promise { + const invoice = await this.findOne(tenantId, id); + + // 1. Delete file if exists + if (invoice.original_file_path) { + await this.localStorageService.deleteFile(invoice.original_file_path); + } + + // 2. Delete from DB (lines will be deleted via CASCADE) + await this.invoiceRepository.delete(id); + } + + /** + * الحصول على الملف كـ Stream + */ + async getFile(tenantId: string, id: string): Promise { + const invoice = await this.findOne(tenantId, id); + if (!invoice.original_file_path) { + throw new NotFoundException('Invoice file not found'); + } + + const storageRoot = this.localStorageService['storageRoot']; // Accessing private for path resolve + const fullPath = path.join(storageRoot, invoice.original_file_path); + + if (!fs.existsSync(fullPath)) { + throw new NotFoundException('File does not exist on disk'); + } + + const file = fs.createReadStream(fullPath); + return new StreamableFile(file); + } } diff --git a/backend/src/modules/invoices/jofotara-gateway.service.ts b/backend/src/modules/invoices/jofotara-gateway.service.ts index 4de7dba..5120d84 100644 --- a/backend/src/modules/invoices/jofotara-gateway.service.ts +++ b/backend/src/modules/invoices/jofotara-gateway.service.ts @@ -35,11 +35,15 @@ export class JoFotaraGatewayService { const url = this.currentEnv === 'production' ? this.prodUrl : this.sandboxUrl; try { + const payload = { + invoice: Buffer.from(xmlContent).toString('base64'), + }; + + this.logger.debug(`Submitting to JoFotara: ${url}/submit`); + const response = await axios.post( `${url}/submit`, - { - invoice: Buffer.from(xmlContent).toString('base64'), - }, + payload, { headers: { 'Content-Type': 'application/json', @@ -49,6 +53,7 @@ export class JoFotaraGatewayService { }, ); + this.logger.log(`JoFotara Response: ${JSON.stringify(response.data)}`); return response.data; } catch (error: any) { this.logger.error(`JoFotara API Error: ${error.response?.data || error.message}`); diff --git a/backend/src/modules/invoices/ubl-generator.service.ts b/backend/src/modules/invoices/ubl-generator.service.ts index 0340fdf..7d03a5e 100644 --- a/backend/src/modules/invoices/ubl-generator.service.ts +++ b/backend/src/modules/invoices/ubl-generator.service.ts @@ -85,6 +85,19 @@ export class UBLGeneratorService { .ele('cac:Price') .ele('cbc:PriceAmount', { currencyID: invoice.currency_code }).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('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('cac:TaxCategory') + .ele('cbc:ID').txt(line.tax_rate > 0 ? 'S' : 'Z').up() + .ele('cbc:Percent').txt((line.tax_rate * 100).toString()).up() + .ele('cac:TaxScheme') + .ele('cbc:ID').txt('VAT').up() + .up() + .up() + .up() .up(); }); diff --git a/backend/src/modules/validation/tax-validation.service.ts b/backend/src/modules/validation/tax-validation.service.ts index 8cd157c..501d0c6 100644 --- a/backend/src/modules/validation/tax-validation.service.ts +++ b/backend/src/modules/validation/tax-validation.service.ts @@ -38,12 +38,28 @@ export class TaxValidationService { // 4. Rule 004: التحقق من المجموع النهائي (Grand Total) this.checkRule004(invoice, errors); + // 5. Rule 005: التحقق من صحة نسب الضريبة الأردنية + this.checkRule005(invoice, errors); + return { isValid: errors.length === 0, errors, }; } + /** + * 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 + invoice.lines.forEach((line) => { + const rate = Number(line.tax_rate); + if (!validRates.includes(rate)) { + errors.push(`خطأ في القاعدة 005: نسبة الضريبة (${rate * 100}%) في البند ${line.line_number} غير مطابقة للنسب المعتمدة`); + } + }); + } + /** * Rule 001: Σ (Quantity * UnitPrice) = Subtotal (Before Tax/Discount) */ diff --git a/frontend/src/pages/invoices/InvoicesPage.tsx b/frontend/src/pages/invoices/InvoicesPage.tsx index ac19ce1..b7f2528 100644 --- a/frontend/src/pages/invoices/InvoicesPage.tsx +++ b/frontend/src/pages/invoices/InvoicesPage.tsx @@ -37,6 +37,12 @@ export const InvoicesPage = () => { const [selectedFile, setSelectedFile] = useState(null); const [isUploading, setIsUploading] = useState(false); + // View Modal State + const [viewingInvoice, setViewingInvoice] = useState(null); + const [isViewModalOpen, setIsViewModalOpen] = useState(false); + const [deleteLoading, setDeleteLoading] = useState(null); + const [submitLoading, setSubmitLoading] = useState(null); + const fetchData = async () => { setIsLoading(true); try { @@ -90,6 +96,36 @@ export const InvoicesPage = () => { } }; + const handleDelete = async (id: string) => { + if (!confirm('هل أنت متأكد من حذف هذه الفاتورة نهائياً؟')) return; + setDeleteLoading(id); + try { + await apiClient.post(`/invoices/${id}/delete`); + fetchData(); + } catch (error) { + alert('فشل حذف الفاتورة'); + } finally { + setDeleteLoading(null); + } + }; + + const handleSubmitToJoFotara = async (inv: any) => { + if (inv.status !== 'validated' && inv.status !== 'extracted') { + alert('يجب أن تكون الفاتورة مدققة أو مستخرجة أولاً'); + return; + } + setSubmitLoading(inv.id); + try { + await apiClient.post(`/invoices/${inv.id}/submit`); + alert('تم الإرسال لـ جو فوترة بنجاح! 🎉'); + fetchData(); + } catch (err) { + alert('فشل الإرسال لـ جو فوترة. تأكد من إعدادات الشركة وصحة البيانات.'); + } finally { + setSubmitLoading(null); + } + }; + const handleFileChange = (e: React.ChangeEvent) => { if (e.target.files && e.target.files[0]) { setSelectedFile(e.target.files[0]); @@ -211,9 +247,13 @@ export const InvoicesPage = () => {
@@ -228,27 +268,22 @@ export const InvoicesPage = () => { {/* Dropdown Menu */}
@@ -372,6 +413,89 @@ export const InvoicesPage = () => {
)} + {/* ── View Invoice Modal ─────────────────────────────────── */} + + {isViewModalOpen && viewingInvoice && ( +
+ +
+
+

معاينة الفاتورة

+

رقم: {viewingInvoice.invoice_number || '---'} • {viewingInvoice.company?.name}

+
+ +
+ +
+
+ Invoice { + // Fallback for PDF or Error + e.currentTarget.style.display = 'none'; + e.currentTarget.parentElement!.innerHTML = ` +
+
+ +
+

تعذر عرض الصورة مباشرة

+

قد يكون الملف بتنسيق PDF أو حدث خطأ أثناء التحميل.

+ فتح الملف في نافذة جديدة +
+ `; + }} + /> +
+
+ + +
+
+ )} +
); };