Klant specifieke prijzen

Martijn
In B2B-handel is het heel normaal dat niet elke klant dezelfde prijs betaalt. Denk bijvoorbeeld aan restaurants die je snel en vers belevert, tegenover resellers die in bulk bestellen en zelf distributie regelen. In zulke gevallen wil je in je webshop verschillende prijzen hanteren, afhankelijk van het type klant. In deze blog laten we zien hoe je in Vendure klant-specifieke prijzen kunt instellen — zodat je flexibel blijft en prijsafspraken eenvoudig kunt beheren.

We regelen klant-specifieke prijzen in Vendure door producten en klantgroepen aan te maken, prijzen per groep in een Google Sheet bij te houden, en die automatisch in te laden — zodra een klant is ingelogd, krijgt die de juiste prijs te zien.
Producten in Vendure aanmaken
We maken 2 voorbeeldproducten aan, "Tuinkers" en "Kiemgroente Erwt". Beide producten hebben 1 enkele variant.
Klantgroepen
Om te bepalen welke prijs een klant te zien krijgt, werken we met klantgroepen. In dit voorbeeld maken we twee groepen aan: Bio Winkel en Premium Restaurant. Klant Niels voegen we toe aan de groep Bio Winkel, en Martijn aan Premium Restaurant. Zo kunnen we eenvoudig verschillende prijsafspraken toepassen per klanttype.
Google Sheet of Excel om prijzen te beheren
Waarom werken met een sheet of Excel-bestand? Het is een eenvoudige manier om prijzen in bulk te beheren, bijvoorbeeld om ze met een vast percentage te verhogen. Veel ondernemers zijn al vertrouwd met deze aanpak, dus die zetten we hier slim in. In dit voorbeeld gebruiken we Google Sheets, die we tijdelijk publiek beschikbaar maken. Normaal is zo’n sheet privé, maar voor deze blog houden we het transparant en eenvoudig.
In dit voorbeeld hebben we prijzen ingevuld voor de twee producten die we in stap 1 hebben aangemaakt.
We maken de sheet publiek beschikbaar: File > Share > Publish to web
. We kunnen de data uit de sheet nu ophalen via de URL https://docs.google.com/spreadsheets/d/e/2PACX-1vRHjU-Bfw7_DbgCxfUvEN7g3Up-vc879xuJN1fTcTo8P8jGGiuc11lZumH9Krw1jNeQpZoTGmsYuyYv/pub?output=csv
.
Vendure plugin om prijzen uit de sheet te halen
In Vendure kunnen we de ProductVariantPriceCalculationStrategy
` implementeren. Deze strategy bepaalt vervolgens wat de prijs moet zijn van een product.
Volledige implementatie bekijken
// 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);
}
}
Vergeet niet om deze strategy in het vendure-config.ts
bestand te zetten:
export const config: VendureConfig = {
catalogOptions: {
productVariantPriceCalculationStrategy: new CustomerSpecificPriceCalculationStrategy(),
},
...
Uiteindelijke resultaat
Als we nu inloggen als Niels, die eigenaar is van een biologische winkel, zie we dat de prijs voor tuinkers €10.99 is:
Als we vervolgens inloggen als Premium Restaurant eigenaar Martijn, zie we dat de tuinkers €12.99 is.
What's next?
Om de leesbaarheid van deze blog te behouden, hebben we enkele stappen achterwege gelaten. Wil je dit productiewaardig implementeren? Houd dan rekening met het volgende:
- Haal de data op tijdens de initialisatie (
init()
functie) van je pricing strategy, zodat deze direct in geheugen beschikbaar is. - Cache de data uit de Google Sheet. Je wilt niet bij elke prijsberekening een API-call doen. Idealiter gebruik je een
stale-while-revalidate
-strategie, zodat decalculate()
functie nooit hoeft te wachten op externe data. - Maak de Google Sheet niet publiek. In een echte toepassing haal je de data op via OAuth uit een privé-sheet.
- Je wil een cache bijhouden van de groepen per
activeUserId
, zodat je niet bij elke prijsberekening de groepen uit de database hoeft te halen.
Wil je dit liever niet zelf bouwen? Uiteraard kun je altijd contact met ons opnemen — we helpen je graag verder!