punchout-simulator 0.3.1 → 0.5.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";
@@ -56,7 +56,10 @@ var PROFILE_PRESETS = [
56
56
  setupOperation: "create",
57
57
  attachmentEncoding: "binary",
58
58
  cartReturnTransport: "cxml-urlencoded",
59
- extrinsics: []
59
+ extrinsics: [],
60
+ addressMode: "full",
61
+ shipToInSetup: false,
62
+ contactInSetup: false
60
63
  },
61
64
  {
62
65
  id: "ariba",
@@ -68,7 +71,12 @@ var PROFILE_PRESETS = [
68
71
  setupOperation: "create",
69
72
  attachmentEncoding: "base64",
70
73
  cartReturnTransport: "cxml-urlencoded",
71
- extrinsics: [{ name: "User", value: "${buyerCookie}", scope: "setup" }]
74
+ extrinsics: [{ name: "User", value: "${buyerCookie}", scope: "setup" }],
75
+ // Ariba sends ShipTo (addressID + postal) already in the SetupRequest so the
76
+ // supplier can return ship-to-specific pricing/availability.
77
+ addressMode: "both",
78
+ shipToInSetup: true,
79
+ contactInSetup: false
72
80
  },
73
81
  {
74
82
  id: "coupa",
@@ -81,7 +89,10 @@ var PROFILE_PRESETS = [
81
89
  setupOperation: "create",
82
90
  attachmentEncoding: "base64",
83
91
  cartReturnTransport: "cxml-urlencoded",
84
- extrinsics: []
92
+ extrinsics: [],
93
+ addressMode: "both",
94
+ shipToInSetup: false,
95
+ contactInSetup: false
85
96
  },
86
97
  {
87
98
  id: "jaggaer",
@@ -93,7 +104,10 @@ var PROFILE_PRESETS = [
93
104
  setupOperation: "create",
94
105
  attachmentEncoding: "binary",
95
106
  cartReturnTransport: "cxml-urlencoded",
96
- extrinsics: []
107
+ extrinsics: [],
108
+ addressMode: "full",
109
+ shipToInSetup: false,
110
+ contactInSetup: false
97
111
  },
98
112
  {
99
113
  id: "oracle",
@@ -105,7 +119,10 @@ var PROFILE_PRESETS = [
105
119
  setupOperation: "create",
106
120
  attachmentEncoding: "binary",
107
121
  cartReturnTransport: "cxml-urlencoded",
108
- extrinsics: []
122
+ extrinsics: [],
123
+ addressMode: "full",
124
+ shipToInSetup: false,
125
+ contactInSetup: false
109
126
  },
110
127
  {
111
128
  id: "sap-srm",
@@ -120,7 +137,11 @@ var PROFILE_PRESETS = [
120
137
  setupOperation: "create",
121
138
  attachmentEncoding: "base64",
122
139
  cartReturnTransport: "cxml-base64",
123
- extrinsics: []
140
+ extrinsics: [],
141
+ // SAP is location/plant-code centric — addresses by reference.
142
+ addressMode: "id-only",
143
+ shipToInSetup: false,
144
+ contactInSetup: false
124
145
  },
125
146
  {
126
147
  id: "workday",
@@ -132,7 +153,10 @@ var PROFILE_PRESETS = [
132
153
  setupOperation: "create",
133
154
  attachmentEncoding: "base64",
134
155
  cartReturnTransport: "cxml-urlencoded",
135
- extrinsics: []
156
+ extrinsics: [],
157
+ addressMode: "full",
158
+ shipToInSetup: false,
159
+ contactInSetup: false
136
160
  }
137
161
  ];
138
162
  var GENERIC_PROFILE = {
@@ -148,6 +172,52 @@ function seedBuiltinProfiles(data, now2) {
148
172
  }
149
173
  }
150
174
 
175
+ // src/server/cxml/product-list-presets.ts
176
+ var PRODUCT_LIST_PRESETS = [
177
+ {
178
+ id: "sample",
179
+ name: "Sample assortment",
180
+ builtin: true,
181
+ description: "A representative office & industrial supplies catalog (~20 items).",
182
+ items: [
183
+ { 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" },
184
+ { 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" },
185
+ { 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" },
186
+ { 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" },
187
+ { supplierPartId: "TAPE-RED", supplierPartAuxiliaryId: "TAPE-RED-50M", description: "Industrial Marking Tape, Red", unitPrice: 5.75, currency: "USD", uom: "RL", classifications: [{ domain: "UNSPSC", value: "31201500" }] },
188
+ { supplierPartId: "TAPE-YEL", supplierPartAuxiliaryId: "TAPE-YEL-50M", description: "Industrial Marking Tape, Yellow", unitPrice: 5.75, currency: "USD", uom: "RL", classifications: [{ domain: "UNSPSC", value: "31201500" }] },
189
+ // Multiple classifications: UNSPSC + a supplier-specific commodity scheme.
190
+ { 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 },
191
+ { 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 },
192
+ { 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 },
193
+ { 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 },
194
+ { 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" },
195
+ { 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" },
196
+ { supplierPartId: "GOGGLE-STD", description: "Safety Goggles, Anti-fog", unitPrice: 4.6, currency: "GBP", uom: "EA", classifications: [{ domain: "UNSPSC", value: "46181702" }], manufacturerName: "SafeHands" },
197
+ { supplierPartId: "HELMET-WHT", description: "Hard Hat, White", unitPrice: 9.8, currency: "GBP", uom: "EA", classifications: [{ domain: "UNSPSC", value: "46181701" }], manufacturerName: "SafeHands" },
198
+ { supplierPartId: "PAPER-A4", description: "Copy Paper A4 80gsm (ream of 500)", unitPrice: 18, currency: "PLN", uom: "RM", classifications: [{ domain: "UNSPSC", value: "14111507" }], manufacturerName: "PaperCo" },
199
+ { 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" },
200
+ { supplierPartId: "BINDER-A4", description: "Lever Arch Binder A4, Black", unitPrice: 11.5, currency: "PLN", uom: "EA", classifications: [{ domain: "UNSPSC", value: "44122011" }], manufacturerName: "Officeline" },
201
+ { 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" },
202
+ { 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" },
203
+ { 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" },
204
+ { 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 }
205
+ ]
206
+ }
207
+ ];
208
+ var SAMPLE_PRODUCT_LIST = {
209
+ ...PRODUCT_LIST_PRESETS[0],
210
+ createdAt: "",
211
+ updatedAt: ""
212
+ };
213
+ function seedBuiltinProductLists(data, now2) {
214
+ for (const preset of PRODUCT_LIST_PRESETS) {
215
+ if (!data.productLists.some((p) => p.id === preset.id)) {
216
+ data.productLists.push({ ...preset, createdAt: now2, updatedAt: now2 });
217
+ }
218
+ }
219
+ }
220
+
151
221
  // src/server/store/paths.ts
152
222
  import { chmodSync, mkdirSync } from "fs";
153
223
  import { resolve } from "path";
@@ -184,15 +254,20 @@ var db = null;
184
254
  async function initConfig() {
185
255
  ensureDirs();
186
256
  const adapter = new JSONFile(configPath());
187
- db = new Low(adapter, { buyers: [], suppliers: [], connections: [], profiles: [] });
257
+ db = new Low(adapter, { buyers: [], suppliers: [], connections: [], profiles: [], productLists: [] });
188
258
  await db.read();
189
- db.data ||= { buyers: [], suppliers: [], connections: [], profiles: [] };
259
+ db.data ||= { buyers: [], suppliers: [], connections: [], profiles: [], productLists: [] };
190
260
  db.data.buyers ||= [];
191
261
  db.data.suppliers ||= [];
192
262
  db.data.connections ||= [];
193
263
  db.data.profiles ||= [];
264
+ db.data.productLists ||= [];
194
265
  seedBuiltinProfiles(db.data, now());
266
+ migrateProfiles(db.data);
267
+ seedBuiltinProductLists(db.data, now());
195
268
  migrateLegacy(db.data);
269
+ migrateInlineCatalogs(db.data);
270
+ migrateClassifications(db.data);
196
271
  await db.write();
197
272
  try {
198
273
  chmodSync2(configPath(), 384);
@@ -347,12 +422,47 @@ function effectiveProfile(connection, buyer) {
347
422
  // Connection attachmentEncoding is concrete by construction → explicit override.
348
423
  attachmentEncoding: connection.attachmentEncoding ?? p.attachmentEncoding,
349
424
  cartReturnTransport: p.cartReturnTransport,
350
- extrinsics: p.extrinsics
425
+ extrinsics: p.extrinsics,
426
+ addressMode: p.addressMode,
427
+ shipToInSetup: p.shipToInSetup,
428
+ contactInSetup: p.contactInSetup
351
429
  };
352
430
  }
353
431
  function dtdVersionFor(eff, docType) {
354
432
  return eff.dtdVersions[docType] ?? eff.dtdVersions.default;
355
433
  }
434
+ var listProductLists = () => requireDb().data.productLists;
435
+ var getProductList = (id) => requireDb().data.productLists.find((p) => p.id === id);
436
+ async function createProductList(input) {
437
+ const list = { ...input, id: input.id ?? nanoid(8), createdAt: now(), updatedAt: now() };
438
+ const d = requireDb();
439
+ d.data.productLists.push(list);
440
+ await d.write();
441
+ return list;
442
+ }
443
+ async function updateProductList(id, patch) {
444
+ const d = requireDb();
445
+ const existing = d.data.productLists.find((p) => p.id === id);
446
+ if (!existing) return void 0;
447
+ Object.assign(existing, patch, { id, updatedAt: now() });
448
+ await d.write();
449
+ return existing;
450
+ }
451
+ async function deleteProductList(id) {
452
+ const d = requireDb();
453
+ if (d.data.suppliers.some((s) => s.productListIds?.includes(id))) {
454
+ throw new Error("product list is referenced by a supplier");
455
+ }
456
+ const before = d.data.productLists.length;
457
+ d.data.productLists = d.data.productLists.filter((p) => p.id !== id);
458
+ const removed = d.data.productLists.length < before;
459
+ if (removed) await d.write();
460
+ return removed;
461
+ }
462
+ function catalogForSupplier(supplier) {
463
+ const ids = supplier.productListIds ?? [];
464
+ return ids.flatMap((id) => getProductList(id)?.items ?? []);
465
+ }
356
466
  function migrateLegacy(data) {
357
467
  const legacy = data.connections.filter((c) => "from" in c || "to" in c);
358
468
  if (legacy.length === 0) return;
@@ -412,6 +522,48 @@ function migrateLegacy(data) {
412
522
  }
413
523
  data.connections = migrated;
414
524
  }
525
+ function migrateInlineCatalogs(data) {
526
+ for (const supplier of data.suppliers) {
527
+ const legacy = supplier.catalog;
528
+ if (!legacy || legacy.length === 0) {
529
+ delete supplier.catalog;
530
+ continue;
531
+ }
532
+ if (supplier.productListIds && supplier.productListIds.length > 0) {
533
+ delete supplier.catalog;
534
+ continue;
535
+ }
536
+ const list = {
537
+ id: nanoid(8),
538
+ name: `${supplier.name} catalog`,
539
+ items: legacy,
540
+ createdAt: now(),
541
+ updatedAt: now()
542
+ };
543
+ data.productLists.push(list);
544
+ supplier.productListIds = [list.id];
545
+ delete supplier.catalog;
546
+ }
547
+ }
548
+ function migrateProfiles(data) {
549
+ for (const p of data.profiles) {
550
+ if (p.addressMode != null && p.shipToInSetup != null && p.contactInSetup != null) continue;
551
+ const preset = PROFILE_PRESETS.find((x) => x.id === p.id);
552
+ p.addressMode ??= preset?.addressMode ?? "full";
553
+ p.shipToInSetup ??= preset?.shipToInSetup ?? false;
554
+ p.contactInSetup ??= preset?.contactInSetup ?? false;
555
+ }
556
+ }
557
+ function migrateClassifications(data) {
558
+ for (const list of data.productLists) {
559
+ for (const item of list.items) {
560
+ if (!Array.isArray(item.classifications) || item.classifications.length === 0) {
561
+ item.classifications = item.unspsc ? [{ domain: "UNSPSC", value: String(item.unspsc) }] : [];
562
+ }
563
+ delete item.unspsc;
564
+ }
565
+ }
566
+ }
415
567
 
416
568
  // src/server/routes/connections.ts
417
569
  var connectionsRoute = new Hono();
@@ -486,12 +638,40 @@ var cred = (c) => ({
486
638
  domain: String(c?.domain ?? ""),
487
639
  identity: String(c?.identity ?? "")
488
640
  });
641
+ var str = (v) => v != null && String(v) !== "" ? String(v) : void 0;
642
+ function address(a) {
643
+ if (a == null || typeof a !== "object") return void 0;
644
+ const out = {
645
+ addressId: str(a.addressId),
646
+ addressIdDomain: str(a.addressIdDomain),
647
+ name: str(a.name),
648
+ deliverTo: str(a.deliverTo),
649
+ street: str(a.street),
650
+ city: str(a.city),
651
+ state: str(a.state),
652
+ postalCode: str(a.postalCode),
653
+ countryIsoCode: str(a.countryIsoCode),
654
+ countryName: str(a.countryName),
655
+ email: str(a.email),
656
+ phone: str(a.phone)
657
+ };
658
+ return Object.values(out).some((v) => v != null) ? out : void 0;
659
+ }
660
+ function contact(c) {
661
+ const a = address(c);
662
+ const role = str(c?.role);
663
+ if (!a && !role) return void 0;
664
+ return { ...a ?? {}, role: role ?? "endUser" };
665
+ }
489
666
  var buyersRoute = new Hono2();
490
667
  function normalizeBuyer(body) {
491
668
  return {
492
669
  name: String(body?.name ?? "Untitled buyer"),
493
670
  identity: cred(body?.identity),
494
- profileId: body?.profileId ? String(body.profileId) : void 0
671
+ profileId: body?.profileId ? String(body.profileId) : void 0,
672
+ shipTo: address(body?.shipTo),
673
+ billTo: address(body?.billTo),
674
+ contact: contact(body?.contact)
495
675
  };
496
676
  }
497
677
  buyersRoute.get("/", (c) => c.json(listBuyers()));
@@ -519,22 +699,14 @@ buyersRoute.delete("/:id", async (c) => {
519
699
  });
520
700
  var suppliersRoute = new Hono2();
521
701
  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;
702
+ const productListIds = Array.isArray(body?.productListIds) ? body.productListIds.map((id) => String(id)) : [];
532
703
  return {
533
704
  name: String(body?.name ?? "Untitled supplier"),
534
705
  identity: cred(body?.identity),
535
706
  punchoutUrl: body?.punchoutUrl ? String(body.punchoutUrl) : void 0,
536
707
  orderUrl: body?.orderUrl ? String(body.orderUrl) : void 0,
537
- catalog
708
+ productListIds,
709
+ allowMixedCurrency: Boolean(body?.allowMixedCurrency)
538
710
  };
539
711
  }
540
712
  suppliersRoute.get("/", (c) => c.json(listSuppliers()));
@@ -593,6 +765,7 @@ function normalizeProfile(body) {
593
765
  const cartReturnTransport = ["cxml-urlencoded", "cxml-base64", "raw"].includes(
594
766
  body?.cartReturnTransport
595
767
  ) ? body.cartReturnTransport : "cxml-urlencoded";
768
+ const addressMode = ["id-only", "full", "both"].includes(body?.addressMode) ? body.addressMode : "full";
596
769
  return {
597
770
  name: String(body?.name ?? "Untitled profile"),
598
771
  platform: body?.platform ? String(body.platform) : void 0,
@@ -601,7 +774,10 @@ function normalizeProfile(body) {
601
774
  setupOperation,
602
775
  attachmentEncoding,
603
776
  cartReturnTransport,
604
- extrinsics: normalizeExtrinsics(body?.extrinsics)
777
+ extrinsics: normalizeExtrinsics(body?.extrinsics),
778
+ addressMode,
779
+ shipToInSetup: Boolean(body?.shipToInSetup),
780
+ contactInSetup: Boolean(body?.contactInSetup)
605
781
  // `builtin` is never set from the wire — only code-seeded presets carry it.
606
782
  };
607
783
  }
@@ -630,8 +806,66 @@ profilesRoute.delete("/:id", async (c) => {
630
806
  });
631
807
  profilePresetsRoute.get("/", (c) => c.json(PROFILE_PRESETS));
632
808
 
633
- // src/server/routes/flow.ts
809
+ // src/server/routes/products.ts
634
810
  import { Hono as Hono4 } from "hono";
811
+ var productsRoute = new Hono4();
812
+ var productListPresetsRoute = new Hono4();
813
+ function normalizeClassifications(it) {
814
+ if (Array.isArray(it?.classifications)) {
815
+ return it.classifications.map((c) => ({ domain: String(c?.domain ?? "UNSPSC"), value: String(c?.value ?? "") })).filter((c) => c.value.trim().length > 0);
816
+ }
817
+ if (it?.unspsc) return [{ domain: "UNSPSC", value: String(it.unspsc) }];
818
+ return [];
819
+ }
820
+ function normalizeItem(it) {
821
+ return {
822
+ supplierPartId: String(it?.supplierPartId ?? ""),
823
+ supplierPartAuxiliaryId: it?.supplierPartAuxiliaryId ? String(it.supplierPartAuxiliaryId) : void 0,
824
+ description: String(it?.description ?? ""),
825
+ unitPrice: Number(it?.unitPrice ?? 0) || 0,
826
+ currency: String(it?.currency ?? "USD"),
827
+ uom: String(it?.uom ?? "EA"),
828
+ classifications: normalizeClassifications(it),
829
+ manufacturerPartId: it?.manufacturerPartId ? String(it.manufacturerPartId) : void 0,
830
+ manufacturerName: it?.manufacturerName ? String(it.manufacturerName) : void 0,
831
+ allowFractional: Boolean(it?.allowFractional)
832
+ };
833
+ }
834
+ function normalizeProductList(body) {
835
+ const items = Array.isArray(body?.items) ? body.items.map(normalizeItem) : [];
836
+ return {
837
+ name: String(body?.name ?? "Untitled product list"),
838
+ description: body?.description ? String(body.description) : void 0,
839
+ items
840
+ };
841
+ }
842
+ productsRoute.get("/", (c) => c.json(listProductLists()));
843
+ productsRoute.post("/", async (c) => {
844
+ const input = normalizeProductList(await c.req.json().catch(() => ({})));
845
+ if (!input.name.trim()) return c.json({ errors: ["name is required"] }, 400);
846
+ return c.json(await createProductList(input), 201);
847
+ });
848
+ productsRoute.get("/:id", (c) => {
849
+ const p = getProductList(c.req.param("id"));
850
+ return p ? c.json(p) : c.json({ error: "not found" }, 404);
851
+ });
852
+ productsRoute.put("/:id", async (c) => {
853
+ if (!getProductList(c.req.param("id"))) return c.json({ error: "not found" }, 404);
854
+ const input = normalizeProductList(await c.req.json().catch(() => ({})));
855
+ return c.json(await updateProductList(c.req.param("id"), input));
856
+ });
857
+ productsRoute.delete("/:id", async (c) => {
858
+ try {
859
+ const ok = await deleteProductList(c.req.param("id"));
860
+ return ok ? c.json({ ok: true }) : c.json({ error: "not found" }, 404);
861
+ } catch (e) {
862
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 409);
863
+ }
864
+ });
865
+ productListPresetsRoute.get("/", (c) => c.json(PRODUCT_LIST_PRESETS));
866
+
867
+ // src/server/routes/flow.ts
868
+ import { Hono as Hono5 } from "hono";
635
869
  import { nanoid as nanoid5 } from "nanoid";
636
870
 
637
871
  // src/server/store/log.ts
@@ -944,6 +1178,15 @@ function escapeXml(value) {
944
1178
  function makePayloadId(host3, nowIso) {
945
1179
  return `${nowIso}.${nanoid4(10)}@${host3}`;
946
1180
  }
1181
+ function lineItemsTotal(items, headerCurrency) {
1182
+ const currencies = new Set(items.map((it) => it.currency || headerCurrency));
1183
+ if (currencies.size > 1) return 0;
1184
+ return items.reduce((sum, it) => sum + (it.unitPriceAmount ?? 0) * it.quantity, 0);
1185
+ }
1186
+ function classificationBlock(it, indent) {
1187
+ const list = it.classifications && it.classifications.length > 0 ? it.classifications : [{ domain: it.classificationDomain ?? "UNSPSC", value: it.classification ?? "" }];
1188
+ return list.map((c) => `${indent}<Classification domain="${escapeXml(c.domain || "UNSPSC")}">${escapeXml(c.value)}</Classification>`).join("\n");
1189
+ }
947
1190
  function credentialBlock(tag, c) {
948
1191
  return ` <${tag}>
949
1192
  <Credential domain="${escapeXml(c.domain)}">
@@ -989,6 +1232,11 @@ function extrinsicBlock(items, indent) {
989
1232
  function buildSetupRequest(o) {
990
1233
  const lang = o.lang ?? "en-US";
991
1234
  const deployment = o.deploymentMode ? ` deploymentMode="${escapeXml(o.deploymentMode)}"` : "";
1235
+ const mode = o.addressMode ?? "full";
1236
+ const shipTo = o.shipTo ? `
1237
+ ${addressBlock("ShipTo", o.shipTo, mode)}` : "";
1238
+ const contact2 = o.contact ? `
1239
+ ${contactBlock(o.contact, mode, " ")}` : "";
992
1240
  const inner = `${header({
993
1241
  from: o.from,
994
1242
  to: o.to,
@@ -1001,26 +1249,51 @@ function buildSetupRequest(o) {
1001
1249
  <BuyerCookie>${escapeXml(o.buyerCookie)}</BuyerCookie>
1002
1250
  <BrowserFormPost>
1003
1251
  <URL>${escapeXml(o.browserFormPostUrl)}</URL>
1004
- </BrowserFormPost>${extrinsicBlock(o.extrinsics, " ")}
1252
+ </BrowserFormPost>${extrinsicBlock(o.extrinsics, " ")}${shipTo}${contact2}
1005
1253
  </PunchOutSetupRequest>
1006
1254
  </Request>`;
1007
1255
  return envelope(o.payloadId, o.timestamp, lang, inner, o.dtdVersion);
1008
1256
  }
1009
- function addressBlock(tag, a) {
1257
+ var wantsId = (m) => m === "id-only" || m === "both";
1258
+ var wantsPostal = (m) => m === "full" || m === "both";
1259
+ var hasPostal = (a) => !!(a.deliverTo || a.street || a.city || a.state || a.postalCode);
1260
+ function idAttrs(a, mode) {
1261
+ if (!wantsId(mode) || !a.addressId) return "";
1262
+ return ` addressID="${escapeXml(a.addressId)}"${a.addressIdDomain ? ` addressIDDomain="${escapeXml(a.addressIdDomain)}"` : ""}`;
1263
+ }
1264
+ function postalBlock(a, indent) {
1265
+ return `${indent}<PostalAddress>
1266
+ ${a.deliverTo ? `${indent} <DeliverTo>${escapeXml(a.deliverTo)}</DeliverTo>
1267
+ ` : ""}${indent} <Street>${escapeXml(a.street ?? "")}</Street>
1268
+ ${indent} <City>${escapeXml(a.city ?? "")}</City>
1269
+ ${indent} <State>${escapeXml(a.state ?? "")}</State>
1270
+ ${indent} <PostalCode>${escapeXml(a.postalCode ?? "")}</PostalCode>
1271
+ ${indent} <Country isoCountryCode="${escapeXml(a.countryIsoCode ?? "US")}">${escapeXml(a.countryName ?? "United States")}</Country>
1272
+ ${indent}</PostalAddress>`;
1273
+ }
1274
+ function contactInfo(a, indent) {
1275
+ const email = a.email ? `
1276
+ ${indent}<Email>${escapeXml(a.email)}</Email>` : "";
1277
+ const phone = a.phone ? `
1278
+ ${indent}<Phone><TelephoneNumber><Number>${escapeXml(a.phone)}</Number></TelephoneNumber></Phone>` : "";
1279
+ return email + phone;
1280
+ }
1281
+ function addressBlock(tag, a, mode) {
1282
+ const postal = wantsPostal(mode) ? `
1283
+ ${postalBlock(a, " ")}` : "";
1010
1284
  return ` <${tag}>
1011
- <Address${a.addressId ? ` addressID="${escapeXml(a.addressId)}"` : ""}>
1012
- <Name xml:lang="en">${escapeXml(a.name ?? "")}</Name>
1013
- <PostalAddress>
1014
- ${a.deliverTo ? ` <DeliverTo>${escapeXml(a.deliverTo)}</DeliverTo>
1015
- ` : ""} <Street>${escapeXml(a.street ?? "")}</Street>
1016
- <City>${escapeXml(a.city ?? "")}</City>
1017
- <State>${escapeXml(a.state ?? "")}</State>
1018
- <PostalCode>${escapeXml(a.postalCode ?? "")}</PostalCode>
1019
- <Country isoCountryCode="${escapeXml(a.countryIsoCode ?? "US")}">${escapeXml(a.countryName ?? "United States")}</Country>
1020
- </PostalAddress>
1285
+ <Address${idAttrs(a, mode)}>
1286
+ <Name xml:lang="en">${escapeXml(a.name ?? "")}</Name>${postal}${contactInfo(a, " ")}
1021
1287
  </Address>
1022
1288
  </${tag}>`;
1023
1289
  }
1290
+ function contactBlock(c, mode, indent) {
1291
+ const postal = mode !== "id-only" && hasPostal(c) ? `
1292
+ ${postalBlock(c, indent + " ")}` : "";
1293
+ return `${indent}<Contact role="${escapeXml(c.role || "endUser")}"${idAttrs(c, mode)}>
1294
+ ${indent} <Name xml:lang="en">${escapeXml(c.name ?? "")}</Name>${postal}${contactInfo(c, indent + " ")}
1295
+ ${indent}</Contact>`;
1296
+ }
1024
1297
  function commentsWithAttachments(cids, indent) {
1025
1298
  if (cids.length === 0) return "";
1026
1299
  const atts = cids.map(
@@ -1054,14 +1327,15 @@ function buildOrderRequest(o) {
1054
1327
  </UnitPrice>
1055
1328
  <Description xml:lang="en">${escapeXml(it.description ?? "")}</Description>
1056
1329
  <UnitOfMeasure>${escapeXml(it.uom ?? "EA")}</UnitOfMeasure>
1057
- <Classification domain="${escapeXml(it.classificationDomain ?? "UNSPSC")}">${escapeXml(
1058
- it.classification ?? ""
1059
- )}</Classification>${it.manufacturerPartId ? `
1330
+ ${classificationBlock(it, " ")}${it.manufacturerPartId ? `
1060
1331
  <ManufacturerPartID>${escapeXml(it.manufacturerPartId)}</ManufacturerPartID>` : ""}${it.manufacturerName ? `
1061
1332
  <ManufacturerName>${escapeXml(it.manufacturerName)}</ManufacturerName>` : ""}${commentsWithAttachments(cids, " ")}
1062
1333
  </ItemDetail>
1063
1334
  </ItemOut>`;
1064
1335
  }).join("\n");
1336
+ const mode = o.addressMode ?? "full";
1337
+ const contact2 = o.contact ? `
1338
+ ${contactBlock(o.contact, mode, " ")}` : "";
1065
1339
  const inner = `${header({
1066
1340
  from: o.from,
1067
1341
  to: o.to,
@@ -1077,8 +1351,8 @@ function buildOrderRequest(o) {
1077
1351
  <Total>
1078
1352
  <Money currency="${escapeXml(o.currency)}">${escapeXml(o.total)}</Money>
1079
1353
  </Total>
1080
- ${addressBlock("ShipTo", o.shipTo ?? {})}
1081
- ${addressBlock("BillTo", o.billTo ?? {})}${commentsWithAttachments(orderLevelCids, " ")}${extrinsicBlock(
1354
+ ${addressBlock("ShipTo", o.shipTo ?? {}, mode)}
1355
+ ${addressBlock("BillTo", o.billTo ?? {}, mode)}${contact2}${commentsWithAttachments(orderLevelCids, " ")}${extrinsicBlock(
1082
1356
  o.extrinsics,
1083
1357
  " "
1084
1358
  )}
@@ -1116,10 +1390,7 @@ function buildResponseStatus(o) {
1116
1390
  return envelope(o.payloadId, o.timestamp, o.lang ?? "en-US", inner, o.dtdVersion);
1117
1391
  }
1118
1392
  function buildPunchOutOrderMessage(o) {
1119
- const total = o.items.reduce(
1120
- (sum, it) => sum + (it.unitPriceAmount ?? 0) * it.quantity,
1121
- 0
1122
- );
1393
+ const total = lineItemsTotal(o.items, o.currency);
1123
1394
  const items = o.items.map(
1124
1395
  (it) => ` <ItemIn quantity="${escapeXml(it.quantity)}">
1125
1396
  <ItemID>
@@ -1134,9 +1405,7 @@ function buildPunchOutOrderMessage(o) {
1134
1405
  </UnitPrice>
1135
1406
  <Description xml:lang="en">${escapeXml(it.description ?? "")}</Description>
1136
1407
  <UnitOfMeasure>${escapeXml(it.uom ?? "EA")}</UnitOfMeasure>
1137
- <Classification domain="${escapeXml(it.classificationDomain ?? "UNSPSC")}">${escapeXml(
1138
- it.classification ?? ""
1139
- )}</Classification>${it.manufacturerPartId ? `
1408
+ ${classificationBlock(it, " ")}${it.manufacturerPartId ? `
1140
1409
  <ManufacturerPartID>${escapeXml(it.manufacturerPartId)}</ManufacturerPartID>` : ""}
1141
1410
  </ItemDetail>
1142
1411
  </ItemIn>`
@@ -1326,8 +1595,8 @@ function parseCart(doc) {
1326
1595
  const items = asArray(pom?.ItemIn).map((it) => {
1327
1596
  const detail = it?.ItemDetail;
1328
1597
  const up = money(detail?.UnitPrice);
1329
- const classification = detail?.Classification;
1330
- const classFirst = Array.isArray(classification) ? classification[0] : classification;
1598
+ const classifications = asArray(detail?.Classification).filter((c) => c != null).map((c) => ({ domain: attr(c, "domain") ?? "", value: text(c) ?? "" }));
1599
+ const classFirst = classifications[0];
1331
1600
  return {
1332
1601
  quantity: Number(attr(it, "quantity") ?? "1") || 1,
1333
1602
  supplierPartId: text(it?.ItemID?.SupplierPartID),
@@ -1336,8 +1605,9 @@ function parseCart(doc) {
1336
1605
  uom: text(detail?.UnitOfMeasure),
1337
1606
  unitPriceAmount: up.amount,
1338
1607
  currency: up.currency,
1339
- classificationDomain: attr(classFirst, "domain"),
1340
- classification: text(classFirst),
1608
+ classifications: classifications.length > 0 ? classifications : void 0,
1609
+ classificationDomain: classFirst?.domain,
1610
+ classification: classFirst?.value,
1341
1611
  manufacturerPartId: text(detail?.ManufacturerPartID),
1342
1612
  manufacturerName: text(detail?.ManufacturerName)
1343
1613
  };
@@ -1500,6 +1770,7 @@ function checkPunchback(doc, ctx, issues) {
1500
1770
  issues.warn("empty-cart", "PunchOutOrderMessage contains no ItemIn elements", "cXML/.../PunchOutOrderMessage");
1501
1771
  }
1502
1772
  let lineSum = 0;
1773
+ const lineCurrencies = [];
1503
1774
  items.forEach((it, i) => {
1504
1775
  const base = `cXML/.../ItemIn[${i + 1}]`;
1505
1776
  const qty = num(attr(it, "quantity"));
@@ -1516,10 +1787,13 @@ function checkPunchback(doc, ctx, issues) {
1516
1787
  }
1517
1788
  const up = detail.UnitPrice?.Money;
1518
1789
  const upAmount = num(text(up));
1790
+ const upCurrency = attr(up, "currency");
1519
1791
  if (up == null) {
1520
1792
  issues.error("item-missing-unitprice", `ItemIn[${i + 1}] is missing ItemDetail/UnitPrice/Money`, `${base}/ItemDetail/UnitPrice`);
1521
- } else if (!attr(up, "currency")) {
1793
+ } else if (!upCurrency) {
1522
1794
  issues.error("item-missing-currency", `ItemIn[${i + 1}] UnitPrice/Money is missing @currency`, `${base}/ItemDetail/UnitPrice/Money`);
1795
+ } else {
1796
+ lineCurrencies.push(upCurrency);
1523
1797
  }
1524
1798
  if (!text(detail.Description)) {
1525
1799
  issues.error("item-missing-description", `ItemIn[${i + 1}] is missing ItemDetail/Description`, `${base}/ItemDetail/Description`);
@@ -1535,7 +1809,14 @@ function checkPunchback(doc, ctx, issues) {
1535
1809
  }
1536
1810
  if (qty != null && upAmount != null) lineSum += qty * upAmount;
1537
1811
  });
1538
- if (totalAmount != null && items.length > 0) {
1812
+ const singleCurrency = checkSingleCurrency(
1813
+ lineCurrencies,
1814
+ totalCurrency,
1815
+ issues,
1816
+ "cXML/.../PunchOutOrderMessageHeader/Total",
1817
+ ctx.allowMixedCurrency
1818
+ );
1819
+ if (singleCurrency && totalAmount != null && items.length > 0) {
1539
1820
  const diff = Math.abs(totalAmount - lineSum);
1540
1821
  if (diff > 0.01) {
1541
1822
  issues.warn(
@@ -1546,6 +1827,41 @@ function checkPunchback(doc, ctx, issues) {
1546
1827
  }
1547
1828
  }
1548
1829
  }
1830
+ function checkSingleCurrency(lineCurrencies, totalCurrency, issues, path, allowMixed = false) {
1831
+ const all = new Set(lineCurrencies.filter(Boolean));
1832
+ if (totalCurrency) all.add(totalCurrency);
1833
+ if (all.size > 1) {
1834
+ const list = [...all].join(", ");
1835
+ if (allowMixed) {
1836
+ issues.warn(
1837
+ "mixed-currency",
1838
+ `Multiple currencies in one document (${list}); allowed for this supplier. The header Total is a single Money \u2014 rely on the per-line currencies.`,
1839
+ path
1840
+ );
1841
+ } else {
1842
+ issues.error(
1843
+ "mixed-currency",
1844
+ `Multiple currencies in one document (${list}). A cXML Total is a single Money \u2014 all line items and the Total must share one currency.`,
1845
+ path
1846
+ );
1847
+ }
1848
+ return false;
1849
+ }
1850
+ return true;
1851
+ }
1852
+ function checkAddressComplete(addr, label, issues) {
1853
+ if (addr == null) return;
1854
+ const hasId = !!attr(addr, "addressID");
1855
+ const postal = addr.PostalAddress;
1856
+ const hasPostal2 = !!(text(postal?.Street) || text(postal?.City));
1857
+ if (!hasId && !hasPostal2) {
1858
+ issues.warn(
1859
+ `${label.toLowerCase()}-incomplete`,
1860
+ `${label}/Address has neither an addressID nor a Street/City \u2014 the address is empty`,
1861
+ `cXML/.../OrderRequestHeader/${label}/Address`
1862
+ );
1863
+ }
1864
+ }
1549
1865
  function checkOrderRequest(doc, ctx, issues) {
1550
1866
  const orderReq = root(doc)?.Request?.OrderRequest;
1551
1867
  if (!orderReq) {
@@ -1564,26 +1880,41 @@ function checkOrderRequest(doc, ctx, issues) {
1564
1880
  }
1565
1881
  if (!header2?.ShipTo) {
1566
1882
  issues.warn("missing-shipto", "OrderRequestHeader/ShipTo is missing", "cXML/.../OrderRequestHeader/ShipTo");
1883
+ } else {
1884
+ checkAddressComplete(header2.ShipTo.Address, "ShipTo", issues);
1567
1885
  }
1568
1886
  if (!header2?.BillTo) {
1569
1887
  issues.warn("missing-billto", "OrderRequestHeader/BillTo is missing", "cXML/.../OrderRequestHeader/BillTo");
1888
+ } else {
1889
+ checkAddressComplete(header2.BillTo.Address, "BillTo", issues);
1890
+ }
1891
+ for (const contact2 of asArray(header2?.Contact)) {
1892
+ if (!attr(contact2, "role")) {
1893
+ issues.warn("contact-missing-role", "OrderRequestHeader/Contact is missing @role", "cXML/.../OrderRequestHeader/Contact");
1894
+ }
1570
1895
  }
1571
1896
  const items = asArray(orderReq.ItemOut);
1572
1897
  if (items.length === 0) {
1573
1898
  issues.error("no-itemout", "OrderRequest contains no ItemOut elements", "cXML/.../OrderRequest");
1574
1899
  }
1900
+ const lineCurrencies = [];
1575
1901
  items.forEach((it, i) => {
1576
1902
  const base = `cXML/.../ItemOut[${i + 1}]`;
1577
1903
  if (!text(it?.ItemID?.SupplierPartID)) {
1578
1904
  issues.error("itemout-missing-id", `ItemOut[${i + 1}] is missing ItemID/SupplierPartID`, `${base}/ItemID`);
1579
1905
  }
1580
- if (it?.ItemDetail?.UnitPrice?.Money == null) {
1906
+ const up = it?.ItemDetail?.UnitPrice?.Money;
1907
+ if (up == null) {
1581
1908
  issues.error("itemout-missing-unitprice", `ItemOut[${i + 1}] is missing ItemDetail/UnitPrice/Money`, `${base}/ItemDetail/UnitPrice`);
1909
+ } else {
1910
+ const cur = attr(up, "currency");
1911
+ if (cur) lineCurrencies.push(cur);
1582
1912
  }
1583
1913
  if (num(attr(it, "quantity")) == null) {
1584
1914
  issues.error("itemout-missing-quantity", `ItemOut[${i + 1}] is missing @quantity`, base);
1585
1915
  }
1586
1916
  });
1917
+ checkSingleCurrency(lineCurrencies, attr(header2?.Total?.Money, "currency"), issues, "cXML/.../OrderRequestHeader/Total", ctx.allowMixedCurrency);
1587
1918
  const refs = collectCidReferences(doc);
1588
1919
  const available = ctx.availableContentIds;
1589
1920
  const referenced = /* @__PURE__ */ new Set();
@@ -1665,7 +1996,7 @@ function validateDocument(raw, ctx = {}) {
1665
1996
  }
1666
1997
 
1667
1998
  // src/server/routes/flow.ts
1668
- var flowRoute = new Hono4();
1999
+ var flowRoute = new Hono5();
1669
2000
  function host() {
1670
2001
  try {
1671
2002
  return new URL(getPublicUrl()).host;
@@ -1690,7 +2021,18 @@ function buyerContext(r) {
1690
2021
  connectionId: connection.id,
1691
2022
  attachmentEncoding: eff.attachmentEncoding,
1692
2023
  eff,
1693
- expected: { from, to, sender, sharedSecret: connection.sharedSecret }
2024
+ expected: { from, to, sender, sharedSecret: connection.sharedSecret },
2025
+ allowMixedCurrency: supplier.allowMixedCurrency ?? false,
2026
+ shipTo: buyer.shipTo,
2027
+ billTo: buyer.billTo,
2028
+ contact: buyer.contact
2029
+ };
2030
+ }
2031
+ function setupAddresses(ctx) {
2032
+ return {
2033
+ shipTo: ctx.eff.shipToInSetup ? ctx.shipTo : void 0,
2034
+ contact: ctx.eff.contactInSetup ? ctx.contact : void 0,
2035
+ addressMode: ctx.eff.addressMode
1694
2036
  };
1695
2037
  }
1696
2038
  function setupExtrinsics(ctx, buyerCookie) {
@@ -1723,7 +2065,8 @@ flowRoute.get("/:id/setup/preview", (c) => {
1723
2065
  operation: ctx.eff.setupOperation,
1724
2066
  dtdVersion: dtdVersionFor(ctx.eff, "SetupRequest"),
1725
2067
  userAgent: ctx.eff.userAgent,
1726
- extrinsics: setupExtrinsics(ctx, buyerCookie)
2068
+ extrinsics: setupExtrinsics(ctx, buyerCookie),
2069
+ ...setupAddresses(ctx)
1727
2070
  });
1728
2071
  return c.json({ buyerCookie, xml, browserFormPostUrl: browserFormPostUrl() });
1729
2072
  });
@@ -1746,7 +2089,8 @@ flowRoute.post("/:id/setup", async (c) => {
1746
2089
  operation: ctx.eff.setupOperation,
1747
2090
  dtdVersion: dtdVersionFor(ctx.eff, "SetupRequest"),
1748
2091
  userAgent: ctx.eff.userAgent,
1749
- extrinsics: setupExtrinsics(ctx, buyerCookie)
2092
+ extrinsics: setupExtrinsics(ctx, buyerCookie),
2093
+ ...setupAddresses(ctx)
1750
2094
  });
1751
2095
  rememberSessionConnection(buyerCookie, ctx.connectionId);
1752
2096
  const reqValidation = validateDocument(xml, { expected: ctx.expected, forceDocType: "SetupRequest" });
@@ -1789,7 +2133,7 @@ flowRoute.post("/:id/setup", async (c) => {
1789
2133
  function buildOrderXml(ctx, body) {
1790
2134
  const items = body.items ?? [];
1791
2135
  const currency = body.currency || items[0]?.currency || "USD";
1792
- const total = body.total ?? items.reduce((s, it) => s + (it.unitPriceAmount ?? 0) * it.quantity, 0);
2136
+ const total = body.total ?? lineItemsTotal(items, currency);
1793
2137
  const orderId = body.orderId || `PO-${nanoid5(8)}`;
1794
2138
  const attMeta = (body.attachments ?? []).map((a) => ({
1795
2139
  contentId: a.contentId,
@@ -1808,8 +2152,12 @@ function buildOrderXml(ctx, body) {
1808
2152
  currency,
1809
2153
  total,
1810
2154
  items,
1811
- shipTo: body.shipTo,
1812
- billTo: body.billTo,
2155
+ // Order addresses default to the buyer's configured defaults when the body
2156
+ // doesn't override them (the UI pre-fills from the buyer, editable per order).
2157
+ shipTo: body.shipTo ?? ctx.shipTo,
2158
+ billTo: body.billTo ?? ctx.billTo,
2159
+ contact: body.contact ?? ctx.contact,
2160
+ addressMode: ctx.eff.addressMode,
1813
2161
  attachments: attMeta,
1814
2162
  dtdVersion: dtdVersionFor(ctx.eff, "OrderRequest"),
1815
2163
  userAgent: ctx.eff.userAgent,
@@ -1863,7 +2211,8 @@ flowRoute.post("/:id/order", async (c) => {
1863
2211
  const reqValidation = validateDocument(xml, {
1864
2212
  expected: ctx.expected,
1865
2213
  forceDocType: "OrderRequest",
1866
- availableContentIds: inputAtts.length > 0 ? availableContentIds : void 0
2214
+ availableContentIds: inputAtts.length > 0 ? availableContentIds : void 0,
2215
+ allowMixedCurrency: ctx.allowMixedCurrency
1867
2216
  });
1868
2217
  const reqLog = appendLog({
1869
2218
  sessionId,
@@ -1904,8 +2253,8 @@ flowRoute.post("/:id/order", async (c) => {
1904
2253
  });
1905
2254
 
1906
2255
  // src/server/routes/punchout-return.ts
1907
- import { Hono as Hono5 } from "hono";
1908
- var punchoutReturnRoute = new Hono5();
2256
+ import { Hono as Hono6 } from "hono";
2257
+ var punchoutReturnRoute = new Hono6();
1909
2258
  async function extractCxml(c) {
1910
2259
  const ct = (c.req.header("content-type") ?? "").toLowerCase();
1911
2260
  if (ct.includes("application/x-www-form-urlencoded") || ct.includes("multipart/form-data")) {
@@ -1973,9 +2322,9 @@ punchoutReturnRoute.post("/return", async (c) => {
1973
2322
  });
1974
2323
 
1975
2324
  // src/server/routes/stream.ts
1976
- import { Hono as Hono6 } from "hono";
2325
+ import { Hono as Hono7 } from "hono";
1977
2326
  import { streamSSE } from "hono/streaming";
1978
- var streamRoute = new Hono6();
2327
+ var streamRoute = new Hono7();
1979
2328
  var MAX_STREAMS = 64;
1980
2329
  var activeStreams = 0;
1981
2330
  streamRoute.get("/stream", (c) => {
@@ -2007,8 +2356,8 @@ streamRoute.get("/stream", (c) => {
2007
2356
  });
2008
2357
 
2009
2358
  // src/server/routes/data.ts
2010
- import { Hono as Hono7 } from "hono";
2011
- var dataRoute = new Hono7();
2359
+ import { Hono as Hono8 } from "hono";
2360
+ var dataRoute = new Hono8();
2012
2361
  function rawMessage(record) {
2013
2362
  const ct = record.contentType ?? record.headers?.["Content-Type"] ?? record.headers?.["content-type"];
2014
2363
  let body = record.body;
@@ -2060,13 +2409,13 @@ dataRoute.get("/attachments/:hash", (c) => {
2060
2409
  });
2061
2410
 
2062
2411
  // src/server/routes/sim.ts
2063
- import { Hono as Hono8 } from "hono";
2412
+ import { Hono as Hono9 } from "hono";
2064
2413
  import { nanoid as nanoid6 } from "nanoid";
2065
- var simRoute = new Hono8();
2414
+ var simRoute = new Hono9();
2066
2415
  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" }
2416
+ { 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" },
2417
+ { 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" },
2418
+ { supplierPartId: "TAPE-RED", description: "Industrial Marking Tape, Red", unitPrice: 5.75, currency: "USD", uom: "RL", classifications: [{ domain: "UNSPSC", value: "31201500" }] }
2070
2419
  ];
2071
2420
  function host2() {
2072
2421
  try {
@@ -2075,7 +2424,10 @@ function host2() {
2075
2424
  return "punchout-simulator";
2076
2425
  }
2077
2426
  }
2078
- var catalogOf = (s) => s.catalog && s.catalog.length > 0 ? s.catalog : DEMO_CATALOG;
2427
+ var catalogOf = (s) => {
2428
+ const items = catalogForSupplier(s);
2429
+ return items.length > 0 ? items : DEMO_CATALOG;
2430
+ };
2079
2431
  function safeHttpUrl(u) {
2080
2432
  if (!u) return "";
2081
2433
  try {
@@ -2152,13 +2504,15 @@ simRoute.get("/:id/catalog", (c) => {
2152
2504
  const bd = c.req.query("bd") ?? "";
2153
2505
  const bi = c.req.query("bi") ?? "";
2154
2506
  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>
2507
+ const rows = items.map((it, i) => {
2508
+ const partId = escapeXml(it.supplierPartId) + (it.supplierPartAuxiliaryId ? ` / ${escapeXml(it.supplierPartAuxiliaryId)}` : "");
2509
+ const cls = (it.classifications ?? []).map((c2) => `${escapeXml(c2.domain)} ${escapeXml(c2.value)}`).join(" \xB7 ");
2510
+ return `<tr>
2511
+ <td><strong>${escapeXml(it.description)}</strong><br><small>${partId} \xB7 ${escapeXml(it.uom)}${cls ? ` \xB7 ${cls}` : ""}</small></td>
2158
2512
  <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");
2513
+ <td><input type="number" name="q_${i}" value="0" min="0" step="${it.allowFractional ? "any" : "1"}" inputmode="${it.allowFractional ? "decimal" : "numeric"}"></td>
2514
+ </tr>`;
2515
+ }).join("\n");
2162
2516
  return c.html(`<!doctype html><html lang="en"><head><meta charset="utf-8">
2163
2517
  <meta name="viewport" content="width=device-width, initial-scale=1">
2164
2518
  <title>${escapeXml(supplier.name)} \u2014 mock catalog</title>
@@ -2197,17 +2551,22 @@ simRoute.post("/:id/checkout", async (c) => {
2197
2551
  const catalog = catalogOf(supplier);
2198
2552
  const items = [];
2199
2553
  catalog.forEach((it, i) => {
2200
- const qty = Number(form[`q_${i}`] ?? 0);
2554
+ let qty = Number(form[`q_${i}`] ?? 0);
2555
+ if (!it.allowFractional) qty = Math.floor(qty);
2201
2556
  if (qty > 0) {
2202
2557
  items.push({
2203
2558
  quantity: qty,
2204
2559
  supplierPartId: it.supplierPartId,
2560
+ supplierPartAuxiliaryId: it.supplierPartAuxiliaryId,
2205
2561
  description: it.description,
2206
2562
  uom: it.uom,
2207
2563
  unitPriceAmount: it.unitPrice,
2208
2564
  currency: it.currency,
2209
- classificationDomain: "UNSPSC",
2210
- classification: it.unspsc,
2565
+ classifications: it.classifications,
2566
+ // Keep the legacy single fields populated from the first classification
2567
+ // for back-compat display (CartView) and any single-domain consumer.
2568
+ classificationDomain: it.classifications[0]?.domain,
2569
+ classification: it.classifications[0]?.value,
2211
2570
  manufacturerPartId: it.manufacturerPartId,
2212
2571
  manufacturerName: it.manufacturerName
2213
2572
  });
@@ -2233,7 +2592,10 @@ simRoute.post("/:id/checkout", async (c) => {
2233
2592
  docType: "PunchOutOrderMessage",
2234
2593
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
2235
2594
  body: xml,
2236
- validation: validateDocument(xml, { forceDocType: "PunchOutOrderMessage" })
2595
+ validation: validateDocument(xml, {
2596
+ forceDocType: "PunchOutOrderMessage",
2597
+ allowMixedCurrency: supplier.allowMixedCurrency
2598
+ })
2237
2599
  });
2238
2600
  const transport = eff?.cartReturnTransport ?? "cxml-urlencoded";
2239
2601
  return c.html(cartReturnPage(formpost, xml, transport));
@@ -2303,7 +2665,8 @@ simRoute.post("/:id/order", async (c) => {
2303
2665
  const validation = validateDocument(xml, {
2304
2666
  expected: expectedFor(supplier.id, from),
2305
2667
  forceDocType: "OrderRequest",
2306
- availableContentIds: isMultipart(ct) ? availableContentIds : void 0
2668
+ availableContentIds: isMultipart(ct) ? availableContentIds : void 0,
2669
+ allowMixedCurrency: supplier.allowMixedCurrency
2307
2670
  });
2308
2671
  appendLog({
2309
2672
  sessionId,
@@ -2350,7 +2713,7 @@ function findSessionForOrder(doc) {
2350
2713
  // src/server/app.ts
2351
2714
  import { relative } from "path";
2352
2715
  function createApp(opts = {}) {
2353
- const app = new Hono9();
2716
+ const app = new Hono10();
2354
2717
  if (!opts.quiet) app.use("*", logger());
2355
2718
  app.use(
2356
2719
  "*",
@@ -2369,6 +2732,8 @@ function createApp(opts = {}) {
2369
2732
  app.route("/api/suppliers", suppliersRoute);
2370
2733
  app.route("/api/profiles", profilesRoute);
2371
2734
  app.route("/api/profile-presets", profilePresetsRoute);
2735
+ app.route("/api/product-lists", productsRoute);
2736
+ app.route("/api/product-list-presets", productListPresetsRoute);
2372
2737
  app.route("/api/connections", connectionsRoute);
2373
2738
  app.route("/api/connections", flowRoute);
2374
2739
  app.route("/api", dataRoute);
@@ -2402,7 +2767,33 @@ async function seedDemoIfEmpty() {
2402
2767
  identity: { domain: "DUNS", identity: "123456789" },
2403
2768
  // Exercise a non-default platform profile end-to-end (Coupa: per-doc-type
2404
2769
  // DTD versions, base64 attachments). Built-in profiles are seeded by initConfig.
2405
- profileId: "coupa"
2770
+ profileId: "coupa",
2771
+ // Default addresses + end-user contact, so the OrderRequest is populated and
2772
+ // the address feature is exercised out of the box.
2773
+ shipTo: {
2774
+ addressId: "1001",
2775
+ addressIdDomain: "buyerSystemID",
2776
+ name: "Demo Buyer HQ \u2014 Receiving",
2777
+ deliverTo: "Dock 3",
2778
+ street: "1 Market St",
2779
+ city: "San Francisco",
2780
+ state: "CA",
2781
+ postalCode: "94105",
2782
+ countryIsoCode: "US",
2783
+ countryName: "United States"
2784
+ },
2785
+ billTo: {
2786
+ addressId: "9001",
2787
+ addressIdDomain: "buyerSystemID",
2788
+ name: "Demo Buyer Accounts Payable",
2789
+ street: "1 Market St",
2790
+ city: "San Francisco",
2791
+ state: "CA",
2792
+ postalCode: "94105",
2793
+ countryIsoCode: "US",
2794
+ countryName: "United States"
2795
+ },
2796
+ contact: { role: "endUser", name: "Jane Buyer", email: "jane.buyer@demo.example", phone: "+1 555 0100" }
2406
2797
  });
2407
2798
  const supplier = await createSupplier({
2408
2799
  id: "demo-supplier",
@@ -2410,7 +2801,8 @@ async function seedDemoIfEmpty() {
2410
2801
  identity: { domain: "DUNS", identity: "987654321" },
2411
2802
  punchoutUrl: `${getPublicUrl()}/sim/demo-supplier/punchout`,
2412
2803
  orderUrl: `${getPublicUrl()}/sim/demo-supplier/order`,
2413
- catalog: []
2804
+ // Serve the built-in sample assortment (seeded by initConfig).
2805
+ productListIds: ["sample"]
2414
2806
  });
2415
2807
  await createConnection({
2416
2808
  id: "demo",