punchout-simulator 0.3.1 → 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.
@@ -8,7 +8,7 @@ import { nanoid as nanoid7 } from "nanoid";
8
8
 
9
9
  // src/server/app.ts
10
10
  import { existsSync as existsSync3 } from "fs";
11
- import { Hono as Hono9 } from "hono";
11
+ import { Hono as Hono10 } from "hono";
12
12
  import { logger } from "hono/logger";
13
13
  import { bodyLimit } from "hono/body-limit";
14
14
  import { getCookie } from "hono/cookie";
@@ -148,6 +148,52 @@ function seedBuiltinProfiles(data, now2) {
148
148
  }
149
149
  }
150
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
+
151
197
  // src/server/store/paths.ts
152
198
  import { chmodSync, mkdirSync } from "fs";
153
199
  import { resolve } from "path";
@@ -184,15 +230,19 @@ var db = null;
184
230
  async function initConfig() {
185
231
  ensureDirs();
186
232
  const adapter = new JSONFile(configPath());
187
- db = new Low(adapter, { buyers: [], suppliers: [], connections: [], profiles: [] });
233
+ db = new Low(adapter, { buyers: [], suppliers: [], connections: [], profiles: [], productLists: [] });
188
234
  await db.read();
189
- db.data ||= { buyers: [], suppliers: [], connections: [], profiles: [] };
235
+ db.data ||= { buyers: [], suppliers: [], connections: [], profiles: [], productLists: [] };
190
236
  db.data.buyers ||= [];
191
237
  db.data.suppliers ||= [];
192
238
  db.data.connections ||= [];
193
239
  db.data.profiles ||= [];
240
+ db.data.productLists ||= [];
194
241
  seedBuiltinProfiles(db.data, now());
242
+ seedBuiltinProductLists(db.data, now());
195
243
  migrateLegacy(db.data);
244
+ migrateInlineCatalogs(db.data);
245
+ migrateClassifications(db.data);
196
246
  await db.write();
197
247
  try {
198
248
  chmodSync2(configPath(), 384);
@@ -353,6 +403,38 @@ function effectiveProfile(connection, buyer) {
353
403
  function dtdVersionFor(eff, docType) {
354
404
  return eff.dtdVersions[docType] ?? eff.dtdVersions.default;
355
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
+ }
356
438
  function migrateLegacy(data) {
357
439
  const legacy = data.connections.filter((c) => "from" in c || "to" in c);
358
440
  if (legacy.length === 0) return;
@@ -412,6 +494,39 @@ function migrateLegacy(data) {
412
494
  }
413
495
  data.connections = migrated;
414
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
+ }
415
530
 
416
531
  // src/server/routes/connections.ts
417
532
  var connectionsRoute = new Hono();
@@ -519,22 +634,14 @@ buyersRoute.delete("/:id", async (c) => {
519
634
  });
520
635
  var suppliersRoute = new Hono2();
521
636
  function normalizeSupplier(body) {
522
- const catalog = Array.isArray(body?.catalog) ? body.catalog.map((it) => ({
523
- supplierPartId: String(it?.supplierPartId ?? ""),
524
- description: String(it?.description ?? ""),
525
- unitPrice: Number(it?.unitPrice ?? 0) || 0,
526
- currency: String(it?.currency ?? "USD"),
527
- uom: String(it?.uom ?? "EA"),
528
- unspsc: String(it?.unspsc ?? ""),
529
- manufacturerPartId: it?.manufacturerPartId ? String(it.manufacturerPartId) : void 0,
530
- manufacturerName: it?.manufacturerName ? String(it.manufacturerName) : void 0
531
- })) : void 0;
637
+ const productListIds = Array.isArray(body?.productListIds) ? body.productListIds.map((id) => String(id)) : [];
532
638
  return {
533
639
  name: String(body?.name ?? "Untitled supplier"),
534
640
  identity: cred(body?.identity),
535
641
  punchoutUrl: body?.punchoutUrl ? String(body.punchoutUrl) : void 0,
536
642
  orderUrl: body?.orderUrl ? String(body.orderUrl) : void 0,
537
- catalog
643
+ productListIds,
644
+ allowMixedCurrency: Boolean(body?.allowMixedCurrency)
538
645
  };
539
646
  }
540
647
  suppliersRoute.get("/", (c) => c.json(listSuppliers()));
@@ -630,8 +737,66 @@ profilesRoute.delete("/:id", async (c) => {
630
737
  });
631
738
  profilePresetsRoute.get("/", (c) => c.json(PROFILE_PRESETS));
632
739
 
633
- // src/server/routes/flow.ts
740
+ // src/server/routes/products.ts
634
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";
635
800
  import { nanoid as nanoid5 } from "nanoid";
636
801
 
637
802
  // src/server/store/log.ts
@@ -944,6 +1109,15 @@ function escapeXml(value) {
944
1109
  function makePayloadId(host3, nowIso) {
945
1110
  return `${nowIso}.${nanoid4(10)}@${host3}`;
946
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
+ }
947
1121
  function credentialBlock(tag, c) {
948
1122
  return ` <${tag}>
949
1123
  <Credential domain="${escapeXml(c.domain)}">
@@ -1054,9 +1228,7 @@ function buildOrderRequest(o) {
1054
1228
  </UnitPrice>
1055
1229
  <Description xml:lang="en">${escapeXml(it.description ?? "")}</Description>
1056
1230
  <UnitOfMeasure>${escapeXml(it.uom ?? "EA")}</UnitOfMeasure>
1057
- <Classification domain="${escapeXml(it.classificationDomain ?? "UNSPSC")}">${escapeXml(
1058
- it.classification ?? ""
1059
- )}</Classification>${it.manufacturerPartId ? `
1231
+ ${classificationBlock(it, " ")}${it.manufacturerPartId ? `
1060
1232
  <ManufacturerPartID>${escapeXml(it.manufacturerPartId)}</ManufacturerPartID>` : ""}${it.manufacturerName ? `
1061
1233
  <ManufacturerName>${escapeXml(it.manufacturerName)}</ManufacturerName>` : ""}${commentsWithAttachments(cids, " ")}
1062
1234
  </ItemDetail>
@@ -1116,10 +1288,7 @@ function buildResponseStatus(o) {
1116
1288
  return envelope(o.payloadId, o.timestamp, o.lang ?? "en-US", inner, o.dtdVersion);
1117
1289
  }
1118
1290
  function buildPunchOutOrderMessage(o) {
1119
- const total = o.items.reduce(
1120
- (sum, it) => sum + (it.unitPriceAmount ?? 0) * it.quantity,
1121
- 0
1122
- );
1291
+ const total = lineItemsTotal(o.items, o.currency);
1123
1292
  const items = o.items.map(
1124
1293
  (it) => ` <ItemIn quantity="${escapeXml(it.quantity)}">
1125
1294
  <ItemID>
@@ -1134,9 +1303,7 @@ function buildPunchOutOrderMessage(o) {
1134
1303
  </UnitPrice>
1135
1304
  <Description xml:lang="en">${escapeXml(it.description ?? "")}</Description>
1136
1305
  <UnitOfMeasure>${escapeXml(it.uom ?? "EA")}</UnitOfMeasure>
1137
- <Classification domain="${escapeXml(it.classificationDomain ?? "UNSPSC")}">${escapeXml(
1138
- it.classification ?? ""
1139
- )}</Classification>${it.manufacturerPartId ? `
1306
+ ${classificationBlock(it, " ")}${it.manufacturerPartId ? `
1140
1307
  <ManufacturerPartID>${escapeXml(it.manufacturerPartId)}</ManufacturerPartID>` : ""}
1141
1308
  </ItemDetail>
1142
1309
  </ItemIn>`
@@ -1326,8 +1493,8 @@ function parseCart(doc) {
1326
1493
  const items = asArray(pom?.ItemIn).map((it) => {
1327
1494
  const detail = it?.ItemDetail;
1328
1495
  const up = money(detail?.UnitPrice);
1329
- const classification = detail?.Classification;
1330
- 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];
1331
1498
  return {
1332
1499
  quantity: Number(attr(it, "quantity") ?? "1") || 1,
1333
1500
  supplierPartId: text(it?.ItemID?.SupplierPartID),
@@ -1336,8 +1503,9 @@ function parseCart(doc) {
1336
1503
  uom: text(detail?.UnitOfMeasure),
1337
1504
  unitPriceAmount: up.amount,
1338
1505
  currency: up.currency,
1339
- classificationDomain: attr(classFirst, "domain"),
1340
- classification: text(classFirst),
1506
+ classifications: classifications.length > 0 ? classifications : void 0,
1507
+ classificationDomain: classFirst?.domain,
1508
+ classification: classFirst?.value,
1341
1509
  manufacturerPartId: text(detail?.ManufacturerPartID),
1342
1510
  manufacturerName: text(detail?.ManufacturerName)
1343
1511
  };
@@ -1500,6 +1668,7 @@ function checkPunchback(doc, ctx, issues) {
1500
1668
  issues.warn("empty-cart", "PunchOutOrderMessage contains no ItemIn elements", "cXML/.../PunchOutOrderMessage");
1501
1669
  }
1502
1670
  let lineSum = 0;
1671
+ const lineCurrencies = [];
1503
1672
  items.forEach((it, i) => {
1504
1673
  const base = `cXML/.../ItemIn[${i + 1}]`;
1505
1674
  const qty = num(attr(it, "quantity"));
@@ -1516,10 +1685,13 @@ function checkPunchback(doc, ctx, issues) {
1516
1685
  }
1517
1686
  const up = detail.UnitPrice?.Money;
1518
1687
  const upAmount = num(text(up));
1688
+ const upCurrency = attr(up, "currency");
1519
1689
  if (up == null) {
1520
1690
  issues.error("item-missing-unitprice", `ItemIn[${i + 1}] is missing ItemDetail/UnitPrice/Money`, `${base}/ItemDetail/UnitPrice`);
1521
- } else if (!attr(up, "currency")) {
1691
+ } else if (!upCurrency) {
1522
1692
  issues.error("item-missing-currency", `ItemIn[${i + 1}] UnitPrice/Money is missing @currency`, `${base}/ItemDetail/UnitPrice/Money`);
1693
+ } else {
1694
+ lineCurrencies.push(upCurrency);
1523
1695
  }
1524
1696
  if (!text(detail.Description)) {
1525
1697
  issues.error("item-missing-description", `ItemIn[${i + 1}] is missing ItemDetail/Description`, `${base}/ItemDetail/Description`);
@@ -1535,7 +1707,14 @@ function checkPunchback(doc, ctx, issues) {
1535
1707
  }
1536
1708
  if (qty != null && upAmount != null) lineSum += qty * upAmount;
1537
1709
  });
1538
- 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) {
1539
1718
  const diff = Math.abs(totalAmount - lineSum);
1540
1719
  if (diff > 0.01) {
1541
1720
  issues.warn(
@@ -1546,6 +1725,28 @@ function checkPunchback(doc, ctx, issues) {
1546
1725
  }
1547
1726
  }
1548
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
+ }
1549
1750
  function checkOrderRequest(doc, ctx, issues) {
1550
1751
  const orderReq = root(doc)?.Request?.OrderRequest;
1551
1752
  if (!orderReq) {
@@ -1572,18 +1773,24 @@ function checkOrderRequest(doc, ctx, issues) {
1572
1773
  if (items.length === 0) {
1573
1774
  issues.error("no-itemout", "OrderRequest contains no ItemOut elements", "cXML/.../OrderRequest");
1574
1775
  }
1776
+ const lineCurrencies = [];
1575
1777
  items.forEach((it, i) => {
1576
1778
  const base = `cXML/.../ItemOut[${i + 1}]`;
1577
1779
  if (!text(it?.ItemID?.SupplierPartID)) {
1578
1780
  issues.error("itemout-missing-id", `ItemOut[${i + 1}] is missing ItemID/SupplierPartID`, `${base}/ItemID`);
1579
1781
  }
1580
- if (it?.ItemDetail?.UnitPrice?.Money == null) {
1782
+ const up = it?.ItemDetail?.UnitPrice?.Money;
1783
+ if (up == null) {
1581
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);
1582
1788
  }
1583
1789
  if (num(attr(it, "quantity")) == null) {
1584
1790
  issues.error("itemout-missing-quantity", `ItemOut[${i + 1}] is missing @quantity`, base);
1585
1791
  }
1586
1792
  });
1793
+ checkSingleCurrency(lineCurrencies, attr(header2?.Total?.Money, "currency"), issues, "cXML/.../OrderRequestHeader/Total", ctx.allowMixedCurrency);
1587
1794
  const refs = collectCidReferences(doc);
1588
1795
  const available = ctx.availableContentIds;
1589
1796
  const referenced = /* @__PURE__ */ new Set();
@@ -1665,7 +1872,7 @@ function validateDocument(raw, ctx = {}) {
1665
1872
  }
1666
1873
 
1667
1874
  // src/server/routes/flow.ts
1668
- var flowRoute = new Hono4();
1875
+ var flowRoute = new Hono5();
1669
1876
  function host() {
1670
1877
  try {
1671
1878
  return new URL(getPublicUrl()).host;
@@ -1690,7 +1897,8 @@ function buyerContext(r) {
1690
1897
  connectionId: connection.id,
1691
1898
  attachmentEncoding: eff.attachmentEncoding,
1692
1899
  eff,
1693
- expected: { from, to, sender, sharedSecret: connection.sharedSecret }
1900
+ expected: { from, to, sender, sharedSecret: connection.sharedSecret },
1901
+ allowMixedCurrency: supplier.allowMixedCurrency ?? false
1694
1902
  };
1695
1903
  }
1696
1904
  function setupExtrinsics(ctx, buyerCookie) {
@@ -1789,7 +1997,7 @@ flowRoute.post("/:id/setup", async (c) => {
1789
1997
  function buildOrderXml(ctx, body) {
1790
1998
  const items = body.items ?? [];
1791
1999
  const currency = body.currency || items[0]?.currency || "USD";
1792
- const total = body.total ?? items.reduce((s, it) => s + (it.unitPriceAmount ?? 0) * it.quantity, 0);
2000
+ const total = body.total ?? lineItemsTotal(items, currency);
1793
2001
  const orderId = body.orderId || `PO-${nanoid5(8)}`;
1794
2002
  const attMeta = (body.attachments ?? []).map((a) => ({
1795
2003
  contentId: a.contentId,
@@ -1863,7 +2071,8 @@ flowRoute.post("/:id/order", async (c) => {
1863
2071
  const reqValidation = validateDocument(xml, {
1864
2072
  expected: ctx.expected,
1865
2073
  forceDocType: "OrderRequest",
1866
- availableContentIds: inputAtts.length > 0 ? availableContentIds : void 0
2074
+ availableContentIds: inputAtts.length > 0 ? availableContentIds : void 0,
2075
+ allowMixedCurrency: ctx.allowMixedCurrency
1867
2076
  });
1868
2077
  const reqLog = appendLog({
1869
2078
  sessionId,
@@ -1904,8 +2113,8 @@ flowRoute.post("/:id/order", async (c) => {
1904
2113
  });
1905
2114
 
1906
2115
  // src/server/routes/punchout-return.ts
1907
- import { Hono as Hono5 } from "hono";
1908
- var punchoutReturnRoute = new Hono5();
2116
+ import { Hono as Hono6 } from "hono";
2117
+ var punchoutReturnRoute = new Hono6();
1909
2118
  async function extractCxml(c) {
1910
2119
  const ct = (c.req.header("content-type") ?? "").toLowerCase();
1911
2120
  if (ct.includes("application/x-www-form-urlencoded") || ct.includes("multipart/form-data")) {
@@ -1973,9 +2182,9 @@ punchoutReturnRoute.post("/return", async (c) => {
1973
2182
  });
1974
2183
 
1975
2184
  // src/server/routes/stream.ts
1976
- import { Hono as Hono6 } from "hono";
2185
+ import { Hono as Hono7 } from "hono";
1977
2186
  import { streamSSE } from "hono/streaming";
1978
- var streamRoute = new Hono6();
2187
+ var streamRoute = new Hono7();
1979
2188
  var MAX_STREAMS = 64;
1980
2189
  var activeStreams = 0;
1981
2190
  streamRoute.get("/stream", (c) => {
@@ -2007,8 +2216,8 @@ streamRoute.get("/stream", (c) => {
2007
2216
  });
2008
2217
 
2009
2218
  // src/server/routes/data.ts
2010
- import { Hono as Hono7 } from "hono";
2011
- var dataRoute = new Hono7();
2219
+ import { Hono as Hono8 } from "hono";
2220
+ var dataRoute = new Hono8();
2012
2221
  function rawMessage(record) {
2013
2222
  const ct = record.contentType ?? record.headers?.["Content-Type"] ?? record.headers?.["content-type"];
2014
2223
  let body = record.body;
@@ -2060,13 +2269,13 @@ dataRoute.get("/attachments/:hash", (c) => {
2060
2269
  });
2061
2270
 
2062
2271
  // src/server/routes/sim.ts
2063
- import { Hono as Hono8 } from "hono";
2272
+ import { Hono as Hono9 } from "hono";
2064
2273
  import { nanoid as nanoid6 } from "nanoid";
2065
- var simRoute = new Hono8();
2274
+ var simRoute = new Hono9();
2066
2275
  var DEMO_CATALOG = [
2067
- { supplierPartId: "WIDGET-001", description: "Premium Steel Widget", unitPrice: 12.5, currency: "USD", uom: "EA", unspsc: "31161500", manufacturerPartId: "MFR-W001", manufacturerName: "Acme Manufacturing" },
2068
- { supplierPartId: "BOLT-250", description: "M8 Hex Bolt (pack of 250)", unitPrice: 34, currency: "USD", uom: "PK", unspsc: "31161600", manufacturerPartId: "MFR-B250", manufacturerName: "Acme Manufacturing" },
2069
- { 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" }] }
2070
2279
  ];
2071
2280
  function host2() {
2072
2281
  try {
@@ -2075,7 +2284,10 @@ function host2() {
2075
2284
  return "punchout-simulator";
2076
2285
  }
2077
2286
  }
2078
- 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
+ };
2079
2291
  function safeHttpUrl(u) {
2080
2292
  if (!u) return "";
2081
2293
  try {
@@ -2152,13 +2364,15 @@ simRoute.get("/:id/catalog", (c) => {
2152
2364
  const bd = c.req.query("bd") ?? "";
2153
2365
  const bi = c.req.query("bi") ?? "";
2154
2366
  const items = catalogOf(supplier);
2155
- const rows = items.map(
2156
- (it, i) => `<tr>
2157
- <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>
2158
2372
  <td class="price">${escapeXml(it.currency)} ${it.unitPrice.toFixed(2)}</td>
2159
- <td><input type="number" name="q_${i}" value="0" min="0" step="1" inputmode="numeric"></td>
2160
- </tr>`
2161
- ).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");
2162
2376
  return c.html(`<!doctype html><html lang="en"><head><meta charset="utf-8">
2163
2377
  <meta name="viewport" content="width=device-width, initial-scale=1">
2164
2378
  <title>${escapeXml(supplier.name)} \u2014 mock catalog</title>
@@ -2197,17 +2411,22 @@ simRoute.post("/:id/checkout", async (c) => {
2197
2411
  const catalog = catalogOf(supplier);
2198
2412
  const items = [];
2199
2413
  catalog.forEach((it, i) => {
2200
- const qty = Number(form[`q_${i}`] ?? 0);
2414
+ let qty = Number(form[`q_${i}`] ?? 0);
2415
+ if (!it.allowFractional) qty = Math.floor(qty);
2201
2416
  if (qty > 0) {
2202
2417
  items.push({
2203
2418
  quantity: qty,
2204
2419
  supplierPartId: it.supplierPartId,
2420
+ supplierPartAuxiliaryId: it.supplierPartAuxiliaryId,
2205
2421
  description: it.description,
2206
2422
  uom: it.uom,
2207
2423
  unitPriceAmount: it.unitPrice,
2208
2424
  currency: it.currency,
2209
- classificationDomain: "UNSPSC",
2210
- 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,
2211
2430
  manufacturerPartId: it.manufacturerPartId,
2212
2431
  manufacturerName: it.manufacturerName
2213
2432
  });
@@ -2233,7 +2452,10 @@ simRoute.post("/:id/checkout", async (c) => {
2233
2452
  docType: "PunchOutOrderMessage",
2234
2453
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
2235
2454
  body: xml,
2236
- validation: validateDocument(xml, { forceDocType: "PunchOutOrderMessage" })
2455
+ validation: validateDocument(xml, {
2456
+ forceDocType: "PunchOutOrderMessage",
2457
+ allowMixedCurrency: supplier.allowMixedCurrency
2458
+ })
2237
2459
  });
2238
2460
  const transport = eff?.cartReturnTransport ?? "cxml-urlencoded";
2239
2461
  return c.html(cartReturnPage(formpost, xml, transport));
@@ -2303,7 +2525,8 @@ simRoute.post("/:id/order", async (c) => {
2303
2525
  const validation = validateDocument(xml, {
2304
2526
  expected: expectedFor(supplier.id, from),
2305
2527
  forceDocType: "OrderRequest",
2306
- availableContentIds: isMultipart(ct) ? availableContentIds : void 0
2528
+ availableContentIds: isMultipart(ct) ? availableContentIds : void 0,
2529
+ allowMixedCurrency: supplier.allowMixedCurrency
2307
2530
  });
2308
2531
  appendLog({
2309
2532
  sessionId,
@@ -2350,7 +2573,7 @@ function findSessionForOrder(doc) {
2350
2573
  // src/server/app.ts
2351
2574
  import { relative } from "path";
2352
2575
  function createApp(opts = {}) {
2353
- const app = new Hono9();
2576
+ const app = new Hono10();
2354
2577
  if (!opts.quiet) app.use("*", logger());
2355
2578
  app.use(
2356
2579
  "*",
@@ -2369,6 +2592,8 @@ function createApp(opts = {}) {
2369
2592
  app.route("/api/suppliers", suppliersRoute);
2370
2593
  app.route("/api/profiles", profilesRoute);
2371
2594
  app.route("/api/profile-presets", profilePresetsRoute);
2595
+ app.route("/api/product-lists", productsRoute);
2596
+ app.route("/api/product-list-presets", productListPresetsRoute);
2372
2597
  app.route("/api/connections", connectionsRoute);
2373
2598
  app.route("/api/connections", flowRoute);
2374
2599
  app.route("/api", dataRoute);
@@ -2410,7 +2635,8 @@ async function seedDemoIfEmpty() {
2410
2635
  identity: { domain: "DUNS", identity: "987654321" },
2411
2636
  punchoutUrl: `${getPublicUrl()}/sim/demo-supplier/punchout`,
2412
2637
  orderUrl: `${getPublicUrl()}/sim/demo-supplier/order`,
2413
- catalog: []
2638
+ // Serve the built-in sample assortment (seeded by initConfig).
2639
+ productListIds: ["sample"]
2414
2640
  });
2415
2641
  await createConnection({
2416
2642
  id: "demo",
@@ -1,4 +1,4 @@
1
- import{m as et}from"./index-BzgSetIt.js";/*!-----------------------------------------------------------------------------
1
+ import{m as et}from"./index-BBqxMuv9.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license
@@ -1,4 +1,4 @@
1
- import{m as f}from"./index-BzgSetIt.js";/*!-----------------------------------------------------------------------------
1
+ import{m as f}from"./index-BBqxMuv9.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license
@@ -1,4 +1,4 @@
1
- import{m as l}from"./index-BzgSetIt.js";/*!-----------------------------------------------------------------------------
1
+ import{m as l}from"./index-BBqxMuv9.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license
@@ -1,4 +1,4 @@
1
- import{m as s}from"./index-BzgSetIt.js";/*!-----------------------------------------------------------------------------
1
+ import{m as s}from"./index-BBqxMuv9.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license
@@ -1,4 +1,4 @@
1
- import{m as lt}from"./index-BzgSetIt.js";/*!-----------------------------------------------------------------------------
1
+ import{m as lt}from"./index-BBqxMuv9.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license