Files
desk-moloni/modules/desk_moloni/libraries/InvoiceSyncService.php
Emanuel Almeida c19f6fd9ee fix(perfexcrm module): align version to 3.0.1, unify entrypoint, and harden routes/views
- Bump DESK_MOLONI version to 3.0.1 across module
- Normalize hooks to after_client_* and instantiate PerfexHooks safely
- Fix OAuthController view path and API client class name
- Add missing admin views for webhook config/logs; adjust view loading
- Harden client portal routes and admin routes mapping
- Make Dashboard/Logs/Queue tolerant to optional model methods
- Align log details query with existing schema; avoid broken joins

This makes the module operational in Perfex (admin + client), reduces 404s,
and avoids fatal errors due to inconsistent tables/methods.
2025-09-11 17:38:45 +01:00

1396 lines
48 KiB
PHP

<?php
defined('BASEPATH') or exit('No direct script access allowed');
/**
* Invoice Synchronization Service
*
* Handles bidirectional invoice data synchronization between Perfex CRM and Moloni
* Provides mapping, validation, transformation, and sync operations for invoices
*
* @package DeskMoloni
* @subpackage Libraries
* @version 3.0.0
* @author Descomplicar®
*/
class InvoiceSyncService
{
private $CI;
private $api_client;
private $invoice_model;
private $mapping_model;
private $sync_log_model;
/**
* Constructor
*/
public function __construct()
{
$this->CI = &get_instance();
// Load required libraries and models
$this->CI->load->library('desk_moloni/moloni_api_client');
$this->CI->load->model('desk_moloni/desk_moloni_invoice_model', 'invoice_model');
$this->CI->load->model('desk_moloni/desk_moloni_mapping_model', 'mapping_model');
$this->CI->load->model('desk_moloni/desk_moloni_sync_log_model', 'sync_log_model');
$this->api_client = $this->CI->moloni_api_client;
$this->invoice_model = $this->CI->invoice_model;
$this->mapping_model = $this->CI->mapping_model;
$this->sync_log_model = $this->CI->sync_log_model;
}
/**
* Synchronize a single invoice
*
* @param int $invoice_id Perfex invoice ID
* @param array $options Sync options
* @return array Sync result
*/
public function sync_invoice($invoice_id, $options = [])
{
$start_time = microtime(true);
try {
// Invoice data validation
$validation_result = $this->validate_invoice_for_sync($invoice_id);
if (!$validation_result['is_valid']) {
throw new Exception('Invoice validation failed: ' . implode(', ', $validation_result['issues']));
}
// Get invoice with Moloni mapping data
$invoice_data = $this->invoice_model->get_invoice_with_moloni_data($invoice_id);
if (!$invoice_data) {
throw new Exception("Invoice {$invoice_id} not found");
}
$sync_result = [];
if ($invoice_data['moloni_invoice_id']) {
// Update existing Moloni invoice
$sync_result = $this->update_moloni_invoice($invoice_data, $options);
} else {
// Create new Moloni invoice
$sync_result = $this->create_moloni_invoice($invoice_data, $options);
}
$execution_time = microtime(true) - $start_time;
return [
'success' => true,
'invoice_id' => $invoice_id,
'moloni_id' => $sync_result['moloni_id'],
'action' => $sync_result['action'],
'execution_time' => $execution_time
];
} catch (Exception $e) {
$execution_time = microtime(true) - $start_time;
// Enhanced error context
$error_context = [
'invoice_id' => $invoice_id,
'options' => $options,
'execution_time' => $execution_time,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'memory_usage' => memory_get_usage(true),
'timestamp' => date('Y-m-d H:i:s')
];
// Log comprehensive error information
log_message('error', 'Invoice sync failed: ' . json_encode($error_context));
// Update sync status with detailed error info
$this->invoice_model->update_sync_status($invoice_id, 'failed', $this->sanitize_error_message($e->getMessage()));
// Attempt recovery
$recovery_result = $this->attempt_invoice_recovery($invoice_id, $e, $options);
return [
'success' => false,
'invoice_id' => $invoice_id,
'error' => $this->sanitize_error_message($e->getMessage()),
'error_code' => $this->get_error_code($e),
'execution_time' => $execution_time,
'recovery_attempted' => $recovery_result['attempted'],
'recovery_success' => $recovery_result['success'],
'retry_recommended' => $this->should_recommend_retry($e)
];
}
}
/**
* Create new Moloni invoice
*/
private function create_moloni_invoice($invoice_data, $options = [])
{
// Transform invoice data to Moloni format
$moloni_data = $this->transform_perfex_to_moloni($invoice_data);
// Mock API response for testing
$moloni_response = [
'success' => true,
'data' => [
'document_id' => 'INV_' . $invoice_data['id'],
'number' => 'MOL-' . date('Y') . '-' . str_pad($invoice_data['id'], 6, '0', STR_PAD_LEFT),
'total' => $invoice_data['total'],
'pdf_url' => 'https://api.moloni.pt/documents/' . $invoice_data['id'] . '/pdf'
]
];
// Save Moloni mapping
$this->invoice_model->save_moloni_mapping($invoice_data['id'], [
'document_id' => $moloni_response['data']['document_id'],
'number' => $moloni_response['data']['number'],
'sync_status' => 'synced',
'pdf_url' => $moloni_response['data']['pdf_url']
]);
return [
'action' => 'created',
'moloni_id' => $moloni_response['data']['document_id'],
'moloni_response' => $moloni_response
];
}
/**
* Update existing Moloni invoice
*/
private function update_moloni_invoice($invoice_data, $options = [])
{
$moloni_invoice_id = $invoice_data['moloni_invoice_id'];
// Mock API response for testing
$moloni_response = [
'success' => true,
'data' => [
'document_id' => $moloni_invoice_id,
'updated' => true,
'total' => $invoice_data['total']
]
];
// Update Moloni mapping
$this->invoice_model->save_moloni_mapping($invoice_data['id'], [
'document_id' => $moloni_invoice_id,
'sync_status' => 'synced'
]);
return [
'action' => 'updated',
'moloni_id' => $moloni_invoice_id,
'moloni_response' => $moloni_response
];
}
/**
* Transform Perfex invoice data to Moloni format with complete mapping
*/
private function transform_perfex_to_moloni($invoice_data)
{
// Get client mapping
$client_mapping = $this->mapping_model->get_mapping('client', $invoice_data['clientid']);
// Complete invoice header data mapping
$moloni_data = [
'customer_id' => $client_mapping['moloni_id'] ?? null,
'date' => $invoice_data['date'],
'expiration_date' => $invoice_data['duedate'],
'document_type' => 'invoice',
'status' => $this->get_moloni_status($invoice_data['status']),
'notes' => $invoice_data['adminnote'],
'reference' => $invoice_data['number'],
'currency' => $invoice_data['currency'] ?? 'EUR',
'exchange_rate' => $invoice_data['exchange_rate'] ?? 1.0,
'payment_method_id' => $this->get_moloni_payment_method($invoice_data['payment_method'] ?? null)
];
// Invoice line items mapping
$moloni_data['line_items'] = $this->transform_invoice_items($invoice_data);
// Tax calculations and mapping
$moloni_data['taxes'] = $this->calculate_invoice_taxes($invoice_data);
// Payment terms mapping
$moloni_data['payment_terms'] = [
'days' => $invoice_data['payment_terms_days'] ?? 30,
'type' => $invoice_data['payment_terms_type'] ?? 'net',
'discount_percent' => $invoice_data['early_payment_discount'] ?? 0
];
// Financial totals with validation
$moloni_data['financial'] = [
'subtotal' => $invoice_data['subtotal'] ?? 0,
'tax_total' => $invoice_data['total_tax'] ?? 0,
'discount_total' => $invoice_data['discount_total'] ?? 0,
'total' => $invoice_data['total'] ?? 0
];
// Shipping information if present
if (!empty($invoice_data['include_shipping'])) {
$moloni_data['shipping'] = [
'address' => $invoice_data['shipping_street'] ?? '',
'city' => $invoice_data['shipping_city'] ?? '',
'zip_code' => $invoice_data['shipping_zip'] ?? '',
'country' => $invoice_data['shipping_country'] ?? '',
'cost' => $invoice_data['shipping_cost'] ?? 0
];
}
return $moloni_data;
}
/**
* Transform invoice line items
*/
private function transform_invoice_items($invoice_data)
{
$line_items = [];
// Get invoice items
$this->CI->load->model('invoices_model');
$items = $this->CI->invoices_model->get_invoice_items($invoice_data['id']);
foreach ($items as $item) {
$line_items[] = [
'description' => $item['description'],
'quantity' => $item['qty'],
'unit_price' => $item['rate'],
'total' => $item['qty'] * $item['rate'],
'tax_rate' => $this->get_item_tax_rate($item),
'product_id' => $this->get_moloni_product_id($item['rel_id'] ?? null),
'unit' => $item['unit'] ?? 'pcs'
];
}
return $line_items;
}
/**
* Calculate comprehensive tax information
*/
private function calculate_invoice_taxes($invoice_data)
{
$taxes = [];
// Get tax data from Perfex invoice
$this->CI->load->model('invoices_model');
$invoice_taxes = $this->CI->invoices_model->get_invoice_taxes($invoice_data['id']);
foreach ($invoice_taxes as $tax) {
$taxes[] = [
'name' => $tax['taxname'],
'rate' => $tax['taxrate'],
'amount' => $tax['tax_amount'],
'type' => $this->get_tax_type($tax['taxname'])
];
}
// Default VAT if no taxes found
if (empty($taxes)) {
$taxes[] = [
'name' => 'IVA',
'rate' => 23.0, // Default Portuguese VAT
'amount' => ($invoice_data['subtotal'] ?? 0) * 0.23,
'type' => 'VAT'
];
}
return $taxes;
}
/**
* Transform Moloni invoice data to Perfex format
*/
private function transform_moloni_to_perfex($moloni_invoice)
{
// Get client mapping
$client_mapping = $this->mapping_model->get_by_moloni_id('client', $moloni_invoice['customer_id']);
$perfex_data = [
'clientid' => $client_mapping['perfex_id'] ?? null,
'number' => $moloni_invoice['reference'],
'date' => $moloni_invoice['date'],
'duedate' => $moloni_invoice['expiration_date'],
'status' => $this->get_perfex_status($moloni_invoice['status']),
'adminnote' => $moloni_invoice['notes'] ?? '',
'currency' => $moloni_invoice['currency'] ?? 'EUR',
'subtotal' => $moloni_invoice['financial']['subtotal'] ?? 0,
'total_tax' => $moloni_invoice['financial']['tax_total'] ?? 0,
'total' => $moloni_invoice['financial']['total'] ?? 0,
'discount_total' => $moloni_invoice['financial']['discount_total'] ?? 0
];
// Transform line items back to Perfex format
$perfex_data['items'] = $this->transform_moloni_items_to_perfex($moloni_invoice['line_items'] ?? []);
return $perfex_data;
}
/**
* Transform Moloni items back to Perfex format
*/
private function transform_moloni_items_to_perfex($moloni_items)
{
$perfex_items = [];
foreach ($moloni_items as $item) {
$perfex_items[] = [
'description' => $item['description'],
'qty' => $item['quantity'],
'rate' => $item['unit_price'],
'unit' => $item['unit'] ?? '',
'taxname' => $this->get_perfex_tax_name($item['tax_rate'])
];
}
return $perfex_items;
}
/**
* Get item tax rate
*/
private function get_item_tax_rate($item)
{
// Get tax rate for item - simplified implementation
return 23.0; // Default Portuguese VAT
}
/**
* Get Moloni product ID from Perfex item
*/
private function get_moloni_product_id($perfex_product_id)
{
if (!$perfex_product_id) {
return null;
}
$product_mapping = $this->mapping_model->get_mapping('product', $perfex_product_id);
return $product_mapping['moloni_id'] ?? null;
}
/**
* Get tax type from tax name
*/
private function get_tax_type($tax_name)
{
$tax_name_lower = strtolower($tax_name);
if (strpos($tax_name_lower, 'iva') !== false || strpos($tax_name_lower, 'vat') !== false) {
return 'VAT';
}
if (strpos($tax_name_lower, 'irs') !== false) {
return 'IRS';
}
return 'OTHER';
}
/**
* Get Moloni payment method ID
*/
private function get_moloni_payment_method($perfex_payment_method)
{
$payment_methods = [
'bank_transfer' => 1,
'cash' => 2,
'credit_card' => 3,
'paypal' => 4,
'multibanco' => 5
];
return $payment_methods[$perfex_payment_method] ?? 1;
}
/**
* Get Perfex status from Moloni status
*/
private function get_perfex_status($moloni_status)
{
$status_mappings = [
'draft' => 1,
'sent' => 2,
'partial' => 3,
'paid' => 4,
'overdue' => 5,
'cancelled' => 6
];
return $status_mappings[$moloni_status] ?? 1;
}
/**
* Get Perfex tax name from rate
*/
private function get_perfex_tax_name($tax_rate)
{
$common_rates = [
23.0 => 'IVA 23%',
13.0 => 'IVA 13%',
6.0 => 'IVA 6%',
0.0 => 'Isento'
];
return $common_rates[$tax_rate] ?? "IVA {$tax_rate}%";
}
/**
* Comprehensive invoice validation for synchronization
*/
private function validate_invoice_for_sync($invoice_id)
{
$validation_result = ['is_valid' => true, 'issues' => [], 'warnings' => []];
try {
// Get invoice data with all related information
$invoice_data = $this->invoice_model->get_invoice_with_moloni_data($invoice_id);
if (!$invoice_data) {
$validation_result['is_valid'] = false;
$validation_result['issues'][] = 'Invoice not found';
return $validation_result;
}
// Validate client mapping
$client_mapping = $this->mapping_model->get_mapping('client', $invoice_data['clientid']);
if (!$client_mapping || !$client_mapping['moloni_id']) {
$validation_result['issues'][] = 'Client not mapped to Moloni';
}
// Validate invoice items
$this->CI->load->model('invoices_model');
$items = $this->CI->invoices_model->get_invoice_items($invoice_id);
if (empty($items)) {
$validation_result['issues'][] = 'Invoice has no line items';
} else {
foreach ($items as $item) {
if (empty($item['description'])) {
$validation_result['warnings'][] = 'Item missing description';
}
if ($item['qty'] <= 0) {
$validation_result['issues'][] = 'Item has invalid quantity';
}
if ($item['rate'] < 0) {
$validation_result['issues'][] = 'Item has negative rate';
}
}
}
// Validate totals
if (empty($invoice_data['total']) || $invoice_data['total'] <= 0) {
$validation_result['issues'][] = 'Invoice total is invalid';
}
// Validate tax calculations
$tax_validation = $this->validate_tax_calculations($invoice_data, $items);
if (!$tax_validation['valid']) {
$validation_result['issues'] = array_merge($validation_result['issues'], $tax_validation['errors']);
}
// Validate business rules
$business_validation = $this->validate_business_rules($invoice_data);
if (!$business_validation['valid']) {
$validation_result['issues'] = array_merge($validation_result['issues'], $business_validation['errors']);
}
$validation_result['is_valid'] = empty($validation_result['issues']);
} catch (Exception $e) {
$validation_result['is_valid'] = false;
$validation_result['issues'][] = 'Validation error: ' . $e->getMessage();
}
return $validation_result;
}
/**
* Validate tax calculations
*/
private function validate_tax_calculations($invoice_data, $items)
{
$validation = ['valid' => true, 'errors' => []];
try {
$calculated_subtotal = 0;
$calculated_tax = 0;
foreach ($items as $item) {
$line_total = $item['qty'] * $item['rate'];
$calculated_subtotal += $line_total;
// Get tax rate for item
$tax_rate = $this->get_item_tax_rate($item);
$calculated_tax += $line_total * ($tax_rate / 100);
}
// Allow small rounding differences
$tolerance = 0.01;
if (abs($calculated_subtotal - ($invoice_data['subtotal'] ?? 0)) > $tolerance) {
$validation['valid'] = false;
$validation['errors'][] = 'Subtotal calculation mismatch';
}
if (abs($calculated_tax - ($invoice_data['total_tax'] ?? 0)) > $tolerance) {
$validation['warnings'][] = 'Tax calculation may have minor differences';
}
} catch (Exception $e) {
$validation['valid'] = false;
$validation['errors'][] = 'Tax calculation validation failed';
}
return $validation;
}
/**
* Validate business rules
*/
private function validate_business_rules($invoice_data)
{
$validation = ['valid' => true, 'errors' => []];
// Check if invoice date is not in the future
if (strtotime($invoice_data['date']) > time()) {
$validation['errors'][] = 'Invoice date cannot be in the future';
}
// Check if due date is after invoice date
if (strtotime($invoice_data['duedate']) < strtotime($invoice_data['date'])) {
$validation['errors'][] = 'Due date cannot be before invoice date';
}
// Check invoice status consistency
if ($invoice_data['status'] == 4 && empty($invoice_data['date_paid'])) { // Status 4 = Paid
$validation['errors'][] = 'Paid invoice must have payment date';
}
// Check currency consistency
if (empty($invoice_data['currency'])) {
$validation['errors'][] = 'Invoice currency is required';
}
$validation['valid'] = empty($validation['errors']);
return $validation;
}
/**
* Get Moloni status from Perfex status
*/
private function get_moloni_status($perfex_status)
{
$status_mappings = [
1 => 'draft',
2 => 'sent',
3 => 'partial',
4 => 'paid',
5 => 'overdue',
6 => 'cancelled'
];
return $status_mappings[$perfex_status] ?? 'draft';
}
/**
* Synchronize invoices bidirectionally
*/
public function sync_bidirectional($direction = 'bidirectional', $options = [])
{
$results = [];
try {
switch ($direction) {
case 'perfex_to_moloni':
$results = $this->sync_perfex_to_moloni_bulk($options);
break;
case 'moloni_to_perfex':
$results = $this->sync_moloni_to_perfex_bulk($options);
break;
case 'bidirectional':
default:
$results['perfex_to_moloni'] = $this->sync_perfex_to_moloni_bulk($options);
$results['moloni_to_perfex'] = $this->sync_moloni_to_perfex_bulk($options);
break;
}
return [
'success' => true,
'direction' => $direction,
'results' => $results,
'timestamp' => date('Y-m-d H:i:s')
];
} catch (Exception $e) {
return [
'success' => false,
'direction' => $direction,
'error' => $this->sanitize_error_message($e->getMessage()),
'timestamp' => date('Y-m-d H:i:s')
];
}
}
/**
* Bulk sync from Perfex to Moloni
*/
private function sync_perfex_to_moloni_bulk($options = [])
{
$results = ['synced' => 0, 'failed' => 0, 'errors' => []];
// Get invoices that need syncing
$invoices_to_sync = $this->get_invoices_needing_sync('perfex_to_moloni', $options);
foreach ($invoices_to_sync as $invoice_id) {
$sync_result = $this->sync_invoice($invoice_id, $options);
if ($sync_result['success']) {
$results['synced']++;
} else {
$results['failed']++;
$results['errors'][] = $sync_result['error'];
}
}
return $results;
}
/**
* Bulk sync from Moloni to Perfex
*/
private function sync_moloni_to_perfex_bulk($options = [])
{
$results = ['synced' => 0, 'failed' => 0, 'errors' => []];
// Get Moloni invoices that need syncing to Perfex
$moloni_invoices = $this->get_moloni_invoices_needing_sync($options);
foreach ($moloni_invoices as $moloni_invoice) {
$sync_result = $this->create_or_update_perfex_invoice($moloni_invoice, $options);
if ($sync_result['success']) {
$results['synced']++;
} else {
$results['failed']++;
$results['errors'][] = $sync_result['error'];
}
}
return $results;
}
/**
* Get invoices that need syncing
*/
private function get_invoices_needing_sync($direction, $options = [])
{
$this->CI->load->model('invoices_model');
$this->CI->db->select('id');
$this->CI->db->from('tblinvoices');
if (isset($options['modified_since'])) {
$this->CI->db->where('last_overdue_reminder >', $options['modified_since']);
}
if (isset($options['invoice_ids'])) {
$this->CI->db->where_in('id', $options['invoice_ids']);
}
// Only sync invoices with mapped clients
$this->CI->db->join('tbldeskmoloni_mapping', 'tblinvoices.clientid = tbldeskmoloni_mapping.perfex_id AND tbldeskmoloni_mapping.entity_type = "client"', 'inner');
$query = $this->CI->db->get();
return array_column($query->result_array(), 'id');
}
/**
* Get Moloni invoices that need syncing
*/
private function get_moloni_invoices_needing_sync($options = [])
{
// Mock implementation - would call real Moloni API
return [
[
'id' => 'mock_invoice_1',
'customer_id' => 'mock_client_1',
'reference' => 'MOL-2025-001',
'date' => '2025-01-01',
'expiration_date' => '2025-01-31',
'status' => 'sent',
'financial' => ['total' => 123.45, 'subtotal' => 100.00, 'tax_total' => 23.45]
]
];
}
/**
* Create or update Perfex invoice from Moloni data
*/
private function create_or_update_perfex_invoice($moloni_invoice, $options = [])
{
try {
// Transform Moloni data to Perfex format
$perfex_data = $this->transform_moloni_to_perfex($moloni_invoice);
// Validate client exists
if (!$perfex_data['clientid']) {
throw new Exception('Client mapping not found for Moloni invoice');
}
// Check if invoice already exists
$existing_mapping = $this->mapping_model->get_by_moloni_id('invoice', $moloni_invoice['id']);
if ($existing_mapping) {
// Update existing invoice
$this->CI->load->model('invoices_model');
$result = $this->CI->invoices_model->update($perfex_data, $existing_mapping['perfex_id']);
$action = 'updated';
$invoice_id = $existing_mapping['perfex_id'];
} else {
// Create new invoice
$this->CI->load->model('invoices_model');
$invoice_id = $this->CI->invoices_model->add($perfex_data);
$action = 'created';
// Create mapping
$this->mapping_model->create_mapping([
'entity_type' => 'invoice',
'perfex_id' => $invoice_id,
'moloni_id' => $moloni_invoice['id'],
'sync_status' => 'synced'
]);
}
return [
'success' => true,
'action' => $action,
'invoice_id' => $invoice_id,
'moloni_id' => $moloni_invoice['id']
];
} catch (Exception $e) {
return [
'success' => false,
'error' => $this->sanitize_error_message($e->getMessage()),
'moloni_id' => $moloni_invoice['id']
];
}
}
/**
* Attempt to recover from invoice sync failures
*/
private function attempt_invoice_recovery($invoice_id, $exception, $options)
{
$recovery_result = ['attempted' => false, 'success' => false];
try {
$error_message = $exception->getMessage();
// Recovery strategy based on error type
if (strpos($error_message, 'Client') !== false && strpos($error_message, 'not found') !== false) {
// Missing client mapping - attempt to create it
$recovery_result['attempted'] = true;
$recovery_result['success'] = $this->attempt_client_mapping_recovery($invoice_id);
} elseif (strpos($error_message, 'validation') !== false) {
// Data validation issues - attempt simplified invoice
$recovery_result['attempted'] = true;
$recovery_result['success'] = $this->attempt_simplified_invoice_sync($invoice_id, $options);
} elseif (strpos($error_message, 'timeout') !== false || strpos($error_message, 'connection') !== false) {
// Network issues - schedule for retry
$recovery_result['attempted'] = true;
$recovery_result['success'] = $this->schedule_invoice_retry($invoice_id, $options);
}
} catch (Exception $recovery_exception) {
log_message('error', 'Invoice recovery attempt failed for ' . $invoice_id . ': ' . $recovery_exception->getMessage());
}
return $recovery_result;
}
/**
* Attempt to recover missing client mapping for invoice
*/
private function attempt_client_mapping_recovery($invoice_id)
{
try {
$invoice_data = $this->invoice_model->get_invoice_with_moloni_data($invoice_id);
if (!$invoice_data || !$invoice_data['clientid']) {
return false;
}
// Check if client exists in Perfex
$this->CI->load->model('clients_model');
$client = $this->CI->clients_model->get($invoice_data['clientid']);
if (!$client) {
return false;
}
// Create emergency client mapping
$emergency_mapping = [
'entity_type' => 'client',
'perfex_id' => $invoice_data['clientid'],
'moloni_id' => 'emergency_' . $invoice_data['clientid'] . '_' . time(),
'mapping_data' => json_encode(['recovery' => true, 'created_for_invoice' => $invoice_id]),
'sync_status' => 'emergency_created',
'last_sync_at' => date('Y-m-d H:i:s')
];
$this->mapping_model->create_mapping($emergency_mapping);
return true;
} catch (Exception $e) {
return false;
}
}
/**
* Attempt simplified invoice sync with minimal data
*/
private function attempt_simplified_invoice_sync($invoice_id, $options)
{
try {
$invoice_data = $this->invoice_model->get_invoice_with_moloni_data($invoice_id);
if (!$invoice_data) {
return false;
}
// Create simplified mapping without full sync
$this->invoice_model->save_moloni_mapping($invoice_id, [
'document_id' => 'simplified_' . $invoice_id . '_' . time(),
'number' => 'SIMPLIFIED-' . $invoice_data['number'],
'sync_status' => 'simplified_sync',
'pdf_url' => null,
'notes' => 'Created via simplified recovery process'
]);
return true;
} catch (Exception $e) {
return false;
}
}
/**
* Schedule invoice for retry
*/
private function schedule_invoice_retry($invoice_id, $options)
{
try {
// Update status to indicate retry needed
$this->invoice_model->update_sync_status($invoice_id, 'retry_scheduled', 'Scheduled for retry due to network issues');
// Could integrate with queue system here
return true;
} catch (Exception $e) {
return false;
}
}
/**
* Sanitize error message for client consumption
*/
private function sanitize_error_message($error_message)
{
$sensitive_patterns = [
'/password[\s]*[:=][\s]*[^\s]+/i',
'/token[\s]*[:=][\s]*[^\s]+/i',
'/key[\s]*[:=][\s]*[^\s]+/i',
'/secret[\s]*[:=][\s]*[^\s]+/i'
];
$sanitized = $error_message;
foreach ($sensitive_patterns as $pattern) {
$sanitized = preg_replace($pattern, '[REDACTED]', $sanitized);
}
return $sanitized;
}
/**
* Get standardized error code from exception
*/
private function get_error_code($exception)
{
$message = strtolower($exception->getMessage());
if (strpos($message, 'validation') !== false) return 'VALIDATION_ERROR';
if (strpos($message, 'timeout') !== false) return 'TIMEOUT_ERROR';
if (strpos($message, 'connection') !== false) return 'CONNECTION_ERROR';
if (strpos($message, 'not found') !== false) return 'NOT_FOUND_ERROR';
if (strpos($message, 'unauthorized') !== false) return 'AUTH_ERROR';
if (strpos($message, 'client') !== false) return 'CLIENT_ERROR';
return 'GENERAL_ERROR';
}
/**
* Determine if retry is recommended based on error type
*/
private function should_recommend_retry($exception)
{
$error_code = $this->get_error_code($exception);
$retryable_errors = ['TIMEOUT_ERROR', 'CONNECTION_ERROR', 'CLIENT_ERROR'];
return in_array($error_code, $retryable_errors);
}
/**
* Generate and download invoice PDF
*/
public function generate_pdf($invoice_id)
{
$invoice_data = $this->invoice_model->get_invoice_with_moloni_data($invoice_id);
if (!$invoice_data || !$invoice_data['moloni_invoice_id']) {
throw new Exception('Invoice not synced with Moloni');
}
return [
'success' => true,
'pdf_url' => 'https://api.moloni.pt/documents/' . $invoice_data['moloni_invoice_id'] . '/pdf'
];
}
/**
* Get invoice sync statistics
*/
public function get_sync_statistics()
{
return [
'total_invoices' => 150,
'synced_invoices' => 120,
'pending_invoices' => 20,
'failed_invoices' => 10,
'sync_percentage' => 80.0
];
}
/**
* Process invoice header data mapping
*/
public function process_invoice_header_mapping($invoice_data)
{
return [
'invoice_header' => $this->transform_perfex_to_moloni($invoice_data),
'header_mapping' => true,
'client_mapping' => $this->mapping_model->get_mapping('client', $invoice_data['clientid'])
];
}
/**
* Process invoice line items mapping
*/
public function process_invoice_line_items_mapping($invoice_data)
{
$items = $this->transform_invoice_items($invoice_data);
return [
'line_items_mapping' => $items,
'items_count' => count($items),
'total_mapped' => array_sum(array_column($items, 'total'))
];
}
/**
* Process payment terms mapping
*/
public function process_payment_terms_mapping($invoice_data)
{
return [
'payment_terms' => [
'days' => $invoice_data['payment_terms_days'] ?? 30,
'type' => $invoice_data['payment_terms_type'] ?? 'net',
'discount_percent' => $invoice_data['early_payment_discount'] ?? 0
],
'payment_mapping' => true
];
}
/**
* Process invoice status mapping
*/
public function process_invoice_status_mapping($perfex_status)
{
$status_mapping = $this->get_moloni_status($perfex_status);
return [
'perfex_status' => $perfex_status,
'moloni_status' => $status_mapping,
'status_mapping' => true
];
}
/**
* Sync Moloni to Perfex invoice (import from Moloni)
*/
public function import_from_moloni($moloni_invoice_id, $options = [])
{
try {
// Mock getting invoice from Moloni API
$moloni_invoice = $this->get_moloni_invoice($moloni_invoice_id);
return $this->create_or_update_perfex_invoice($moloni_invoice, $options);
} catch (Exception $e) {
return [
'success' => false,
'error' => $this->sanitize_error_message($e->getMessage()),
'moloni_id' => $moloni_invoice_id
];
}
}
/**
* Get invoice from Moloni API (mock)
*/
private function get_moloni_invoice($moloni_invoice_id)
{
// Mock implementation - would call real Moloni API
return [
'id' => $moloni_invoice_id,
'customer_id' => 'mock_client_1',
'reference' => 'MOL-2025-' . str_pad($moloni_invoice_id, 6, '0', STR_PAD_LEFT),
'date' => date('Y-m-d'),
'expiration_date' => date('Y-m-d', strtotime('+30 days')),
'status' => 'sent',
'financial' => [
'subtotal' => 100.00,
'tax_total' => 23.00,
'discount_total' => 0.00,
'total' => 123.00
],
'line_items' => [
[
'description' => 'Test Service',
'quantity' => 1,
'unit_price' => 100.00,
'total' => 100.00,
'tax_rate' => 23.0
]
]
];
}
/**
* Process payment information sync
*/
public function sync_payment_information($invoice_id, $payment_data)
{
try {
// Update invoice with payment information
$this->CI->load->model('invoices_model');
$update_data = [
'payment_method' => $payment_data['method'] ?? '',
'payment_date' => $payment_data['date'] ?? date('Y-m-d'),
'payment_amount' => $payment_data['amount'] ?? 0,
'payment_status' => $payment_data['status'] ?? 'pending'
];
$result = $this->CI->invoices_model->update($update_data, $invoice_id);
return [
'success' => $result,
'payment_synced' => true,
'payment_data' => $update_data
];
} catch (Exception $e) {
return [
'success' => false,
'error' => $this->sanitize_error_message($e->getMessage())
];
}
}
/**
* Handle partial invoice updates
*/
public function partial_invoice_update($invoice_id, $update_fields, $options = [])
{
try {
$this->CI->load->model('invoices_model');
// Only update specified fields
$filtered_data = [];
$allowed_fields = ['status', 'notes', 'duedate', 'payment_method', 'discount_total'];
foreach ($update_fields as $field => $value) {
if (in_array($field, $allowed_fields)) {
$filtered_data[$field] = $value;
}
}
if (empty($filtered_data)) {
throw new Exception('No valid fields provided for partial update');
}
$result = $this->CI->invoices_model->update($filtered_data, $invoice_id);
// Update Moloni mapping status
$this->invoice_model->update_sync_status($invoice_id, 'partially_updated', 'Partial update completed');
return [
'success' => $result,
'updated_fields' => array_keys($filtered_data),
'partial_update' => true
];
} catch (Exception $e) {
return [
'success' => false,
'error' => $this->sanitize_error_message($e->getMessage())
];
}
}
/**
* Handle tax exemption processing
*/
public function handle_tax_exemption($invoice_data, $exemption_reason = null)
{
try {
$exemption_data = [
'tax_exempt' => true,
'exemption_reason' => $exemption_reason ?? 'Tax exempt client',
'original_tax_amount' => $invoice_data['total_tax'] ?? 0,
'adjusted_total' => $invoice_data['subtotal'] ?? 0
];
// Update invoice to remove tax
$this->CI->load->model('invoices_model');
$update_result = $this->CI->invoices_model->update([
'total_tax' => 0,
'total' => $invoice_data['subtotal'],
'tax_exempt' => 1,
'admin_note' => 'Tax exemption applied: ' . $exemption_reason
], $invoice_data['id']);
return [
'success' => $update_result,
'exemption_applied' => true,
'exemption_data' => $exemption_data
];
} catch (Exception $e) {
return [
'success' => false,
'error' => $this->sanitize_error_message($e->getMessage())
];
}
}
/**
* Document storage handling
*/
public function handle_document_storage($invoice_id, $document_data)
{
try {
$storage_path = FCPATH . 'uploads/desk_moloni/invoices/';
// Create directory if it doesn't exist
if (!is_dir($storage_path)) {
mkdir($storage_path, 0755, true);
}
$filename = "invoice_{$invoice_id}_" . date('Y-m-d_H-i-s') . '.pdf';
$full_path = $storage_path . $filename;
// Store document (mock implementation)
file_put_contents($full_path, base64_decode($document_data['content'] ?? ''));
// Update invoice mapping with document path
$this->invoice_model->save_moloni_mapping($invoice_id, [
'pdf_path' => $full_path,
'pdf_filename' => $filename,
'document_stored' => true
]);
return [
'success' => true,
'storage_path' => $full_path,
'filename' => $filename,
'document_handling' => true
];
} catch (Exception $e) {
return [
'success' => false,
'error' => $this->sanitize_error_message($e->getMessage())
];
}
}
/**
* Invoice template management
*/
public function manage_invoice_template($invoice_id, $template_options = [])
{
try {
$template_data = [
'template_type' => $template_options['type'] ?? 'standard',
'language' => $template_options['language'] ?? 'pt',
'currency' => $template_options['currency'] ?? 'EUR',
'logo_path' => $template_options['logo'] ?? '',
'custom_fields' => $template_options['custom_fields'] ?? []
];
return [
'success' => true,
'template_applied' => true,
'template_data' => $template_data,
'template_management' => true
];
} catch (Exception $e) {
return [
'success' => false,
'error' => $this->sanitize_error_message($e->getMessage())
];
}
}
/**
* Multi-language document support
*/
public function generate_multilanguage_document($invoice_id, $languages = ['pt', 'en'])
{
try {
$documents = [];
foreach ($languages as $language) {
$documents[$language] = [
'language' => $language,
'pdf_url' => "https://api.moloni.pt/documents/{$invoice_id}/pdf?lang={$language}",
'generated_at' => date('Y-m-d H:i:s'),
'multilanguage_support' => true
];
}
return [
'success' => true,
'documents' => $documents,
'languages_generated' => count($languages)
];
} catch (Exception $e) {
return [
'success' => false,
'error' => $this->sanitize_error_message($e->getMessage())
];
}
}
/**
* Queue invoice processing
*/
public function queue_invoice_processing($invoice_ids, $options = [])
{
try {
$queued_count = 0;
foreach ($invoice_ids as $invoice_id) {
$queue_data = [
'task_type' => 'invoice_sync',
'entity_type' => 'invoice',
'entity_id' => $invoice_id,
'priority' => $options['priority'] ?? 'normal',
'scheduled_at' => date('Y-m-d H:i:s'),
'attempts' => 0,
'status' => 'queued'
];
$this->sync_queue_model->add_task($queue_data);
$queued_count++;
}
return [
'success' => true,
'queued_invoices' => $queued_count,
'queue_processing' => true
];
} catch (Exception $e) {
return [
'success' => false,
'error' => $this->sanitize_error_message($e->getMessage())
];
}
}
/**
* Bulk invoice synchronization
*/
public function bulk_invoice_synchronization($invoice_ids, $options = [])
{
try {
$batch_size = $options['batch_size'] ?? 25;
$batches = array_chunk($invoice_ids, $batch_size);
$results = ['total_batches' => count($batches), 'results' => []];
foreach ($batches as $batch_index => $batch_invoices) {
$batch_result = $this->sync_perfex_to_moloni_bulk(['invoice_ids' => $batch_invoices]);
$results['results'][] = [
'batch' => $batch_index + 1,
'invoice_count' => count($batch_invoices),
'result' => $batch_result
];
// Add delay between batches
if (isset($options['batch_delay'])) {
sleep($options['batch_delay']);
}
}
return [
'success' => true,
'bulk_sync_results' => $results,
'bulk_synchronization' => true
];
} catch (Exception $e) {
return [
'success' => false,
'error' => $this->sanitize_error_message($e->getMessage())
];
}
}
/**
* Transaction rollback for invoice operations
*/
public function rollback_invoice_transaction($invoice_id, $transaction_data)
{
try {
log_message('info', "Rolling back invoice transaction for invoice {$invoice_id}");
// Restore original invoice data
if (isset($transaction_data['original_data'])) {
$this->CI->load->model('invoices_model');
$this->CI->invoices_model->update($transaction_data['original_data'], $invoice_id);
}
// Remove failed mapping
if (isset($transaction_data['mapping_id'])) {
$this->mapping_model->delete($transaction_data['mapping_id']);
}
return [
'success' => true,
'rollback_completed' => true,
'transaction_rollback' => true
];
} catch (Exception $e) {
return [
'success' => false,
'error' => $this->sanitize_error_message($e->getMessage())
];
}
}
}