store = $store;
$this->client = $client;
add_filter( 'woocommerce_get_sections_integration', array( $this, 'section' ) );
add_action( 'admin_menu', array( $this, 'menu' ) );
add_action( 'admin_post_miplata_pair', array( $this, 'handle_pair' ) );
add_action( 'admin_post_miplata_disconnect', array( $this, 'handle_disconnect' ) );
add_action( 'admin_post_miplata_save', array( $this, 'handle_save' ) );
add_action( 'admin_post_miplata_resend', array( $this, 'handle_resend' ) );
add_action( 'admin_notices', array( $this, 'notices' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'assets' ) );
add_filter( 'plugin_action_links_' . MIPLATA_BASENAME, array( $this, 'action_links' ) );
add_filter( 'manage_woocommerce_page_wc-orders_columns', array( $this, 'order_column' ), 20 );
add_action( 'manage_woocommerce_page_wc-orders_custom_column', array( $this, 'order_column_value' ), 20, 2 );
add_filter( 'manage_edit-shop_order_columns', array( $this, 'order_column' ), 20 );
add_action( 'manage_shop_order_posts_custom_column', array( $this, 'order_column_value_legacy' ), 20, 2 );
add_action( 'add_meta_boxes', array( $this, 'order_box' ) );
}
public function section( array $sections ): array {
return $sections;
}
public function menu(): void {
add_submenu_page( 'woocommerce', __( 'miPlata FACTURAR', 'miplata' ), __( 'miPlata', 'miplata' ), 'manage_woocommerce', self::PAGE, array( $this, 'render' ) );
}
public function action_links( array $links ): array {
array_unshift( $links, '' . esc_html__( 'Ajustes', 'miplata' ) . ' ' );
return $links;
}
public function assets( string $hook ): void {
if ( false !== strpos( $hook, self::PAGE ) ) {
wp_enqueue_style( 'miplata-admin', MIPLATA_URL . 'assets/css/admin.css', array(), MIPLATA_VERSION );
}
}
private function url( array $args = array() ): string {
return add_query_arg( array_merge( array( 'page' => self::PAGE ), $args ), admin_url( 'admin.php' ) );
}
/* ─────────────── notices ─────────────── */
/** Notices only on our page, the Plugins list and WooCommerce order screens — never site-wide. */
private function notice_screen(): bool {
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
if ( ! $screen ) {
return false;
}
return false !== strpos( (string) $screen->id, self::PAGE )
|| in_array( $screen->id, array( 'plugins', 'woocommerce_page_wc-orders', 'edit-shop_order' ), true );
}
public function notices(): void {
if ( ! current_user_can( 'manage_woocommerce' ) || ! $this->notice_screen() ) {
return;
}
$outdated = get_option( 'miplata_plugin_outdated', '' );
if ( '' !== $outdated ) {
echo '
' . sprintf( /* translators: %s: version */ esc_html__( 'miPlata: esta versión del plugin ya no es aceptada (mínimo %s). Actualícelo para seguir facturando.', 'miplata' ), esc_html( $outdated ) ) . '
';
}
$conn = $this->store->connection();
if ( ! empty( $conn['rejected'] ) ) {
echo '';
}
if ( ! MiPlata_Crypto::sodium_available() ) {
echo '' . esc_html__( 'miPlata requiere la extensión sodium de PHP (incluida desde PHP 7.2). Pida a su proveedor de hosting que la active.', 'miplata' ) . '
';
}
if ( ! is_ssl() && ! ( isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) && 'https' === $_SERVER['HTTP_X_FORWARDED_PROTO'] ) ) { // phpcs:ignore
echo '' . esc_html__( 'miPlata: la tienda debe servirse por HTTPS para conectarse.', 'miplata' ) . '
';
}
}
/* ─────────────── page ─────────────── */
public function render(): void {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'Sin permiso.', 'miplata' ) );
}
$conn = $this->store->connection();
$connected = $this->client->connected();
$caps = $connected ? $this->client->capabilities() : null;
$s = $this->store->settings();
$msg = isset( $_GET['miplata_msg'] ) ? sanitize_key( wp_unslash( $_GET['miplata_msg'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$err = isset( $_GET['miplata_err'] ) ? sanitize_text_field( wp_unslash( $_GET['miplata_err'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
echo '' . esc_html__( 'miPlata FACTURAR', 'miplata' ) . ' ';
if ( '' !== $err ) {
echo '
';
}
if ( 'paired' === $msg ) {
echo '
' . esc_html__( 'Tienda conectada. Revise el correo que miPlata envió a los administradores de su cuenta.', 'miplata' ) . '
';
} elseif ( 'saved' === $msg ) {
echo '
' . esc_html__( 'Ajustes guardados.', 'miplata' ) . '
';
} elseif ( 'disconnected' === $msg ) {
echo '
' . esc_html__( 'Tienda desconectada. Las claves se eliminaron de este sitio.', 'miplata' ) . '
';
}
if ( ! $connected ) {
$this->render_pairing();
} else {
$this->render_status( $conn, $caps );
$this->render_settings( $s, $caps );
}
echo '
';
}
private function render_pairing(): void {
$site = MiPlata_Protocol::normalize_site_host( (string) wp_parse_url( home_url( '/' ), PHP_URL_HOST ) );
echo '' . esc_html__( 'Conectar con miPlata', 'miplata' ) . ' ';
echo '
';
echo '' . wp_kses_post( __( 'En miPlata vaya a Configuración → Integraciones → Conectar tienda → WooCommerce , escriba el dominio de esta tienda y genere el código.', 'miplata' ) ) . ' ';
echo '' . esc_html__( 'Pegue el código aquí en los próximos 10 minutos. Solo un administrador de miPlata puede generarlo; miPlata nunca se lo pedirá por correo ni por teléfono.', 'miplata' ) . ' ';
echo ' ';
if ( '' === $site ) {
echo '
' . esc_html__( 'Este sitio no tiene un dominio público válido (HTTPS, sin IP ni puerto). La conexión no es posible desde aquí.', 'miplata' ) . '
';
}
echo '
';
if ( ! $this->store->uses_configured_key() ) {
echo '
' . wp_kses_post( __( 'Recomendado: defina MIPLATA_ENCRYPTION_KEY (32 bytes aleatorios en hex) en wp-config.php antes de conectar. Sin ella, la clave de cifrado se deriva de las sales de WordPress y cambiarlas obligará a reconectar.', 'miplata' ) ) . '
';
}
echo '
';
}
private function render_status( array $conn, ?array $caps ): void {
$health = $caps['health'] ?? null;
$wava_pl = MiPlata_Plugin::wava_plugin_active();
echo '' . esc_html__( 'Conexión', 'miplata' ) . ' ';
echo '' . esc_html__( 'Nombre en miPlata', 'miplata' ) . ' ' . esc_html( (string) ( $conn['name'] ?? '' ) ) . ' ';
echo '' . esc_html__( 'Cuenta', 'miplata' ) . ' ' . esc_html( (string) ( $conn['host'] ?? '' ) ) . ' ';
echo '' . esc_html__( 'Permisos', 'miplata' ) . ' ' . esc_html( (string) ( $conn['preset'] ?? '' ) ) . ' ';
echo '' . esc_html__( 'Huella de clave', 'miplata' ) . ' ' . esc_html( $this->store->key_fingerprint() ) . ' ';
echo '' . esc_html__( 'Conectada el', 'miplata' ) . ' ' . esc_html( ! empty( $conn['paired_at'] ) ? wp_date( 'Y-m-d H:i', (int) $conn['paired_at'] ) : '—' ) . ' ';
if ( null === $caps ) {
echo '' . esc_html__( 'Estado', 'miplata' ) . ' ' . esc_html__( 'No se pudo consultar miPlata (ver registro).', 'miplata' ) . ' ';
} else {
$ok = ! empty( $health['ok'] );
echo '' . esc_html__( 'Listo para facturar', 'miplata' ) . ' ' . ( $ok ? esc_html__( 'Sí', 'miplata' ) : esc_html( (string) ( $health['range']['warning'] ?? $health['certificate']['warning'] ?? __( 'No', 'miplata' ) ) ) ) . ' ';
echo '' . esc_html__( 'Ambiente DIAN', 'miplata' ) . ' ' . esc_html( (string) ( $health['environment'] ?? '' ) ) . ( ! empty( $caps['settings']['test_mode'] ) ? ' · ' . esc_html__( 'Modo prueba', 'miplata' ) . ' ' : '' ) . ' ';
if ( ! empty( $health['range']['left'] ) && (int) $health['range']['left'] < 100 ) {
echo '' . sprintf( /* translators: %d: numbers left */ esc_html__( 'Quedan %d números en el rango de facturación.', 'miplata' ), (int) $health['range']['left'] ) . ' ';
}
echo '' . esc_html__( 'Pagos WAVA', 'miplata' ) . ' ';
if ( ! empty( $health['wava_verified'] ) ) {
echo '' . esc_html__( 'Pagos WAVA verificados ✓', 'miplata' ) . ' ';
} elseif ( ! empty( $health['wava_link'] ) ) {
echo esc_html__( 'Cuenta WAVA vinculada; se verificará con el primer pago.', 'miplata' );
} else {
echo esc_html__( 'Sin cuenta WAVA vinculada en miPlata (los pedidos pagados con WAVA quedarán sin verificar).', 'miplata' );
}
echo $wava_pl ? ' · ' . esc_html__( 'Plugin de WAVA activo', 'miplata' ) : '';
echo ' ';
}
$lh = get_option( 'miplata_last_health', array() );
if ( is_array( $lh ) && ! empty( $lh['at'] ) ) {
echo '' . esc_html__( 'Diagnóstico', 'miplata' ) . ' ' . esc_html( sprintf( 'cron %s · smtp %s · %s', empty( $lh['cron_ok'] ) ? '✗' : '✓', empty( $lh['smtp_ok'] ) ? '?' : '✓', wp_date( 'Y-m-d H:i', (int) $lh['at'] ) ) ) . ' ';
}
echo ' ';
if ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON && ! wp_next_scheduled( 'miplata_poll' ) ) {
echo '
' . esc_html__( 'WP-Cron está desactivado: asegúrese de que un cron del sistema llame a wp-cron.php, o las facturas tardarán en reflejarse.', 'miplata' ) . '
';
}
echo '
';
echo '
';
}
private function render_settings( array $s, ?array $caps ): void {
$classes = array( '' => __( 'Estándar', 'miplata' ) );
if ( class_exists( 'WC_Tax' ) ) {
foreach ( WC_Tax::get_tax_classes() as $name ) {
$classes[ sanitize_title( $name ) ] = $name;
}
}
$treatments = array(
'iva19' => 'IVA 19 %',
'iva5' => 'IVA 5 %',
'iva0' => 'IVA 0 %',
'exento' => __( 'Exento de IVA', 'miplata' ),
'excluido' => __( 'Excluido de IVA', 'miplata' ),
'inc8' => 'INC 8 %',
'inc4' => 'INC 4 %',
'native' => __( 'Según la tasa del pedido', 'miplata' ),
);
$mode_b_ok = in_array( $this->store->preset(), array( 'pay_invoice', 'pay_only' ), true );
echo '';
}
private function row_checkbox( string $k, string $label, $val, string $desc = '' ): void {
echo '' . esc_html( $label ) . ' ';
if ( '' !== $desc ) {
echo ' ' . esc_html( $desc ) . ' ';
}
echo ' ';
}
private function row_select( string $k, string $label, $val, array $opts, string $desc = '' ): void {
echo '' . esc_html( $label ) . ' ';
foreach ( $opts as $ok => $ov ) {
echo '' . esc_html( $ov ) . ' ';
}
echo ' ';
if ( '' !== $desc ) {
echo '' . esc_html( $desc ) . '
';
}
echo ' ';
}
/* ─────────────── handlers ─────────────── */
private function back( array $args ): void {
wp_safe_redirect( $this->url( $args ) );
exit;
}
public function handle_pair(): void {
check_admin_referer( 'miplata_pair' );
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'Sin permiso.', 'miplata' ) );
}
if ( ! MiPlata_Crypto::sodium_available() ) {
$this->back( array( 'miplata_err' => __( 'Falta la extensión sodium de PHP.', 'miplata' ) ) );
}
$code = MiPlata_Protocol::normalize_pairing_code( isset( $_POST['code'] ) ? sanitize_text_field( wp_unslash( $_POST['code'] ) ) : '' );
if ( '' === $code ) {
$this->back( array( 'miplata_err' => __( 'El código no tiene el formato MPC-cuenta-XXXX-XXXX-XXXX.', 'miplata' ) ) );
}
$host = MiPlata_Protocol::host_for_code( $code, MIPLATA_ROOT_DOMAIN );
$site = MiPlata_Protocol::normalize_site_host( (string) wp_parse_url( home_url( '/' ), PHP_URL_HOST ) );
if ( '' === $host || '' === $site ) {
$this->back( array( 'miplata_err' => __( 'No se pudo determinar la cuenta de miPlata o el dominio de la tienda.', 'miplata' ) ) );
}
$kp = $this->store->begin_pairing();
// Make sure our ownership endpoint is reachable before asking miPlata to fetch it.
flush_rewrite_rules( false );
$body = array(
'code' => $code,
'public_key' => $kp['public_key'],
'plugin_version' => MIPLATA_VERSION,
'site_host' => $site,
'proof' => MiPlata_Protocol::redeem_proof( $kp['seed'], $code, $site ),
);
$r = MiPlata_Client::redeem( $host, $body );
if ( ! $r['ok'] ) {
delete_option( MiPlata_Store::OPT_PENDING );
$code_err = (string) ( $r['body']['code'] ?? '' );
$hint = array(
'ownership_failed' => __( 'miPlata no pudo leer /wp-json/miplata/v1/connect-key en su dominio. Revise que el sitio sea público por HTTPS y que un firewall no bloquee el agente "miPlata-Connect".', 'miplata' ),
'ownership_key_mismatch' => __( 'Otra instalación responde por este dominio (¿caché o un sitio de staging con el mismo dominio?).', 'miplata' ),
'site_mismatch' => __( 'El dominio escrito en miPlata no coincide con el de esta tienda.', 'miplata' ),
'code_unknown' => __( 'Código desconocido, usado o vencido. Genere uno nuevo en miPlata.', 'miplata' ),
'code_burned' => __( 'Demasiados intentos con este código. Genere uno nuevo.', 'miplata' ),
'plugin_outdated' => __( 'Actualice el plugin de miPlata.', 'miplata' ),
'too_many_attempts' => __( 'Demasiados intentos desde esta IP; espere unos minutos.', 'miplata' ),
'unreachable' => __( 'No se pudo contactar a miPlata desde el servidor de la tienda (salida HTTPS bloqueada).', 'miplata' ),
);
MiPlata_Log::warn( 'pair_failed', array( 'status' => $r['status'], 'code' => $code_err ) );
$this->back( array( 'miplata_err' => $hint[ $code_err ] ?? ( (string) ( $r['body']['message'] ?? __( 'Error al conectar.', 'miplata' ) ) . ' (' . $r['status'] . ')' ) ) );
}
$this->store->finish_pairing( $kp, $r['body'] );
MiPlata_Plugin::schedule_cron();
MiPlata_Log::info( 'paired', array( 'connection' => $r['body']['connection_id'] ?? '' ) );
$this->client->capabilities( true );
$this->back( array( 'miplata_msg' => 'paired' ) );
}
public function handle_disconnect(): void {
check_admin_referer( 'miplata_disconnect' );
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'Sin permiso.', 'miplata' ) );
}
if ( $this->client->connected() ) {
$this->client->revoke_self();
}
$this->store->wipe();
MiPlata_Plugin::unschedule_cron();
$this->back( array( 'miplata_msg' => 'disconnected' ) );
}
public function handle_save(): void {
check_admin_referer( 'miplata_save' );
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'Sin permiso.', 'miplata' ) );
}
$p = wp_unslash( $_POST ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$map = array();
foreach ( (array) ( $p['tax_map'] ?? array() ) as $slug => $treat ) {
$slug = sanitize_title( (string) $slug );
$treat = sanitize_key( (string) $treat );
if ( isset( MiPlata_Mapper::TAX_TREATMENTS[ $treat ] ) || 'native' === $treat ) {
$map[ $slug ] = $treat;
}
}
$this->store->save_settings(
array(
'enabled' => isset( $p['enabled'] ) ? 'yes' : 'no',
'mode_b' => isset( $p['mode_b'] ) ? 'yes' : 'no',
'delivery' => in_array( $p['delivery'] ?? '', array( 'shop', 'miplata', 'both' ), true ) ? $p['delivery'] : 'shop',
'doc_fields' => in_array( $p['doc_fields'] ?? '', array( 'auto', 'ours', 'none' ), true ) ? $p['doc_fields'] : 'auto',
'doc_required' => isset( $p['doc_required'] ) ? 'yes' : 'no',
'city_dropdown' => isset( $p['city_dropdown'] ) ? 'yes' : 'no',
'refund_credit_note' => isset( $p['refund_credit_note'] ) ? 'yes' : 'no',
'invoice_type' => in_array( $p['invoice_type'] ?? '', array( '01', '02' ), true ) ? $p['invoice_type'] : '01',
'tax_map' => $map,
)
);
$this->back( array( 'miplata_msg' => 'saved' ) );
}
public function handle_resend(): void {
check_admin_referer( 'miplata_resend' );
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'Sin permiso.', 'miplata' ) );
}
$order_id = isset( $_GET['order'] ) ? absint( wp_unslash( $_GET['order'] ) ) : 0;
$order = $order_id ? wc_get_order( $order_id ) : null;
if ( $order ) {
$chk = (string) $order->get_meta( MiPlata_Orders::META_CHECKOUT, true );
if ( '' === $chk ) {
miplata()->orders->create_for_order( $order_id );
} else {
$order->delete_meta_data( MiPlata_Orders::META_MAIL );
$order->save();
miplata()->orders->refresh( $order, $chk );
if ( '' !== (string) $order->get_meta( MiPlata_Orders::META_PDF, true ) ) {
miplata()->orders->send_mail( $order );
}
}
}
$back = wp_get_referer();
wp_safe_redirect( $back ? $back : admin_url( 'admin.php?page=wc-orders' ) );
exit;
}
/* ─────────────── order list / order box ─────────────── */
public function order_column( array $cols ): array {
$out = array();
foreach ( $cols as $k => $v ) {
$out[ $k ] = $v;
if ( 'order_status' === $k ) {
$out['miplata'] = __( 'Factura', 'miplata' );
}
}
return $out;
}
public function order_column_value( string $col, $order ): void {
if ( 'miplata' !== $col || ! $order ) {
return;
}
$inv = $order->get_meta( MiPlata_Orders::META_INVOICE, true );
$st = (string) $order->get_meta( MiPlata_Orders::META_STATUS, true );
if ( is_array( $inv ) && ! empty( $inv['number'] ) ) {
echo '' . esc_html( (string) $inv['number'] ) . ' ';
} elseif ( '' !== $st ) {
echo '' . esc_html( MiPlata_Orders::status_label( $st ) ) . ' ';
}
}
public function order_column_value_legacy( string $col, $post_id ): void {
$this->order_column_value( $col, wc_get_order( $post_id ) );
}
public function order_box(): void {
$screen = function_exists( 'wc_get_page_screen_id' ) ? wc_get_page_screen_id( 'shop-order' ) : 'shop_order';
add_meta_box( 'miplata_order', __( 'miPlata · Factura electrónica', 'miplata' ), array( $this, 'order_box_render' ), $screen, 'side', 'high' );
}
public function order_box_render( $post_or_order ): void {
$order = $post_or_order instanceof WC_Order ? $post_or_order : wc_get_order( $post_or_order->ID ?? 0 );
if ( ! $order ) {
return;
}
$inv = $order->get_meta( MiPlata_Orders::META_INVOICE, true );
$st = (string) $order->get_meta( MiPlata_Orders::META_STATUS, true );
$err = (string) $order->get_meta( MiPlata_Orders::META_ERROR, true );
$cn = $order->get_meta( MiPlata_Orders::META_CN, true );
echo '' . esc_html__( 'Estado:', 'miplata' ) . ' ' . esc_html( '' === $st ? __( 'Sin registrar', 'miplata' ) : MiPlata_Orders::status_label( $st ) ) . '
';
if ( is_array( $inv ) && ! empty( $inv['number'] ) ) {
echo '' . esc_html( (string) $inv['number'] ) . ' · ' . esc_html( (string) ( $inv['dian_status'] ?? '' ) ) . '
';
if ( ! empty( $inv['cufe'] ) ) {
echo 'CUFE ' . esc_html( (string) $inv['cufe'] ) . '
';
}
$viewer = miplata()->viewer;
if ( $viewer && '' !== (string) $order->get_meta( MiPlata_Orders::META_PDF, true ) ) {
echo 'PDF XML
';
}
}
if ( is_array( $cn ) && ( ! empty( $cn['id'] ) || ! empty( $cn['number'] ) ) ) {
echo '' . ( empty( $cn['number'] ) ? esc_html__( 'Nota crédito emitida.', 'miplata' ) : sprintf( /* translators: %s: credit note number */ esc_html__( 'Nota crédito %s emitida.', 'miplata' ), '' . esc_html( (string) $cn['number'] ) . ' ' ) ) . '
';
}
if ( '' !== $err && in_array( $st, array( 'error', 'failed', 'disputed' ), true ) ) {
echo '' . esc_html( $err ) . '
';
}
$url = wp_nonce_url( add_query_arg( array( 'action' => 'miplata_resend', 'order' => $order->get_id() ), admin_url( 'admin-post.php' ) ), 'miplata_resend' );
echo '' . ( is_array( $inv ) && ! empty( $inv['id'] ) ? esc_html__( 'Reenviar factura', 'miplata' ) : esc_html__( 'Registrar / actualizar', 'miplata' ) ) . '
';
}
}