Feat: Invoice viewing, JoFotara UBL refinements, delete functionality, and tax rate validation

This commit is contained in:
Hamza-Ayed
2026-04-19 15:03:39 +03:00
parent 47d473add5
commit 3acd9f261b
7 changed files with 247 additions and 25 deletions

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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<void> {
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<StreamableFile> {
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);
}
}

View File

@@ -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}`);

View File

@@ -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();
});

View File

@@ -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)
*/

View File

@@ -37,6 +37,12 @@ export const InvoicesPage = () => {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
// View Modal State
const [viewingInvoice, setViewingInvoice] = useState<any | null>(null);
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState<string | null>(null);
const [submitLoading, setSubmitLoading] = useState<string | null>(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<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setSelectedFile(e.target.files[0]);
@@ -211,9 +247,13 @@ export const InvoicesPage = () => {
<td className="px-6 py-4 text-center relative">
<div className="flex items-center justify-center gap-2">
<button
onClick={(e) => { e.stopPropagation(); console.log('View', inv.id); }}
onClick={(e) => {
e.stopPropagation();
setViewingInvoice(inv);
setIsViewModalOpen(true);
}}
className="p-2 text-slate-400 hover:text-primary-600 hover:bg-primary-50 rounded-lg transition-all"
title="عرض التفاصيل"
title="عرض الفاتورة"
>
<Eye className="w-4 h-4" />
</button>
@@ -228,27 +268,22 @@ export const InvoicesPage = () => {
{/* Dropdown Menu */}
<div className="absolute left-0 mt-2 w-48 bg-white border border-slate-100 rounded-2xl shadow-xl shadow-slate-200/50 opacity-0 invisible group-hover/menu:opacity-100 group-hover/menu:visible transition-all z-10 py-2">
<button
onClick={async (e) => {
e.stopPropagation();
if (inv.status !== 'validated') {
alert('يجب أن تكون الفاتورة مدققة أولاً');
return;
}
try {
await apiClient.post(`/invoices/${inv.id}/submit`);
alert('تم الإرسال لجو فوترة بنجاح! 🎉');
fetchData();
} catch (err) {
alert('فشل الإرسال لجو فوترة');
}
}}
className="w-full text-right px-4 py-2 text-sm text-slate-700 hover:bg-slate-50 flex items-center gap-2"
disabled={submitLoading === inv.id}
onClick={(e) => { e.stopPropagation(); handleSubmitToJoFotara(inv); }}
className="w-full text-right px-4 py-2 text-sm text-slate-700 hover:bg-slate-50 flex items-center gap-2 disabled:opacity-50"
>
{submitLoading === inv.id ? (
<div className="w-4 h-4 border-2 border-primary-600/30 border-t-primary-600 rounded-full animate-spin" />
) : (
<Send className="w-4 h-4 text-emerald-500" />
)}
إرسال لـ جو فوترة
</button>
<button
onClick={(e) => { e.stopPropagation(); window.open(inv.original_file_path, '_blank'); }}
onClick={(e) => {
e.stopPropagation();
window.open(`${apiClient.defaults.baseURL}/invoices/${inv.id}/file`, '_blank');
}}
className="w-full text-right px-4 py-2 text-sm text-slate-700 hover:bg-slate-50 flex items-center gap-2"
>
<Download className="w-4 h-4 text-slate-400" />
@@ -256,9 +291,15 @@ export const InvoicesPage = () => {
</button>
<div className="h-px bg-slate-100 my-1 mx-2" />
<button
className="w-full text-right px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
disabled={deleteLoading === inv.id}
onClick={(e) => { e.stopPropagation(); handleDelete(inv.id); }}
className="w-full text-right px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 disabled:opacity-50"
>
{deleteLoading === inv.id ? (
<div className="w-4 h-4 border-2 border-red-600/30 border-t-red-600 rounded-full animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
حذف الفاتورة
</button>
</div>
@@ -372,6 +413,89 @@ export const InvoicesPage = () => {
</div>
)}
</AnimatePresence>
{/* ── View Invoice Modal ─────────────────────────────────── */}
<AnimatePresence>
{isViewModalOpen && viewingInvoice && (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-slate-900/80 backdrop-blur-md">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="bg-white rounded-[40px] w-full max-w-5xl h-[90vh] shadow-2xl flex flex-col overflow-hidden"
>
<header className="px-8 py-6 border-b border-slate-100 flex items-center justify-between bg-white">
<div>
<h3 className="text-2xl font-bold text-slate-900">معاينة الفاتورة</h3>
<p className="text-slate-500 font-medium">رقم: {viewingInvoice.invoice_number || '---'} {viewingInvoice.company?.name}</p>
</div>
<button
onClick={() => setIsViewModalOpen(false)}
className="w-12 h-12 flex items-center justify-center rounded-2xl bg-slate-50 text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-all"
>
<ChevronLeft className="w-6 h-6 rotate-180" />
</button>
</header>
<div className="flex-1 overflow-auto bg-slate-100 p-8 flex justify-center items-start">
<div className="bg-white shadow-2xl rounded-sm overflow-hidden max-w-full">
<img
src={`${apiClient.defaults.baseURL}/invoices/${viewingInvoice.id}/file`}
alt="Invoice"
className="max-w-full h-auto"
onError={(e) => {
// Fallback for PDF or Error
e.currentTarget.style.display = 'none';
e.currentTarget.parentElement!.innerHTML = `
<div class="p-20 text-center">
<div class="w-20 h-20 bg-slate-50 rounded-full flex items-center justify-center mx-auto mb-4 border border-slate-200">
<svg class="w-10 h-10 text-slate-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>
</div>
<h4 class="text-xl font-bold text-slate-900 mb-2">تعذر عرض الصورة مباشرة</h4>
<p class="text-slate-500 mb-6">قد يكون الملف بتنسيق PDF أو حدث خطأ أثناء التحميل.</p>
<a href="${apiClient.defaults.baseURL}/invoices/${viewingInvoice.id}/file" target="_blank" class="btn-primary px-8 py-3 rounded-xl inline-block">فتح الملف في نافذة جديدة</a>
</div>
`;
}}
/>
</div>
</div>
<footer className="px-8 py-6 border-t border-slate-100 flex items-center justify-between bg-slate-50/50">
<div className="flex items-center gap-6">
<div>
<p className="text-[10px] uppercase tracking-widest text-slate-400 font-bold mb-1">المجموع الكلي</p>
<p className="text-xl font-black text-primary-700">{Number(viewingInvoice.grand_total).toLocaleString('en-US', { minimumFractionDigits: 3 })} JOD</p>
</div>
<div className="w-px h-10 bg-slate-200" />
<div>
<p className="text-[10px] uppercase tracking-widest text-slate-400 font-bold mb-1">الحالة</p>
<StatusBadge status={viewingInvoice.status} />
</div>
</div>
<div className="flex gap-4">
<button
onClick={() => window.open(`${apiClient.defaults.baseURL}/invoices/${viewingInvoice.id}/file`, '_blank')}
className="px-6 py-3 rounded-xl bg-white border border-slate-200 text-slate-700 font-bold flex items-center gap-2 hover:bg-slate-50 transition-all shadow-sm"
>
<Download className="w-4 h-4" />
تحميل الأصلي
</button>
<button
onClick={() => {
setIsViewModalOpen(false);
handleSubmitToJoFotara(viewingInvoice);
}}
className="btn-primary px-8 py-3 rounded-xl flex items-center gap-2 shadow-lg shadow-primary-500/20"
>
<Send className="w-4 h-4" />
إرسال لجو فوترة
</button>
</div>
</footer>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
};