Customer-specific pricing in Vendure for B2B clients

Martijn null

Martijn

4 min read , ~3 ~hours to implement

In B2B commerce, it’s perfectly normal for different customers to pay different prices. For example, you might deliver fresh and fast to restaurants, while resellers order in bulk and handle their own distribution. In such cases, you want your webshop to reflect these differences with customer-specific pricing. In this blog, we’ll show you how to set up customer-specific pricing in Vendure — giving you the flexibility to manage tailored pricing agreements with ease.

Customer-specific pricing in Vendure for B2B clients

We handle customer-specific pricing in Vendure by creating products and customer groups, maintaining group-specific prices in a Google Sheet, and loading those prices automatically — once a customer logs in, they’ll see the correct price.

Creating products in Vendure

We create 2 example products: "Tuinkers" (cress) and "Kiemgroente Erwt" (pea shoots). Both products have a single variant.

Customer groups

To determine which price a customer sees, we work with customer groups. In this example, we create two groups: Bio Shop and Premium Restaurant. We assign customer Niels to the Bio Shop group, and Martijn to the Premium Restaurant group. This allows us to apply different pricing agreements based on customer type.

Managing prices with Google Sheets or Excel

Why use a sheet or Excel file? It's a simple way to manage prices in bulk — for example, to increase them by a fixed percentage. Many business owners are already familiar with this approach, so we’re reusing it here. In this example, we’re using Google Sheets and temporarily making it public. Normally, such a sheet would be private, but for the sake of simplicity in this blog, it’s open.


In this example, we've entered prices for the two products we created in step 1.

We publish the sheet: File > Share > Publish to web. Now we can fetch the data from the sheet using the URL:
https://docs.google.com/spreadsheets/d/e/2PACX-1vRHjU-Bfw7_DbgCxfUvEN7g3Up-vc879xuJN1fTcTo8P8jGGiuc11lZumH9Krw1jNeQpZoTGmsYuyYv/pub?output=csv.

Vendure plugin to fetch prices from the sheet

In Vendure, we can implement the ProductVariantPriceCalculationStrategy` interface. This strategy determines what the price of a product should be.

View full implementation
// customer-specific-price-calculation-strategy.ts
import {
CacheService,
Customer,
Injector,
Logger,
PriceCalculationResult,
ProductVariantPriceCalculationArgs,
ProductVariantPriceCalculationStrategy,
RequestContext,
TransactionalConnection
} from "@vendure/core";
import { parse } from "csv-parse/sync";

// The column names coming from the CSV
interface Row {
sku: string;
"Premium restaurant": number;
"Bio winkel": number;
Groothandel: number;
Consument: number;
}

const loggerCtx = "CustomerSpecificPriceCalculationStrategy";

export class CustomerSpecificPriceCalculationStrategy
implements ProductVariantPriceCalculationStrategy
{
private cache: CacheService;
private transactionalConnection: TransactionalConnection;

async init(injector: Injector) {
  this.cache = injector.get(CacheService);
  this.transactionalConnection = injector.get(TransactionalConnection);
}

async calculate({
  ctx,
  productVariant,
}: ProductVariantPriceCalculationArgs): Promise<PriceCalculationResult> {
  // Get customer group and price data
  const customerGroups = await this.getCustomerGroupNames(ctx);
  const priceData = await this.fetchPriceData();

  // Find the product row in our price data by matching SKU
  const productPrice = priceData.find(
    (p) => p.sku.toLowerCase() === productVariant.sku?.toLowerCase()
  );

  if (!productPrice) {
    // The SKU was not found in our price data sheet
    Logger.error(
      `No price data found for product ${productVariant.sku}`,
      loggerCtx
    );
    return {
      price: productVariant.listPrice,
      priceIncludesTax: productVariant.listPriceIncludesTax,
    };
  }

  // Get price based on customer group
  const price = this.getPriceForCustomerGroup(productPrice, customerGroups);

  return {
    price: Math.round(price * 100), // Convert to cents
    priceIncludesTax: false, // We manage prices in the sheet without tax
  };
}

/**
 * Get the name of the customer groups of the logged in customers
 */
private async getCustomerGroupNames(ctx: RequestContext): Promise<string[]> {
  if (!ctx.activeUserId) {
    return [];
  }
  const cacheKey = `customer-groups-${ctx.activeUserId}`;
  const cacheHit = await this.cache.get(cacheKey);
  if (cacheHit) {
    return cacheHit as string[];
  }
  const customer = await this.transactionalConnection
    .getRepository(ctx, Customer)
    .findOne({
      where: { user: { id: ctx.activeUserId } },
      relations: ["groups"],
    });
  const customerGroups = customer?.groups?.map((group) => group.name) || [];
  await this.cache.set(cacheKey, customerGroups, {
    ttl: 5 * 60 * 1000, // Cache for five minutes
  });
  return customerGroups;
}

private async fetchPriceData(): Promise<Row[]> {
  // TODO: prevent concurrent requests when no cache is present
  const cacheKey = "customer-specific-price-data";
  const cacheHit = await this.cache.get(cacheKey);
  if (cacheHit) {
    return cacheHit as Row[];
  }
  const response = await fetch(
    "https://docs.google.com/spreadsheets/d/e/2PACX-1vRHjU-Bfw7_DbgCxfUvEN7g3Up-vc879xuJN1fTcTo8P8jGGiuc11lZumH9Krw1jNeQpZoTGmsYuyYv/pub?output=csv"
  );
  const csvData = await response.text();
  const records = parse(csvData, {
    columns: true,
    skip_empty_lines: true,
  });
  // Cache for one minute
  await this.cache.set(cacheKey, records, {
    ttl: 1 * 60 * 1000,
  });
  return records;
}

/**
 * Get price for given customer group.
 * Falls back to consumer price if no price is found for the given group.
 */
private getPriceForCustomerGroup(
  row: Row,
  groups: string[]
): number {
  const prices = groups.map((group) => (row[group as keyof Row] as number));
  if (!prices.length) {
    // Fall back to consument price if no price is found for the given group
    return row.Consument;
  }
  return Math.min(...prices);
}
}

Don’t forget to register the strategy in your vendure-config.ts:

export const config: VendureConfig = {
    catalogOptions: {
        productVariantPriceCalculationStrategy: new CustomerSpecificPriceCalculationStrategy(),
    },
    ...

Final result

When we log in as Niels, owner of a bio store, we see the price for tuinkers is €10.99:
Prijs voor Bio Winkels

Then, when we log in as Premium Restaurant owner Martijn, the tuinkers price is €12.99.
Prijs voor Preimium Restaurants

What's next?

To keep this blog readable, we’ve skipped a few production-level improvements. If you want to implement this in a real-world setting, consider the following:

  1. Load the data during the init() function of your pricing strategy so it’s ready in memory.
  2. Cache the data from Google Sheets. You don’t want to call the API for every price lookup. Ideally, use a stale-while-revalidate pattern so the calculate() function never waits.
  3. Don't use a public sheet. In a real implementation, fetch the data securely via OAuth from a private sheet.
  4. Cache customer groups per activeUserId to avoid database queries on every price check.

Prefer not to build this yourself? Feel free to reach out — we’re happy to help!