DEV Community

Björn Meyer for shopware

Posted on

Gross and Net-Switch for B2B and B2C Shops built with Composable Frontends

As a seller, you always want to offer your customers the best possible user experience. If an online store is not exclusively geared towards B2C or B2B, this becomes difficult, as these target groups have different requirements. One of the main requirements of B2B customers is, for example, the display of prices without VAT.

Whether the prices are displayed with or without VAT is controlled in Shopware via the customer group. However, if the user is not logged in, he automatically receives the default customer group, which has set the price display incl. VAT in the default.

In order to enable the user to switch from B2C to B2B in a non-logged-in state, a corresponding switch is offered in the store in many cases.

For a Shopware installation with default storefront (Twig) the way seems very obvious, but for the implementation with Composable Frontends we asked ourselves some questions:

  • Where should the setting of the switch be stored?
  • How does the correct price output take place?
  • How can a corporate customer be automatically registered with the B2B customer group?

Local storage instead of session

If we would probably store the switch setting in the session (PHP) so far, our choice for Composable Frontends falls on the local storage. The reason for this is that the handling of the price display mainly takes place in the frontend and the adjustment is easier to implement here.

For this purpose we use the useLocalStorage() function of the VueUse Collection, which simplifies the handling of the variable from the local storage and brings the usual reactivity of Vue at the same time.

So we need a Vue component for the switch, we call this component TaxSwitch. We include this in the header so that it is visible on every page in the store.

The switch receives the values net (for B2C) and gross (for B2B). Since by default the customers are B2C customers, the local storage variable in the second parameter receives the default value gross.

The button should only be visible when the customer is not logged in, which we achieve with the simple query v-if="!isLoggedIn" within the component.

Shows a switch to change prices from net to gross

💡 We use Vuetify components (v-switch, v-col) in the templates above, these are recognizable by the v- prefix. To use the same components you need to install it into your project via a package manager.

<script setup lang="ts">
import { useLocalStorage } from "@vueuse/core";
const { isLoggedIn } = useUser();
const taxSwitch = useLocalStorage("taxSwitch", "gross");
</script>
<template>
  // ... v-switch component is provided by vuetify
  <v-switch
    v-if="!isLoggedIn"
    v-model="taxSwitch"
    hide-details
    true-value="net"
    false-value="gross"
    class="flex-0-0"
    data-testid="tax-switch"
  >
    <template #prepend> Private</template>
    <template #append> Company</template>
  </v-switch>
</template>
Enter fullscreen mode Exit fullscreen mode

Price calculation based on the setting of the B2C / B2B switch

In Shopware store API, gross and net prices are not returned at the same time, so we need to calculate the prices according to the switch setting.

To simplify this, we use the SharedPrice component from the Demo Store template consistently for every price display in the store. This way we only have to implement the logic in one place.

We have to calculate the price only in the following case:

If the customer is not logged in
and the switch has the value net
and the store setting of the tax has the value gross.

In all other situations, the price is passed on without calculation.

Depending on the implementation, there are places in the cart and checkout where no calculation of the tax should take place. That's why we decided to use the props.taxRate as an indicator for this purpose. If this is not passed to the SharedPrice component, nothing has to be calculated.

Furthermore, we have added an output to the price, which gives the user a hint (taxHint) whether the price is displayed including or excluding VAT.

Thanks to the reactive mechanics of the Vue framework, all prices in the frontend are automatically adjusted when the switch is changed.

<script setup lang="ts">
const { getFormattedPrice } = usePrice();
const { isLoggedIn } = useUser();
const { taxState } = useSessionContext();
const taxSwitch = useLocalStorage("taxSwitch", "gross");
const props = defineProps<{
  value: object | undefined;
}>();
const getPrice = computed<string>(() => {
  return !isLoggedIn.value &&
    taxSwitch.value === "net" &&
    taxState.value === "gross" &&
    props.value.taxRate !== undefined
    ? getFormattedPrice((props.value.price / (100 + props.value.taxRate)) * 100)
    : getFormattedPrice(props.value.price);
});
const taxHint = computed(() => {
  if (props.value.taxRate === undefined) {
    return "";
  }
  if (
    (!isLoggedIn.value && taxSwitch.value === "net") ||
    (isLoggedIn.value && taxState.value === "net")
  ) {
    return "ohne&nbsp;USt.";
  } else {
    return "inkl.&nbsp;USt.";
  }
});
</script>
<template>
  <span>
    <slot name="beforePrice" />
    <span data-testid="shared-price">{{ getPrice }}</span>
    &nbsp;<span class="text-caption" v-html="taxHint"></span>
    <slot name="afterPrice" />
  </span>
</template>
Enter fullscreen mode Exit fullscreen mode

Automatic registration of the customer group based on the setting of the B2C / B2B switch

To ensure that a new B2B customer retains the correct display of VAT in the order process after registration, the B2B customer group must be set during registration.

Plugin configuration

For this reason, we create a plugin configuration with a selection field that allows you to specify the B2B customer group. Additionally, we added an option to activate the plugin.

