Settings > eFacture FR. * * @package EFactureFR */ defined( 'ABSPATH' ) || exit; /** * Seller identity and invoicing preferences. */ class EFacture_FR_Settings { const TAB = 'efacture_fr'; /** * VAT regimes. They decide how often B2C sales must be e-reported. * * @return array */ public static function vat_regimes() { return array( 'reel_normal_mensuel' => __( 'Réel normal – monthly VAT return (e-reporting every 10 days)', 'factella' ), 'reel_normal_trimestriel' => __( 'Réel normal – quarterly VAT return (monthly e-reporting)', 'factella' ), 'simplifie' => __( 'Réel simplifié (monthly e-reporting)', 'factella' ), 'franchise_en_base' => __( 'Franchise en base de TVA (e-reporting every two months)', 'factella' ), ); } /** * Hook into WooCommerce settings. */ public static function init() { add_filter( 'woocommerce_settings_tabs_array', array( __CLASS__, 'add_tab' ), 50 ); add_action( 'woocommerce_settings_tabs_' . self::TAB, array( __CLASS__, 'render' ) ); add_action( 'woocommerce_update_options_' . self::TAB, array( __CLASS__, 'save' ) ); add_filter( 'woocommerce_admin_settings_sanitize_option', array( __CLASS__, 'sanitize' ), 10, 3 ); add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_media_picker' ) ); // Custom field types for the SUPER PDP section. add_action( 'woocommerce_admin_field_factella_secret', array( __CLASS__, 'render_secret_field' ) ); add_action( 'woocommerce_admin_field_factella_test_button', array( __CLASS__, 'render_test_button' ) ); add_action( 'wp_ajax_factella_pdp_test', array( __CLASS__, 'ajax_test_connection' ) ); } /** * Secrets are write-only in the UI: an empty password input, never echoing the * stored value. Leaving it blank keeps the saved secret. * * @param array $field Field definition. */ public static function render_secret_field( $field ) { $id = isset( $field['id'] ) ? $field['id'] : ''; $saved = '' !== (string) get_option( $id, '' ); $placeholder = $saved ? __( 'saved — leave blank to keep', 'factella' ) : __( 'Paste your client secret', 'factella' ); ?>

__( 'Permission refusée.', 'factella' ) ), 403 ); } $mode = isset( $_POST['mode'] ) ? sanitize_key( wp_unslash( $_POST['mode'] ) ) : 'sandbox'; $mode = 'production' === $mode ? 'production' : 'sandbox'; $id = isset( $_POST['client_id'] ) ? sanitize_text_field( wp_unslash( $_POST['client_id'] ) ) : ''; $secret = isset( $_POST['client_secret'] ) ? sanitize_text_field( wp_unslash( $_POST['client_secret'] ) ) : ''; // Fall back to the stored values if the fields were left blank. if ( '' === $id ) { $id = (string) get_option( 'efacture_fr_pdp_' . $mode . '_client_id', '' ); } if ( '' === $secret ) { $secret = EFacture_FR_Crypto::decrypt( (string) get_option( 'efacture_fr_pdp_' . $mode . '_client_secret', '' ) ); } $connector = new EFacture_FR_SuperPDP_Connector(); $result = $connector->test_connection( $mode, $id, $secret ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ) ); } if ( ! empty( $result['verified'] ) ) { $company = '' !== $result['company'] ? ' (' . $result['company'] . ')' : ''; /* translators: %s: company name in parentheses, may be empty */ $message = sprintf( __( 'Connected. Company verified%s.', 'factella' ), $company ); } else { /* translators: %s: verification status */ $message = sprintf( __( 'Connected, but your company is not verified yet (status: %s). Real transmission needs a verified company.', 'factella' ), '' !== $result['status'] ? $result['status'] : '—' ); } // Remember the outcome for the readiness check (no live API call on page load). update_option( 'efacture_fr_pdp_last_test', array( 'mode' => $mode, 'verified' => ! empty( $result['verified'] ), 'status' => $result['status'], 'time' => time(), ), false ); wp_send_json_success( array( 'message' => $message, 'verified' => ! empty( $result['verified'] ) ) ); } /** * Enqueue the media library picker for the logo field — only on our own settings tab. * * @param string $hook Current admin page hook. */ public static function enqueue_media_picker( $hook ) { if ( 'woocommerce_page_wc-settings' !== $hook ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- reading the tab for a conditional enqueue only. $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : ''; if ( self::TAB !== $tab ) { return; } wp_enqueue_media(); $choose = esc_js( __( 'Select image', 'factella' ) ); $title = esc_js( __( 'Choose your logo', 'factella' ) ); $js = "jQuery(function($){var i=$('#efacture_fr_logo');if(!i.length)return;" . "var b=$('');i.after(b);" . "b.on('click',function(e){e.preventDefault();var f=wp.media({title:'$title',button:{text:'$choose'},multiple:false,library:{type:'image'}});" . "f.on('select',function(){var a=f.state().get('selection').first().toJSON();i.val(a.url).trigger('change');});f.open();});});"; wp_add_inline_script( 'jquery-core', $js ); // SUPER PDP "test connection" button. $net = esc_js( __( 'Network error.', 'factella' ) ); $wait = esc_js( __( 'Testing…', 'factella' ) ); $test = "jQuery(function($){var b=$('#factella-pdp-test');if(!b.length)return;" . "b.on('click',function(){var m=$('#efacture_fr_pdp_mode').val()||'sandbox';" . "var id=$('#efacture_fr_pdp_'+m+'_client_id').val();var s=$('#efacture_fr_pdp_'+m+'_client_secret').val();" . "var r=$('#factella-pdp-test-result');r.css('color','#646970').text('$wait');" . "$.post(ajaxurl,{action:'factella_pdp_test',nonce:b.data('nonce'),mode:m,client_id:id,client_secret:s},function(res){" . "if(res&&res.success){r.css('color',res.data.verified?'#1a7f37':'#996800').text(res.data.message);}" . "else{r.css('color','#d63638').text(res&&res.data&&res.data.message?res.data.message:'$net');}})" . ".fail(function(){r.css('color','#d63638').text('$net');});});});"; wp_add_inline_script( 'jquery-core', $test ); } /** * Add the tab. * * @param array $tabs Existing tabs. * @return array */ public static function add_tab( $tabs ) { $tabs[ self::TAB ] = __( 'Factella', 'factella' ); return $tabs; } /** * Field definitions in the WooCommerce settings API format. * * @return array */ public static function fields() { return array( array( 'title' => __( 'Seller identity', 'factella' ), 'type' => 'title', 'desc' => __( 'These details appear on every invoice and identify your business to the tax administration.', 'factella' ), 'id' => 'efacture_fr_seller', ), array( 'title' => __( 'Legal name', 'factella' ), 'id' => 'efacture_fr_seller_name', 'type' => 'text', ), array( 'title' => __( 'SIREN', 'factella' ), 'id' => 'efacture_fr_seller_siren', 'type' => 'text', 'desc_tip' => __( '9-digit company number.', 'factella' ), ), array( 'title' => __( 'VAT number', 'factella' ), 'id' => 'efacture_fr_seller_vat', 'type' => 'text', 'desc_tip' => __( 'Leave empty if you are under the franchise en base de TVA.', 'factella' ), ), array( 'title' => __( 'VAT regime', 'factella' ), 'id' => 'efacture_fr_vat_regime', 'type' => 'select', 'options' => self::vat_regimes(), 'default' => 'reel_normal_trimestriel', ), array( 'type' => 'sectionend', 'id' => 'efacture_fr_seller', ), array( 'title' => __( 'Bank details', 'factella' ), 'type' => 'title', 'desc' => __( 'Shown on invoices so customers can pay by bank transfer. The bank account is a mandatory invoice mention when you accept transfers.', 'factella' ), 'id' => 'efacture_fr_bank', ), array( 'title' => __( 'IBAN', 'factella' ), 'id' => 'efacture_fr_iban', 'type' => 'text', 'desc_tip' => __( 'International bank account number for incoming transfers.', 'factella' ), ), array( 'title' => __( 'BIC / SWIFT', 'factella' ), 'id' => 'efacture_fr_bic', 'type' => 'text', 'desc_tip' => __( '8 or 11 characters.', 'factella' ), ), array( 'type' => 'sectionend', 'id' => 'efacture_fr_bank', ), array( 'title' => __( 'Invoice appearance', 'factella' ), 'type' => 'title', 'desc' => __( 'Your logo and colour on the human-readable PDF invoice.', 'factella' ), 'id' => 'efacture_fr_appearance', ), array( 'title' => __( 'Logo URL', 'factella' ), 'id' => 'efacture_fr_logo', 'type' => 'text', 'desc_tip' => __( 'Full URL to your logo image (PNG or JPG). Leave empty for no logo.', 'factella' ), ), array( 'title' => __( 'Accent colour', 'factella' ), 'id' => 'efacture_fr_color', 'type' => 'color', 'default' => '#2a5bd0', ), array( 'type' => 'sectionend', 'id' => 'efacture_fr_appearance', ), array( 'title' => __( 'Invoicing', 'factella' ), 'type' => 'title', 'id' => 'efacture_fr_invoicing', ), array( 'title' => __( 'Invoice number prefix', 'factella' ), 'id' => 'efacture_fr_invoice_prefix', 'type' => 'text', 'default' => 'F', 'desc_tip' => __( 'Up to 6 letters, digits or dashes. Numbers look like F2027-000001.', 'factella' ), ), array( 'title' => __( 'Create the invoice when the order is', 'factella' ), 'id' => 'efacture_fr_trigger_status', 'type' => 'select', 'options' => array( 'processing' => __( 'Processing (payment received)', 'factella' ), 'completed' => __( 'Completed (shipped)', 'factella' ), ), 'default' => 'processing', ), array( 'title' => __( 'What do you sell?', 'factella' ), 'id' => 'efacture_fr_billing_mode', 'type' => 'select', 'options' => array( 'B1' => __( 'Goods (physical products)', 'factella' ), 'S1' => __( 'Services', 'factella' ), 'M1' => __( 'Mixed (goods and services)', 'factella' ), ), 'default' => 'B1', 'desc_tip' => __( 'Sets the French billing framework (BT-23) required from 2026.', 'factella' ), ), array( 'title' => __( 'Next invoice number', 'factella' ), 'id' => 'efacture_fr_start_number', 'type' => 'number', 'desc_tip' => __( 'Migrating from another system? Enter the sequence number for your next invoice (e.g. 457). Applied only if higher than the current value, so numbers are never reused. Leave empty to keep counting.', 'factella' ), 'custom_attributes' => array( 'min' => '1', 'step' => '1', ), ), array( 'type' => 'sectionend', 'id' => 'efacture_fr_invoicing', ), array( 'title' => __( 'SUPER PDP transmission', 'factella' ), 'type' => 'title', 'desc' => __( 'Send your B2B invoices to the SUPER PDP platform with your own SUPER PDP account. Create an OAuth2 application (client_credentials) in your SUPER PDP console and paste its client ID and secret below. B2C sales are never transmitted — they are declared through e-reporting.', 'factella' ), 'id' => 'efacture_fr_pdp', ), array( 'title' => __( 'Environment', 'factella' ), 'id' => 'efacture_fr_pdp_mode', 'type' => 'select', 'options' => array( 'sandbox' => __( 'Sandbox (testing)', 'factella' ), 'production' => __( 'Production (real invoices)', 'factella' ), ), 'default' => 'sandbox', 'desc_tip' => __( 'SUPER PDP uses the same server for both; the environment is decided by which credentials are used.', 'factella' ), ), array( 'title' => __( 'Automatic sending', 'factella' ), 'desc' => __( 'Send B2B / B2BInt invoices and credit notes to SUPER PDP automatically when they are created.', 'factella' ), 'id' => 'efacture_fr_pdp_auto_send', 'type' => 'checkbox', 'default' => 'no', ), array( 'title' => __( 'Sandbox client ID', 'factella' ), 'id' => 'efacture_fr_pdp_sandbox_client_id', 'type' => 'text', 'class' => 'regular-text', ), array( 'title' => __( 'Sandbox client secret', 'factella' ), 'id' => 'efacture_fr_pdp_sandbox_client_secret', 'type' => 'factella_secret', ), array( 'title' => __( 'Production client ID', 'factella' ), 'id' => 'efacture_fr_pdp_production_client_id', 'type' => 'text', 'class' => 'regular-text', ), array( 'title' => __( 'Production client secret', 'factella' ), 'id' => 'efacture_fr_pdp_production_client_secret', 'type' => 'factella_secret', ), array( 'type' => 'factella_test_button', 'id' => 'efacture_fr_pdp_test', ), array( 'type' => 'sectionend', 'id' => 'efacture_fr_pdp', ), ); } /** * Render the tab. */ public static function render() { self::render_readiness(); woocommerce_admin_fields( self::fields() ); } /** * Readiness check shown at the top of our settings tab (green/red list). * Only rendered here — never as a dashboard notice. */ public static function render_readiness() { $name = self::get( 'seller_name' ); $siren = self::get( 'seller_siren' ); $vat = self::get( 'seller_vat' ); $regime = self::get( 'vat_regime' ); $franchise = 'franchise_en_base' === $regime || '' === $vat; $iban = self::get( 'iban' ); $store = (string) get_option( 'woocommerce_store_address', '' ); global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-off readiness count. $rate_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}woocommerce_tax_rates" ); // Among the most recent orders, how many of those that have an invoice are missing their PDF. $recent = wc_get_orders( array( 'limit' => 40, 'orderby' => 'date', 'order' => 'DESC', 'return' => 'ids' ) ); $checked = 0; $missing = 0; foreach ( $recent as $id ) { $order = wc_get_order( $id ); if ( ! $order || '' === (string) $order->get_meta( EFacture_FR_Invoice_Manager::META_NUMBER ) ) { continue; } ++$checked; $pdf = (string) $order->get_meta( EFacture_FR_Facturx::META_PDF ); if ( '' === $pdf || ! file_exists( EFacture_FR_Facturx::storage_dir() . $pdf ) ) { ++$missing; } if ( $checked >= 10 ) { break; } } $items = array(); $items[] = array( '' !== $name, __( 'Seller legal name is set', 'factella' ) ); $items[] = array( '' !== $siren && EFacture_FR_Identifiers::is_valid_siren( $siren ), __( 'SIREN is set and valid', 'factella' ) ); $items[] = array( $franchise || '' !== $vat, __( 'VAT number is set (or franchise en base)', 'factella' ) ); $items[] = array( '' !== $store, __( 'Store address is set in WooCommerce', 'factella' ) ); $items[] = array( '' !== $iban, __( 'IBAN is set (for bank transfer)', 'factella' ) ); $items[] = array( $franchise || $rate_count > 0, __( 'WooCommerce VAT rates are defined', 'factella' ) ); if ( $checked > 0 ) { /* translators: %d: number of recent invoices checked */ $items[] = array( 0 === $missing, sprintf( __( 'The last %d invoices all have a Factur-X PDF', 'factella' ), $checked ) ); } // SUPER PDP transmission readiness. $connector = class_exists( 'EFacture_FR_Connectors' ) ? EFacture_FR_Connectors::active() : null; $pdp_configured = $connector && $connector->is_configured(); $items[] = array( $pdp_configured, __( 'SUPER PDP is connected', 'factella' ) ); if ( $pdp_configured ) { $mode = (string) get_option( 'efacture_fr_pdp_mode', 'sandbox' ); $last = get_option( 'efacture_fr_pdp_last_test' ); $verified = is_array( $last ) && ! empty( $last['verified'] ) && isset( $last['mode'] ) && $last['mode'] === $mode; $items[] = array( $verified, __( 'SUPER PDP company is verified (use “Test connection”)', 'factella' ) ); if ( 'sandbox' === $mode ) { $items[] = array( 'warn', __( 'Sandbox mode — no real invoice is sent until you switch to Production', 'factella' ) ); } } $alert = get_option( 'factella_rounding_alert' ); if ( is_array( $alert ) && isset( $alert['order'] ) ) { /* translators: 1: order id, 2: amount */ $items[] = array( false, sprintf( __( 'Order #%1$d had a total mismatch of %2$s — please check it', 'factella' ), (int) $alert['order'], number_format( (float) $alert['delta'], 2 ) ) ); } echo '
'; echo '

