Update: 2026-05-04 02:53:16
This commit is contained in:
91
app/modules_app/invoices/approve.php
Normal file
91
app/modules_app/invoices/approve.php
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Approve Invoice & Submit to JoFotara
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\JoFotara;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
$decoded = AuthMiddleware::check();
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$id = $data['id'] ?? null;
|
||||||
|
|
||||||
|
if (!$id) {
|
||||||
|
json_error('Invoice ID is required', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db->beginTransaction();
|
||||||
|
|
||||||
|
// 1. Fetch Invoice
|
||||||
|
$stmt = $db->prepare("SELECT * FROM invoices WHERE id = ? FOR UPDATE");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$invoice = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$invoice) {
|
||||||
|
json_error('Invoice not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($invoice['status'] === 'approved') {
|
||||||
|
json_error('Invoice is already approved', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorization
|
||||||
|
if ($decoded['role'] !== 'super_admin' && $invoice['tenant_id'] !== $decoded['tenant_id']) {
|
||||||
|
json_error('Unauthorized', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch Line Items
|
||||||
|
$stmtLines = $db->prepare("SELECT * FROM invoice_lines WHERE invoice_id = ?");
|
||||||
|
$stmtLines->execute([$id]);
|
||||||
|
$invoice['items'] = $stmtLines->fetchAll();
|
||||||
|
|
||||||
|
// 3. Decrypt Sensitive Data for XML Generation
|
||||||
|
$invoice['supplier_name'] = \App\Core\Encryption::decrypt($invoice['supplier_name']) ?: '';
|
||||||
|
$invoice['supplier_tin'] = \App\Core\Encryption::decrypt($invoice['supplier_tin']) ?: '';
|
||||||
|
$invoice['buyer_name'] = \App\Core\Encryption::decrypt($invoice['buyer_name']) ?: '';
|
||||||
|
$invoice['buyer_tin'] = \App\Core\Encryption::decrypt($invoice['buyer_tin']) ?: '';
|
||||||
|
|
||||||
|
// 4. Initialize JoFotara Core
|
||||||
|
$jofotara = new JoFotara();
|
||||||
|
|
||||||
|
// 5. Generate TLV QR Code Base64
|
||||||
|
$qrBase64 = $jofotara->generateQRCode($invoice);
|
||||||
|
|
||||||
|
// 6. Generate UBL 2.1 XML
|
||||||
|
$xmlContent = $jofotara->generateXML($invoice);
|
||||||
|
|
||||||
|
// 7. Submit to JoFotara API (Simulation for now)
|
||||||
|
$apiResponse = $jofotara->submitInvoice($xmlContent);
|
||||||
|
|
||||||
|
if (!$apiResponse['success']) {
|
||||||
|
throw new \Exception("JoFotara Rejection: " . ($apiResponse['error'] ?? 'Unknown Error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Update Invoice Status & Save JoFotara UUID/QR
|
||||||
|
$updateStmt = $db->prepare("
|
||||||
|
UPDATE invoices
|
||||||
|
SET status = 'approved',
|
||||||
|
jofotara_uuid = ?,
|
||||||
|
qr_code = ?,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = ?
|
||||||
|
");
|
||||||
|
$updateStmt->execute([$apiResponse['uuid'] ?? 'mock-uuid', $qrBase64, $id]);
|
||||||
|
|
||||||
|
$db->commit();
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'message' => 'Invoice approved and submitted to JoFotara successfully.',
|
||||||
|
'jofotara_uuid' => $apiResponse['uuid'] ?? 'mock-uuid',
|
||||||
|
'qr_code' => $qrBase64
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$db->rollBack();
|
||||||
|
error_log("JoFotara Approve Error: " . $e->getMessage());
|
||||||
|
json_error('Failed to approve invoice: ' . $e->getMessage(), 500);
|
||||||
|
}
|
||||||
@@ -6,6 +6,19 @@
|
|||||||
use App\Core\Database;
|
use App\Core\Database;
|
||||||
use App\Middleware\AuthMiddleware;
|
use App\Middleware\AuthMiddleware;
|
||||||
|
|
||||||
|
// Helper to output error as an image for debugging
|
||||||
|
function outputErrorImage($message) {
|
||||||
|
header('Content-Type: image/png');
|
||||||
|
$im = imagecreatetruecolor(400, 100);
|
||||||
|
$bg = imagecolorallocate($im, 20, 20, 20);
|
||||||
|
$tc = imagecolorallocate($im, 255, 50, 50);
|
||||||
|
imagefilledrectangle($im, 0, 0, 400, 100, $bg);
|
||||||
|
imagestring($im, 5, 10, 40, $message, $tc);
|
||||||
|
imagepng($im);
|
||||||
|
imagedestroy($im);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// Extract token from header OR query string
|
// Extract token from header OR query string
|
||||||
$headers = getallheaders();
|
$headers = getallheaders();
|
||||||
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';
|
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';
|
||||||
@@ -17,44 +30,41 @@ if (preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) {
|
|||||||
$token = $_GET['token'];
|
$token = $_GET['token'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$token) die('Forbidden: No token provided');
|
if (!$token) outputErrorImage('Forbidden: No token');
|
||||||
|
|
||||||
$decoded = \App\Core\JWT::decode($token);
|
$decoded = \App\Core\JWT::decode($token);
|
||||||
if (!$decoded) die('Forbidden: Invalid token');
|
if (!$decoded) outputErrorImage('Forbidden: Invalid token');
|
||||||
|
|
||||||
$db = Database::getInstance();
|
$db = Database::getInstance();
|
||||||
|
|
||||||
$id = input('id');
|
$id = input('id');
|
||||||
if (!$id) die('Forbidden');
|
if (!$id) outputErrorImage('Forbidden: No ID');
|
||||||
|
|
||||||
$stmt = $db->prepare("SELECT tenant_id, original_file_path FROM invoices WHERE id = ?");
|
$stmt = $db->prepare("SELECT tenant_id, original_file_path FROM invoices WHERE id = ?");
|
||||||
$stmt->execute([$id]);
|
$stmt->execute([$id]);
|
||||||
$invoice = $stmt->fetch();
|
$invoice = $stmt->fetch();
|
||||||
|
|
||||||
if (!$invoice) die('Not found');
|
if (!$invoice) outputErrorImage('Error: Invoice not found');
|
||||||
|
|
||||||
// Authorization
|
// Authorization
|
||||||
if ($decoded['role'] !== 'super_admin' && $invoice['tenant_id'] !== $decoded['tenant_id']) {
|
if ($decoded['role'] !== 'super_admin' && $invoice['tenant_id'] !== $decoded['tenant_id']) {
|
||||||
die('Unauthorized');
|
outputErrorImage('Error: Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
$filePath = $invoice['original_file_path'];
|
$filePath = $invoice['original_file_path'];
|
||||||
|
|
||||||
if (!file_exists($filePath)) {
|
if (!file_exists($filePath)) {
|
||||||
error_log("FILE PROXY ERROR: File not found at " . $filePath);
|
outputErrorImage('Error: File missing on disk');
|
||||||
header("HTTP/1.0 404 Not Found");
|
|
||||||
exit('File missing');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!is_readable($filePath)) {
|
if (!is_readable($filePath)) {
|
||||||
error_log("FILE PROXY ERROR: File not readable at " . $filePath);
|
outputErrorImage('Error: Permission denied');
|
||||||
header("HTTP/1.0 403 Forbidden");
|
|
||||||
exit('Permission denied');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$mime = mime_content_type($filePath);
|
$mime = mime_content_type($filePath);
|
||||||
|
if (!$mime) $mime = 'application/octet-stream';
|
||||||
|
|
||||||
header("Content-Type: $mime");
|
header("Content-Type: $mime");
|
||||||
header("Content-Length: " . filesize($filePath));
|
header("Content-Length: " . filesize($filePath));
|
||||||
header("Cache-Control: public, max-age=3600"); // Add caching for speed
|
header("Cache-Control: public, max-age=3600");
|
||||||
readfile($filePath);
|
readfile($filePath);
|
||||||
exit;
|
exit;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ $routes = [
|
|||||||
'v1/invoices' => ['GET', 'invoices/index.php'],
|
'v1/invoices' => ['GET', 'invoices/index.php'],
|
||||||
'v1/invoices/view' => ['GET', 'invoices/view.php'],
|
'v1/invoices/view' => ['GET', 'invoices/view.php'],
|
||||||
'v1/invoices/file' => ['GET', 'invoices/file.php'],
|
'v1/invoices/file' => ['GET', 'invoices/file.php'],
|
||||||
|
'v1/invoices/approve' => ['POST', 'invoices/approve.php'],
|
||||||
'v1/invoices/upload' => ['POST', 'invoices/upload.php'],
|
'v1/invoices/upload' => ['POST', 'invoices/upload.php'],
|
||||||
'v1/dashboard/stats' => ['GET', 'dashboard/stats.php'],
|
'v1/dashboard/stats' => ['GET', 'dashboard/stats.php'],
|
||||||
'v1/tenants' => ['GET', 'tenants/index.php'],
|
'v1/tenants' => ['GET', 'tenants/index.php'],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrious/4.0.2/qrious.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--emerald: #10b981;
|
--emerald: #10b981;
|
||||||
@@ -326,8 +327,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-6 bg-gray-950/50 border-t border-gray-800 flex gap-4">
|
<div class="p-6 bg-gray-950/50 border-t border-gray-800 flex gap-4">
|
||||||
<button class="flex-1 bg-emerald-600 hover:bg-emerald-500 py-3 rounded-xl font-bold transition">✅ اعتماد الفاتورة</button>
|
<template x-if="currentInvoice?.status === 'extracted'">
|
||||||
<button class="flex-1 border border-gray-800 hover:bg-gray-800 py-3 rounded-xl font-bold transition">📝 تعديل البيانات</button>
|
<button @click="approveInvoice(currentInvoice.id)" class="flex-1 bg-emerald-600 hover:bg-emerald-500 py-3 rounded-xl font-bold transition flex items-center justify-center gap-2">
|
||||||
|
<span>✅ اعتماد الفاتورة وتوليد QR</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template x-if="currentInvoice?.status === 'approved'">
|
||||||
|
<div class="flex-1 flex flex-col items-center justify-center bg-gray-900 rounded-xl p-4 border border-emerald-500/20">
|
||||||
|
<span class="text-xs text-emerald-500 font-bold mb-2">تم الاعتماد لدى جوفوترة</span>
|
||||||
|
<template x-if="currentInvoice?.qr_code">
|
||||||
|
<img :src="'data:image/png;base64,' + generateQRPng(currentInvoice.qr_code)" class="w-24 h-24 rounded bg-white p-1" alt="QR Code">
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -456,6 +468,38 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async approveInvoice(id) {
|
||||||
|
if (!confirm('هل أنت متأكد من اعتماد الفاتورة وإرسالها إلى جوفوترة؟')) return;
|
||||||
|
|
||||||
|
const res = await fetch('/index.php?route=v1/invoices/approve', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + this.token(),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ id: id })
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
|
||||||
|
if (json.success) {
|
||||||
|
alert('تم الاعتماد بنجاح!');
|
||||||
|
this.viewInvoice(id); // Reload to show QR
|
||||||
|
this.loadInvoices();
|
||||||
|
} else {
|
||||||
|
this.showError(json.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
generateQRPng(base64Tlv) {
|
||||||
|
const qr = new QRious({
|
||||||
|
value: base64Tlv,
|
||||||
|
size: 200,
|
||||||
|
level: 'M'
|
||||||
|
});
|
||||||
|
// Remove 'data:image/png;base64,' from the return, as the template adds it
|
||||||
|
return qr.toDataURL().split(',')[1];
|
||||||
|
},
|
||||||
|
|
||||||
handleFile(e) { this.selectedFile = e.target.files[0]; },
|
handleFile(e) { this.selectedFile = e.target.files[0]; },
|
||||||
|
|
||||||
async uploadInvoice() {
|
async uploadInvoice() {
|
||||||
|
|||||||
@@ -106,12 +106,13 @@ CREATE TABLE invoices (
|
|||||||
tax_amount DECIMAL(15,3) DEFAULT 0,
|
tax_amount DECIMAL(15,3) DEFAULT 0,
|
||||||
grand_total DECIMAL(15,3) DEFAULT 0,
|
grand_total DECIMAL(15,3) DEFAULT 0,
|
||||||
currency_code CHAR(3) DEFAULT 'JOD',
|
currency_code CHAR(3) DEFAULT 'JOD',
|
||||||
status ENUM('uploaded','extracting','extracted','validated','validation_failed','submitting','approved','rejected') DEFAULT 'uploaded',
|
status ENUM('extracted', 'approved', 'rejected') DEFAULT 'extracted',
|
||||||
uploaded_by CHAR(36) NULL,
|
jofotara_uuid VARCHAR(255) NULL,
|
||||||
|
qr_code TEXT NULL,
|
||||||
|
invoice_number VARCHAR(50) NULL,
|
||||||
original_file_path TEXT NULL,
|
original_file_path TEXT NULL,
|
||||||
invoice_category VARCHAR(20) DEFAULT 'simplified',
|
invoice_category VARCHAR(20) DEFAULT 'simplified',
|
||||||
validation_errors JSON NULL,
|
validation_errors JSON NULL,
|
||||||
qr_code TEXT NULL,
|
|
||||||
ai_confidence_score DECIMAL(4,3) NULL,
|
ai_confidence_score DECIMAL(4,3) NULL,
|
||||||
ai_prompt_tokens INT DEFAULT 0,
|
ai_prompt_tokens INT DEFAULT 0,
|
||||||
ai_completion_tokens INT DEFAULT 0,
|
ai_completion_tokens INT DEFAULT 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user