'peppolkit/vat-number', 'label' => $label(), 'location' => 'address', 'required' => false, ) ); } } ); // Classic checkout. add_filter( 'woocommerce_billing_fields', function ( $fields ) use ( $label ) { $fields['billing_vat_number'] = array( 'label' => $label(), 'required' => false, 'class' => array( 'form-row-wide' ), 'priority' => 35, ); return $fields; } ); // Block checkout: the Store API saves address fields on every change, so the cart totals update right away. add_action( 'woocommerce_store_api_cart_update_customer_from_request', array( __CLASS__, 'apply_vat_exemption_from_store_api' ) ); // Block checkout stores the field under its own key; copy it to the meta the mapper reads. add_action( 'woocommerce_store_api_checkout_order_processed', function ( $order ) { $vat = $order->get_meta( '_wc_billing/peppolkit/vat-number' ); if ( $vat ) { $order->update_meta_data( PeppolKit_Mapper::VAT_META, strtoupper( preg_replace( '/[\s.\-]/', '', $vat ) ) ); $order->save(); } } ); } /** True when the number is valid in VIES (cached for a day). Public endpoint, no API key needed. */ public static function vies_valid( $vat ) { $vat = strtoupper( preg_replace( '/[\s.\-]/', '', (string) $vat ) ); if ( strlen( $vat ) < 8 ) { return false; } $key = 'peppolkit_vies_' . md5( $vat ); $cached = get_transient( $key ); if ( false !== $cached ) { return 'valid' === $cached; } $res = wp_remote_get( 'https://app.peppolkit.com/api/vat-check?vat=' . rawurlencode( $vat ), array( 'timeout' => 15 ) ); if ( is_wp_error( $res ) ) { return false; } $data = json_decode( wp_remote_retrieve_body( $res ), true ); $status = is_array( $data ) ? (string) ( $data['status'] ?? '' ) : ''; if ( in_array( $status, array( 'valid', 'invalid' ), true ) ) { set_transient( $key, $status, DAY_IN_SECONDS ); } return 'valid' === $status; } /** Reverse charge applies to a valid VAT number from another EU country than the shop's. */ private static function set_exemption( $vat, $country ) { if ( ! WC()->customer ) { return; } $base = WC()->countries->get_base_country(); $eu = WC()->countries->get_european_union_countries(); $prefix = strtoupper( substr( preg_replace( '/[\s.\-]/', '', (string) $vat ), 0, 2 ) ); $prefix = 'EL' === $prefix ? 'GR' : $prefix; $exempt = $vat && $country && $country !== $base && in_array( $country, $eu, true ) && $prefix === $country && self::vies_valid( $vat ); WC()->customer->set_is_vat_exempt( $exempt ); } public static function apply_vat_exemption_from_review( $posted ) { parse_str( (string) $posted, $data ); self::set_exemption( $data['billing_vat_number'] ?? '', strtoupper( $data['billing_country'] ?? '' ) ); } public static function apply_vat_exemption_from_store_api( $customer ) { if ( ! class_exists( '\\Automattic\\WooCommerce\\Blocks\\Package' ) ) { return; } $fields = \Automattic\WooCommerce\Blocks\Package::container()->get( \Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields::class ); $vat = (string) $fields->get_field_from_object( 'peppolkit/vat-number', $customer, 'billing' ); self::set_exemption( $vat, strtoupper( $customer->get_billing_country() ) ); } public static function apply_vat_exemption_from_post() { // phpcs:ignore WordPress.Security.NonceVerification -- WooCommerce verifies the checkout nonce. self::set_exemption( sanitize_text_field( wp_unslash( $_POST['billing_vat_number'] ?? '' ) ), strtoupper( sanitize_text_field( wp_unslash( $_POST['billing_country'] ?? '' ) ) ) ); } public static function store_vat_number( $order, $data ) { if ( ! empty( $data['billing_vat_number'] ) ) { $order->update_meta_data( PeppolKit_Mapper::VAT_META, strtoupper( preg_replace( '/[\s.\-]/', '', $data['billing_vat_number'] ) ) ); } } public static function queue_order( $order_id ) { $order = wc_get_order( $order_id ); if ( ! $order || $order->get_meta( self::META_INVOICE ) || ! PeppolKit_Api::connected() ) { return; } as_enqueue_async_action( 'peppolkit_send_order', array( (int) $order_id ), 'peppolkit' ); } public static function queue_refund( $order_id, $refund_id ) { if ( PeppolKit_Api::connected() ) { as_enqueue_async_action( 'peppolkit_send_refund', array( (int) $order_id, (int) $refund_id ), 'peppolkit' ); } } public static function send_order( $order_id ) { $order = wc_get_order( $order_id ); if ( ! $order || $order->get_meta( self::META_INVOICE ) ) { return; } $res = PeppolKit_Api::request( 'POST', '/orders', PeppolKit_Mapper::order( $order ) ); if ( is_wp_error( $res ) ) { /* translators: %s: error message */ $order->add_order_note( sprintf( __( 'PeppolKit: invoice not created – %s', 'peppolkit' ), $res->get_error_message() ) ); self::handle_failure( $res, $order, 'peppolkit_send_order', array( (int) $order_id ) ); return; } if ( ! empty( $res['invoice'] ) ) { $order->delete_meta_data( self::META_PENDING ); $order->update_meta_data( self::META_INVOICE, $res['invoice'] ); delete_transient( 'peppolkit_docs_' . $order->get_id() ); /* translators: %s: invoice number */ $order->add_order_note( sprintf( __( 'PeppolKit: invoice %s created.', 'peppolkit' ), $res['invoice']['number'] ) ); foreach ( (array) ( $res['warnings'] ?? array() ) as $w ) { $order->add_order_note( 'PeppolKit: ' . $w ); } $order->save(); } } public static function send_refund( $order_id, $refund_id ) { $order = wc_get_order( $order_id ); $refund = wc_get_order( $refund_id ); if ( ! $order || ! $refund || $refund->get_meta( self::META_REFUND ) ) { return; } $res = PeppolKit_Api::request( 'POST', '/refunds', array( 'refund' => PeppolKit_Mapper::refund( $refund ), 'orderTotal' => (float) $order->get_total(), ) ); if ( is_wp_error( $res ) ) { /* translators: %s: error message */ $order->add_order_note( sprintf( __( 'PeppolKit: credit note not created – %s', 'peppolkit' ), $res->get_error_message() ) ); self::handle_failure( $res, $refund, 'peppolkit_send_refund', array( (int) $order_id, (int) $refund_id ) ); return; } if ( ! empty( $res['creditNote'] ) ) { $refund->delete_meta_data( self::META_PENDING ); $refund->update_meta_data( self::META_REFUND, $res['creditNote']['number'] ); $refund->save(); delete_transient( 'peppolkit_docs_' . $order->get_id() ); $notes = array_filter( (array) $order->get_meta( self::META_CREDIT ), 'is_array' ); $notes[] = $res['creditNote']; $order->update_meta_data( self::META_CREDIT, $notes ); /* translators: %s: credit note number */ $order->add_order_note( sprintf( __( 'PeppolKit: credit note %s created.', 'peppolkit' ), $res['creditNote']['number'] ) ); $order->save(); } } public static function meta_box() { $screen = function_exists( 'wc_get_page_screen_id' ) ? wc_get_page_screen_id( 'shop-order' ) : 'shop_order'; add_meta_box( 'peppolkit-invoice', 'PeppolKit', array( __CLASS__, 'render_meta_box' ), $screen, 'side' ); } /** * 402 (invoice limit) and 409 (no company details) wait for the merchant: mark as pending. * Network and server errors: let Action Scheduler retry later. */ private static function handle_failure( $error, $object, $hook, $args ) { $code = (string) $error->get_error_code(); if ( in_array( $code, array( 'peppolkit_http_402', 'peppolkit_http_409' ), true ) ) { $object->update_meta_data( self::META_PENDING, time() ); $object->save(); delete_transient( 'peppolkit_pending_count' ); return; } if ( 0 === strpos( $code, 'http_request' ) || preg_match( '/^peppolkit_http_5\d\d$/', $code ) ) { as_schedule_single_action( time() + 600, $hook, $args, 'peppolkit' ); } } /** Orders and refunds waiting for an invoice. */ private static function pending_ids() { return wc_get_orders( array( 'type' => array( 'shop_order', 'shop_order_refund' ), 'limit' => 250, 'return' => 'ids', 'status' => array_keys( wc_get_order_statuses() ), 'meta_key' => self::META_PENDING, // phpcs:ignore WordPress.DB.SlowDBQuery 'meta_compare' => 'EXISTS', ) ); } public static function pending_count() { $count = get_transient( 'peppolkit_pending_count' ); if ( false === $count ) { $count = count( self::pending_ids() ); set_transient( 'peppolkit_pending_count', $count, 5 * MINUTE_IN_SECONDS ); } return (int) $count; } /** Queues every pending order and refund again; returns how many. */ public static function requeue_pending() { $n = 0; foreach ( self::pending_ids() as $id ) { $o = wc_get_order( $id ); if ( ! $o ) { continue; } $o->delete_meta_data( self::META_PENDING ); $o->save(); if ( $o instanceof WC_Order_Refund ) { as_enqueue_async_action( 'peppolkit_send_refund', array( (int) $o->get_parent_id(), (int) $id ), 'peppolkit' ); } else { as_enqueue_async_action( 'peppolkit_send_order', array( (int) $id ), 'peppolkit' ); } ++$n; } delete_transient( 'peppolkit_pending_count' ); return $n; } /** Queues paid orders of the last $days days that have no invoice yet. */ public static function backfill( $days, $max ) { $ids = wc_get_orders( array( 'type' => 'shop_order', 'status' => array( 'wc-processing', 'wc-completed' ), 'date_created' => '>' . ( time() - $days * DAY_IN_SECONDS ), 'limit' => $max, 'return' => 'ids', 'meta_key' => self::META_INVOICE, // phpcs:ignore WordPress.DB.SlowDBQuery 'meta_compare' => 'NOT EXISTS', ) ); $n = 0; foreach ( $ids as $id ) { if ( ! as_has_scheduled_action( 'peppolkit_send_order', array( (int) $id ), 'peppolkit' ) ) { as_enqueue_async_action( 'peppolkit_send_order', array( (int) $id ), 'peppolkit' ); ++$n; } } return $n; } public static function peppol_label( $status ) { $labels = array( 'pending' => _x( 'Waiting to send', 'Peppol status', 'peppolkit' ), 'sent' => _x( 'Sent', 'Peppol status', 'peppolkit' ), 'delivered' => _x( 'Delivered', 'Peppol status', 'peppolkit' ), 'failed' => _x( 'Failed', 'Peppol status', 'peppolkit' ), 'not_on_peppol' => _x( 'Customer not on Peppol (e-mailed)', 'Peppol status', 'peppolkit' ), 'skipped' => _x( 'Not needed (consumer)', 'Peppol status', 'peppolkit' ), ); if ( ! $status ) { return '–'; } return $labels[ $status ] ?? (string) $status; } public static function email_label( $doc ) { if ( ! empty( $doc['emailError'] ) ) { return _x( 'Failed', 'E-mail status', 'peppolkit' ); } return empty( $doc['emailedAt'] ) ? _x( 'Not sent', 'E-mail status', 'peppolkit' ) : _x( 'Sent', 'E-mail status', 'peppolkit' ); } private static function format_time( $iso ) { $ts = strtotime( (string) $iso ); return $ts ? wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $ts ) : (string) $iso; } public static function render_meta_box( $post_or_order ) { $order = $post_or_order instanceof WC_Order ? $post_or_order : wc_get_order( $post_or_order->ID ); $docs = self::documents( $order ); if ( ! $docs ) { if ( $order->get_meta( self::META_PENDING ) ) { echo '
' . esc_html__( 'Waiting for an invoice: invoice limit reached or company details missing.', 'peppolkit' ) . '
'; } else { echo '' . esc_html__( 'No invoice yet. It is created when the order is paid.', 'peppolkit' ) . '
'; } return; } echo '' . esc_html( is_user_logged_in() ? __( 'Your invoice is being prepared. It will be e-mailed to you and be available in My Account shortly.', 'peppolkit' ) : __( 'Your invoice is being prepared and will be e-mailed to you shortly.', 'peppolkit' ) ) . '
'; } /** Invoice link in the customer's processing / completed e-mails. */ public static function email_links( $order, $sent_to_admin, $plain_text, $email = null ) { if ( $sent_to_admin || ! $email || ! in_array( $email->id, array( 'customer_processing_order', 'customer_completed_order' ), true ) ) { return; } $docs = array_filter( self::documents( $order ), function ( $d ) { return 'invoice' === ( $d['type'] ?? '' ); } ); foreach ( $docs as $d ) { /* translators: %s: invoice number */ $text = sprintf( __( 'Download invoice %s (PDF)', 'peppolkit' ), $d['number'] ); if ( $plain_text ) { echo "\n" . esc_html( $text ) . ': ' . esc_url( $d['pdfUrl'] ) . "\n"; } else { printf( '', esc_url( $d['pdfUrl'] ), esc_html( $text ) ); } } } /** Invoice download on the customer's order page in My Account. */ public static function customer_links( $order ) { if ( self::$shown ) { return; } $docs = self::documents( $order ); if ( ! $docs ) { return; } echo '