validator = $validator;
}
public function register(): void {
add_action( 'woocommerce_init', array( $this, 'register_block_field' ) );
add_action( 'woocommerce_set_additional_field_value', array( $this, 'mirror_block_value' ), 10, 4 );
add_filter( 'woocommerce_get_default_value_for_' . self::FIELD_ID, array( $this, 'block_default' ), 10, 3 );
add_filter( 'woocommerce_billing_fields', array( $this, 'add_classic_field' ) );
add_filter( 'woocommerce_form_field', array( $this, 'add_classic_disclosure' ), 10, 2 );
add_filter( 'woocommerce_form_field', array( $this, 'mark_classic_required' ), 11, 2 );
add_filter( 'woocommerce_checkout_get_value', array( $this, 'classic_default' ), 10, 2 );
add_action( 'woocommerce_checkout_update_order_review', array( $this, 'on_update_order_review' ) );
add_filter( 'woocommerce_checkout_posted_data', array( $this, 'on_posted_data' ) );
add_action( 'woocommerce_checkout_create_order', array( $this, 'copy_to_order' ), 5 );
add_action( 'woocommerce_checkout_update_user_meta', array( $this, 'copy_to_new_account' ) );
add_action( 'woocommerce_review_order_before_order_total', array( $this, 'render_classic_notice' ) );
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_classic_script' ) );
add_filter( 'woocommerce_admin_billing_fields', array( $this, 'add_admin_field' ), 20, 2 );
add_filter( 'woocommerce_customer_meta_fields', array( $this, 'add_profile_field' ) );
}
public function register_block_field(): void {
if ( ! function_exists( 'woocommerce_register_additional_checkout_field' ) ) {
return;
}
woocommerce_register_additional_checkout_field(
array(
'id' => self::FIELD_ID,
'label' => __( 'VAT number', 'vatright' ),
'location' => 'contact',
'type' => 'text',
'required' => self::block_required(),
// BillingAddress adds the number to the billing address, which every order shows.
'show_in_order_confirmation' => false,
'hidden' => array(
'type' => 'object',
'required' => array( 'customer' ),
'properties' => array(
'customer' => array(
'required' => array( 'billing_address' ),
'properties' => array(
'billing_address' => array(
'required' => array( 'country' ),
'properties' => array(
'country' => array(
'minLength' => 1,
'not' => array( 'enum' => self::COUNTRIES ),
),
),
),
),
),
),
),
'sanitize_callback' => array( self::class, 'normalise' ),
'validate_callback' => array( $this, 'validate_block_value' ),
)
);
}
/**
* Business-only mode makes the number mandatory in the countries it
* covers. WooCommerce evaluates this schema against the checkout, so the
* block field gains or loses its "(optional)" label as the billing country
* changes. An empty field is then refused by WooCommerce before checkout.
*
* @return bool|array
*/
public static function block_required() {
$settings = Settings::get();
switch ( $settings->b2b_only() ) {
case B2bScope::EVERYWHERE:
$countries = self::COUNTRIES;
break;
case B2bScope::EU:
$countries = Territory::EU;
break;
case B2bScope::COUNTRIES:
$countries = array_map( 'strtoupper', $settings->b2b_only_countries() );
break;
default:
return false;
}
return array(
'type' => 'object',
'required' => array( 'customer' ),
'properties' => array(
'customer' => array(
'required' => array( 'billing_address' ),
'properties' => array(
'billing_address' => array(
'required' => array( 'country' ),
'properties' => array(
'country' => array( 'enum' => array_values( $countries ) ),
),
),
),
),
),
);
}
/**
* The canonical form when the text parses as a VAT number, otherwise the
* trimmed upper-case text (a bare number that needs the billing country
* to be understood).
*
* @param mixed $value
*/
public static function normalise( $value ): string {
$raw = trim( (string) $value );
$number = VatNumber::parse( $raw, '' );
return null === $number ? strtoupper( $raw ) : $number->canonical();
}
/**
* Format and cached-verdict checks only; the registry is asked later, in
* the cart update or at checkout.
*
* @param mixed $value
* @return WP_Error|true
*/
public function validate_block_value( $value ) {
$raw = trim( (string) $value );
if ( '' === $raw ) {
return true;
}
$number = VatNumber::parse( $raw, self::billing_country() );
if ( null === $number || ! FormatCheck::is_plausible( $number ) ) {
return new WP_Error( 'vatright_format', __( 'This does not look like a VAT number. Check it, or leave the field empty to pay with VAT.', 'vatright' ) );
}
if ( ValidationResult::INVALID === $this->validator->validate( $number, false )->status() ) {
return new WP_Error( 'vatright_invalid', CheckoutDecider::message( 'invalid_vat_number' ) );
}
return true;
}
/**
* A cleared field clears the number.
*
* @param string $key
* @param mixed $value
* @param string $group
* @param WC_Data $wc_object
*/
public function mirror_block_value( $key, $value, $group, $wc_object ): void {
if ( self::FIELD_ID !== $key ) {
return;
}
$wc_object->update_meta_data( InputGatherer::NUMBER_META, self::normalise( $value ) );
}
/**
* Fills the block field on the checkout and My Account for a customer
* whose number WooCommerce's own copy doesn't hold: one saved at
* registration, or left by another VAT plugin.
*
* @param mixed $value
* @param string $group
* @param mixed $wc_object
* @return mixed
*/
public function block_default( $value, $group, $wc_object ) {
if ( null !== $value || ! $wc_object instanceof WC_Customer ) {
return $value;
}
$number = LegacyData::customer_number( $wc_object );
return '' === $number ? null : $number;
}
/**
* @param array $fields
* @return array
*/
public function add_classic_field( $fields ) {
if ( ! self::is_classic_checkout() ) {
return $fields;
}
$fields[ self::CLASSIC_FIELD ] = array(
'type' => 'text',
'label' => __( 'VAT number', 'vatright' ),
'required' => false,
'class' => array( 'form-row-wide' ),
'priority' => 125,
'autocomplete' => 'off',
'custom_attributes' => array( 'aria-describedby' => 'vatright-disclosure' ),
);
return $fields;
}
/**
* Tells the shopper where the number goes before they enter it.
*/
public static function disclosure(): string {
if ( Settings::get()->refuse_name_mismatch() ) {
return __( 'Your VAT number and company name are sent to the official tax registry for its country, such as the EU\'s VIES service, to confirm them before VAT is removed.', 'vatright' );
}
return __( 'Your VAT number is sent to the official tax registry for its country, such as the EU\'s VIES service, to confirm it before VAT is removed.', 'vatright' );
}
/**
* WooCommerce's own field description stays hidden until the input has
* focus and is hidden from screen readers, so the disclosure is ours.
*
* @param string $html
* @param string $key
*/
public function add_classic_disclosure( $html, $key ): string {
$html = (string) $html;
$end = strrpos( $html, '
' );
if ( self::CLASSIC_FIELD !== $key || false === $end ) {
return $html;
}
return substr( $html, 0, $end ) . '' . esc_html( self::disclosure() ) . '' . substr( $html, $end );
}
/**
* Business-only mode refuses an empty field with its own message, so the
* field stays optional to WooCommerce and only looks required. The script
* keeps the label in step with the billing country.
*
* @param string $html
* @param string $key
*/
public function mark_classic_required( $html, $key ): string {
$html = (string) $html;
if ( self::CLASSIC_FIELD !== $key || ! WC()->customer instanceof WC_Customer || ! B2bOnly::applies( WC()->customer->get_billing_country() ) ) { // @phpstan-ignore instanceof.alwaysTrue
return $html;
}
$required = esc_attr__( 'required', 'woocommerce' ); // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- the label wording WooCommerce itself uses.
$html = preg_replace( '#[^<]*#', '*', $html, 1 ) ?? $html;
return str_replace( 'form-row ', 'form-row validate-required ', $html );
}
/**
* @return array{mode:string,countries:string[],eu:string[],optional:string,required:string}
*/
private static function classic_script_data(): array {
$settings = Settings::get();
return array(
'mode' => $settings->b2b_only(),
'countries' => $settings->b2b_only_countries(),
'eu' => Territory::EU,
'optional' => esc_html__( 'optional', 'woocommerce' ), // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- the label wording WooCommerce itself uses.
'required' => esc_attr__( 'required', 'woocommerce' ), // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- the label wording WooCommerce itself uses.
);
}
/**
* @param mixed $value
* @param string $input
* @return mixed
*/
public function classic_default( $value, $input ) {
if ( self::CLASSIC_FIELD !== $input || ! WC()->customer instanceof WC_Customer ) { // @phpstan-ignore instanceof.alwaysTrue
return $value;
}
return LegacyData::customer_number( WC()->customer );
}
/**
* Runs before WooCommerce applies the posted address to the customer, so
* the billing country is read from the posted data.
*
* @param string $post_data
*/
public function on_update_order_review( $post_data ): void {
parse_str( (string) $post_data, $posted );
$this->apply_posted( (array) wc_clean( wp_unslash( $posted ) ) );
}
/**
* @param array $data
* @return array
*/
public function on_posted_data( $data ) {
// WooCommerce fills a registered field that was not posted with an empty string; only a field that was posted may clear the number.
$this->apply_posted( (array) wc_clean( wp_unslash( $_POST ) ) + array( 'billing_country' => (string) ( $data['billing_country'] ?? '' ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce verified the checkout nonce.
return $data;
}
/**
* @param \WC_Order $order
*/
public function copy_to_order( $order ): void {
$number = WC()->customer instanceof WC_Customer ? LegacyData::customer_number( WC()->customer ) : ''; // @phpstan-ignore instanceof.alwaysTrue
if ( '' !== $number ) {
$order->update_meta_data( InputGatherer::NUMBER_META, $number );
}
}
/**
* An account created at checkout is a new customer record, so the number
* the visitor typed, held on the session customer, has to be copied over.
*
* @param mixed $user_id
*/
public function copy_to_new_account( $user_id ): void {
$number = WC()->customer instanceof WC_Customer ? (string) WC()->customer->get_meta( InputGatherer::NUMBER_META ) : ''; // @phpstan-ignore instanceof.alwaysTrue
if ( '' !== $number && '' === (string) get_user_meta( (int) $user_id, InputGatherer::NUMBER_META, true ) ) {
update_user_meta( (int) $user_id, InputGatherer::NUMBER_META, $number );
}
}
public function render_classic_notice(): void {
$decision = Plugin::cart_steering() ? Plugin::cart_steering()->current() : null;
$status = StoreApiExtension::status( $decision );
if ( '' === $status['message'] && ! $status['needs_self_certification'] && ! $status['self_certified'] ) {
return;
}
echo '| ';
if ( '' !== $status['message'] ) {
echo ' ' . esc_html( $status['message'] ) . ' ';
}
if ( $status['needs_self_certification'] || $status['self_certified'] ) {
echo '';
echo '';
}
echo ' |
';
}
public function enqueue_classic_script(): void {
if ( ! self::is_classic_checkout() ) {
return;
}
$script = self::asset( 'assets/classic-checkout.js' );
wp_enqueue_script( 'vatright-classic-checkout', $script['url'], array( 'jquery', 'wc-checkout' ), $script['version'], true );
wp_localize_script( 'vatright-classic-checkout', 'vatrightB2b', self::classic_script_data() );
$style = self::asset( 'assets/checkout-block/style.css' );
wp_enqueue_style( 'vatright-checkout', $style['url'], array(), $style['version'] );
}
/**
* WooCommerce lists its own copy of the block field here, but only for
* orders placed through the Store API that have a value, so an admin could
* not add a number to any other order. One input of ours replaces it; the
* saved value is read from _billing_vatright_number by AdminRecalc.
*
* @param array $fields
* @param mixed $order
* @return array
*/
public function add_admin_field( $fields, $order = null ) {
unset( $fields[ self::FIELD_ID ], $fields[ '_wc_other/' . self::FIELD_ID ] );
$fields['vatright_number'] = array(
'label' => __( 'VAT number', 'vatright' ),
'wrapper_class' => 'form-field-wide',
'show' => true,
'value' => $order instanceof \WC_Order ? LegacyData::order_number( $order ) : '',
);
return $fields;
}
/**
* @param array $fields
* @return array
*/
public function add_profile_field( $fields ) {
if ( isset( $fields['billing']['fields'] ) ) {
$fields['billing']['fields'][ InputGatherer::NUMBER_META ] = array(
'label' => __( 'VAT number', 'vatright' ),
'description' => '',
);
}
return $fields;
}
/**
* Registry lookup for a number entered by the visitor. The cart update
* and the classic order review call this, so the next totals calculation
* finds the verdict in the cache.
*/
public function set_number( WC_Customer $customer, string $raw, string $billing_country, string $company = '' ): void {
$customer->update_meta_data( InputGatherer::NUMBER_META, self::normalise( $raw ) );
$number = VatNumber::parse( $raw, $billing_country );
if ( null !== $number ) {
$this->validator->validate_for_visitor( $number, 'update', NameMismatch::lookup_name( $company ) );
}
}
/**
* @param array $posted
*/
private function apply_posted( array $posted ): void {
$customer = WC()->customer;
if ( ! $customer instanceof WC_Customer ) { // @phpstan-ignore instanceof.alwaysTrue
return;
}
$billing = strtoupper( (string) ( $posted['billing_country'] ?? $posted['country'] ?? $customer->get_billing_country() ) );
if ( array_key_exists( self::CLASSIC_FIELD, $posted ) ) {
$this->set_number( $customer, (string) $posted[ self::CLASSIC_FIELD ], $billing, (string) ( $posted['billing_company'] ?? $customer->get_billing_company() ) );
if ( $customer->get_id() ) {
$customer->update_meta_data( '_wc_other/' . self::FIELD_ID, self::normalise( $posted[ self::CLASSIC_FIELD ] ) );
}
}
if ( array_key_exists( self::SELF_CERTIFY_MARKER, $posted ) && null !== CartSteering::session() ) {
CartSteering::session()->set( InputGatherer::SELF_CERTIFIED_SESSION, isset( $posted[ self::SELF_CERTIFY_FIELD ] ) ? $billing : null );
}
}
/**
* @return array{url: string, version: string}
*/
public static function asset( string $path ): array {
$main = dirname( __DIR__, 2 ) . '/vatright.php';
return array(
'url' => plugins_url( $path, $main ),
'version' => (string) filemtime( dirname( __DIR__, 2 ) . '/' . $path ),
);
}
private static function billing_country(): string {
return WC()->customer instanceof WC_Customer ? strtoupper( WC()->customer->get_billing_country() ) : ''; // @phpstan-ignore instanceof.alwaysTrue
}
/**
* The classic input belongs on a classic checkout page only: not on a
* block checkout page, in REST requests, or on My Account, where
* WooCommerce adds the block field to the address form itself.
*/
private static function is_classic_checkout(): bool {
if ( defined( 'REST_REQUEST' ) || ( ! did_action( 'wp' ) && ! defined( 'WOOCOMMERCE_CHECKOUT' ) ) || ! is_checkout() ) {
return false;
}
$page = get_queried_object_id();
return ! ( $page > 0 && has_block( 'woocommerce/checkout', $page ) );
}
}