' . esc_html__( 'Readiness check', 'factella' ) . '

'; } /** * Save the tab. */ public static function save() { woocommerce_update_options( self::fields() ); // "Next invoice number" is a one-time action, not a stored setting: apply it to // the sequence (only if it moves forward) and then clear the field. $start = (int) get_option( 'efacture_fr_start_number', 0 ); if ( $start > 0 ) { if ( EFacture_FR_Invoice_Numbering::set_next( $start ) ) { /* translators: %d: invoice sequence number */ WC_Admin_Settings::add_message( sprintf( __( 'The next invoice will be numbered %d.', 'factella' ), $start ) ); } else { WC_Admin_Settings::add_error( __( 'The next invoice number must be higher than the current one, so existing numbers are never reused.', 'factella' ) ); } } delete_option( 'efacture_fr_start_number' ); } /** * Validate values before WooCommerce stores them. * * @param mixed $value Sanitized value. * @param array $option Field definition. * @param mixed $raw_value Raw value. * @return mixed */ public static function sanitize( $value, $option, $raw_value ) { $id = isset( $option['id'] ) ? $option['id'] : ''; // SUPER PDP secrets: encrypt before storing; blank keeps the saved value. if ( 'efacture_fr_pdp_sandbox_client_secret' === $id || 'efacture_fr_pdp_production_client_secret' === $id ) { $raw = is_string( $raw_value ) ? trim( $raw_value ) : ''; if ( '' === $raw ) { return (string) get_option( $id, '' ); // Keep the existing secret. } return EFacture_FR_Crypto::encrypt( $raw ); } if ( 'efacture_fr_seller_siren' === $id && '' !== $value ) { $value = EFacture_FR_Identifiers::normalize( $value ); if ( ! EFacture_FR_Identifiers::is_valid_siren( $value ) ) { WC_Admin_Settings::add_error( __( 'The SIREN is not valid. Please check the 9 digits.', 'factella' ) ); return get_option( $id, '' ); } } if ( 'efacture_fr_seller_vat' === $id && '' !== $value ) { $value = EFacture_FR_Identifiers::normalize( $value ); if ( ! EFacture_FR_Identifiers::is_valid_fr_vat( $value ) ) { WC_Admin_Settings::add_error( __( 'The VAT number is not a valid French VAT number.', 'factella' ) ); return get_option( $id, '' ); } } if ( 'efacture_fr_iban' === $id && '' !== $value ) { $value = strtoupper( preg_replace( '/\s+/', '', (string) $value ) ); if ( ! preg_match( '/^[A-Z]{2}[0-9A-Z]{13,32}$/', $value ) ) { WC_Admin_Settings::add_error( __( 'The IBAN does not look valid.', 'factella' ) ); return get_option( $id, '' ); } } if ( 'efacture_fr_bic' === $id && '' !== $value ) { $value = strtoupper( preg_replace( '/\s+/', '', (string) $value ) ); if ( ! preg_match( '/^[A-Z0-9]{8}([A-Z0-9]{3})?$/', $value ) ) { WC_Admin_Settings::add_error( __( 'The BIC / SWIFT code must be 8 or 11 characters.', 'factella' ) ); return get_option( $id, '' ); } } if ( 'efacture_fr_invoice_prefix' === $id ) { $value = strtoupper( (string) $value ); if ( ! preg_match( '/^[A-Z0-9\-]{1,6}$/', $value ) ) { WC_Admin_Settings::add_error( __( 'The invoice prefix must be 1 to 6 letters, digits or dashes.', 'factella' ) ); return get_option( $id, 'F' ); } } return $value; } /** * Read a setting with its default. * * @param string $key Option name without the efacture_fr_ prefix. * @return string */ public static function get( $key ) { $defaults = array( 'invoice_prefix' => 'F', 'trigger_status' => 'processing', 'vat_regime' => 'reel_normal_trimestriel', 'billing_mode' => 'B1', 'color' => '#2a5bd0', ); $default = isset( $defaults[ $key ] ) ? $defaults[ $key ] : ''; return (string) get_option( 'efacture_fr_' . $key, $default ); } }