<?xml version="1.0" encoding="UTF-8"?>
<config
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:noNamespaceSchemaLocation="https://raw.githubusercontent.com/shopware/platform/trunk/src/Core/System/SystemConfig/Schema/config.xsd"
>
  <card>
    <title>#ConVisTaxSwitch# Settings</title>
    <title lang="de-DE">#ConVisTaxSwitch# Einstellungen</title>
    <input-field type="bool">
      <label>Active</label>
      <label lang="de-DE">Aktiviert</label>
    </input-field>
    <component name="sw-entity-single-select">
      <name>b2bCustomerGroup</name>
      <entity>customer_group</entity>
      <label>Choose the B2B customer group.</label>
    </component>
  </card>
</config>
Enter fullscreen mode Exit fullscreen mode

Customize registration form

To allow the user to register as a company in the registration process, we have added the fields AccountType and Company to the registration form of the Demo Store template.

In the initial state we automatically set the AccountType to private, if the switch is set to gross we change it to business. To make the Company field mandatory for the AccountType business, we extend the form validation rules accordingly.

// pages/checkout/index.vue
<script setup lang="ts">
// ...
const state = reactive({
  accountType: taxSwitch.value === "gross" ? "private" : "business",
  salutationId: "",
  firstName: "",
  lastName: "",
  email: "",
  password: "",
  guest: false,
  billingAddress: {
    company: "",
    street: "",
    zipcode: "",
    city: "",
    countryId: "",
    countryStateId: "",
  },
  customShipping: false,
});
// ...
const rules = computed(() => ({
  accountType: {
    required,
  },
  salutationId: {
    required,
  },
  firstName: {
    required,
    minLength: minLength(3),
  },
  lastName: {
    required,
    minLength: minLength(3),
  },
  email: {
    required,
    email,
  },
  password: {
    required: requiredIf(() => {
      return !state.guest;
    }),
    minLength: minLength(8),
  },
  billingAddress: {
    company: {
      required: requiredIf(() => {
        return state.accountType === "business";
      }),
    },
    street: {
      required,
      minLength: minLength(3),
    },
    zipcode: {
      required,
    },
    city: {
      required,
    },
    countryId: {
      required,
    },
    countryStateId: {
      required: requiredIf(() => {
        return
        !!getStatesForCountry(state.billingAddress.countryId)?.length;
      }),
    },
  },
}));
// ...
</script>

// pages/checkout/index.vue
<template>
  // ... v-col component is provided by vuetify
  <v-col cols="12">
    <v-select
      v-model="state.accountType"
      id="accountType"
      :label="$t('form.accountType')"
      :items="accountTypes"
      name="accountType"
      :disabled="loading"
      data-testid="registration-account-type-select"
      :error-messages="$v.accountType.$errors.map((e) => e.$message)"
      @blur="$v.accountType.$touch()"
      @update:modelValue="changeTaxSwitchByAccountType"
    ></v-select>
  </v-col>
  // ... v-col component is provided by vuetify
  <v-col v-if="state.accountType === 'business'" cols="12">
    <v-text-field
      id="company"
      v-model="state.billingAddress.company"
      :label="$t('form.company') + ' *'"
      name="company"
      type="text"
      autocomplete="company"
      :disabled="loading"
      data-testid="registration-company-input"
      :error-messages="$v.billingAddress.company.$errors.map((e) => e.$message)"
      @blur="$v.billingAddress.company.$touch()"
    />
  </v-col>
  // ...
</template>
Enter fullscreen mode Exit fullscreen mode

Create Subscriber

In order to be able to directly overwrite the customer data with the B2B customer group after registration, we create a subscriber for the CustomerEvents::CUSTOMER_WRITTEN_EVENT.

If the plugin is active and the Company field has a value, the user is a B2B customer according to our definition. Thus, we set the B2B customer group that we had previously defined in the plugin configuration for this customer.

<?php declare(strict_types=1);

namespace ConVis\TaxSwitch\Subscriber;

use Shopware\Core\Checkout\Customer\CustomerEvents;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenEvent;
use Shopware\Core\System\SystemConfig\SystemConfigService;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class CreateCustomerSubscriber implements EventSubscriberInterface
{
    public function __construct(
        private readonly EntityRepository $customerRepository,
        private readonly SystemConfigService $systemConfigService
    ) {}

    public static function getSubscribedEvents(): array
    {
        return [
            CustomerEvents::CUSTOMER_WRITTEN_EVENT => 'onCustomerWritten'
        ];
    }

    public function onCustomerWritten(EntityWrittenEvent $event): void
    {
        $context = $event->getContext();
        $salesChannelId = $context->getSource()->getSalesChannelId();
        $pluginActive = $this->systemConfigService->get('ConVisTaxSwitch.config.active', $salesChannelId);

        if (!$pluginActive) {
            return;
        }

        $b2bCustomerGroupId = $this->systemConfigService->get('ConVisTaxSwitch.config.b2bCustomerGroup', $salesChannelId);
        $writeResults = $event->getWriteResults();
        $payload = $writeResults[0]->getPayload();

        if (!empty($payload['company']) && $payload['groupId'] !== $b2bCustomerGroupId) {
            $this->customerRepository->update([
                [
                    'id' => $payload['id'],
                    'groupId' => $b2bCustomerGroupId,
                    'requestedGroupId' => null,
                ]
            ], $context);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

If the customer has set the switch to B2B, now in the store not only the price display is adjusted, but also automatically receives the B2B customer group during registration and during checkout.

This blog post was provided by our partner con-vis and René Altmann. It shows very well that the headless approach with Shopware Composable Frontends can be suitable for both B2C and B2B customers. Thank you for sharing this knowledge.

Top comments (0)