punchout-simulator 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/server/cli.ts
4
+ import { readFileSync as readFileSync3 } from "fs";
4
5
  import { fileURLToPath } from "url";
5
6
  import { serve } from "@hono/node-server";
6
7
  import { nanoid as nanoid7 } from "nanoid";
7
8
 
8
9
  // src/server/app.ts
9
10
  import { existsSync as existsSync3 } from "fs";
10
- import { Hono as Hono9 } from "hono";
11
+ import { Hono as Hono10 } from "hono";
11
12
  import { logger } from "hono/logger";
12
13
  import { bodyLimit } from "hono/body-limit";
13
14
  import { getCookie } from "hono/cookie";
@@ -24,6 +25,9 @@ function setRuntime(r) {
24
25
  function getToken() {
25
26
  return runtime.token;
26
27
  }
28
+ function getVersion() {
29
+ return runtime.version;
30
+ }
27
31
  function getPublicUrl() {
28
32
  return runtime.publicUrl.replace(/\/$/, "");
29
33
  }
@@ -144,6 +148,52 @@ function seedBuiltinProfiles(data, now2) {
144
148
  }
145
149
  }
146
150
 
151
+ // src/server/cxml/product-list-presets.ts
152
+ var PRODUCT_LIST_PRESETS = [
153
+ {
154
+ id: "sample",
155
+ name: "Sample assortment",
156
+ builtin: true,
157
+ description: "A representative office & industrial supplies catalog (~20 items).",
158
+ items: [
159
+ { supplierPartId: "WIDGET-001", supplierPartAuxiliaryId: "WIDGET-001-STD", description: "Premium Steel Widget", unitPrice: 12.5, currency: "USD", uom: "EA", classifications: [{ domain: "UNSPSC", value: "31161500" }], manufacturerPartId: "MFR-W001", manufacturerName: "Acme Manufacturing" },
160
+ { supplierPartId: "BOLT-250", supplierPartAuxiliaryId: "BOLT-250-CONTRACT-A", description: "M8 Hex Bolt (pack of 250)", unitPrice: 34, currency: "USD", uom: "PK", classifications: [{ domain: "UNSPSC", value: "31161600" }], manufacturerPartId: "MFR-B250", manufacturerName: "Acme Manufacturing" },
161
+ { supplierPartId: "NUT-M8-500", description: "M8 Hex Nut (pack of 500)", unitPrice: 28.5, currency: "USD", uom: "PK", classifications: [{ domain: "UNSPSC", value: "31161700" }], manufacturerPartId: "MFR-N8500", manufacturerName: "Acme Manufacturing" },
162
+ { supplierPartId: "WASHER-M8", description: "M8 Flat Washer (pack of 1000)", unitPrice: 19.95, currency: "USD", uom: "PK", classifications: [{ domain: "UNSPSC", value: "31161800" }], manufacturerName: "Acme Manufacturing" },
163
+ { supplierPartId: "TAPE-RED", supplierPartAuxiliaryId: "TAPE-RED-50M", description: "Industrial Marking Tape, Red", unitPrice: 5.75, currency: "USD", uom: "RL", classifications: [{ domain: "UNSPSC", value: "31201500" }] },
164
+ { supplierPartId: "TAPE-YEL", supplierPartAuxiliaryId: "TAPE-YEL-50M", description: "Industrial Marking Tape, Yellow", unitPrice: 5.75, currency: "USD", uom: "RL", classifications: [{ domain: "UNSPSC", value: "31201500" }] },
165
+ // Multiple classifications: UNSPSC + a supplier-specific commodity scheme.
166
+ { supplierPartId: "CABLE-CU-2.5", supplierPartAuxiliaryId: "CABLE-CU-2.5-RAL", description: "Copper Cable 2.5mm\xB2 (by the metre)", unitPrice: 1.15, currency: "EUR", uom: "MTR", classifications: [{ domain: "UNSPSC", value: "26121600" }, { domain: "eCl@ss", value: "27-06-01-01" }], manufacturerPartId: "WIRE-25", manufacturerName: "Volt Industries", allowFractional: true },
167
+ { supplierPartId: "CABLE-CU-4.0", supplierPartAuxiliaryId: "CABLE-CU-4.0-RAL", description: "Copper Cable 4.0mm\xB2 (by the metre)", unitPrice: 1.8, currency: "EUR", uom: "MTR", classifications: [{ domain: "UNSPSC", value: "26121600" }, { domain: "eCl@ss", value: "27-06-01-02" }], manufacturerPartId: "WIRE-40", manufacturerName: "Volt Industries", allowFractional: true },
168
+ { supplierPartId: "PAINT-WHT", description: "Acrylic Wall Paint, White (by the litre)", unitPrice: 8.4, currency: "USD", uom: "LTR", classifications: [{ domain: "UNSPSC", value: "31211500" }], manufacturerName: "ColorWorks", allowFractional: true },
169
+ { supplierPartId: "LUBE-OIL", description: "Machine Lubricating Oil (by the litre)", unitPrice: 6.9, currency: "USD", uom: "LTR", classifications: [{ domain: "UNSPSC", value: "15121800" }], manufacturerName: "SlickPro", allowFractional: true },
170
+ { supplierPartId: "GLOVE-NIT-M", supplierPartAuxiliaryId: "GLOVE-NIT-M-CE", description: "Nitrile Gloves, Medium (box of 100)", unitPrice: 9.95, currency: "GBP", uom: "BX", classifications: [{ domain: "UNSPSC", value: "46181504" }], manufacturerName: "SafeHands" },
171
+ { supplierPartId: "GLOVE-NIT-L", supplierPartAuxiliaryId: "GLOVE-NIT-L-CE", description: "Nitrile Gloves, Large (box of 100)", unitPrice: 9.95, currency: "GBP", uom: "BX", classifications: [{ domain: "UNSPSC", value: "46181504" }], manufacturerName: "SafeHands" },
172
+ { supplierPartId: "GOGGLE-STD", description: "Safety Goggles, Anti-fog", unitPrice: 4.6, currency: "GBP", uom: "EA", classifications: [{ domain: "UNSPSC", value: "46181702" }], manufacturerName: "SafeHands" },
173
+ { supplierPartId: "HELMET-WHT", description: "Hard Hat, White", unitPrice: 9.8, currency: "GBP", uom: "EA", classifications: [{ domain: "UNSPSC", value: "46181701" }], manufacturerName: "SafeHands" },
174
+ { supplierPartId: "PAPER-A4", description: "Copy Paper A4 80gsm (ream of 500)", unitPrice: 18, currency: "PLN", uom: "RM", classifications: [{ domain: "UNSPSC", value: "14111507" }], manufacturerName: "PaperCo" },
175
+ { supplierPartId: "PEN-BLU-12", description: "Ballpoint Pens, Blue (pack of 12)", unitPrice: 12.99, currency: "PLN", uom: "PK", classifications: [{ domain: "UNSPSC", value: "44121701" }], manufacturerName: "WriteRight" },
176
+ { supplierPartId: "BINDER-A4", description: "Lever Arch Binder A4, Black", unitPrice: 11.5, currency: "PLN", uom: "EA", classifications: [{ domain: "UNSPSC", value: "44122011" }], manufacturerName: "Officeline" },
177
+ { supplierPartId: "TONER-BLK", supplierPartAuxiliaryId: "TN-1000-OEM", description: "Laser Toner Cartridge, Black", unitPrice: 64, currency: "USD", uom: "EA", classifications: [{ domain: "UNSPSC", value: "44103105" }], manufacturerPartId: "TN-1000", manufacturerName: "PrintMax" },
178
+ { supplierPartId: "BATT-AA-24", description: "Alkaline Batteries AA (pack of 24)", unitPrice: 14.5, currency: "USD", uom: "PK", classifications: [{ domain: "UNSPSC", value: "26111702" }], manufacturerName: "PowerCell" },
179
+ { supplierPartId: "DRILL-BIT-SET", supplierPartAuxiliaryId: "DBS-19-HSS", description: "HSS Drill Bit Set (19 pieces)", unitPrice: 22.75, currency: "USD", uom: "ST", classifications: [{ domain: "UNSPSC", value: "27112700" }], manufacturerPartId: "DBS-19", manufacturerName: "Acme Manufacturing" },
180
+ { supplierPartId: "GAFF-CLEAN", description: "All-purpose Cleaner Concentrate (by the litre)", unitPrice: 3.6, currency: "USD", uom: "LTR", classifications: [{ domain: "UNSPSC", value: "47131800" }], manufacturerName: "CleanLine", allowFractional: true }
181
+ ]
182
+ }
183
+ ];
184
+ var SAMPLE_PRODUCT_LIST = {
185
+ ...PRODUCT_LIST_PRESETS[0],
186
+ createdAt: "",
187
+ updatedAt: ""
188
+ };
189
+ function seedBuiltinProductLists(data, now2) {
190
+ for (const preset of PRODUCT_LIST_PRESETS) {
191
+ if (!data.productLists.some((p) => p.id === preset.id)) {
192
+ data.productLists.push({ ...preset, createdAt: now2, updatedAt: now2 });
193
+ }
194
+ }
195
+ }
196
+
147
197
  // src/server/store/paths.ts
