Ett B2B-prisplugin för WooCommerce behöver inte vara stort. Det jag bygger här är en fil på strax under 200 rader som gör tre saker. En kundgrupp som egen användarroll, ett B2B-pris per produkt med en procentrabatt som reserv, och priser exklusive moms för den gruppen. Allt ligger som ett lager ovanpå WooCommerce. Ordinarie priser rörs inte, och tar du bort pluginet är butiken en vanlig butik igen.
Koden är kassaoberoende. Prisfiltren körs i produktobjektet, och både blockkassan och den klassiska kassan hämtar sina priser därifrån via varukorgen. Därför finns ingen kassakod i guiden. Dolda priser för utloggade bygger jag inte om heller, det har jag redan gjort i inlägget om att dölja priser för utloggade kunder. De två bitarna passar ihop utan att veta om varandra.
Det här bygger vi
Pluginet heter WPU B2B-priser och ligger i wp-content/plugins/wpu-b2b-priser/wpu-b2b-priser.php. När det är aktiverat finns en roll som heter B2B-kund under Användare, ett fält som heter B2B-pris i produktens flik Allmänt och i varje variation, och två inställningar under WooCommerce, Inställningar, Produkter: Rabatt i procent och Visa priser exkl. moms.
Reglerna är få. En inloggad kund med rollen B2B-kund ser B2B-priset om produkten har ett. Saknar produkten B2B-pris får kunden procentrabatten på det pris alla andra ser, reapris inräknat. Priset slår igenom i produktlistor, på produktsidan, i varukorgen, i kassan och på ordern, eftersom alla läser samma pris. Alla andra, inloggade eller inte, märker ingenting.
Att gruppen är en roll och inte ett eget fält på användaren är ett medvetet val. Rollen syns i användarlistan, går att filtrera på, kan sättas vid import med WP-CLI och fungerar med de plugins som redan förstår roller. Ett eget fält hade krävt egen admin för allt det. Begränsningen är att profilsidan i WordPress bara låter dig välja en roll per användare, men det räcker här. En B2B-kund är en kund med ett annat pris.
Jag bygger filen i fem steg och visar hela filen sist. Delstegen är inpackade i function_exists-kontroller så att ett delsteg och den färdiga filen kan ligga aktiva samtidigt utan att krascha. Den färdiga filen längst ner saknar dem.
Principen: ett lager ovanpå
Det här är samma princip som i de B2B-butiker vi bygger åt kunder, och den är viktigare än någon enskild rad kod. Pluginet ändrar aldrig WooCommerces egna data. Det skriver inte om ordinarie pris, det rör inte ordrar och det byter inte roll på befintliga användare. Allt pluginet äger är en roll, två inställningar och en metanyckel, _wpu_b2b_price, som WooCommerce inte känner till och därför ignorerar.
Konsekvensen är att varje beslut går att testa med en enda fråga. Fungerar butiken normalt om pluginet försvinner? Inaktiverar du pluginet står priserna kvar som de var, kunderna kan logga in och ordrarna är vanliga ordrar. Avinstallerar du det städar det efter sig, och B2B-kunderna blir vanliga kunder i stället för rollösa.
Steg 1: kundgruppen som en roll
Enklaste sättet att hålla reda på vilka som är B2B-kunder är en egen användarroll. Den får samma capabilities som WooCommerces roll customer, vilket i praktiken bara är read. Rollen skapas vid aktivering och tas bort vid avinstallation.
Två saker i koden är lätta att göra fel. register_uninstall_hook() sparar sin callback i alternativet uninstall_plugins, så anropet ska ligga i aktiveringsfunktionen och inte överst i filen, annars skriver pluginet till databasen vid varje sidladdning tills alternativet är satt. Och vid avinstallation måste användarna byta roll innan rollen tas bort. En borttagen roll försvinner inte ur användarens metadata, men WordPress filtrerar bort okända roller, så användaren står utan roll och kan inte längre handla som kund.
<?php
/**
* Plugin Name: WPU B2B-priser
* Description: Kundgrupp, B2B-pris per produkt, procentrabatt och priser exkl. moms för inloggade B2B-kunder.
* Version: 1.0.0
* Requires Plugins: woocommerce
* Requires PHP: 8.2
* Text Domain: wpu-b2b
*/
defined( 'ABSPATH' ) || exit;
if ( ! function_exists( 'wpu_b2b_activate' ) ) :
register_activation_hook( __FILE__, 'wpu_b2b_activate' );
function wpu_b2b_activate() {
$customer = get_role( 'customer' );
add_role( 'b2b_kund', 'B2B-kund', $customer ? $customer->capabilities : array( 'read' => true ) );
// WordPress stores the uninstall callback in an option, so it belongs here and not at file load.
register_uninstall_hook( __FILE__, 'wpu_b2b_uninstall' );
}
function wpu_b2b_uninstall() {
// A removed role would leave its users without any role, so they become plain customers first.
foreach ( get_users( array( 'role' => 'b2b_kund', 'fields' => 'ID' ) ) as $user_id ) {
$user = new WP_User( $user_id );
$user->remove_role( 'b2b_kund' );
if ( empty( $user->roles ) ) {
$user->add_role( 'customer' );
}
}
remove_role( 'b2b_kund' );
delete_option( 'wpu_b2b_discount_percent' );
delete_option( 'wpu_b2b_show_excl_tax' );
delete_post_meta_by_key( '_wpu_b2b_price' );
}
// The plugin never touches orders, but the declaration keeps it off the "uncertain" list under Features.
add_action( 'before_woocommerce_init', 'wpu_b2b_declare_hpos' );
function wpu_b2b_declare_hpos() {
if ( class_exists( AutomatticWooCommerceUtilitiesFeaturesUtil::class ) ) {
AutomatticWooCommerceUtilitiesFeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true );
}
}
endif;
Aktiveringshooken är skälet till att det här ska vara ett riktigt plugin och inte en snutt i Code Snippets eller temats functions.php. Där körs ingen aktivering, och rollen skapas aldrig.
Steg 2: rollkontrollen och inställningarna
All logik i pluginet hänger på en fråga. Är den inloggade användaren B2B-kund? Jag lägger den i en egen funktion utan cache, eftersom wp_get_current_user() redan är billig och en statisk cache ställer till det i tester där användaren byts mitt i körningen.
Inställningarna lägger jag sist på fliken Produkter med filtret woocommerce_product_settings. WooCommerce ritar en egen rubrik, ett nummerfält och en kryssruta, och sparar dem som vanliga alternativ. Ingen egen inställningssida behövs.
<?php
if ( ! function_exists( 'wpu_b2b_is_customer' ) ) :
function wpu_b2b_is_customer() {
$user = wp_get_current_user();
return $user->exists() && in_array( 'b2b_kund', (array) $user->roles, true );
}
add_filter( 'woocommerce_product_settings', 'wpu_b2b_settings' );
function wpu_b2b_settings( $settings ) {
$settings[] = array(
'title' => 'B2B-priser',
'type' => 'title',
'id' => 'wpu_b2b_options',
'desc' => 'Gäller inloggade kunder med rollen B2B-kund.',
);
$settings[] = array(
'title' => 'Rabatt i procent',
'id' => 'wpu_b2b_discount_percent',
'type' => 'number',
'default' => '0',
'desc_tip' => 'Används på produkter som saknar eget B2B-pris.',
'custom_attributes' => array(
'min' => '0',
'max' => '100',
'step' => '0.01',
),
);
$settings[] = array(
'title' => 'Visa priser exkl. moms',
'id' => 'wpu_b2b_show_excl_tax',
'type' => 'checkbox',
'default' => 'no',
'desc' => 'B2B-kunder ser priser exklusive moms i butik, varukorg och kassa.',
);
$settings[] = array(
'type' => 'sectionend',
'id' => 'wpu_b2b_options',
);
return $settings;
}
endif;
Steg 3: B2B-priset på produkt och variation
Fältet på enkla produkter hängs på woocommerce_product_options_pricing, som körs direkt efter reapriset i fliken Allmänt. woocommerce_wp_text_input() med data_type satt till price ger samma formatering och samma decimaltecken som WooCommerces egna prisfält. Gruppen med prisfält är normalt dold för variabla produkter, så där visas fältet i stället per variation via woocommerce_variation_options_pricing.
Sparandet gör jag på woocommerce_admin_process_product_object och woocommerce_admin_process_variation_object. Båda körs med det färdiga produktobjektet precis innan WooCommerce anropar save(), så det räcker att sätta metadatan. Den äldre hooken woocommerce_process_product_meta finns kvar, men den ger bara ett inläggs-ID och kräver att du sparar själv. HPOS spelar ingen roll här, det flyttar ordrar till egna tabeller medan produkter och variationer fortfarande är inlägg med postmeta.
<?php
if ( ! function_exists( 'wpu_b2b_product_field' ) ) :
add_action( 'woocommerce_product_options_pricing', 'wpu_b2b_product_field' );
function wpu_b2b_product_field() {
woocommerce_wp_text_input(
array(
'id' => '_wpu_b2b_price',
'label' => 'B2B-pris (' . get_woocommerce_currency_symbol() . ')',
'data_type' => 'price',
'desc_tip' => true,
'description' => 'Pris för inloggade B2B-kunder. Tomt betyder procentrabatten i inställningarna.',
)
);
}
add_action( 'woocommerce_admin_process_product_object', 'wpu_b2b_save_product_field' );
function wpu_b2b_save_product_field( $product ) {
// WooCommerce has verified the product form nonce before this hook runs.
if ( ! isset( $_POST['_wpu_b2b_price'] ) || is_array( $_POST['_wpu_b2b_price'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
return;
}
wpu_b2b_store_price( $product, wc_clean( wp_unslash( $_POST['_wpu_b2b_price'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
}
add_action( 'woocommerce_variation_options_pricing', 'wpu_b2b_variation_field', 10, 3 );
function wpu_b2b_variation_field( $loop, $variation_data, $variation ) {
// $variation is a WP_Post here; variations are posts even with HPOS, which only moves orders.
woocommerce_wp_text_input(
array(
'id' => "_wpu_b2b_variation_price_{$loop}",
'name' => "_wpu_b2b_variation_price[{$loop}]",
'value' => get_post_meta( $variation->ID, '_wpu_b2b_price', true ),
'label' => 'B2B-pris (' . get_woocommerce_currency_symbol() . ')',
'data_type' => 'price',
'wrapper_class' => 'form-row form-row-full',
)
);
}
add_action( 'woocommerce_admin_process_variation_object', 'wpu_b2b_save_variation_field', 10, 2 );
function wpu_b2b_save_variation_field( $variation, $i ) {
if ( ! isset( $_POST['_wpu_b2b_variation_price'][ $i ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
return;
}
wpu_b2b_store_price( $variation, wc_clean( wp_unslash( $_POST['_wpu_b2b_variation_price'][ $i ] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
}
function wpu_b2b_store_price( $product, $raw ) {
// The product saves itself right after these hooks, so only the meta needs setting.
$value = wc_format_decimal( $raw );
if ( '' === $value ) {
$product->delete_meta_data( '_wpu_b2b_price' );
} else {
$product->update_meta_data( '_wpu_b2b_price', $value );
}
}
endif;
wc_format_decimal() tar emot det lokaliserade värdet från fältet, 1 234,50 med komma, och lagrar 1234.50 med punkt. Samma metanyckel används på enkla produkter och variationer, så prisfunktionen i nästa steg behöver inte veta vilken typ den fått.
Steg 4: prisfiltren
Här sitter själva priset, och det är en funktion. Den får det lagrade priset och produktobjektet, returnerar B2B-priset om produkten har ett, annars priset minus procenten, annars priset orört. Det som avgör att det fungerar överallt är var funktionen hängs in.
WooCommerce läser aktiva priser på tre ställen, och jag har gått igenom alla tre i källkoden för 11.2. Det vanliga är get_price() på en enkel produkt eller en variation. Den går via WC_Data::get_prop(), som i view-läge kör filtret woocommerce_product_get_price för produkter och woocommerce_product_variation_get_price för variationer. Det tredje stället är prisintervallet på en variabel produkt, ”från 199 kr”. Där läser datalagret varje variation med get_price( 'edit' ), och i edit-läge körs inga av filtren ovan. I stället finns woocommerce_variation_prices_price, med samma argument, så samma funktion får hänga där också.
Intervallet cachas i en transient per produkt, och cachenyckeln byggs med woocommerce_get_variation_prices_hash. Lägger du inte in gruppen i hashen får den som först laddar sidan bestämma intervallet för alla. WooCommerce rensar själv cachen när en produkt eller variation sparas, så ett nytt B2B-pris i admin slår igenom direkt.
<?php
if ( ! function_exists( 'wpu_b2b_price' ) ) :
function wpu_b2b_price( $price, $product ) {
if ( '' === $price || null === $price || ! wpu_b2b_is_customer() ) {
return $price;
}
$fixed = $product->get_meta( '_wpu_b2b_price' );
if ( '' !== $fixed && null !== $fixed ) {
return wc_format_decimal( $fixed, wc_get_price_decimals() );
}
$percent = (float) get_option( 'wpu_b2b_discount_percent', 0 );
if ( $percent <= 0 || $percent > 100 ) {
return $price;
}
// Work on $price as given. Calling $product->get_price() in here would run this filter again.
return wc_format_decimal( (float) $price * ( 1 - $percent / 100 ), wc_get_price_decimals() );
}
add_filter( 'woocommerce_product_get_price', 'wpu_b2b_price', 10, 2 );
add_filter( 'woocommerce_product_variation_get_price', 'wpu_b2b_price', 10, 2 );
// The price range of a variable product reads its variations in "edit" context, which skips the two filters above.
add_filter( 'woocommerce_variation_prices_price', 'wpu_b2b_price', 10, 2 );
add_filter( 'woocommerce_get_variation_prices_hash', 'wpu_b2b_prices_hash' );
function wpu_b2b_prices_hash( $hash ) {
// The range is cached in a transient. Without this key one group's range would be served to the other.
$hash['wpu_b2b'] = wpu_b2b_is_customer() ? (string) get_option( 'wpu_b2b_discount_percent', 0 ) : 'no';
return $hash;
}
endif;
Kontrollen på tom sträng först i funktionen är inte pedanteri. En variation som inte fått något pris, eller en produkt där priset är borttaget, returnerar tom sträng, och WooCommerce visar den utan pris och utan köpknapp. Gör jag om tomt till noll med en typomvandling blir produkten gratis för B2B-kunderna i stället. Avrundningen sker med wc_format_decimal() och butikens antal decimaler, så 20 % på 279 kr blir 223,20 och inte en sträng med sex decimaler som WooCommerce sedan avrundar olika på olika ställen.
Jag filtrerar bara price, inte regular_price eller sale_price. Ordinarie pris är listpriset och ska stå kvar som det är. Är produkten på rea avgör is_on_sale() fortfarande utifrån de lagrade reauppgifterna, och visningen blir listpriset överstruket följt av B2B-priset. Utan rea visas bara B2B-priset. Vill du visa listpriset överstruket för B2B-kunder på alla produkter är det ett filter på woocommerce_get_price_html, inte ett ingrepp i reapriset, som sätter reaetiketten på allt.
Det här är också varför pluginet är kassaoberoende. WC_Cart_Totals hämtar radpriset med $cart_item['data']->get_price(), det vill säga genom samma filter, och Store API som blockkassan använder räknar på samma varukorg. Den klassiska kassan läser WC()->cart direkt. Ingen av dem ser något annat pris än det filtret ger.
Steg 5: priser exkl. moms för gruppen
Momsvisningen styrs av två inställningar i WooCommerce, woocommerce_tax_display_shop för butiken och woocommerce_tax_display_cart för varukorg och kassa. De ligger i databasen med värdet incl eller excl, och det är dem du ställer in under WooCommerce, Inställningar, Moms. Gamla forumtrådar beskriver ibland woocommerce_tax_display_shop som ett filter man kan haka på med add_filter(), men något sådant filter finns inte i WooCommerce. Det är bara namnet på inställningen. wc_get_price_to_display() läser inställningarna med get_option(), WC_Cart::get_tax_price_display_mode() läser varukorgens, och Cart- och Checkout-blocken läser varukorgens när sidan renderas och skickar värdet till sitt skript som displayCartPricesIncludingTax.
Att alla läser med get_option() är det som gör det enkelt. WordPress kör sitt eget filter option_{namn} på varje sådan läsning, alltså option_woocommerce_tax_display_shop och option_woocommerce_tax_display_cart, så ett filter på de två alternativen når butik, varukorg och båda kassorna. Kontrollen på init finns där för att användaren inte är säkert bestämd tidigare än så.
<?php
if ( ! function_exists( 'wpu_b2b_tax_display' ) ) :
add_filter( 'option_woocommerce_tax_display_shop', 'wpu_b2b_tax_display' );
add_filter( 'option_woocommerce_tax_display_cart', 'wpu_b2b_tax_display' );
function wpu_b2b_tax_display( $value ) {
if ( ! did_action( 'init' ) || 'yes' !== get_option( 'wpu_b2b_show_excl_tax', 'no' ) || ! wpu_b2b_is_customer() ) {
return $value;
}
return 'excl';
}
endif;
Lagrar butiken priser inklusive moms, vilket är det normala i Sverige, räknar WooCommerce bort momsen vid visning. Ett B2B-pris på 223,20 kr visas då som 178,56 kr för en B2B-kund när momssatsen är 25 %. Det belopp som faktureras är detsamma oavsett visningsläge, så det här steget ändrar bara vad kunden ser.
Hela pluginet
Det här är den färdiga filen, wpu-b2b-priser.php, utan delstegens function_exists-kontroller. Lägg den i wp-content/plugins/wpu-b2b-priser/ och aktivera.
<?php
/**
* Plugin Name: WPU B2B-priser
* Description: Kundgrupp, B2B-pris per produkt, procentrabatt och priser exkl. moms för inloggade B2B-kunder.
* Version: 1.0.0
* Requires Plugins: woocommerce
* Requires PHP: 8.2
* Text Domain: wpu-b2b
*/
defined( 'ABSPATH' ) || exit;
/*
* 1. The customer group: a role with the same capabilities as "customer".
*/
register_activation_hook( __FILE__, 'wpu_b2b_activate' );
function wpu_b2b_activate() {
$customer = get_role( 'customer' );
add_role( 'b2b_kund', 'B2B-kund', $customer ? $customer->capabilities : array( 'read' => true ) );
// WordPress stores the uninstall callback in an option, so it belongs here and not at file load.
register_uninstall_hook( __FILE__, 'wpu_b2b_uninstall' );
}
function wpu_b2b_uninstall() {
// A removed role would leave its users without any role, so they become plain customers first.
foreach ( get_users( array( 'role' => 'b2b_kund', 'fields' => 'ID' ) ) as $user_id ) {
$user = new WP_User( $user_id );
$user->remove_role( 'b2b_kund' );
if ( empty( $user->roles ) ) {
$user->add_role( 'customer' );
}
}
remove_role( 'b2b_kund' );
delete_option( 'wpu_b2b_discount_percent' );
delete_option( 'wpu_b2b_show_excl_tax' );
delete_post_meta_by_key( '_wpu_b2b_price' );
}
// The plugin never touches orders, but the declaration keeps it off the "uncertain" list under Features.
add_action( 'before_woocommerce_init', 'wpu_b2b_declare_hpos' );
function wpu_b2b_declare_hpos() {
if ( class_exists( AutomatticWooCommerceUtilitiesFeaturesUtil::class ) ) {
AutomatticWooCommerceUtilitiesFeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true );
}
}
/*
* 2. Who gets B2B prices, and the two settings under WooCommerce > Settings > Products.
*/
function wpu_b2b_is_customer() {
$user = wp_get_current_user();
return $user->exists() && in_array( 'b2b_kund', (array) $user->roles, true );
}
add_filter( 'woocommerce_product_settings', 'wpu_b2b_settings' );
function wpu_b2b_settings( $settings ) {
$settings[] = array(
'title' => 'B2B-priser',
'type' => 'title',
'id' => 'wpu_b2b_options',
'desc' => 'Gäller inloggade kunder med rollen B2B-kund.',
);
$settings[] = array(
'title' => 'Rabatt i procent',
'id' => 'wpu_b2b_discount_percent',
'type' => 'number',
'default' => '0',
'desc_tip' => 'Används på produkter som saknar eget B2B-pris.',
'custom_attributes' => array(
'min' => '0',
'max' => '100',
'step' => '0.01',
),
);
$settings[] = array(
'title' => 'Visa priser exkl. moms',
'id' => 'wpu_b2b_show_excl_tax',
'type' => 'checkbox',
'default' => 'no',
'desc' => 'B2B-kunder ser priser exklusive moms i butik, varukorg och kassa.',
);
$settings[] = array(
'type' => 'sectionend',
'id' => 'wpu_b2b_options',
);
return $settings;
}
/*
* 3. The B2B price field on simple products and on every variation.
*/
add_action( 'woocommerce_product_options_pricing', 'wpu_b2b_product_field' );
function wpu_b2b_product_field() {
woocommerce_wp_text_input(
array(
'id' => '_wpu_b2b_price',
'label' => 'B2B-pris (' . get_woocommerce_currency_symbol() . ')',
'data_type' => 'price',
'desc_tip' => true,
'description' => 'Pris för inloggade B2B-kunder. Tomt betyder procentrabatten i inställningarna.',
)
);
}
add_action( 'woocommerce_admin_process_product_object', 'wpu_b2b_save_product_field' );
function wpu_b2b_save_product_field( $product ) {
// WooCommerce has verified the product form nonce before this hook runs.
if ( ! isset( $_POST['_wpu_b2b_price'] ) || is_array( $_POST['_wpu_b2b_price'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
return;
}
wpu_b2b_store_price( $product, wc_clean( wp_unslash( $_POST['_wpu_b2b_price'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
}
add_action( 'woocommerce_variation_options_pricing', 'wpu_b2b_variation_field', 10, 3 );
function wpu_b2b_variation_field( $loop, $variation_data, $variation ) {
// $variation is a WP_Post here; variations are posts even with HPOS, which only moves orders.
woocommerce_wp_text_input(
array(
'id' => "_wpu_b2b_variation_price_{$loop}",
'name' => "_wpu_b2b_variation_price[{$loop}]",
'value' => get_post_meta( $variation->ID, '_wpu_b2b_price', true ),
'label' => 'B2B-pris (' . get_woocommerce_currency_symbol() . ')',
'data_type' => 'price',
'wrapper_class' => 'form-row form-row-full',
)
);
}
add_action( 'woocommerce_admin_process_variation_object', 'wpu_b2b_save_variation_field', 10, 2 );
function wpu_b2b_save_variation_field( $variation, $i ) {
if ( ! isset( $_POST['_wpu_b2b_variation_price'][ $i ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
return;
}
wpu_b2b_store_price( $variation, wc_clean( wp_unslash( $_POST['_wpu_b2b_variation_price'][ $i ] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
}
function wpu_b2b_store_price( $product, $raw ) {
// The product saves itself right after these hooks, so only the meta needs setting.
$value = wc_format_decimal( $raw );
if ( '' === $value ) {
$product->delete_meta_data( '_wpu_b2b_price' );
} else {
$product->update_meta_data( '_wpu_b2b_price', $value );
}
}
/*
* 4. The price. One function, hooked into every place WooCommerce reads an active price.
*/
function wpu_b2b_price( $price, $product ) {
if ( '' === $price || null === $price || ! wpu_b2b_is_customer() ) {
return $price;
}
$fixed = $product->get_meta( '_wpu_b2b_price' );
if ( '' !== $fixed && null !== $fixed ) {
return wc_format_decimal( $fixed, wc_get_price_decimals() );
}
$percent = (float) get_option( 'wpu_b2b_discount_percent', 0 );
if ( $percent <= 0 || $percent > 100 ) {
return $price;
}
// Work on $price as given. Calling $product->get_price() in here would run this filter again.
return wc_format_decimal( (float) $price * ( 1 - $percent / 100 ), wc_get_price_decimals() );
}
add_filter( 'woocommerce_product_get_price', 'wpu_b2b_price', 10, 2 );
add_filter( 'woocommerce_product_variation_get_price', 'wpu_b2b_price', 10, 2 );
// The price range of a variable product reads its variations in "edit" context, which skips the two filters above.
add_filter( 'woocommerce_variation_prices_price', 'wpu_b2b_price', 10, 2 );
add_filter( 'woocommerce_get_variation_prices_hash', 'wpu_b2b_prices_hash' );
function wpu_b2b_prices_hash( $hash ) {
// The range is cached in a transient. Without this key one group's range would be served to the other.
$hash['wpu_b2b'] = wpu_b2b_is_customer() ? (string) get_option( 'wpu_b2b_discount_percent', 0 ) : 'no';
return $hash;
}
/*
* 5. Prices without VAT for the group. WooCommerce reads the display mode with get_option(),
* in the shop, in the cart and when the Cart and Checkout blocks render, so the option filter covers them all.
*/
add_filter( 'option_woocommerce_tax_display_shop', 'wpu_b2b_tax_display' );
add_filter( 'option_woocommerce_tax_display_cart', 'wpu_b2b_tax_display' );
function wpu_b2b_tax_display( $value ) {
if ( ! did_action( 'init' ) || 'yes' !== get_option( 'wpu_b2b_show_excl_tax', 'no' ) || ! wpu_b2b_is_customer() ) {
return $value;
}
return 'excl';
}
Så testade jag
Jag laddade filen i en testbutik med WooCommerce 11.2.1 via WP-CLI och körde ett beteendetest i samma stil, utan att lämna några spår. Rollen lades till i minnet med wp_roles()->use_db avstängt, inställningarna sattes med pre_option-filter, och en tillfällig användare skapades med wp_insert_user(), fick rollen, och raderades med wp_delete_user() i samma körning. Produkten var en befintlig enkel produkt med lagrat pris 279 kr, och B2B-priset sattes med update_meta_data() på objektet utan att spara.
Utloggad gav get_price() 279,00. Med rollen aktiv och 20 % i inställningen gav en ny wc_get_product() 223,20, ordinarie pris stod kvar på 279,00, och det visade priset blev 178,56 kr eftersom testbutiken lagrar priser inklusive 25 % moms och visar dem exklusive. Med ett fast B2B-pris på 99,5 på objektet gav get_price() 99,50, och woocommerce_variation_prices_price gav samma svar. Prishashen fick värdet 20 för B2B-kunden och no för alla andra. Efter utloggning var priset 279,00 igen, användaren var borta, rollerna i databasen var oförändrade och inget alternativ hade skrivits. Det jag inte har gjort är att provhandla i webbläsaren med varukorg, kassa och en variabel produkt, så gör det i din butik innan du släpper in kunder.
Vill du köra ett liknande test själv lägger du det i en fil och kör wp eval-file fil.php --skip-themes från butikens rotmapp. Det tar några sekunder och lämnar inga spår, förutsatt att du raderar användaren i samma körning och aldrig anropar save() på produkten.
Fallgropar
Den vanligaste fallgropen är dubbel rabatt i varukorgen, och den kommer från ett mönster som finns i många guider. Där hängs rabatten på woocommerce_before_calculate_totals med $cart_item['data']->set_price( $cart_item['data']->get_price() * 0.8 ). Det går fel av två skäl. calculate_totals() körs när varukorgen laddas från sessionen och igen vid varje ändring av antal, och eftersom get_price() returnerar det pris som redan satts blir det 0,8 gånger 0,8 vid andra varvet. Har du dessutom ett filter på woocommerce_product_get_price räknas rabatten en gång till ovanpå det. Regeln jag följer är att bara filtrera gettern, räkna på det $price som filtret får in, och aldrig anropa get_price() inne i filtret.
Rea och procent läggs ihop i koden ovan. En B2B-kund med 20 % får rabatten på reapriset, inte på ordinarie pris, eftersom $price som kommer in i filtret är det aktiva priset. Det är ett val och inte en bugg, men det ska stå i avtalet med kunden, och det ska bestämmas innan första kampanjen och inte efter. Vill du räkna procenten på ordinarie pris byter du $price i procentgrenen mot $product->get_regular_price( 'edit' ). Läget edit kör inga filter, så det är säkert att anropa inne i funktionen.
Variabla produkter är den andra. Fungerar rabatten på produktsidan när en variation är vald men inte i intervallet i listan, saknas woocommerce_variation_prices_price. Visas rätt intervall för första besökaren och fel för nästa, saknas hashen. Båda är beskrivna i kommentaren till filtret i WooCommerces datalager, men lätta att missa.
Den tredje är avinstallationen. Tar du bort rollen med remove_role() utan att först flytta användarna står de utan roll och kan inte handla. Koden i steg 1 flyttar dem till customer, men bara när pluginet avinstalleras via WordPress. Raderar du mappen med FTP körs ingen avinstallation, och rollen står kvar i databasen tills någon tar bort den.
Momsvisningen kan också överraska. Visar butiken inte exklusive moms för B2B-kunden trots kryssrutan beror det nästan alltid på att Aktivera moms inte är ikryssat, eller att produkten saknar momsklass som matchar en momssats. Filtret gör sitt jobb, men WooCommerce har inget att dra av.
Nästa steg
Behöver butiken flera grupper, säg återförsäljare och grossister med olika listor, byter du rollkontrollen mot en funktion som returnerar gruppens nyckel, och metanyckeln blir _wpu_b2b_price_<grupp>. Prishashen får gruppens namn i stället för en procentsats. Resten av koden är densamma. Vill du dessutom visa listpriset överstruket för B2B-kunder hänger du ett filter på woocommerce_get_price_html som skriver ut get_regular_price() före get_price().
Ska utloggade inte se priser alls kombinerar du det här med koden i inlägget om dolda priser. De två rör olika saker, visningen respektive priset, och stör inte varandra.
Pihlfelt & Vänner
Behöver du mer än kärnan?
Företagskonton med flera användare, prislistor per kund och beställ igen från tidigare ordrar är det vi bygger varje vecka.
Vanliga frågor
Fungerar pluginet med blockkassan?
Ja. Prisfiltren körs inne i produktobjektet, och både blockkassan och den klassiska kassan hämtar priset därifrån via varukorgen. Momsvisningen läser Cart- och Checkout-blocken från inställningen woocommerce_tax_display_cart när sidan renderas, och den går genom samma filter.
Vad händer när produkten är på rea?
Ett fast B2B-pris gäller oavsett rea. Procentrabatten räknas på det pris kunden annars hade fått, alltså reapriset om ett sådant finns. Vill du att rean ska slå det fasta priset när den är lägre lägger du en min() i wpu_b2b_price.
Kan jag ha flera kundgrupper med olika prislistor?
Mönstret håller. En roll per grupp, en metanyckel per grupp och en rollkontroll som returnerar gruppens nyckel i stället för sant eller falskt. Prishashen måste då innehålla gruppens namn.
Varför ser vanliga kunder B2B-intervallet på en variabel produkt?
Prisintervallet cachas i en transient per produkt, och nyckeln byggs av woocommerce_get_variation_prices_hash. Saknas gruppen i hashen får den som först laddar sidan bestämma intervallet för alla. Koden i guiden lägger till den.
Måste momsberäkning vara påslagen för att visa exkl. moms?
Ja. Visningsläget gör bara skillnad om Aktivera moms är ikryssat och en momssats matchar produkten. Utan momssats finns det inget att dra av, och priset visas som det är lagrat.
Källor
- WC_Data::get_prop, filtren woocommerce_product_get_*
- WC_Product_Variation::get_hook_prefix (woocommerce_product_variation_get_*)
- WC_Product_Variable_Data_Store_CPT::read_price_data (woocommerce_variation_prices_price, woocommerce_get_variation_prices_hash)
- WC_Meta_Box_Product_Data (woocommerce_admin_process_product_object, woocommerce_admin_process_variation_object)
- wc_get_price_to_display, wc-product-functions.php
- WC_Cart_Totals::get_items_from_cart, priset i varukorgen
- register_uninstall_hook(), WordPress Developer Resources
- add_role(), WordPress Developer Resources
