
CSV Import for Procurement and Vendor Management Software
A supplier list grows one buyer at a time. Somebody adds a vendor for a single purchase order, somebody else adds payment terms, and finance adds a tax ID a year later. By the time that list leaves the old system it holds every field anyone there ever needed.
Payment terms and tax IDs are the fields your platform expects. The rest is whatever that one company decided to track, and it reaches you as a column header you have never seen.
Six of the eighteen headers in the file below name fields this platform never
shipped. Three of those, Risk Tier, Insurance Expiry and Plants Served,
exist nowhere outside this one customer.
A procurement platform lets its customers decide what a supplier is. Coupa writes the consequence into its own integration documentation. "For CSV Import, Prompt field value is used as column header." A Prompt is a string a buyer typed into an admin screen, and it becomes a column header in the export. Your code cannot carry that string, because your code shipped before the customer existed.
So the field list arrives over the wire and the schema is built at mount. Customer data onboarding is the work of turning a customer's supplier list into your own records. On a procurement platform the target of that work moves with the tenant.
The file below runs 3,120 rows over 18 columns, 545 KB of .csv. Every figure
in this article was read off imports of that one file.
The supplier list an ERP hands over
A customer moving onto your platform exports the suppliers they already hold.
The column names come out of the system they are leaving, so a Coupa supplier
information export carries Supplier Number, Preferred Currency,
Invoice Matching Level, Minority Indicator and Minority Type. A NetSuite
vendor list carries different ones. Seven rows out of this file, in the spelling
the export used.
| A | B | C | D | E | F | G | H | I | J | K | L | M | N | O | P | Q | R | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | Supplier Number | Company Name | Display Name | SIM Status | Organization Type | Federal Tax ID | DUNS | Country of Operation | Preferred Currency | Payment Term | Invoice Matching Level | Minority Indicator | Minority Type | Primary Contact Email | Pay Group | Risk Tier | Insurance Expiry | Plants Served |
| 2 | V-100424 | Quarrington Springs PLC | Quarrington Springs | Pending | Corporation | 355869704 | 97825272 | GB | usd | 2/10 Net 30 | 3-way | TRUE | SDVOSB | ap@quarringtonspr.com; quarrington.springs@quarringtonspr.com | 2 | 03/26/2026 | PLT-08;PLT-04;PLT-02 | |
| 3 | V-100862 | Lansdowne Forgings Ltd | Draft | LLC | 66-4988758 | 902636648 | USA | USD | Net 30 | 3-way | False | ap@lansdownefor.com; lansdowne.forgings@lansdownefor.com | High | 2026-11-25 | PLT-05 / PLT-12 | |||
| 4 | V-100031 | Ashworth Foundry Corp | Ashworth Foundry | Active | S-Corp | 38-3386951 | 703093457 | DE | USD | Net 30 | 3-way | 1 | WBE | ashworth.foundry@ashworthfou.com | CA-AP | 1 | 2026-12-10 | PLT-11 |
| 5 | Draycott Seals Inc. | Draycott Seals | A | Limited Liability Company | 99-6577700 | 908805639 | DE | USD | Net 90 | 3-way-direct | YES | DBE | draycott.seals@draycottsea.com | EU-AP | Low | PLT-12;PLT-05 | ||
| 6 | V-100022 | Saltburn Gasket GmbH | A | 698433731 | 258080784 | US | USD | Net 30 | 3-way | False | saltburn.gasket.saltburngas.com | MX-AP | 3 | 09/27/2028 | PLT-02 | |||
| 7 | V-100023 | Ingleby Forgings Inc | Active | Limited Liability Company | 242799638 | 15-184-8574 | CA | Net 45 | 2 way | F | ingleby.forgings.inglebyfor.com | US-AP | Low | 2026-06-05 | PLT-05, PLT-02 | |||
| 3113 rows not shown | ||||||||||||||||||
| 3121 | V-103118 | Wendover Bearings Ltd | Wendover Bearings | Active | Corporation | 84-2210567 | 551908364 | GB | USD | Net 30 | 3-way | False | ap@wendoverbea.com | EU-AP | Low | 2027-02-19 | PLT-08 | |
1Supplier Number,Company Name,Display Name,SIM Status,Organization Type,Federal Tax ID,DUNS,Country of Operation,Preferred Currency,Payment Term,Invoice Matching Level,Minority Indicator,Minority Type,Primary Contact Email,Pay Group,Risk Tier,Insurance Expiry,Plants Served2V-100424,Quarrington Springs PLC,Quarrington Springs,Pending,Corporation,355869704,97825272,GB,usd,2/10 Net 30,3-way,TRUE,SDVOSB,ap@quarringtonspr.com; quarrington.springs@quarringtonspr.com,,2,03/26/2026,PLT-08;PLT-04;PLT-023V-100862,Lansdowne Forgings Ltd,,Draft,LLC,66-4988758,902636648,USA,USD,Net 30,3-way,False,,ap@lansdownefor.com; lansdowne.forgings@lansdownefor.com,,High,2026-11-25,PLT-05 / PLT-124V-100031,Ashworth Foundry Corp,Ashworth Foundry,Active,S-Corp,38-3386951,703093457,DE,USD,Net 30,3-way,1,WBE,ashworth.foundry@ashworthfou.com,CA-AP,1,2026-12-10,PLT-115,Draycott Seals Inc.,Draycott Seals,A,Limited Liability Company,99-6577700,908805639,DE,USD,Net 90,3-way-direct,YES,DBE,draycott.seals@draycottsea.com,EU-AP,Low,,PLT-12;PLT-056V-100022,Saltburn Gasket GmbH,,A,,698433731,258080784,US,USD,Net 30,3-way,False,,saltburn.gasket.saltburngas.com,MX-AP,3,09/27/2028,PLT-027V-100023,Ingleby Forgings Inc,,Active,Limited Liability Company,242799638,15-184-8574,CA,,Net 45,2 way,F,,ingleby.forgings.inglebyfor.com,US-AP,Low,2026-06-05,"PLT-05, PLT-02"⋮3113 rows not shown3121V-103118,Wendover Bearings Ltd,Wendover Bearings,Active,Corporation,84-2210567,551908364,GB,USD,Net 30,3-way,False,,ap@wendoverbea.com,EU-AP,Low,2027-02-19,PLT-08V-100424 has a DUNS of eight digits. V-100862 writes two plant codes with
a slash between them. The row for Draycott Seals arrives with no supplier number
at all. And two addresses share the Primary Contact Email cell, which is how
accounts payable keeps a mailbox and a person in one field.
The file names 3,120 suppliers and 2,857 tax numbers. Updog reads it, matches it and checks it in the tab the person opened, so your endpoint is the first machine to receive a row. Where that work runs is a design decision, and client-side and server-side import walks both.
The fields your customer added themselves
Twelve fields are yours and ship with the product. Supplier number, legal name, display name, status, organization type, tax id, D-U-N-S, country, currency, payment terms, invoice matching level and contact email. Every customer gets those.
The rest comes back from your own configuration endpoint.
export type SupplierField = | { kind: "text"; id: string; label: string; headerAliases?: string[] } | { kind: "date"; id: string; label: string; headerAliases?: string[] } | { kind: "choice"; id: string; label: string; options: string[]; headerAliases?: string[]; valueAliases?: Record<string, string[]>; } | { kind: "multi"; id: string; label: string; options: string[]; separator?: string; headerAliases?: string[]; } | { kind: "boolean"; id: string; label: string; headerAliases?: string[]; valueAliases?: Record<string, string[]>; };
// What GET /tenants/:id/supplier-fields answers for one customer.export const fields: SupplierField[] = [ { kind: "boolean", id: "diverseOwned", label: "Diverse Owned", headerAliases: ["minority indicator", "diverse supplier"], valueAliases: { Yes: ["t", "1"], No: ["f", "0"] }, }, { kind: "choice", id: "diversityCategory", label: "Diversity Category", options: ["MBE", "WBE", "WOSB", "VOSB", "SDVOSB", "HUBZone", "DBE"], headerAliases: ["minority type", "certification"], }, { kind: "choice", id: "payGroup", label: "Pay Group", options: ["US-AP", "EU-AP", "CA-AP", "MX-AP", "UK-AP"], }, { kind: "choice", id: "riskTier", label: "Risk Tier", options: ["1", "2", "3"], valueAliases: { "1": ["low"], "2": ["medium"], "3": ["high"] }, }, { kind: "date", id: "insuranceExpiry", label: "Insurance Expiry" }, { kind: "multi", id: "plantsServed", label: "Plants Served", options: ["PLT-01", "PLT-02", "PLT-03", "PLT-04", "PLT-05", "PLT-06", "PLT-07", "PLT-08", "PLT-09", "PLT-10", "PLT-11", "PLT-12"], separator: " / ", },];That array is data your admin screen already writes. It holds the label the
customer chose, the options they set, and two things worth adding while you are
there. headerAliases records what the field is called in the systems this
customer exports from. separator records how their files write a list in one
cell. Both answers exist inside the customer and nowhere in your repository.
A schema assembled at mount
columns takes an array of plain objects, so a schema is data and data can be
built. One function turns a configured field into a column.
import type { DataEditorColumn } from "@updog/data-editor";import { core } from "./core";import type { SupplierField } from "./fields";
const toColumn = (field: SupplierField): DataEditorColumn => { switch (field.kind) { case "date": return { id: field.id, title: field.label, editor: { type: "date" } }; case "boolean": return { id: field.id, title: field.label, editor: { type: "select", options: ["Yes", "No"], enableCustomValue: false }, }; case "choice": return { id: field.id, title: field.label, editor: { type: "select", options: field.options, enableCustomValue: false }, validators: [{ type: "oneOf", values: field.options }], }; case "multi": return { id: field.id, title: field.label, editor: { type: "multiselect", options: field.options, delimiter: field.separator, enableCustomValue: false, }, }; default: return { id: field.id, title: field.label }; }};
export const buildColumns = (fields: SupplierField[]): DataEditorColumn[] => { return [...core, ...fields.map(toColumn)];};A choice field becomes a select with enableCustomValue: false, which closes
the list so nobody invents an eighth diversity certification during an import. A
multi field becomes a multiselect over the same options. A boolean becomes a
two-option select, because seventeen written forms of yes and no have to land on
one of two strings, and the value-matching step is where they land.
Nothing in that function is procurement. It is a switch over your own field kinds, and it runs before the importer opens.
Diverse Owned against Minority Indicator
Sixteen of the eighteen headers reach a column before an alias is applied. The
two that miss are Minority Indicator and Minority Type, against columns titled
Diverse Owned and Diversity Category.
Neither name is wrong. The file speaks the vocabulary of the system that wrote it, and the schema speaks the vocabulary the buyer typed into your admin screen. No matcher bridges those on its own, because the two strings share no word.
The bridge is the same array that produced the columns.
export const buildSynonyms = (fields: SupplierField[]) => { const columns: Record<string, string[]> = { ...coreColumnAliases }; const values: Record<string, string[]> = { ...coreValueAliases };
for (const field of fields) { if (field.headerAliases) { columns[field.id] = field.headerAliases; } if ("valueAliases" in field && field.valueAliases) { for (const [option, aliases] of Object.entries(field.valueAliases)) { values[option] = aliases; } } }
return { columns, values };};With that table in place all eighteen headers reach a column. The gain is not
limited to the configured fields. Canada, Germany, Deutschland, Mexico,
United Kingdom and United States reach nothing against a five-code country
list on their own, and the core alias table lands all six. U.S. and usd
already land without help, because an abbreviation sits closer to a code than a
spelled-out name does.
An unmatched value does not arrive as itself. A select value survives the value-matching step only when it is mapped, so the cell lands empty. Without the alias table 913 country cells empty out that way, and 683 risk tiers, 571 statuses, 395 organization types and 119 currencies go with them. No rule fires on any of them. With the table in place, country, risk tier, organization type and currency all come back to the count of cells the file itself left blank.
Every way a system writes yes
Minority Indicator arrives as seventeen distinct strings, and 215 rows leave
it empty. NetSuite publishes
what its own vendor import accepts for a boolean, and the list is
"True, true, TRUE, T, yes, Yes, YES". A file written to that spec carries every
one of them.
Updog holds a built-in value table covering common categorical vocabularies,
and it lands 13 of the 17 on Yes or No with no configuration. Everything
spelled out arrives. true, True, TRUE, Y, yes, Yes, YES, false,
False, FALSE, N, no and No all reach an option.
The four it leaves are T, F, 0 and 1, and 705 rows of this file sit on
those four strings. They go in the valueAliases of the configured field, next
to the label, where the customer's own answer already lives.
Pending and On Hold in the status column reach nothing either, and that
result is correct. Neither is a spelling of active, inactive or draft.
Only the buyer can say which one they meant, and the value-matching step is
where they say it.
The identifier that lost a digit
A D-U-N-S Number is nine digits. The digits carry no meaning, so about one identifier in ten opens with a zero, and Excel drops that zero on the way in. Dun & Bradstreet publishes the problem in its own help centre. "Leading zeros in number fields such as zip code and D-U-N-S Number are not visible in Excel unless you apply custom formatting."
In this file 219 rows carry eight digits where nine were written. Another 161 carry the number with dashes in it. A regex rule reports all 380.
| What arrives | Rows | What the rule does |
|---|---|---|
| nine digits | 2,431 | passes |
| eight digits | 219 | flagged |
15-184-8574 |
161 | flagged |
| empty | 309 | passes |
The 309 empty cells are the part worth reading twice. A regex has nothing to
test against an empty string, so a supplier with no D-U-N-S at all travels
through in silence. Whether an
empty D-U-N-S blocks a supplier is a platform decision, and
{ type: "required" } beside the regex is where you write it down.
A term your option list does not hold
Payment terms have to land on a list your system already holds. Coupa states it as a rule for its supplier import, "Must match existing payment terms", and NetSuite states the same thing the other way round. "You must create payment terms prior to referencing them."
Thirteen distinct terms arrive against six options. Eleven of them land on their
own. NET30, Net30, N30 and 30 days all reach Net 30, which is right.
COD and Immediate reach nothing, and those are decisions for the buyer. 213
rows carry one of the two, and every one of them reaches the grid with an empty
payment term.
One auto-match lands and is wrong. 2/10 Net 30 reaches Net 30 on 151 rows.
The term means two percent off for paying inside ten days, and the option list
holds no term that carries a discount. The value arrives looking settled, and
nothing in the wizard flags it.
The fix is the option list. A closed select and a oneOf rule keep a value
inside the set, and they cannot invent a set member that finance never created.
When a term matters to a customer, that customer configures it, which is the
same loop the rest of this schema runs on.
The separator this export uses
Plants Served holds a list in one cell, and this file writes that list three
ways. 1,157 rows separate with a semicolon, 556 with a comma and a space, and
155 with a slash.
Updog detects the separator among comma, semicolon, pipe, newline and tab by checking which one splits cells into tokens that resemble your options. It picks the semicolon here, and it recovers the comma rows too, because every piece of those splits into a known plant.
The slash rows are the ones to watch. A slash is no candidate, so
PLT-05 / PLT-12 stays whole, fuzzy-matches to PLT-05 and looks settled. 252
plant references disappear that way, and 146 strings reach the value-matching
step where twelve belong.
Declaring the separator takes the column to twelve exact values with nothing
lost, because the recovery rule still splits the semicolon and comma cells
underneath. That is what separator: " / " is doing in the configuration
object. The customer knows which system writes their files, and your code
does not.
The rows that stop at a rule
Out of 3,120 rows, 840 carry something a rule caught.
| Message | Rows | What is in the cell |
|---|---|---|
| Invalid email address | 443 | 284 cells holding two addresses, 159 with no @ |
| DUNS must be 9 digits | 380 | 219 short, 161 with dashes |
| Value must be unique | 48 | 24 supplier numbers written on two rows each |
| This field is required | 40 | an empty supplier number |
The 24 repeated supplier numbers are the reason primaryKey names that column
and the schema puts { type: "unique" } on it. Company names repeat as well,
486 of them, and a repeated legal name is ordinary. Two branches of one group
trade under one name and settle their invoices apart.
One column raised nothing. Insurance Expiry arrives with 1,876 dates in ISO
and 790 written 03/26/2026, and every one of them parsed.
A rule fires on what arrived. The cells that never arrived are the other half of
the list. 484 rows reach the grid with no status, where the file left 133 blank.
The 351 in between held Pending or On Hold, and the value-matching step
dropped both because neither maps to an option. Payment terms behave the same
way, 434 empty against 221 blank in the file.
An empty cell carries no error beside it. { type: "required" } on the columns
a supplier record cannot go without is what turns that silence into one of the
messages above, and which columns those are is a decision your platform
makes.
What your handler receives
const fields = await loadSupplierFields(tenantId);const columns = buildColumns(fields);const synonyms = buildSynonyms(fields);
<DataEditor<Supplier> apiKey={apiKey} open={open} onClose={close} columns={columns} synonyms={synonyms} primaryKey="supplierNumber" onComplete={async (result) => { for (const source of result.sources) { const rows = source.rows.filter((r) => r.isValid && !r.isDeleted); await upsertSuppliers(tenantId, rows.map((r) => r.row)); } }}/>onComplete hands back flat rows grouped by source, each carrying isNew,
isChanged, isDeleted and isValid. The row shape follows the schema you
built, so a configured field arrives under the id the customer's configuration
gave it and your upsert writes it as a custom attribute.
primaryKey is a single column here, since a supplier number identifies a
supplier inside one tenant. A file whose numbers repeat across branches takes a
list instead, and a row merges only when every part matches.
No supplier schema ships in the box
Updog ships no supplier template, no diversity certification list and no connector to any procurement suite. Updog Importer reads CSV, TSV, JSON, XML, XLSX, XLS, XLSB and ODS. A signed supplier questionnaire that arrives as a PDF or a scan needs a parser you supply, and its rows land in the grid beside the spreadsheet rows. The twelve core columns, the field mapper, the alias builder and every option list are yours to write and yours to keep.
The catalog side of procurement stays out too. A price file keyed on a supplier part number against your own item number is a different import with a different key, and a published catalog format like CIF reaches the drop zone the same way a PDF does, through a parser you write.
One thing in this file is worth a decision before you map it. A supplier export can carry a social security number, because a sole proprietor's tax number is their social security number. Leaving that column out of the schema is enough to drop it, since an unmapped header lands nowhere.
The same code at the second customer
Mount the same build against a second customer and the field array comes back
different. Broker code, contract end, service line. Twelve of the eighteen
headers in this same file reach a column, and the six that miss are Pay Group,
Risk Tier, Insurance Expiry, Plants Served, Minority Indicator and
Minority Type.
All six are fields this customer does not keep. Nothing is broken. A header with nowhere to land is the correct answer when the customer never asked for the field, and the person sees it sitting unmapped and moves on.
That is the whole shape of a procurement import. The file comes from a system you do not control, the schema comes from a customer you have not met, and the code you write is the function between them.