148
198
  import { chmodSync, mkdirSync } from "fs";
149
199
  import { resolve } from "path";
@@ -180,15 +230,19 @@ var db = null;
180
230
  async function initConfig() {
181
231
  ensureDirs();
182
232
  const adapter = new JSONFile(configPath());
183
- db = new Low(adapter, { buyers: [], suppliers: [], connections: [], profiles: [] });
233
+ db = new Low(adapter, { buyers: [], suppliers: [], connections: [], profiles: [], productLists: [] });
184
234
  await db.read();
185
- db.data ||= { buyers: [], suppliers: [], connections: [], profiles: [] };
235
+ db.data ||= { buyers: [], suppliers: [], connections: [], profiles: [], productLists: [] };
186
236
  db.data.buyers ||= [];
187
237
  db.data.suppliers ||= [];
188
238
  db.data.connections ||= [];
189
239
  db.data.profiles ||= [];
240
+ db.data.productLists ||= [];
190
241
  seedBuiltinProfiles(db.data, now());
242
+ seedBuiltinProductLists(db.data, now());
191
243
  migrateLegacy(db.data);
244
+ migrateInlineCatalogs(db.data);
245
+ migrateClassifications(db.data);
192
246
  await db.write();
193
247
  try {
194
248
  chmodSync2(configPath(), 384);
@@ -349,6 +403,38 @@ function effectiveProfile(connection, buyer) {
349
403
  function dtdVersionFor(eff, docType) {
350
404
  return eff.dtdVersions[docType] ?? eff.dtdVersions.default;
351
405
  }
406
+ var listProductLists = () => requireDb().data.productLists;
407
+ var getProductList = (id) => requireDb().data.productLists.find((p) => p.id === id);
408
+ async function createProductList(input) {
409
+ const list = { ...input, id: input.id ?? nanoid(8), createdAt: now(), updatedAt: now() };
410
+ const d = requireDb();
411
+ d.data.productLists.push(list);
412
+ await d.write();
413
+ return list;
414
+ }
415
+ async function updateProductList(id, patch) {
416
+ const d = requireDb();
417
+ const existing = d.data.productLists.find((p) => p.id === id);
418
+ if (!existing) return void 0;
419
+ Object.assign(existing, patch, { id, updatedAt: now() });
420
+ await d.write();
421
+ return existing;
422
+ }
423
+ async function deleteProductList(id) {
424
+ const d = requireDb();
425
+ if (d.data.suppliers.some((s) => s.productListIds?.includes(id))) {
426
+ throw new Error("product list is referenced by a supplier");
427
+ }
428
+ const before = d.data.productLists.length;
429
+ d.data.productLists = d.data.productLists.filter((p) => p.id !== id);
430
+ const removed = d.data.productLists.length < before;
431
+ if (removed) await d.write();
432
+ return removed;
433
+ }
434
+ function catalogForSupplier(supplier) {
435
+ const ids = supplier.productListIds ?? [];
436
+ return ids.flatMap((id) => getProductList(id)?.items ?? []);
437
+ }
352
438
  function migrateLegacy(data) {
353
439
  const legacy = data.connections.filter((c) => "from" in c || "to" in c);
354
440
  if (legacy.length === 0) return;
@@ -408,6 +494,39 @@ function migrateLegacy(data) {
408
494
  }
409
495
  data.connections = migrated;
410
496
  }
497
+ function migrateInlineCatalogs(data) {
498
+ for (const supplier of data.suppliers) {
499
+ const legacy = supplier.catalog;
500
+ if (!legacy || legacy.length === 0) {
501
+ delete supplier.catalog;
502
+ continue;
503
+ }
504
+ if (supplier.productListIds && supplier.productListIds.length > 0) {
505
+ delete supplier.catalog;
506
+ continue;
507
+ }
508
+ const list = {
509
+ id: nanoid(8),
510
+ name: `${supplier.name} catalog`,
511
+ items: legacy,
512
+ createdAt: now(),
513
+ updatedAt: now()
514
+ };
515
+ data.productLists.push(list);
516
+ supplier.productListIds = [list.id];
517
+ delete supplier.catalog;
518
+ }
519
+ }
520
+ function migrateClassifications(data) {
521
+ for (const list of data.productLists) {
522
+ for (const item of list.items) {
523
+ if (!Array.isArray(item.classifications) || item.classifications.length === 0) {
524
+ item.classifications = item.unspsc ? [{ domain: "UNSPSC", value: String(item.unspsc) }] : [];
525
+ }
526
+ delete item.unspsc;
527
+ }
528
+ }
529
+ }
411
530
 
412
531
  // src/server/routes/connections.ts
413
532
  var connectionsRoute = new Hono();
@@ -515,22 +634,14 @@ buyersRoute.delete("/:id", async (c) => {
515
634
  });
516
635
  var suppliersRoute = new Hono2();
517
636
  function normalizeSupplier(body) {
518
- const catalog = Array.isArray(body?.catalog) ? body.catalog.map((it) => ({
519
- supplierPartId: String(it?.supplierPartId ?? ""),
520
- description: String(it?.description ?? ""),
521
- unitPrice: Number(it?.unitPrice ?? 0) || 0,
522
- currency: String(it?.currency ?? "USD"),
523
- uom: String(it?.uom ?? "EA"),
524
- unspsc: String(it?.unspsc ?? ""),
525
- manufacturerPartId: it?.manufacturerPartId ? String(it.manufacturerPartId) : void 0,
526
- manufacturerName: it?.manufacturerName ? String(it.manufacturerName) : void 0
527
- })) : void 0;
637
+ const productListIds = Array.isArray(body?.productListIds) ? body.productListIds.map((id) => String(id)) : [];
528
638
  return {
529
639
  name: String(body?.name ?? "Untitled supplier"),
530
640
  identity: cred(body?.identity),
531
641
  punchoutUrl: body?.punchoutUrl ? String(body.punchoutUrl) : void 0,
532
642
  orderUrl: body?.orderUrl ? String(body.orderUrl) : void 0,
533
- catalog
643
+ productListIds,
644
+ allowMixedCurrency: Boolean(body?.allowMixedCurrency)
534
645
  };
535
646
  }
536
647
  suppliersRoute.get("/", (c) => c.json(listSuppliers()));
@@ -626,8 +737,66 @@ profilesRoute.delete("/:id", async (c) => {
626
737
  });
627
738
  profilePresetsRoute.get("/", (c) => c.json(PROFILE_PRESETS));
628
739
 
629
- // src/server/routes/flow.ts
740
+ // src/server/routes/products.ts
630
741
  import { Hono as Hono4 } from "hono";
742
+ var productsRoute = new Hono4();
743
+ var productListPresetsRoute = new Hono4();
744
+ function normalizeClassifications(it) {
745
+ if (Array.isArray(it?.classifications)) {
746
+ return it.classifications.map((c) => ({ domain: String(c?.domain ?? "UNSPSC"), value: String(c?.value ?? "") })).filter((c) => c.value.trim().length > 0);
747
+ }
748
+ if (it?.unspsc) return [{ domain: "UNSPSC", value: String(it.unspsc) }];
749
+ return [];
750
+ }
751
+ function normalizeItem(it) {
752
+ return {
753
+ supplierPartId: String(it?.supplierPartId ?? ""),
754
+ supplierPartAuxiliaryId: it?.supplierPartAuxiliaryId ? String(it.supplierPartAuxiliaryId) : void 0,
755
+ description: String(it?.description ?? ""),
756
+ unitPrice: Number(it?.unitPrice ?? 0) || 0,
757
+ currency: String(it?.currency ?? "USD"),
758
+ uom: String(it?.uom ?? "EA"),
759
+ classifications: normalizeClassifications(it),
760
+ manufacturerPartId: it?.manufacturerPartId ? String(it.manufacturerPartId) : void 0,
761
+ manufacturerName: it?.manufacturerName ? String(it.manufacturerName) : void 0,
762
+ allowFractional: Boolean(it?.allowFractional)
763
+ };
764
+ }
765
+ function normalizeProductList(body) {
766
+ const items = Array.isArray(body?.items) ? body.items.map(normalizeItem) : [];
767
+ return {
768
+ name: String(body?.name ?? "Untitled product list"),
769
+ description: body?.description ? String(body.description) : void 0,
770
+ items
771
+ };
772
+ }
773
+ productsRoute.get("/", (c) => c.json(listProductLists()));
774
+ productsRoute.post("/", async (c) => {
775
+ const input = normalizeProductList(await c.req.json().catch(() => ({})));
776
+ if (!input.name.trim()) return c.json({ errors: ["name is required"] }, 400);
777
+ return c.json(await createProductList(input), 201);
778
+ });
779
+ productsRoute.get("/:id", (c) => {
780
+ const p = getProductList(c.req.param("id"));
781
+ return p ? c.json(p) : c.json({ error: "not found" }, 404);
782
+ });
783
+ productsRoute.put("/:id", async (c) => {
784
+ if (!getProductList(c.req.param("id"))) return c.json({ error: "not found" }, 404);
785
+ const input = normalizeProductList(await c.req.json().catch(() => ({})));
786
+ return c.json(await updateProductList(c.req.param("id"), input));
787
+ });
788
+ productsRoute.delete("/:id", async (c) => {
789
+ try {
790
+ const ok = await deleteProductList(c.req.param("id"));
791
+ return ok ? c.json({ ok: true }) : c.json({ error: "not found" }, 404);
792
+ } catch (e) {
793
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 409);
794
+ }
795
+ });
796
+ productListPresetsRoute.get("/", (c) => c.json(PRODUCT_LIST_PRESETS));
797
+
798
+ // src/server/routes/flow.ts
799
+ import { Hono as Hono5 } from "hono";
631
800
  import { nanoid as nanoid5 } from "nanoid";
632
801
 
633
802
  // src/server/store/log.ts
@@ -940,6 +1109,15 @@ function escapeXml(value) {
940
1109
  function makePayloadId(host3, nowIso) {
941
1110
  return `${nowIso}.${nanoid4(10)}@${host3}`;
942
1111
  }
1112
+ function lineItemsTotal(items, headerCurrency) {
1113
+ const currencies = new Set(items.map((it) => it.currency || headerCurrency));
1114
+ if (currencies.size > 1) return 0;
1115
+ return items.reduce((sum, it) => sum + (it.unitPriceAmount ?? 0) * it.quantity, 0);
1116
+ }
1117
+ function classificationBlock(it, indent) {
1118
+ const list = it.classifications && it.classifications.length > 0 ? it.classifications : [{ domain: it.classificationDomain ?? "UNSPSC", value: it.classification ?? "" }];
1119
+ return list.map((c) => `${indent}<Classification domain="${escapeXml(c.domain || "UNSPSC")}">${escapeXml(c.value)}</Classification>`).join("\n");
1120
+ }
943
1121
  function credentialBlock(tag, c) {
944
1122
  return ` <${tag}>
945
1123
  <Credential domain="${escapeXml(c.domain)}">
@@ -1050,9 +1228,7 @@ function buildOrderRequest(o) {
1050
1228
  </UnitPrice>
1051
1229
  <Description xml:lang="en">${escapeXml(it.description ?? "")}</Description>
1052
1230
  <UnitOfMeasure>${escapeXml(it.uom ?? "EA")}</UnitOfMeasure>
1053
- <Classification domain="${escapeXml(it.classificationDomain ?? "UNSPSC")}">${escapeXml(
1054
- it.classification ?? ""
1055
- )}</Classification>${it.manufacturerPartId ? `
1231
+ ${classificationBlock(it, " ")}${it.manufacturerPartId ? `
1056
1232
  <ManufacturerPartID>${escapeXml(it.manufacturerPartId)}</ManufacturerPartID>` : ""}${it.manufacturerName ? `
1057
1233
  <ManufacturerName>${escapeXml(it.manufacturerName)}</ManufacturerName>` : ""}${commentsWithAttachments(cids, " ")}
1058
1234
  </ItemDetail>
@@ -1112,10 +1288,7 @@ function buildResponseStatus(o) {
1112
1288
  return envelope(o.payloadId, o.timestamp, o.lang ?? "en-US", inner, o.dtdVersion);
1113
1289
  }
1114
1290
  function buildPunchOutOrderMessage(o) {
1115
- const total = o.items.reduce(
1116
- (sum, it) => sum + (it.unitPriceAmount ?? 0) * it.quantity,
1117
- 0
1118
- );
1291
+ const total = lineItemsTotal(o.items, o.currency);
1119
1292
  const items = o.items.map(
1120
1293
  (it) => ` <ItemIn quantity="${escapeXml(it.quantity)}">
1121
1294
  <ItemID>
@@ -1130,9 +1303,7 @@ function buildPunchOutOrderMessage(o) {
1130
1303
  </UnitPrice>
1131
1304
  <Description xml:lang="en">${escapeXml(it.description ?? "")}</Description>
1132
1305
  <UnitOfMeasure>${escapeXml(it.uom ?? "EA")}</UnitOfMeasure>
1133
- <Classification domain="${escapeXml(it.classificationDomain ?? "UNSPSC")}">${escapeXml(
1134
- it.classification ?? ""
1135
- )}</Classification>${it.manufacturerPartId ? `
1306
+ ${classificationBlock(it, " ")}${it.manufacturerPartId ? `
1136
1307
  <ManufacturerPartID>${escapeXml(it.manufacturerPartId)}</ManufacturerPartID>` : ""}
1137
1308
  </ItemDetail>
1138
1309
  </ItemIn>`
@@ -1322,8 +1493,8 @@ function parseCart(doc) {
1322
1493
  const items = asArray(pom?.ItemIn).map((it) => {
1323
1494
  const detail = it?.ItemDetail;
1324
1495
  const up = money(detail?.UnitPrice);
1325
- const classification = detail?.Classification;
1326
- const classFirst = Array.isArray(classification) ? classification[0] : classification;
1496
+ const classifications = asArray(detail?.Classification).filter((c) => c != null).map((c) => ({ domain: attr(c, "domain") ?? "", value: text(c) ?? "" }));
1497
+ const classFirst = classifications[0];
1327
1498
  return {
1328
1499
  quantity: Number(attr(it, "quantity") ?? "1") || 1,
1329
1500
  supplierPartId: text(it?.ItemID?.SupplierPartID),
@@ -1332,8 +1503,9 @@ function parseCart(doc) {
1332
1503
  uom: text(detail?.UnitOfMeasure),
1333
1504
  unitPriceAmount: up.amount,
1334
1505
  currency: up.currency,
1335
- classificationDomain: attr(classFirst, "domain"),
1336
- classification: text(classFirst),
1506
+ classifications: classifications.length > 0 ? classifications : void 0,
1507
+ classificationDomain: classFirst?.domain,
1508
+ classification: classFirst?.value,
1337
1509
  manufacturerPartId: text(detail?.ManufacturerPartID),
1338
1510
  manufacturerName: text(detail?.ManufacturerName)
1339
1511
  };
@@ -1496,6 +1668,7 @@ function checkPunchback(doc, ctx, issues) {
1496
1668
  issues.warn("empty-cart", "PunchOutOrderMessage contains no ItemIn elements", "cXML/.../PunchOutOrderMessage");
1497
1669
  }
1498
1670
  let lineSum = 0;
1671
+ const lineCurrencies = [];
1499
1672
  items.forEach((it, i) => {
1500
1673
  const base = `cXML/.../ItemIn[${i + 1}]`;
1501
1674
  const qty = num(attr(it, "quantity"));
@@ -1512,10 +1685,13 @@ function checkPunchback(doc, ctx, issues) {
1512
1685
  }
1513
1686
  const up = detail.UnitPrice?.Money;
1514
1687
  const upAmount = num(text(up));
1688
+ const upCurrency = attr(up, "currency");
1515
1689
  if (up == null) {
1516
1690
  issues.error("item-missing-unitprice", `ItemIn[${i + 1}] is missing ItemDetail/UnitPrice/Money`, `${base}/ItemDetail/UnitPrice`);
1517
- } else if (!attr(up, "currency")) {
1691
+ } else if (!upCurrency) {
1518
1692
  issues.error("item-missing-currency", `ItemIn[${i + 1}] UnitPrice/Money is missing @currency`, `${base}/ItemDetail/UnitPrice/Money`);
1693
+ } else {
1694
+ lineCurrencies.push(upCurrency);
1519
1695
  }
1520
1696
  if (!text(detail.Description)) {
1521
1697
  issues.error("item-missing-description", `ItemIn[${i + 1}] is missing ItemDetail/Description`, `${base}/ItemDetail/Description`);
@@ -1531,7 +1707,14 @@ function checkPunchback(doc, ctx, issues) {
1531
1707
  }
1532
1708
  if (qty != null && upAmount != null) lineSum += qty * upAmount;
1533
1709
  });
1534
- if (totalAmount != null && items.length > 0) {
1710
+ const singleCurrency = checkSingleCurrency(
1711
+ lineCurrencies,
1712
+ totalCurrency,
1713
+ issues,
1714
+ "cXML/.../PunchOutOrderMessageHeader/Total",
1715
+ ctx.allowMixedCurrency
1716
+ );
1717
+ if (singleCurrency && totalAmount != null && items.length > 0) {
1535
1718
  const diff = Math.abs(totalAmount - lineSum);
1536
1719
  if (diff > 0.01) {
1537
1720
  issues.warn(
@@ -1542,6 +1725,28 @@ function checkPunchback(doc, ctx, issues) {
1542
1725
  }
1543
1726
  }
1544
1727
  }
1728
+ function checkSingleCurrency(lineCurrencies, totalCurrency, issues, path, allowMixed = false) {
1729
+ const all = new Set(lineCurrencies.filter(Boolean));
1730
+ if (totalCurrency) all.add(totalCurrency);
1731
+ if (all.size > 1) {
1732
+ const list = [...all].join(", ");
1733
+ if (allowMixed) {
1734
+ issues.warn(
1735
+ "mixed-currency",
1736
+ `Multiple currencies in one document (${list}); allowed for this supplier. The header Total is a single Money \u2014 rely on the per-line currencies.`,
1737
+ path
1738
+ );
1739
+ } else {
1740
+ issues.error(
1741
+ "mixed-currency",
1742
+ `Multiple currencies in one document (${list}). A cXML Total is a single Money \u2014 all line items and the Total must share one currency.`,
1743
+ path
1744
+ );
1745
+ }
1746
+ return false;
1747
+ }
1748
+ return true;
1749
+ }
1545
1750
  function checkOrderRequest(doc, ctx, issues) {
1546
1751
  const orderReq = root(doc)?.Request?.OrderRequest;
1547
1752
  if (!orderReq) {
@@ -1568,18 +1773,24 @@ function checkOrderRequest(doc, ctx, issues) {
1568
1773
  if (items.length === 0) {
1569
1774
  issues.error("no-itemout", "OrderRequest contains no ItemOut elements", "cXML/.../OrderRequest");
1570
1775
  }
1776
+ const lineCurrencies = [];
1571
1777
  items.forEach((it, i) => {
1572
1778
  const base = `cXML/.../ItemOut[${i + 1}]`;
1573
1779
  if (!text(it?.ItemID?.SupplierPartID)) {
1574
1780
  issues.error("itemout-missing-id", `ItemOut[${i + 1}] is missing ItemID/SupplierPartID`, `${base}/ItemID`);
1575
1781
  }
1576
- if (it?.ItemDetail?.UnitPrice?.Money == null) {
1782
+ const up = it?.ItemDetail?.UnitPrice?.Money;
1783
+ if (up == null) {
1577
1784
  issues.error("itemout-missing-unitprice", `ItemOut[${i + 1}] is missing ItemDetail/UnitPrice/Money`, `${base}/ItemDetail/UnitPrice`);
1785
+ } else {
1786
+ const cur = attr(up, "currency");
1787
+ if (cur) lineCurrencies.push(cur);
1578
1788
  }
1579
1789
  if (num(attr(it, "quantity")) == null) {
1580
1790
  issues.error("itemout-missing-quantity", `ItemOut[${i + 1}] is missing @quantity`, base);
1581
1791
  }
1582
1792
  });
1793
+ checkSingleCurrency(lineCurrencies, attr(header2?.Total?.Money, "currency"), issues, "cXML/.../OrderRequestHeader/Total", ctx.allowMixedCurrency);
1583
1794
  const refs = collectCidReferences(doc);
1584
1795
  const available = ctx.availableContentIds;
1585
1796
  const referenced = /* @__PURE__ */ new Set();
@@ -1661,7 +1872,7 @@ function validateDocument(raw, ctx = {}) {
1661
1872
  }
1662
1873
 
1663
1874
  // src/server/routes/flow.ts
1664
- var flowRoute = new Hono4();
1875
+ var flowRoute = new Hono5();
1665
1876
  function host() {
1666
1877
  try {
1667
1878
  return new URL(getPublicUrl()).host;
@@ -1686,7 +1897,8 @@ function buyerContext(r) {
1686
1897
  connectionId: connection.id,
1687
1898
  attachmentEncoding: eff.attachmentEncoding,
1688
1899
  eff,
1689
- expected: { from, to, sender, sharedSecret: connection.sharedSecret }
1900
+ expected: { from, to, sender, sharedSecret: connection.sharedSecret },
1901
+ allowMixedCurrency: supplier.allowMixedCurrency ?? false
1690
1902
  };
1691
1903
  }
1692
1904
  function setupExtrinsics(ctx, buyerCookie) {
@@ -1785,7 +1997,7 @@ flowRoute.post("/:id/setup", async (c) => {
1785
1997
  function buildOrderXml(ctx, body) {
1786
1998
  const items = body.items ?? [];
1787
1999
  const currency = body.currency || items[0]?.currency || "USD";
1788
- const total = body.total ?? items.reduce((s, it) => s + (it.unitPriceAmount ?? 0) * it.quantity, 0);
2000
+ const total = body.total ?? lineItemsTotal(items, currency);
1789
2001
  const orderId = body.orderId || `PO-${nanoid5(8)}`;
1790
2002
  const attMeta = (body.attachments ?? []).map((a) => ({
1791
2003
  contentId: a.contentId,
@@ -1859,7 +2071,8 @@ flowRoute.post("/:id/order", async (c) => {
1859
2071
  const reqValidation = validateDocument(xml, {
1860
2072
  expected: ctx.expected,
1861
2073
  forceDocType: "OrderRequest",
1862
- availableContentIds: inputAtts.length > 0 ? availableContentIds : void 0
2074
+ availableContentIds: inputAtts.length > 0 ? availableContentIds : void 0,
2075
+ allowMixedCurrency: ctx.allowMixedCurrency
1863
2076
  });
1864
2077
  const reqLog = appendLog({
1865
2078
  sessionId,
@@ -1900,8 +2113,8 @@ flowRoute.post("/:id/order", async (c) => {
1900
2113
  });
1901
2114
 
1902
2115
  // src/server/routes/punchout-return.ts
1903
- import { Hono as Hono5 } from "hono";
1904
- var punchoutReturnRoute = new Hono5();
2116
+ import { Hono as Hono6 } from "hono";
2117
+ var punchoutReturnRoute = new Hono6();
1905
2118
  async function extractCxml(c) {
1906
2119
  const ct = (c.req.header("content-type") ?? "").toLowerCase();
1907
2120
  if (ct.includes("application/x-www-form-urlencoded") || ct.includes("multipart/form-data")) {
@@ -1969,9 +2182,9 @@ punchoutReturnRoute.post("/return", async (c) => {
1969
2182
  });
1970
2183
 
1971
2184
  // src/server/routes/stream.ts
1972
- import { Hono as Hono6 } from "hono";
2185
+ import { Hono as Hono7 } from "hono";
1973
2186
  import { streamSSE } from "hono/streaming";
1974
- var streamRoute = new Hono6();
2187
+ var streamRoute = new Hono7();
1975
2188
  var MAX_STREAMS = 64;
1976
2189
  var activeStreams = 0;
1977
2190
  streamRoute.get("/stream", (c) => {
@@ -2003,8 +2216,8 @@ streamRoute.get("/stream", (c) => {
2003
2216
  });
2004
2217
 
2005
2218
  // src/server/routes/data.ts
2006
- import { Hono as Hono7 } from "hono";
2007
- var dataRoute = new Hono7();
2219
+ import { Hono as Hono8 } from "hono";
2220
+ var dataRoute = new Hono8();
2008
2221
  function rawMessage(record) {
2009
2222
  const ct = record.contentType ?? record.headers?.["Content-Type"] ?? record.headers?.["content-type"];
2010
2223
  let body = record.body;
@@ -2030,7 +2243,7 @@ ${body}` : body;
2030
2243
  dataRoute.get("/health", (c) => c.json({ ok: true }));
2031
2244
  dataRoute.get(
2032
2245
  "/runtime",
2033
- (c) => c.json({ publicUrl: getPublicUrl(), callbackUrl: `${getPublicUrl()}/punchout/return` })
2246
+ (c) => c.json({ publicUrl: getPublicUrl(), callbackUrl: `${getPublicUrl()}/punchout/return`, version: getVersion() })
2034
2247
  );
2035
2248
  dataRoute.get("/sessions", (c) => c.json(listSessions()));
2036
2249
  dataRoute.get("/sessions/:id", (c) => c.json(readSession(c.req.param("id"))));
@@ -2056,13 +2269,13 @@ dataRoute.get("/attachments/:hash", (c) => {
2056
2269
  });
2057
2270
 
2058
2271
  // src/server/routes/sim.ts
2059
- import { Hono as Hono8 } from "hono";
2272
+ import { Hono as Hono9 } from "hono";
2060
2273
  import { nanoid as nanoid6 } from "nanoid";
2061
- var simRoute = new Hono8();
2274
+ var simRoute = new Hono9();
2062
2275
  var DEMO_CATALOG = [
2063
- { supplierPartId: "WIDGET-001", description: "Premium Steel Widget", unitPrice: 12.5, currency: "USD", uom: "EA", unspsc: "31161500", manufacturerPartId: "MFR-W001", manufacturerName: "Acme Manufacturing" },
2064
- { supplierPartId: "BOLT-250", description: "M8 Hex Bolt (pack of 250)", unitPrice: 34, currency: "USD", uom: "PK", unspsc: "31161600", manufacturerPartId: "MFR-B250", manufacturerName: "Acme Manufacturing" },
2065
- { supplierPartId: "TAPE-RED", description: "Industrial Marking Tape, Red", unitPrice: 5.75, currency: "USD", uom: "RL", unspsc: "31201500" }
2276
+ { supplierPartId: "WIDGET-001", description: "Premium Steel Widget", unitPrice: 12.5, currency: "USD", uom: "EA", classifications: [{ domain: "UNSPSC", value: "31161500" }], manufacturerPartId: "MFR-W001", manufacturerName: "Acme Manufacturing" },
2277
+ { supplierPartId: "BOLT-250", description: "M8 Hex Bolt (pack of 250)", unitPrice: 34, currency: "USD", uom: "PK", classifications: [{ domain: "UNSPSC", value: "31161600" }], manufacturerPartId: "MFR-B250", manufacturerName: "Acme Manufacturing" },
2278
+ { supplierPartId: "TAPE-RED", description: "Industrial Marking Tape, Red", unitPrice: 5.75, currency: "USD", uom: "RL", classifications: [{ domain: "UNSPSC", value: "31201500" }] }
2066
2279
  ];
2067
2280
  function host2() {
2068
2281
  try {
@@ -2071,7 +2284,10 @@ function host2() {
2071
2284
  return "punchout-simulator";
2072
2285
  }
2073
2286
  }
2074
- var catalogOf = (s) => s.catalog && s.catalog.length > 0 ? s.catalog : DEMO_CATALOG;
2287
+ var catalogOf = (s) => {
2288
+ const items = catalogForSupplier(s);
2289
+ return items.length > 0 ? items : DEMO_CATALOG;
2290
+ };
2075
2291
  function safeHttpUrl(u) {
2076
2292
  if (!u) return "";
2077
2293
  try {
@@ -2148,13 +2364,15 @@ simRoute.get("/:id/catalog", (c) => {
2148
2364
  const bd = c.req.query("bd") ?? "";
2149
2365
  const bi = c.req.query("bi") ?? "";
2150
2366
  const items = catalogOf(supplier);
2151
- const rows = items.map(
2152
- (it, i) => `<tr>
2153
- <td><strong>${escapeXml(it.description)}</strong><br><small>${escapeXml(it.supplierPartId)} \xB7 ${escapeXml(it.uom)} \xB7 UNSPSC ${escapeXml(it.unspsc)}</small></td>
2367
+ const rows = items.map((it, i) => {
2368
+ const partId = escapeXml(it.supplierPartId) + (it.supplierPartAuxiliaryId ? ` / ${escapeXml(it.supplierPartAuxiliaryId)}` : "");
2369
+ const cls = (it.classifications ?? []).map((c2) => `${escapeXml(c2.domain)} ${escapeXml(c2.value)}`).join(" \xB7 ");
2370
+ return `<tr>
2371
+ <td><strong>${escapeXml(it.description)}</strong><br><small>${partId} \xB7 ${escapeXml(it.uom)}${cls ? ` \xB7 ${cls}` : ""}</small></td>
2154
2372
  <td class="price">${escapeXml(it.currency)} ${it.unitPrice.toFixed(2)}</td>
2155
- <td><input type="number" name="q_${i}" value="0" min="0" step="1" inputmode="numeric"></td>
2156
- </tr>`
2157
- ).join("\n");
2373
+ <td><input type="number" name="q_${i}" value="0" min="0" step="${it.allowFractional ? "any" : "1"}" inputmode="${it.allowFractional ? "decimal" : "numeric"}"></td>
2374
+ </tr>`;
2375
+ }).join("\n");
2158
2376
  return c.html(`<!doctype html><html lang="en"><head><meta charset="utf-8">
2159
2377
  <meta name="viewport" content="width=device-width, initial-scale=1">
2160
2378
  <title>${escapeXml(supplier.name)} \u2014 mock catalog</title>
@@ -2193,17 +2411,22 @@ simRoute.post("/:id/checkout", async (c) => {
2193
2411
  const catalog = catalogOf(supplier);
2194
2412
  const items = [];
2195
2413
  catalog.forEach((it, i) => {
2196
- const qty = Number(form[`q_${i}`] ?? 0);
2414
+ let qty = Number(form[`q_${i}`] ?? 0);
2415
+ if (!it.allowFractional) qty = Math.floor(qty);
2197
2416
  if (qty > 0) {
2198
2417
  items.push({
2199
2418
  quantity: qty,
2200
2419
  supplierPartId: it.supplierPartId,
2420
+ supplierPartAuxiliaryId: it.supplierPartAuxiliaryId,
2201
2421
  description: it.description,
2202
2422
  uom: it.uom,
2203
2423
  unitPriceAmount: it.unitPrice,
2204
2424
  currency: it.currency,
2205
- classificationDomain: "UNSPSC",
2206
- classification: it.unspsc,
2425
+ classifications: it.classifications,
2426
+ // Keep the legacy single fields populated from the first classification
2427
+ // for back-compat display (CartView) and any single-domain consumer.
2428
+ classificationDomain: it.classifications[0]?.domain,
2429
+ classification: it.classifications[0]?.value,
2207
2430
  manufacturerPartId: it.manufacturerPartId,
2208
2431
  manufacturerName: it.manufacturerName
2209
2432
  });
@@ -2229,7 +2452,10 @@ simRoute.post("/:id/checkout", async (c) => {
2229
2452
  docType: "PunchOutOrderMessage",
2230
2453
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
2231
2454
  body: xml,
2232
- validation: validateDocument(xml, { forceDocType: "PunchOutOrderMessage" })
2455
+ validation: validateDocument(xml, {
2456
+ forceDocType: "PunchOutOrderMessage",
2457
+ allowMixedCurrency: supplier.allowMixedCurrency
2458
+ })
2233
2459
  });
2234
2460
  const transport = eff?.cartReturnTransport ?? "cxml-urlencoded";
2235
2461
  return c.html(cartReturnPage(formpost, xml, transport));
@@ -2299,7 +2525,8 @@ simRoute.post("/:id/order", async (c) => {
2299
2525
  const validation = validateDocument(xml, {
2300
2526
  expected: expectedFor(supplier.id, from),
2301
2527
  forceDocType: "OrderRequest",
2302
- availableContentIds: isMultipart(ct) ? availableContentIds : void 0
2528
+ availableContentIds: isMultipart(ct) ? availableContentIds : void 0,
2529
+ allowMixedCurrency: supplier.allowMixedCurrency
2303
2530
  });
2304
2531
  appendLog({
2305
2532
  sessionId,
@@ -2346,7 +2573,7 @@ function findSessionForOrder(doc) {
2346
2573
  // src/server/app.ts
2347
2574
  import { relative } from "path";
2348
2575
  function createApp(opts = {}) {
2349
- const app = new Hono9();
2576
+ const app = new Hono10();
2350
2577
  if (!opts.quiet) app.use("*", logger());
2351
2578
  app.use(
2352
2579
  "*",
@@ -2365,6 +2592,8 @@ function createApp(opts = {}) {
2365
2592
  app.route("/api/suppliers", suppliersRoute);
2366
2593
  app.route("/api/profiles", profilesRoute);
2367
2594
  app.route("/api/profile-presets", profilePresetsRoute);
2595
+ app.route("/api/product-lists", productsRoute);
2596
+ app.route("/api/product-list-presets", productListPresetsRoute);
2368
2597
  app.route("/api/connections", connectionsRoute);
2369
2598
  app.route("/api/connections", flowRoute);
2370
2599
  app.route("/api", dataRoute);
@@ -2406,7 +2635,8 @@ async function seedDemoIfEmpty() {
2406
2635
  identity: { domain: "DUNS", identity: "987654321" },
2407
2636
  punchoutUrl: `${getPublicUrl()}/sim/demo-supplier/punchout`,
2408
2637
  orderUrl: `${getPublicUrl()}/sim/demo-supplier/order`,
2409
- catalog: []
2638
+ // Serve the built-in sample assortment (seeded by initConfig).
2639
+ productListIds: ["sample"]
2410
2640
  });
2411
2641
  await createConnection({
2412
2642
  id: "demo",
@@ -2477,6 +2707,13 @@ function hostnameOf(url) {
2477
2707
  return "";
2478
2708
  }
2479
2709
  }
2710
+ function readVersion() {
2711
+ try {
2712
+ return JSON.parse(readFileSync3(new URL("../../package.json", import.meta.url), "utf8")).version;
2713
+ } catch {
2714
+ return void 0;
2715
+ }
2716
+ }
2480
2717
  function printHelp() {
2481
2718
  console.log(`punchout-simulator \u2014 test cXML PunchOut integrations as a virtual counterparty
2482
2719
 
@@ -2503,7 +2740,7 @@ async function main() {
2503
2740
  const exposed = !LOOPBACK.has(hostnameOf(publicUrl)) || !LOOPBACK.has(bindHost);
2504
2741
  const token = flags.token || (exposed ? nanoid7(24) : void 0);
2505
2742
  setDataDir(flags.dataDir);
2506
- setRuntime({ port: flags.port, publicUrl, token });
2743
+ setRuntime({ port: flags.port, publicUrl, token, version: readVersion() });
2507
2744
  await initConfig();
2508
2745
  if (flags.seed) await seedDemoIfEmpty();
2509
2746
  const webRoot = flags.dev ? void 0 : fileURLToPath(new URL("../web", import.meta.url));