The tag trap: why customer tags cannot drive Shopify Plus B2B
For a decade, Shopify agencies handled wholesale using customer tags like wholesale or net_30. Liquid themes inspected customer.tags to render discounted prices or reveal manual payment methods.
In native Shopify Plus B2B, customer tags carry zero operational authority: they do not assign B2B Catalogs, cannot enforce CompanyLocation payment terms, and cause dual-identity leaks when staff members transition between wholesale entities.
The B2B entity graph: Companies, Locations, and Catalogs
Placing a wholesale order in Shopify Plus requires an interconnected entity graph: Company, CompanyLocation, CompanyContact, assigned Catalogs, and payment terms schedules.
Manual administration requires clicking through five disjointed screens per applicant. High-volume wholesale operations require automated programmatic orchestration via GraphQL.
Automated fiscal verification: real-time European VIES VAT validation
Under EU Council Directive 2006/112/EC, cross-border intra-Community supplies are zero-rated for VAT only if the buyer's VAT ID is actively registered in VIES.
Regex checks confirm string syntax but cannot prove fiscal activity or legal name alignment. Our engine pairs local format guards with real-time VIES API lookups, falling back to manual review if government services fluctuate.
import assert from "node:assert/strict";
export interface TradeApplicationInput {
companyName: string;
countryCode: "NL" | "DE" | "BE" | "FR" | "GB" | "US";
taxRegistrationNumber: string;
contactFirstName: string;
contactLastName: string;
contactEmail: string;
addressLine1: string;
city: string;
postalCode: string;
requestedCreditLimitEur?: number;
yearsInBusiness: number;
}
export interface ViesValidationResult {
isValid: boolean;
registeredName?: string;
serviceAvailable: boolean;
}
export interface B2BOnboardingDecision {
status: "APPROVED" | "MANUAL_REVIEW" | "REJECTED";
riskScore: number;
assignedCreditLimitEur: number;
assignedPaymentTerms: "NET_15" | "NET_30" | "NET_60" | "DUE_ON_RECEIPT";
assignedCatalogHandle: string;
rejectionReasons: string[];
manualReviewFlags: string[];
graphqlPayload?: {
companyInput: { name: string };
locationInput: {
name: string;
shippingAddress: { address1: string; city: string; zip: string; countryCode: string };
billingAddress: { address1: string; city: string; zip: string; countryCode: string };
};
contactInput: {
firstName: string;
lastName: string;
email: string;
role: "LOCATION_ADMIN" | "ORDERING_ONLY";
};
paymentTermsTemplate: string;
catalogHandle: string;
};
}
export function validateViesVatFormat(countryCode: string, vatNumber: string): boolean {
const cleanVat = vatNumber.replace(/[\s.-]/g, "").toUpperCase();
if (!cleanVat.startsWith(countryCode)) return false;
const digits = cleanVat.slice(countryCode.length);
switch (countryCode) {
case "NL": return /^\d{9}B\d{2}$/.test(digits);
case "DE": return /^\d{9}$/.test(digits);
case "BE": return /^\d{10}$/.test(digits);
case "FR": return /^[A-Z0-9]{2}\d{9}$/.test(digits);
case "GB": return /^\d{9}(\d{3})?$/.test(digits);
default: return cleanVat.length >= 8 && cleanVat.length <= 15;
}
}
export function processB2BTradeApplication(
app: TradeApplicationInput,
viesResult: ViesValidationResult
): B2BOnboardingDecision {
const rejectionReasons: string[] = [];
const manualReviewFlags: string[] = [];
let riskScore = 10;
if (!app.contactEmail.includes("@") || !app.contactEmail.includes(".")) {
rejectionReasons.push("INVALID_EMAIL_ADDRESS");
}
const consumerDomains = ["gmail.com", "hotmail.com", "yahoo.com", "outlook.com"];
const emailDomain = app.contactEmail.split("@")[1]?.toLowerCase() ?? "";
if (consumerDomains.includes(emailDomain)) {
manualReviewFlags.push("FREE_MAIL_PROVIDER_REQUIRES_IDENTITY_CHECK");
riskScore += 25;
}
if (!validateViesVatFormat(app.countryCode, app.taxRegistrationNumber)) {
rejectionReasons.push("INVALID_TAX_ID_FORMAT");
}
if (!viesResult.serviceAvailable) {
manualReviewFlags.push("VIES_SERVICE_UNAVAILABLE_FALLBACK_TO_MANUAL");
riskScore += 20;
} else if (!viesResult.isValid) {
rejectionReasons.push("VIES_TAX_ID_NOT_REGISTERED_FOR_CROSS_BORDER_TRADE");
}
if (app.yearsInBusiness < 1) {
manualReviewFlags.push("NEW_BUSINESS_UNDER_1_YEAR");
riskScore += 30;
}
if (
viesResult.registeredName &&
!app.companyName.toLowerCase().includes(viesResult.registeredName.toLowerCase().slice(0, 5)) &&
!viesResult.registeredName.toLowerCase().includes(app.companyName.toLowerCase().slice(0, 5))
) {
manualReviewFlags.push("COMPANY_NAME_DEVIATES_FROM_GOVERNMENT_REGISTRATION");
riskScore += 20;
}
if (rejectionReasons.length > 0) {
return {
status: "REJECTED",
riskScore: 100,
assignedCreditLimitEur: 0,
assignedPaymentTerms: "DUE_ON_RECEIPT",
assignedCatalogHandle: "retail-fallback",
rejectionReasons,
manualReviewFlags,
};
}
if (manualReviewFlags.length > 0 || riskScore >= 40) {
return {
status: "MANUAL_REVIEW",
riskScore,
assignedCreditLimitEur: 0,
assignedPaymentTerms: "DUE_ON_RECEIPT",
assignedCatalogHandle: "wholesale-pending-verification",
rejectionReasons: [],
manualReviewFlags,
};
}
let assignedPaymentTerms: "NET_15" | "NET_30" | "NET_60" | "DUE_ON_RECEIPT" = "DUE_ON_RECEIPT";
let assignedCreditLimitEur = 0;
if (app.yearsInBusiness >= 5 && riskScore <= 15) {
assignedPaymentTerms = "NET_30";
assignedCreditLimitEur = Math.min(app.requestedCreditLimitEur ?? 5000, 15000);
} else if (app.yearsInBusiness >= 2) {
assignedPaymentTerms = "NET_15";
assignedCreditLimitEur = Math.min(app.requestedCreditLimitEur ?? 2500, 5000);
}
return {
status: "APPROVED",
riskScore,
assignedCreditLimitEur,
assignedPaymentTerms,
assignedCatalogHandle: "b2b-tier-1-wholesale",
rejectionReasons: [],
manualReviewFlags: [],
graphqlPayload: {
companyInput: { name: app.companyName },
locationInput: {
name: `${app.city} Distribution Hub`,
shippingAddress: { address1: app.addressLine1, city: app.city, zip: app.postalCode, countryCode: app.countryCode },
billingAddress: { address1: app.addressLine1, city: app.city, zip: app.postalCode, countryCode: app.countryCode },
},
contactInput: {
firstName: app.contactFirstName,
lastName: app.contactLastName,
email: app.contactEmail,
role: "LOCATION_ADMIN",
},
paymentTermsTemplate: assignedPaymentTerms,
catalogHandle: "b2b-tier-1-wholesale",
},
};
}Atomic GraphQL orchestration: companyCreate to catalogContextUpdate
Once an application is approved, the backend executes four sequential mutations: companyCreate, companyContactCreate, catalogContextUpdate, and companyLocationAssignPaymentTerms.
The entire sequence executes in under 1,200ms, immediately triggering a New Customer Accounts passwordless single sign-on email.
Platform boundaries: B2B role limits, New Customer Accounts, and audit logging
Native Shopify B2B strictly requires New Customer Accounts; Classic Customer Accounts will not render B2B company switching or wholesale checkout surfaces.
Shopify supports two native roles per location: LOCATION_ADMIN and ORDERING_ONLY. Custom granular permissions require custom Checkout Validation Functions.
When this needs an engineer
You do not need a custom build for two wholesale accounts a month. You need an engineer when seasonal order spikes flood your sales desk with international applications, when EU VIES compliance must be automated to stop tax liabilities, and when credit terms must be algorithmically gated by tenure.
Review our work in B2B operations engineering, explore the Weldaad Supplier Portal case study, or inspect our Shopify B2B development services.
