Update: 2026-05-04 14:40:41
This commit is contained in:
@@ -20,45 +20,69 @@ class AI
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$prompt = "You are an expert in Jordanian E-Invoicing (UBL 2.1).
|
$prompt = "أنت نظام متخصص في استخلاص بيانات الفواتير التجارية. مهمتك واحدة فقط: استخراج البيانات من الفاتورة المرفقة بدقة تامة.
|
||||||
Extract all data from this invoice image/document into a JSON format.
|
|
||||||
|
|
||||||
CRITICAL RULES:
|
## قواعد صارمة:
|
||||||
1. DO NOT TRANSLATE ANY TEXT. Keep the exact original language (if Arabic, keep Arabic).
|
**اللغة:**
|
||||||
2. ALL numbers and quantities MUST be in Latin numerals (0-9). Do not use Arabic/Indic numerals (٠-٩).
|
- إذا كانت الفاتورة بالعربية: أبقِ جميع أسماء السلع والعناوين بالعربية بدون ترجمة
|
||||||
3. Identify the Supplier TIN (Tax Identification Number) and Buyer TIN (if present).
|
- إذا كانت بالإنجليزية: أبقِها بالإنجليزية بدون ترجمة
|
||||||
4. Identify if the invoice is 'Cash' or 'Credit'.
|
- الأرقام دائماً بالأرقام اللاتينية (0-9) بغض النظر عن لغة الفاتورة
|
||||||
5. Identify if it is 'Simplified' (B2C) or 'Standard' (B2B).
|
- المبالغ بـ 3 أرقام عشرية (مثال: 15.000 وليس 15)
|
||||||
6. Extract line items precisely.
|
|
||||||
7. Return ONLY valid JSON, no markdown formatting.
|
|
||||||
|
|
||||||
Required JSON Structure:
|
**الدقة:**
|
||||||
{
|
- لا تخترع أي بيانات غير موجودة في الفاتورة — أعد null إذا لم تجد المعلومة
|
||||||
\"invoice_number\": \"\",
|
- تحقق رياضياً: subtotal = مجموع (quantity × unit_price - discount) لكل سطر
|
||||||
\"invoice_date\": \"YYYY-MM-DD\",
|
- تحقق: grand_total = subtotal - discount_total + tax_amount
|
||||||
\"invoice_type\": \"cash|credit\",
|
- إذا وجدت تناقضاً بين الأرقام في الفاتورة، سجِّله في حقل \"validation_warnings\"
|
||||||
\"invoice_category\": \"simplified|standard\",
|
|
||||||
\"supplier_tin\": \"\",
|
**الضريبة:**
|
||||||
\"supplier_name\": \"\",
|
- في الأردن: ضريبة المبيعات العامة (GST) = 16% للسلع العامة
|
||||||
\"supplier_address\": \"\",
|
- سلع معفاة من الضريبة: المواد الغذائية الأساسية، الأدوية، الكتب، بعض المعدات الطبية
|
||||||
\"buyer_tin\": \"\",
|
- سلع بضريبة مخفضة: قد تكون 4% أو 8% — استخرج النسبة الفعلية من الفاتورة
|
||||||
\"buyer_name\": \"\",
|
- لكل سطر: حدد tax_rate الفعلي (0 للمعفاة، وإلا النسبة المئوية كعدد عشري مثل 0.16)
|
||||||
\"buyer_national_id\": \"\",
|
|
||||||
\"subtotal\": 0.000,
|
## البيانات المطلوبة (JSON فقط، بدون أي نص إضافي):
|
||||||
\"tax_amount\": 0.000,
|
|
||||||
\"discount_total\": 0.000,
|
```json
|
||||||
\"grand_total\": 0.000,
|
{
|
||||||
\"currency\": \"JOD\",
|
\"invoice_number\": \"string | null\",
|
||||||
\"items\": [
|
\"invoice_date\": \"YYYY-MM-DD | null\",
|
||||||
{
|
\"invoice_type\": \"cash | credit\",
|
||||||
\"description\": \"\",
|
\"payment_method_code\": \"013 | 010 | 001\",
|
||||||
\"quantity\": 0,
|
\"supplier\": {
|
||||||
\"unit_price\": 0.000,
|
\"name\": \"string | null\",
|
||||||
\"tax_amount\": 0.000,
|
\"tin\": \"string | null\",
|
||||||
\"total\": 0.000
|
\"address\": \"string | null\"
|
||||||
}
|
},
|
||||||
]
|
\"buyer\": {
|
||||||
}";
|
\"name\": \"string | null\",
|
||||||
|
\"tin\": \"string | null\",
|
||||||
|
\"national_id\": \"string | null\"
|
||||||
|
},
|
||||||
|
\"lines\": [
|
||||||
|
{
|
||||||
|
\"line_number\": 1,
|
||||||
|
\"description\": \"string\",
|
||||||
|
\"quantity\": 0.000,
|
||||||
|
\"unit_price\": 0.000,
|
||||||
|
\"discount\": 0.000,
|
||||||
|
\"tax_rate\": 0.16,
|
||||||
|
\"tax_exempt_reason\": \"string | null\",
|
||||||
|
\"line_total\": 0.000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
\"subtotal\": 0.000,
|
||||||
|
\"discount_total\": 0.000,
|
||||||
|
\"tax_amount\": 0.000,
|
||||||
|
\"grand_total\": 0.000,
|
||||||
|
\"currency_code\": \"JOD\",
|
||||||
|
\"math_verified\": true,
|
||||||
|
\"validation_warnings\": [],
|
||||||
|
\"ai_confidence\": 0.95
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
أعد JSON فقط بدون أي شرح أو مقدمة أو علامات Markdown.";
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
"contents" => [
|
"contents" => [
|
||||||
|
|||||||
@@ -8,35 +8,140 @@ namespace App\Core;
|
|||||||
*/
|
*/
|
||||||
class JoFotara
|
class JoFotara
|
||||||
{
|
{
|
||||||
private string $clientId;
|
private string $baseUrl = 'https://backend.jofotara.gov.jo/core/invoices/';
|
||||||
private string $secretKey;
|
|
||||||
private string $environment;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
// Load credentials from DB or Environment
|
|
||||||
$this->clientId = env('JOFOTARA_CLIENT_ID', '');
|
|
||||||
$this->secretKey = env('JOFOTARA_SECRET', '');
|
|
||||||
$this->environment = env('JOFOTARA_ENV', 'sandbox'); // sandbox or production
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 1. Generate UBL 2.1 XML for an invoice
|
* 1. Generate UBL 2.1 XML for an invoice
|
||||||
*/
|
*/
|
||||||
public function generateXML(array $invoiceData): string
|
public function generateXML(array $invoice, array $company): string
|
||||||
{
|
{
|
||||||
// To be implemented: Full XML DOM Document generation based on UBL 2.1 schema
|
$issueDate = $invoice['invoice_date'] ?? date('Y-m-d');
|
||||||
// This will map $invoiceData (Supplier, Buyer, Lines, Taxes) to exact XML nodes.
|
$issueTime = date('H:i:s');
|
||||||
return "<Invoice><dummy>This will be full UBL 2.1 XML</dummy></Invoice>";
|
$typeCode = $invoice['ubl_type_code'] ?? '388';
|
||||||
|
$category = $invoice['invoice_category'] ?? 'simplified';
|
||||||
|
|
||||||
|
// Prepare data outside heredoc for clean interpolation
|
||||||
|
$buyerName = $this->xmlEscape($invoice['buyer_name'] ?: 'عميل نقدي');
|
||||||
|
$buyerId = $invoice['buyer_tin'] ?: $invoice['buyer_national_id'] ?: '000000000';
|
||||||
|
$payMethod = $invoice['payment_method_code'] ?: '013';
|
||||||
|
$supplierName = $this->xmlEscape($company['name']);
|
||||||
|
$supplierAddress = $this->xmlEscape($company['address'] ?? '');
|
||||||
|
|
||||||
|
$xml = <<<XML
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
|
||||||
|
xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
|
||||||
|
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">
|
||||||
|
|
||||||
|
<cbc:UBLVersionID>2.1</cbc:UBLVersionID>
|
||||||
|
<cbc:CustomizationID>urn:www.cen.eu:en16931:2017#compliant#urn:www.josefotara.jo:trns:ubl:3.0</cbc:CustomizationID>
|
||||||
|
<cbc:ProfileID>reporting:1.0</cbc:ProfileID>
|
||||||
|
<cbc:ID>{$invoice['invoice_number']}</cbc:ID>
|
||||||
|
<cbc:IssueDate>{$issueDate}</cbc:IssueDate>
|
||||||
|
<cbc:IssueTime>{$issueTime}</cbc:IssueTime>
|
||||||
|
<cbc:InvoiceTypeCode name="{$category}">{$typeCode}</cbc:InvoiceTypeCode>
|
||||||
|
<cbc:DocumentCurrencyCode>JOD</cbc:DocumentCurrencyCode>
|
||||||
|
<cbc:TaxCurrencyCode>JOD</cbc:TaxCurrencyCode>
|
||||||
|
|
||||||
|
<cac:AccountingSupplierParty>
|
||||||
|
<cac:Party>
|
||||||
|
<cac:PartyName><cbc:Name>{$supplierName}</cbc:Name></cac:PartyName>
|
||||||
|
<cac:PostalAddress>
|
||||||
|
<cbc:StreetName>{$supplierAddress}</cbc:StreetName>
|
||||||
|
<cac:Country><cbc:IdentificationCode>JO</cbc:IdentificationCode></cac:Country>
|
||||||
|
</cac:PostalAddress>
|
||||||
|
<cac:PartyTaxScheme>
|
||||||
|
<cbc:CompanyID>{$company['tax_identification_number']}</cbc:CompanyID>
|
||||||
|
<cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>
|
||||||
|
</cac:PartyTaxScheme>
|
||||||
|
<cac:PartyLegalEntity>
|
||||||
|
<cbc:RegistrationName>{$supplierName}</cbc:RegistrationName>
|
||||||
|
</cac:PartyLegalEntity>
|
||||||
|
</cac:Party>
|
||||||
|
</cac:AccountingSupplierParty>
|
||||||
|
|
||||||
|
<cac:AccountingCustomerParty>
|
||||||
|
<cac:Party>
|
||||||
|
<cac:PartyName><cbc:Name>{$buyerName}</cbc:Name></cac:PartyName>
|
||||||
|
<cac:PartyTaxScheme>
|
||||||
|
<cbc:CompanyID>{$buyerId}</cbc:CompanyID>
|
||||||
|
<cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>
|
||||||
|
</cac:PartyTaxScheme>
|
||||||
|
</cac:Party>
|
||||||
|
</cac:AccountingCustomerParty>
|
||||||
|
|
||||||
|
<cac:PaymentMeans>
|
||||||
|
<cbc:PaymentMeansCode>{$payMethod}</cbc:PaymentMeansCode>
|
||||||
|
</cac:PaymentMeans>
|
||||||
|
|
||||||
|
<cac:TaxTotal>
|
||||||
|
<cbc:TaxAmount currencyID="JOD">{$this->fmt($invoice['tax_amount'])}</cbc:TaxAmount>
|
||||||
|
<cac:TaxSubtotal>
|
||||||
|
<cbc:TaxableAmount currencyID="JOD">{$this->fmt($invoice['subtotal'] - $invoice['discount_total'])}</cbc:TaxableAmount>
|
||||||
|
<cbc:TaxAmount currencyID="JOD">{$this->fmt($invoice['tax_amount'])}</cbc:TaxAmount>
|
||||||
|
<cac:TaxCategory>
|
||||||
|
<cbc:ID>S</cbc:ID>
|
||||||
|
<cbc:Percent>16.000</cbc:Percent>
|
||||||
|
<cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>
|
||||||
|
</cac:TaxCategory>
|
||||||
|
</cac:TaxSubtotal>
|
||||||
|
</cac:TaxTotal>
|
||||||
|
|
||||||
|
<cac:LegalMonetaryTotal>
|
||||||
|
<cbc:LineExtensionAmount currencyID="JOD">{$this->fmt($invoice['subtotal'])}</cbc:LineExtensionAmount>
|
||||||
|
<cbc:TaxExclusiveAmount currencyID="JOD">{$this->fmt($invoice['subtotal'] - $invoice['discount_total'])}</cbc:TaxExclusiveAmount>
|
||||||
|
<cbc:TaxInclusiveAmount currencyID="JOD">{$this->fmt($invoice['grand_total'])}</cbc:TaxInclusiveAmount>
|
||||||
|
<cbc:AllowanceTotalAmount currencyID="JOD">{$this->fmt($invoice['discount_total'])}</cbc:AllowanceTotalAmount>
|
||||||
|
<cbc:PayableAmount currencyID="JOD">{$this->fmt($invoice['grand_total'])}</cbc:PayableAmount>
|
||||||
|
</cac:LegalMonetaryTotal>
|
||||||
|
|
||||||
|
{$this->buildInvoiceLines($invoice['items'])}
|
||||||
|
</Invoice>
|
||||||
|
XML;
|
||||||
|
return $xml;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function buildInvoiceLines(array $items): string
|
||||||
|
{
|
||||||
|
$result = '';
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$taxAmount = round($item['line_total'] * $item['tax_rate'], 3);
|
||||||
|
$taxCategory = $item['tax_rate'] > 0 ? 'S' : 'Z';
|
||||||
|
|
||||||
|
$result .= <<<XML
|
||||||
|
<cac:InvoiceLine>
|
||||||
|
<cbc:ID>{$item['line_number']}</cbc:ID>
|
||||||
|
<cbc:InvoicedQuantity unitCode="PCE">{$this->fmt($item['quantity'])}</cbc:InvoicedQuantity>
|
||||||
|
<cbc:LineExtensionAmount currencyID="JOD">{$this->fmt($item['line_total'])}</cbc:LineExtensionAmount>
|
||||||
|
<cac:TaxTotal>
|
||||||
|
<cbc:TaxAmount currencyID="JOD">{$this->fmt($taxAmount)}</cbc:TaxAmount>
|
||||||
|
<cac:TaxSubtotal>
|
||||||
|
<cbc:TaxableAmount currencyID="JOD">{$this->fmt($item['line_total'])}</cbc:TaxableAmount>
|
||||||
|
<cbc:TaxAmount currencyID="JOD">{$this->fmt($taxAmount)}</cbc:TaxAmount>
|
||||||
|
<cac:TaxCategory>
|
||||||
|
<cbc:ID>{$taxCategory}</cbc:ID>
|
||||||
|
<cbc:Percent>{$this->fmt($item['tax_rate'] * 100)}</cbc:Percent>
|
||||||
|
<cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>
|
||||||
|
</cac:TaxCategory>
|
||||||
|
</cac:TaxSubtotal>
|
||||||
|
</cac:TaxTotal>
|
||||||
|
<cac:Item>
|
||||||
|
<cbc:Description>{$this->xmlEscape($item['description'])}</cbc:Description>
|
||||||
|
</cac:Item>
|
||||||
|
<cac:Price>
|
||||||
|
<cbc:PriceAmount currencyID="JOD">{$this->fmt($item['unit_price'])}</cbc:PriceAmount>
|
||||||
|
</cac:Price>
|
||||||
|
</cac:InvoiceLine>
|
||||||
|
XML;
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fmt(float $val): string { return number_format($val, 3, '.', ''); }
|
||||||
|
private function xmlEscape(string $str): string { return htmlspecialchars($str, ENT_XML1, 'UTF-8'); }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 2. Generate Base64 TLV QR Code (required by Jordan Tax Authority)
|
* 2. Generate Base64 TLV QR Code (Local Fallback)
|
||||||
* Tag 1: Seller Name
|
|
||||||
* Tag 2: Tax Number
|
|
||||||
* Tag 3: Timestamp
|
|
||||||
* Tag 4: Invoice Total
|
|
||||||
* Tag 5: VAT Total
|
|
||||||
*/
|
*/
|
||||||
public function generateQRCode(array $invoiceData): string
|
public function generateQRCode(array $invoiceData): string
|
||||||
{
|
{
|
||||||
@@ -63,10 +168,46 @@ class JoFotara
|
|||||||
/**
|
/**
|
||||||
* 3. Submit Invoice to JoFotara API
|
* 3. Submit Invoice to JoFotara API
|
||||||
*/
|
*/
|
||||||
public function submitInvoice(string $xmlContent): array
|
public function submitInvoice(string $xmlContent, string $clientId, string $secretKey): array
|
||||||
{
|
{
|
||||||
// To be implemented: cURL request to JoFotara Core API
|
// For production, we must encode XML in Base64 and wrap in JSON
|
||||||
// Requires ECDSA signing of the XML before submission
|
$payload = json_encode([
|
||||||
return ['success' => true, 'uuid' => 'dummy-jofotara-id'];
|
'invoice' => base64_encode($xmlContent)
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ch = curl_init($this->baseUrl);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
"ClientId: $clientId",
|
||||||
|
"SecretKey: $secretKey"
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 30
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
$decoded = json_decode($response, true) ?? [];
|
||||||
|
$decoded['_http_code'] = $httpCode;
|
||||||
|
|
||||||
|
if ($httpCode === 200) {
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'uuid' => $decoded['invoiceUUID'] ?? $decoded['uuid'] ?? 'mock-' . uniqid(),
|
||||||
|
'qrCode' => $decoded['qrCode'] ?? $decoded['QRCode'] ?? null,
|
||||||
|
'raw' => $decoded
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => $decoded['errorMessage'] ?? 'API Connection Failed',
|
||||||
|
'raw' => $decoded
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,68 +20,91 @@ if (!$id) {
|
|||||||
try {
|
try {
|
||||||
$db->beginTransaction();
|
$db->beginTransaction();
|
||||||
|
|
||||||
// 1. Fetch Invoice
|
// 1. Fetch Invoice & Company
|
||||||
$stmt = $db->prepare("SELECT * FROM invoices WHERE id = ? FOR UPDATE");
|
$stmt = $db->prepare("
|
||||||
|
SELECT i.*, c.name as company_name, c.tax_identification_number as company_tin,
|
||||||
|
c.address as company_address, c.jofotara_client_id_encrypted, c.jofotara_secret_key_encrypted
|
||||||
|
FROM invoices i
|
||||||
|
JOIN companies c ON i.company_id = c.id
|
||||||
|
WHERE i.id = ? FOR UPDATE
|
||||||
|
");
|
||||||
$stmt->execute([$id]);
|
$stmt->execute([$id]);
|
||||||
$invoice = $stmt->fetch();
|
$invoice = $stmt->fetch();
|
||||||
|
|
||||||
if (!$invoice) {
|
if (!$invoice) json_error('Invoice not found', 404);
|
||||||
json_error('Invoice not found', 404);
|
if ($invoice['status'] === 'approved') json_error('Already approved', 400);
|
||||||
}
|
|
||||||
|
|
||||||
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
|
// 2. Fetch Line Items
|
||||||
$stmtLines = $db->prepare("SELECT * FROM invoice_lines WHERE invoice_id = ?");
|
$stmtLines = $db->prepare("SELECT * FROM invoice_lines WHERE invoice_id = ?");
|
||||||
$stmtLines->execute([$id]);
|
$stmtLines->execute([$id]);
|
||||||
$invoice['items'] = $stmtLines->fetchAll();
|
$invoice['items'] = $stmtLines->fetchAll();
|
||||||
|
|
||||||
// 3. Decrypt Sensitive Data for XML Generation
|
// 3. Decrypt Company Keys for JoFotara
|
||||||
$invoice['supplier_name'] = \App\Core\Encryption::decrypt($invoice['supplier_name']) ?: '';
|
$clientId = \App\Core\Encryption::decrypt($invoice['jofotara_client_id_encrypted']);
|
||||||
$invoice['supplier_tin'] = \App\Core\Encryption::decrypt($invoice['supplier_tin']) ?: '';
|
$secretKey = \App\Core\Encryption::decrypt($invoice['jofotara_secret_key_encrypted']);
|
||||||
$invoice['buyer_name'] = \App\Core\Encryption::decrypt($invoice['buyer_name']) ?: '';
|
|
||||||
$invoice['buyer_tin'] = \App\Core\Encryption::decrypt($invoice['buyer_tin']) ?: '';
|
|
||||||
|
|
||||||
// 4. Initialize JoFotara Core
|
if (!$clientId || !$secretKey) {
|
||||||
|
throw new \Exception("JoFotara credentials missing for company: " . $invoice['company_name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt Buyer Info
|
||||||
|
$invoice['buyer_name'] = \App\Core\Encryption::decrypt($invoice['buyer_name']) ?: '';
|
||||||
|
$invoice['buyer_tin'] = \App\Core\Encryption::decrypt($invoice['buyer_tin']) ?: '';
|
||||||
|
|
||||||
|
// 4. Initialize JoFotara Service
|
||||||
$jofotara = new JoFotara();
|
$jofotara = new JoFotara();
|
||||||
|
|
||||||
// 5. Generate TLV QR Code Base64
|
// 5. Generate UBL 2.1 XML
|
||||||
$qrBase64 = $jofotara->generateQRCode($invoice);
|
$companyData = [
|
||||||
|
'name' => $invoice['company_name'],
|
||||||
|
'tax_identification_number' => $invoice['company_tin'],
|
||||||
|
'address' => $invoice['company_address']
|
||||||
|
];
|
||||||
|
$xmlContent = $jofotara->generateXML($invoice, $companyData);
|
||||||
|
|
||||||
// 6. Generate UBL 2.1 XML
|
// 6. Submit to JoFotara API
|
||||||
$xmlContent = $jofotara->generateXML($invoice);
|
$apiResponse = $jofotara->submitInvoice($xmlContent, $clientId, $secretKey);
|
||||||
|
|
||||||
// 7. Submit to JoFotara API (Simulation for now)
|
// 7. Record Submission (Audit Log)
|
||||||
$apiResponse = $jofotara->submitInvoice($xmlContent);
|
$submissionId = \App\Core\Database::generateUuid();
|
||||||
|
$stmtSub = $db->prepare("
|
||||||
|
INSERT INTO jofotara_submissions
|
||||||
|
(id, invoice_id, company_id, tenant_id, xml_payload, xml_hash,
|
||||||
|
jofotara_uuid, qr_code_raw, response_code, response_body, status, submitted_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||||
|
");
|
||||||
|
|
||||||
|
$status = $apiResponse['success'] ? 'accepted' : 'rejected';
|
||||||
|
$stmtSub->execute([
|
||||||
|
$submissionId,
|
||||||
|
$id,
|
||||||
|
$invoice['company_id'],
|
||||||
|
$invoice['tenant_id'],
|
||||||
|
$xmlContent,
|
||||||
|
hash('sha256', $xmlContent),
|
||||||
|
$apiResponse['uuid'] ?? null,
|
||||||
|
$apiResponse['qrCode'] ?? null,
|
||||||
|
$apiResponse['_http_code'] ?? '0',
|
||||||
|
json_encode($apiResponse['raw'] ?? []),
|
||||||
|
$status
|
||||||
|
]);
|
||||||
|
|
||||||
if (!$apiResponse['success']) {
|
if (!$apiResponse['success']) {
|
||||||
throw new \Exception("JoFotara Rejection: " . ($apiResponse['error'] ?? 'Unknown Error'));
|
throw new \Exception("JoFotara Rejection: " . ($apiResponse['error'] ?? 'Unknown Error'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Update Invoice Status & Save JoFotara UUID/QR
|
// 8. Update Invoice
|
||||||
$updateStmt = $db->prepare("
|
$updateStmt = $db->prepare("
|
||||||
UPDATE invoices
|
UPDATE invoices SET status = 'approved', jofotara_uuid = ?, qr_code = ?, updated_at = NOW() WHERE id = ?
|
||||||
SET status = 'approved',
|
|
||||||
jofotara_uuid = ?,
|
|
||||||
qr_code = ?,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = ?
|
|
||||||
");
|
");
|
||||||
$updateStmt->execute([$apiResponse['uuid'] ?? 'mock-uuid', $qrBase64, $id]);
|
$updateStmt->execute([$apiResponse['uuid'], $apiResponse['qrCode'], $id]);
|
||||||
|
|
||||||
$db->commit();
|
$db->commit();
|
||||||
|
|
||||||
json_success([
|
json_success([
|
||||||
'message' => 'Invoice approved and submitted to JoFotara successfully.',
|
'message' => 'Approved and submitted to JoFotara.',
|
||||||
'jofotara_uuid' => $apiResponse['uuid'] ?? 'mock-uuid',
|
'uuid' => $apiResponse['uuid'],
|
||||||
'qr_code' => $qrBase64
|
'qr_code' => $apiResponse['qrCode']
|
||||||
]);
|
]);
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
|||||||
@@ -108,41 +108,34 @@ if (move_uploaded_file($_FILES['invoice']['tmp_name'], $targetFile)) {
|
|||||||
'date' => $extracted['invoice_date'] ?? null,
|
'date' => $extracted['invoice_date'] ?? null,
|
||||||
'type' => $extracted['invoice_type'] ?? 'cash',
|
'type' => $extracted['invoice_type'] ?? 'cash',
|
||||||
'cat' => $extracted['invoice_category'] ?? 'simplified',
|
'cat' => $extracted['invoice_category'] ?? 'simplified',
|
||||||
's_tin' => \App\Core\Encryption::encrypt($extracted['supplier_tin'] ?? ''),
|
's_tin' => \App\Core\Encryption::encrypt($extracted['supplier']['tin'] ?? ''),
|
||||||
's_name' => \App\Core\Encryption::encrypt($extracted['supplier_name'] ?? ''),
|
's_name' => \App\Core\Encryption::encrypt($extracted['supplier']['name'] ?? ''),
|
||||||
's_addr' => \App\Core\Encryption::encrypt($extracted['supplier_address'] ?? ''),
|
's_addr' => \App\Core\Encryption::encrypt($extracted['supplier']['address'] ?? ''),
|
||||||
'b_tin' => \App\Core\Encryption::encrypt($extracted['buyer_tin'] ?? ''),
|
'b_tin' => \App\Core\Encryption::encrypt($extracted['buyer']['tin'] ?? ''),
|
||||||
'b_name' => \App\Core\Encryption::encrypt($extracted['buyer_name'] ?? ''),
|
'b_name' => \App\Core\Encryption::encrypt($extracted['buyer']['name'] ?? ''),
|
||||||
'b_nid' => \App\Core\Encryption::encrypt($extracted['buyer_national_id'] ?? ''),
|
'b_nid' => \App\Core\Encryption::encrypt($extracted['buyer']['national_id'] ?? ''),
|
||||||
'sub' => $extracted['subtotal'] ?? 0,
|
'sub' => $extracted['subtotal'] ?? 0,
|
||||||
'tax' => $extracted['tax_amount'] ?? 0,
|
'tax' => $extracted['tax_amount'] ?? 0,
|
||||||
'disc' => $extracted['discount_total'] ?? 0,
|
'disc' => $extracted['discount_total'] ?? 0,
|
||||||
'total' => $extracted['grand_total'] ?? 0,
|
'total' => $extracted['grand_total'] ?? 0,
|
||||||
'cur' => $extracted['currency'] ?? 'JOD'
|
'cur' => $extracted['currency_code'] ?? 'JOD'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Save Line Items
|
// Save Line Items
|
||||||
if (!empty($extracted['items'])) {
|
if (!empty($extracted['lines'])) {
|
||||||
$lineStmt = $db->prepare("
|
$lineStmt = $db->prepare("
|
||||||
INSERT INTO invoice_lines (invoice_id, line_number, description, quantity, unit_price, tax_rate, line_total)
|
INSERT INTO invoice_lines (invoice_id, line_number, description, quantity, unit_price, tax_rate, line_total)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
");
|
");
|
||||||
$lineNo = 1;
|
foreach ($extracted['lines'] as $item) {
|
||||||
foreach ($extracted['items'] as $item) {
|
|
||||||
// Calculate tax rate if not provided (fallback to 0.16 for Jordan)
|
|
||||||
$taxRate = 0.16;
|
|
||||||
if (!empty($item['unit_price']) && !empty($item['tax_amount'])) {
|
|
||||||
$taxRate = round($item['tax_amount'] / ($item['unit_price'] * ($item['quantity'] ?: 1)), 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
$lineStmt->execute([
|
$lineStmt->execute([
|
||||||
$invoiceId,
|
$invoiceId,
|
||||||
$lineNo++,
|
$item['line_number'] ?? 1,
|
||||||
$item['description'] ?? 'N/A',
|
$item['description'] ?? 'N/A',
|
||||||
$item['quantity'] ?? 1,
|
$item['quantity'] ?? 1,
|
||||||
$item['unit_price'] ?? 0,
|
$item['unit_price'] ?? 0,
|
||||||
$taxRate,
|
$item['tax_rate'] ?? 0.16,
|
||||||
$item['total'] ?? 0
|
$item['line_total'] ?? 0
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,22 +153,24 @@ CREATE TABLE invoices (
|
|||||||
invoice_type ENUM('cash','credit') DEFAULT 'cash',
|
invoice_type ENUM('cash','credit') DEFAULT 'cash',
|
||||||
ubl_type_code CHAR(3) DEFAULT '388',
|
ubl_type_code CHAR(3) DEFAULT '388',
|
||||||
payment_method_code CHAR(3) DEFAULT '013',
|
payment_method_code CHAR(3) DEFAULT '013',
|
||||||
supplier_tin VARCHAR(20) NULL,
|
supplier_tin TEXT NULL,
|
||||||
supplier_name VARCHAR(255) NULL,
|
supplier_name TEXT NULL,
|
||||||
supplier_address TEXT NULL,
|
supplier_address TEXT NULL,
|
||||||
buyer_tin VARCHAR(20) NULL,
|
buyer_tin TEXT NULL,
|
||||||
buyer_national_id VARCHAR(20) NULL,
|
buyer_national_id TEXT NULL,
|
||||||
buyer_name VARCHAR(255) NULL,
|
buyer_name TEXT NULL,
|
||||||
subtotal DECIMAL(15,3) DEFAULT 0,
|
subtotal DECIMAL(15,3) DEFAULT 0,
|
||||||
discount_total DECIMAL(15,3) DEFAULT 0,
|
discount_total DECIMAL(15,3) DEFAULT 0,
|
||||||
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',
|
||||||
|
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,
|
||||||
@@ -309,3 +311,110 @@ echo "--- Migration Complete ---\n";
|
|||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## File: `seed_super_admin.php`
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Seed Super Admin Script
|
||||||
|
* Run this from CLI: php scripts/seed_super_admin.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../app/bootstrap/init.php';
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Encryption;
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
echo "--- Starting Super Admin Seeding ---\n";
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db->beginTransaction();
|
||||||
|
|
||||||
|
// 1. We must create a "System Tenant" for the Super Admin to satisfy the Foreign Key constraint
|
||||||
|
$systemTenantId = '00000000-0000-0000-0000-000000000000';
|
||||||
|
|
||||||
|
// Check if system tenant exists
|
||||||
|
$stmt = $db->prepare("SELECT id FROM tenants WHERE id = ?");
|
||||||
|
$stmt->execute([$systemTenantId]);
|
||||||
|
if (!$stmt->fetch()) {
|
||||||
|
$stmt = $db->prepare("INSERT INTO tenants (id, name, email, status, created_at) VALUES (?, 'System Administration', 'system@musadaq.com', 'active', NOW())");
|
||||||
|
$stmt->execute([$systemTenantId]);
|
||||||
|
echo "[OK] System Tenant created.\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Setup Super Admin details
|
||||||
|
$adminEmail = 'admin@musadaq.app';
|
||||||
|
$adminName = 'Hamza';
|
||||||
|
$adminPassword = 'password123'; // Default password
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
$emailHash = hash('sha256', strtolower($adminEmail));
|
||||||
|
$stmt = $db->prepare("SELECT id FROM users WHERE email_hash = ?");
|
||||||
|
$stmt->execute([$emailHash]);
|
||||||
|
|
||||||
|
if ($stmt->fetch()) {
|
||||||
|
echo "[INFO] Super Admin already exists with this email.\n";
|
||||||
|
} else {
|
||||||
|
$adminId = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||||
|
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||||
|
mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000,
|
||||||
|
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
||||||
|
);
|
||||||
|
|
||||||
|
$encryptedName = Encryption::encrypt($adminName);
|
||||||
|
$encryptedEmail = Encryption::encrypt($adminEmail);
|
||||||
|
$passwordHash = password_hash($adminPassword, PASSWORD_DEFAULT);
|
||||||
|
|
||||||
|
$stmt = $db->prepare("INSERT INTO users (id, tenant_id, name, email, email_hash, password_hash, role, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?, 'super_admin', 1, NOW())");
|
||||||
|
$stmt->execute([
|
||||||
|
$adminId,
|
||||||
|
$systemTenantId,
|
||||||
|
$encryptedName,
|
||||||
|
$encryptedEmail,
|
||||||
|
$emailHash,
|
||||||
|
$passwordHash
|
||||||
|
]);
|
||||||
|
|
||||||
|
echo "[OK] Super Admin created successfully!\n";
|
||||||
|
echo "----------------------------------------\n";
|
||||||
|
echo "Email: $adminEmail\n";
|
||||||
|
echo "Password: $adminPassword\n";
|
||||||
|
echo "Role: super_admin\n";
|
||||||
|
echo "----------------------------------------\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->commit();
|
||||||
|
echo "--- Seeding Complete ---\n";
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$db->rollBack();
|
||||||
|
echo "[ERROR] Seeding failed: " . $e->getMessage() . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## File: `debug_data.php`
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../app/bootstrap/init.php';
|
||||||
|
use App\Core\Database;
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
echo "--- TENANTS ---\n";
|
||||||
|
$stmt = $db->query("SELECT * FROM tenants");
|
||||||
|
print_r($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||||
|
|
||||||
|
echo "\n--- USERS ---\n";
|
||||||
|
$stmt = $db->query("SELECT u.id, u.name, u.role, u.tenant_id, t.name as tenant_name FROM users u LEFT JOIN tenants t ON u.tenant_id = t.id");
|
||||||
|
print_r($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||||
|
|
||||||
|
echo "\n--- COMPANIES ---\n";
|
||||||
|
$stmt = $db->query("SELECT * FROM companies");
|
||||||
|
print_r($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,6 @@ CREATE TABLE invoices (
|
|||||||
status ENUM('extracted', 'approved', 'rejected') DEFAULT 'extracted',
|
status ENUM('extracted', 'approved', 'rejected') DEFAULT 'extracted',
|
||||||
jofotara_uuid VARCHAR(255) NULL,
|
jofotara_uuid VARCHAR(255) NULL,
|
||||||
qr_code TEXT 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,
|
||||||
@@ -119,12 +118,37 @@ CREATE TABLE invoices (
|
|||||||
ai_total_cost DECIMAL(10,6) DEFAULT 0,
|
ai_total_cost DECIMAL(10,6) DEFAULT 0,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
INDEX idx_tenant (tenant_id),
|
deleted_at DATETIME NULL,
|
||||||
INDEX idx_company (company_id),
|
INDEX idx_tenant (tenant_id),
|
||||||
INDEX idx_status (status),
|
INDEX idx_company (company_id),
|
||||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
|
INDEX idx_status (status),
|
||||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
|
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
);
|
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- JoFotara Submissions (Audit Trail)
|
||||||
|
CREATE TABLE jofotara_submissions (
|
||||||
|
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
|
||||||
|
invoice_id CHAR(36) NOT NULL,
|
||||||
|
company_id CHAR(36) NOT NULL,
|
||||||
|
tenant_id CHAR(36) NOT NULL,
|
||||||
|
xml_payload LONGTEXT NULL,
|
||||||
|
xml_hash VARCHAR(64) NULL,
|
||||||
|
jofotara_uuid VARCHAR(255) NULL,
|
||||||
|
qr_code_raw TEXT NULL,
|
||||||
|
response_code VARCHAR(20) NULL,
|
||||||
|
response_body JSON NULL,
|
||||||
|
status ENUM('pending','submitted','accepted','rejected','error') DEFAULT 'pending',
|
||||||
|
error_message TEXT NULL,
|
||||||
|
retry_count TINYINT DEFAULT 0,
|
||||||
|
submitted_at DATETIME NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- Invoice Lines
|
-- Invoice Lines
|
||||||
CREATE TABLE invoice_lines (
|
CREATE TABLE invoice_lines (
|
||||||
|
|||||||
Reference in New Issue
Block a user