@develemit/commerce-server 0.6.0 → 0.7.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.
package/dist/index.js CHANGED
@@ -72,13 +72,13 @@ var products = pgTable(
72
72
  {
73
73
  id: uuid("id").primaryKey().defaultRandom(),
74
74
  slug: text("slug").notNull().unique(),
75
- name: text("name").notNull(),
75
+ name: text("name").notNull().default(""),
76
76
  description: text("description").notNull().default(""),
77
77
  materials: text("materials"),
78
78
  dimensions: text("dimensions"),
79
- priceCents: integer("price_cents").notNull(),
79
+ priceCents: integer("price_cents"),
80
80
  currency: text("currency").notNull(),
81
- category: text("category").notNull(),
81
+ category: text("category"),
82
82
  quantity: integer("quantity").notNull().default(1),
83
83
  status: productStatus("status").notNull().default("draft"),
84
84
  featured: boolean("featured").notNull().default(false),
@@ -338,10 +338,13 @@ import { Readable } from "stream";
338
338
  import {
339
339
  and as and2,
340
340
  asc as asc2,
341
+ desc,
341
342
  eq as eq2,
342
343
  gt,
343
344
  ilike,
344
345
  inArray,
346
+ lt,
347
+ ne,
345
348
  or,
346
349
  sql as sql2
347
350
  } from "drizzle-orm";
@@ -361,9 +364,12 @@ async function withImages(db, rows) {
361
364
  function escapeLike(value) {
362
365
  return value.replace(/[\\%_]/g, (c) => `\\${c}`);
363
366
  }
367
+ var incompleteDraft = sql2`(${products.status} = 'draft' and (${products.name} = '' or ${products.priceCents} is null or ${products.category} is null))`;
364
368
  function pageConditions(filter) {
365
369
  const parts = [];
366
370
  if (filter.status) parts.push(eq2(products.status, filter.status));
371
+ if (filter.excludeArchived) parts.push(ne(products.status, "archived"));
372
+ if (filter.incomplete) parts.push(incompleteDraft);
367
373
  if (filter.stock === "in") parts.push(gt(products.quantity, 0));
368
374
  if (filter.stock === "sold") parts.push(eq2(products.quantity, 0));
369
375
  if (filter.q) parts.push(ilike(products.name, `%${escapeLike(filter.q)}%`));
@@ -371,10 +377,10 @@ function pageConditions(filter) {
371
377
  const createdAt = sql2`${filter.after.createdAt}::timestamptz`;
372
378
  parts.push(
373
379
  or(
374
- gt(products.createdAt, createdAt),
380
+ lt(products.createdAt, createdAt),
375
381
  and2(
376
382
  eq2(products.createdAt, createdAt),
377
- gt(products.slug, filter.after.slug)
383
+ lt(products.slug, filter.after.slug)
378
384
  )
379
385
  )
380
386
  );
@@ -385,7 +391,7 @@ async function selectProductPage(db, filter) {
385
391
  const selected = await db.select({
386
392
  product: products,
387
393
  createdAtKey: sql2`${products.createdAt}::text`
388
- }).from(products).where(pageConditions(filter)).orderBy(asc2(products.createdAt), asc2(products.slug)).limit(filter.limit);
394
+ }).from(products).where(pageConditions(filter)).orderBy(desc(products.createdAt), desc(products.slug)).limit(filter.limit);
389
395
  const records = await withImages(
390
396
  db,
391
397
  selected.map((s) => s.product)
@@ -429,6 +435,17 @@ async function setImagePosition(db, id, position) {
429
435
  async function setImageAlt(db, id, alt) {
430
436
  await db.update(productImages).set({ alt }).where(eq2(productImages.id, id));
431
437
  }
438
+ async function selectProductCounts(db) {
439
+ const [row] = await db.select({
440
+ all: sql2`count(*) filter (where ${products.status} <> 'archived')::int`,
441
+ live: sql2`count(*) filter (where ${products.status} = 'active')::int`,
442
+ draft: sql2`count(*) filter (where ${products.status} = 'draft')::int`,
443
+ sold: sql2`count(*) filter (where ${products.quantity} = 0)::int`,
444
+ archived: sql2`count(*) filter (where ${products.status} = 'archived')::int`,
445
+ incompleteDrafts: sql2`count(*) filter (where ${products.status} = 'draft' and (${products.name} = '' or ${products.priceCents} is null or ${products.category} is null))::int`
446
+ }).from(products);
447
+ return row;
448
+ }
432
449
 
433
450
  // src/admin/service.ts
434
451
  import {
@@ -436,6 +453,54 @@ import {
436
453
  adminProductListQuerySchema,
437
454
  adminProductPatchSchema
438
455
  } from "@develemit/commerce-contracts";
456
+ import { randomUUID } from "crypto";
457
+
458
+ // src/admin/types.ts
459
+ var ProductImageNotFoundError = class extends Error {
460
+ constructor(imageId) {
461
+ super(`Image not found: ${imageId}`);
462
+ this.imageId = imageId;
463
+ this.name = "ProductImageNotFoundError";
464
+ }
465
+ imageId;
466
+ code = "IMAGE_NOT_FOUND";
467
+ };
468
+ var InvalidImageOrderError = class extends Error {
469
+ code = "INVALID_IMAGE_ORDER";
470
+ constructor() {
471
+ super("imageIds must list every image of the product exactly once");
472
+ this.name = "InvalidImageOrderError";
473
+ }
474
+ };
475
+ var ProductIncompleteError = class extends Error {
476
+ constructor(missing) {
477
+ super(`Product is missing: ${missing.join(", ")}`);
478
+ this.missing = missing;
479
+ this.name = "ProductIncompleteError";
480
+ }
481
+ missing;
482
+ code = "PRODUCT_INCOMPLETE";
483
+ };
484
+
485
+ // src/admin/publish.ts
486
+ function temporarySlug(id) {
487
+ return `draft-${id.slice(0, 8)}`;
488
+ }
489
+ function missingForPublish({ row, images }, patch, requirePhoto) {
490
+ const name = patch.name ?? row.name;
491
+ const priceCents = patch.priceCents ?? row.priceCents;
492
+ const category = patch.category ?? row.category;
493
+ const missing = [];
494
+ if (!name) missing.push("name");
495
+ if (priceCents === null) missing.push("priceCents");
496
+ if (!category) missing.push("category");
497
+ if (requirePhoto && images.length === 0) missing.push("images");
498
+ return missing;
499
+ }
500
+ function assertPublishable(record, patch, requirePhoto) {
501
+ const missing = missingForPublish(record, patch, requirePhoto);
502
+ if (missing.length > 0) throw new ProductIncompleteError(missing);
503
+ }
439
504
 
440
505
  // src/admin/slug.ts
441
506
  function slugify(name) {
@@ -485,6 +550,8 @@ async function listAdminProducts(db, rawQuery = {}) {
485
550
  const query = adminProductListQuerySchema.parse(rawQuery);
486
551
  const found = await selectProductPage(db, {
487
552
  status: query.status,
553
+ excludeArchived: query.excludeArchived === "true",
554
+ incomplete: query.incomplete === "true",
488
555
  stock: query.stock,
489
556
  q: query.q,
490
557
  after: query.cursor ? decodeProductCursor(query.cursor) : void 0,
@@ -500,47 +567,48 @@ async function listAdminProducts(db, rawQuery = {}) {
500
567
  async function getAdminProduct(db, id) {
501
568
  return toAdminProduct(await requireProduct(db, id));
502
569
  }
503
- async function createAdminProduct(db, currency, input) {
570
+ async function createAdminProduct(db, currency, input, { requirePhotoToPublish = true } = {}) {
504
571
  const data = adminProductInputSchema.parse(input);
505
- const base = slugify(data.name);
572
+ if (data.status === "active" && requirePhotoToPublish) {
573
+ throw new ProductIncompleteError(["images"]);
574
+ }
575
+ const newId = randomUUID();
576
+ const base = data.name ? slugify(data.name) : temporarySlug(newId);
506
577
  const id = await db.transaction(async (tx) => {
507
578
  const slug = uniqueSlug(base, await slugsStartingWith(tx, base));
508
579
  return insertProduct(tx, {
509
580
  ...data,
581
+ id: newId,
510
582
  slug,
511
583
  currency: data.currency ?? currency
512
584
  });
513
585
  });
514
586
  return getAdminProduct(db, id);
515
587
  }
516
- async function patchAdminProduct(db, id, input) {
588
+ async function patchAdminProduct(db, id, input, { requirePhotoToPublish = true } = {}) {
517
589
  const { sold, ...fields } = adminProductPatchSchema.parse(input);
518
- await requireProduct(db, id);
519
- const set = sold ? { ...fields, quantity: 0 } : fields;
520
- if (Object.keys(set).length > 0) await updateProduct(db, id, set);
590
+ const record = await requireProduct(db, id);
591
+ if (fields.status === "active") {
592
+ const photoNeeded = requirePhotoToPublish && record.row.status !== "active";
593
+ assertPublishable(record, fields, photoNeeded);
594
+ }
595
+ const set = sold ? { ...fields, quantity: 0 } : { ...fields };
596
+ const renames = fields.name && record.row.slug === temporarySlug(id);
597
+ await db.transaction(async (tx) => {
598
+ if (renames) {
599
+ const base = slugify(fields.name);
600
+ set.slug = uniqueSlug(base, await slugsStartingWith(tx, base));
601
+ }
602
+ if (Object.keys(set).length > 0) await updateProduct(tx, id, set);
603
+ });
521
604
  return getAdminProduct(db, id);
522
605
  }
523
-
524
- // src/admin/types.ts
525
- var ProductImageNotFoundError = class extends Error {
526
- constructor(imageId) {
527
- super(`Image not found: ${imageId}`);
528
- this.imageId = imageId;
529
- this.name = "ProductImageNotFoundError";
530
- }
531
- imageId;
532
- code = "IMAGE_NOT_FOUND";
533
- };
534
- var InvalidImageOrderError = class extends Error {
535
- code = "INVALID_IMAGE_ORDER";
536
- constructor() {
537
- super("imageIds must list every image of the product exactly once");
538
- this.name = "InvalidImageOrderError";
539
- }
540
- };
606
+ async function getAdminProductCounts(db) {
607
+ return selectProductCounts(db);
608
+ }
541
609
 
542
610
  // src/admin/upload.ts
543
- import { randomUUID } from "crypto";
611
+ import { randomUUID as randomUUID2 } from "crypto";
544
612
  import { fileTypeFromBuffer } from "file-type";
545
613
  var MAX_IMAGE_BYTES = 8 * 1024 * 1024;
546
614
  var EXTENSIONS = {
@@ -565,7 +633,7 @@ async function detectImage(bytes) {
565
633
  return { mime: type.mime, ext };
566
634
  }
567
635
  function imageKey(productId, ext) {
568
- return `products/${productId}/${randomUUID()}.${ext}`;
636
+ return `products/${productId}/${randomUUID2()}.${ext}`;
569
637
  }
570
638
 
571
639
  // src/admin/images.ts
@@ -625,7 +693,7 @@ async function updateProductImageAlt(db, productId, imageId, alt) {
625
693
  function isZodError(err) {
626
694
  return err instanceof Error && err.name === "ZodError";
627
695
  }
628
- var adminPlugin = async (app, { db, currency, authorize, storage }) => {
696
+ var adminPlugin = async (app, { db, currency, authorize, storage, requirePhotoToPublish }) => {
629
697
  const { default: multipart } = await import("@fastify/multipart");
630
698
  await app.register(multipart, {
631
699
  limits: { fileSize: MAX_IMAGE_BYTES, files: 1 }
@@ -637,6 +705,13 @@ var adminPlugin = async (app, { db, currency, authorize, storage }) => {
637
705
  if (err instanceof ProductNotFoundError || err instanceof ProductImageNotFoundError) {
638
706
  return reply.code(404).send({ error: err.code, message: err.message });
639
707
  }
708
+ if (err instanceof ProductIncompleteError) {
709
+ return reply.code(422).send({
710
+ error: err.code,
711
+ message: err.message,
712
+ missing: err.missing
713
+ });
714
+ }
640
715
  if (err instanceof InvalidCursorError || err instanceof InvalidImageError || err instanceof InvalidImageOrderError) {
641
716
  return reply.code(400).send({ error: err.code, message: err.message });
642
717
  }
@@ -656,9 +731,12 @@ var adminPlugin = async (app, { db, currency, authorize, storage }) => {
656
731
  await listAdminProducts(db, req.query)
657
732
  )
658
733
  );
734
+ app.get("/products/counts", async () => getAdminProductCounts(db));
659
735
  app.post("/products", async (req, reply) => {
660
736
  const input = adminProductInputSchema2.parse(req.body);
661
- const created = await createAdminProduct(db, currency, input);
737
+ const created = await createAdminProduct(db, currency, input, {
738
+ requirePhotoToPublish
739
+ });
662
740
  return reply.code(201).send(created);
663
741
  });
664
742
  app.get(
@@ -670,7 +748,8 @@ var adminPlugin = async (app, { db, currency, authorize, storage }) => {
670
748
  async (req) => patchAdminProduct(
671
749
  db,
672
750
  req.params.id,
673
- adminProductPatchSchema2.parse(req.body)
751
+ adminProductPatchSchema2.parse(req.body),
752
+ { requirePhotoToPublish }
674
753
  )
675
754
  );
676
755
  app.post(
@@ -758,7 +837,7 @@ function addCatalogCaching(app, { cacheControl = DEFAULT_CATALOG_CACHE_CONTROL }
758
837
  }
759
838
 
760
839
  // src/inventory/repo.ts
761
- import { and as and3, asc as asc3, eq as eq3, gt as gt2, inArray as inArray2, lt, sql as sql3 } from "drizzle-orm";
840
+ import { and as and3, asc as asc3, eq as eq3, gt as gt2, inArray as inArray2, lt as lt2, sql as sql3 } from "drizzle-orm";
762
841
  async function lockProductQuantities(tx, productIds) {
763
842
  const rows = await tx.select({ id: products.id, quantity: products.quantity }).from(products).where(inArray2(products.id, productIds)).orderBy(asc3(products.id)).for("update");
764
843
  return new Map(rows.map((r) => [r.id, r.quantity]));
@@ -845,7 +924,7 @@ async function releaseExpiredHolds(db) {
845
924
  const released = await db.update(inventoryHolds).set({ status: "released" }).where(
846
925
  and3(
847
926
  eq3(inventoryHolds.status, "active"),
848
- lt(inventoryHolds.expiresAt, sql3`now()`)
927
+ lt2(inventoryHolds.expiresAt, sql3`now()`)
849
928
  )
850
929
  ).returning({ reference: inventoryHolds.reference });
851
930
  return released.map((r) => r.reference);
@@ -950,7 +1029,12 @@ async function restock(tx, items) {
950
1029
  }
951
1030
 
952
1031
  // src/catalog/repo.ts
953
- import { and as and4, asc as asc4, eq as eq4, gt as gt3, inArray as inArray3, or as or2, sql as sql4 } from "drizzle-orm";
1032
+ import { and as and4, asc as asc4, eq as eq4, gt as gt3, inArray as inArray3, isNotNull, or as or2, sql as sql4 } from "drizzle-orm";
1033
+ var isListed = and4(
1034
+ eq4(products.status, "active"),
1035
+ isNotNull(products.priceCents),
1036
+ isNotNull(products.category)
1037
+ );
954
1038
  async function attachImages(db, rows, firstOnly = false) {
955
1039
  if (rows.length === 0) return [];
956
1040
  const where = inArray3(
@@ -964,7 +1048,7 @@ async function attachImages(db, rows, firstOnly = false) {
964
1048
  }));
965
1049
  }
966
1050
  async function listActiveProducts(db, filter) {
967
- const conditions2 = [eq4(products.status, "active")];
1051
+ const conditions2 = [isListed];
968
1052
  if (filter.category) conditions2.push(eq4(products.category, filter.category));
969
1053
  if (filter.featured !== void 0)
970
1054
  conditions2.push(eq4(products.featured, filter.featured));
@@ -995,22 +1079,30 @@ async function listActiveProducts(db, filter) {
995
1079
  }));
996
1080
  }
997
1081
  async function findActiveProductBySlug(db, slug) {
998
- const rows = await db.select().from(products).where(and4(eq4(products.slug, slug), eq4(products.status, "active"))).limit(1);
1082
+ const rows = await db.select().from(products).where(and4(eq4(products.slug, slug), isListed)).limit(1);
999
1083
  return (await attachImages(db, rows))[0];
1000
1084
  }
1001
1085
  async function countActiveByCategory(db) {
1002
1086
  const rows = await db.select({
1003
1087
  category: products.category,
1004
1088
  count: sql4`count(*)::int`
1005
- }).from(products).where(eq4(products.status, "active")).groupBy(products.category).orderBy(asc4(products.category));
1006
- return rows;
1089
+ }).from(products).where(isListed).groupBy(products.category).orderBy(asc4(products.category));
1090
+ return rows.flatMap(
1091
+ (r) => r.category === null ? [] : [{ category: r.category, count: r.count }]
1092
+ );
1007
1093
  }
1008
1094
  async function findActiveProductsByIds(db, ids) {
1009
1095
  if (ids.length === 0) return [];
1010
- return db.select().from(products).where(and4(inArray3(products.id, ids), eq4(products.status, "active")));
1096
+ return db.select().from(products).where(and4(inArray3(products.id, ids), isListed));
1011
1097
  }
1012
1098
 
1013
1099
  // src/catalog/service.ts
1100
+ function completeFields(row) {
1101
+ if (row.priceCents === null || row.category === null) {
1102
+ throw new Error(`Active product ${row.id} is missing price or category`);
1103
+ }
1104
+ return { priceCents: row.priceCents, category: row.category };
1105
+ }
1014
1106
  function availabilityOf(quantity, free) {
1015
1107
  if (free > 0) return "available";
1016
1108
  return quantity > 0 ? "reserved" : "sold";
@@ -1024,9 +1116,8 @@ function toProduct({ row, images }, free) {
1024
1116
  description: row.description,
1025
1117
  ...row.materials ? { materials: row.materials } : {},
1026
1118
  ...row.dimensions ? { dimensions: row.dimensions } : {},
1027
- priceCents: row.priceCents,
1119
+ ...completeFields(row),
1028
1120
  currency: row.currency,
1029
- category: row.category,
1030
1121
  images: images.map((i) => ({ url: i.url, alt: i.alt })),
1031
1122
  quantity: freeQty,
1032
1123
  availability: availabilityOf(row.quantity, freeQty),
@@ -1079,7 +1170,7 @@ async function findPurchasable(db, ids) {
1079
1170
  return rows.map((row) => ({
1080
1171
  id: row.id,
1081
1172
  name: row.name,
1082
- priceCents: row.priceCents,
1173
+ priceCents: completeFields(row).priceCents,
1083
1174
  currency: row.currency
1084
1175
  }));
1085
1176
  }
@@ -1127,7 +1218,7 @@ import {
1127
1218
  } from "@develemit/commerce-contracts";
1128
1219
 
1129
1220
  // src/orders/admin-repo.ts
1130
- import { and as and5, desc, eq as eq5, ilike as ilike2, lt as lt2, or as or3, sql as sql5 } from "drizzle-orm";
1221
+ import { and as and5, desc as desc2, eq as eq5, ilike as ilike2, lt as lt3, or as or3, sql as sql5 } from "drizzle-orm";
1131
1222
  function escapeLike2(value) {
1132
1223
  return value.replace(/[\\%_]/g, (c) => `\\${c}`);
1133
1224
  }
@@ -1153,8 +1244,8 @@ function conditions(filter) {
1153
1244
  const createdAt = sql5`${filter.after.createdAt}::timestamptz`;
1154
1245
  parts.push(
1155
1246
  or3(
1156
- lt2(orders.createdAt, createdAt),
1157
- and5(eq5(orders.createdAt, createdAt), lt2(orders.id, id))
1247
+ lt3(orders.createdAt, createdAt),
1248
+ and5(eq5(orders.createdAt, createdAt), lt3(orders.id, id))
1158
1249
  )
1159
1250
  );
1160
1251
  }
@@ -1174,7 +1265,7 @@ async function selectOrderPage(db, filter) {
1174
1265
  itemCount,
1175
1266
  createdAt: orders.createdAt,
1176
1267
  createdAtKey: sql5`${orders.createdAt}::text`
1177
- }).from(orders).where(conditions(filter)).orderBy(desc(orders.createdAt), desc(orders.id)).limit(filter.limit);
1268
+ }).from(orders).where(conditions(filter)).orderBy(desc2(orders.createdAt), desc2(orders.id)).limit(filter.limit);
1178
1269
  }
1179
1270
  async function findOrderRefunds(db, orderId) {
1180
1271
  return db.select().from(orderRefunds).where(eq5(orderRefunds.orderId, orderId)).orderBy(orderRefunds.createdAt, orderRefunds.id);
@@ -1184,10 +1275,10 @@ async function findOrderRefunds(db, orderId) {
1184
1275
  import { and as and7, asc as asc5, eq as eq7, sql as sql7 } from "drizzle-orm";
1185
1276
 
1186
1277
  // src/orders/refund-repo.ts
1187
- import { and as and6, eq as eq6, isNotNull, isNull, or as or4, sql as sql6 } from "drizzle-orm";
1278
+ import { and as and6, eq as eq6, isNotNull as isNotNull2, isNull, or as or4, sql as sql6 } from "drizzle-orm";
1188
1279
  var isRecordedRefund = or4(
1189
1280
  isNull(orderRefunds.idempotencyKey),
1190
- isNotNull(orderRefunds.gatewayRefundId)
1281
+ isNotNull2(orderRefunds.gatewayRefundId)
1191
1282
  );
1192
1283
  async function lockOrderForRefund(tx, id) {
1193
1284
  const [row] = await tx.select({
@@ -1520,14 +1611,14 @@ function evaluateDiscount(discount, ctx) {
1520
1611
  // src/discounts/repo.ts
1521
1612
  import {
1522
1613
  and as and8,
1523
- desc as desc2,
1614
+ desc as desc3,
1524
1615
  eq as eq8,
1525
1616
  getTableColumns,
1526
1617
  gt as gt4,
1527
1618
  ilike as ilike3,
1528
1619
  inArray as inArray4,
1529
1620
  isNull as isNull2,
1530
- lt as lt3,
1621
+ lt as lt4,
1531
1622
  lte,
1532
1623
  or as or5,
1533
1624
  sql as sql8
@@ -1582,8 +1673,8 @@ function listConditions(filter) {
1582
1673
  const createdAt = sql8`${filter.after.createdAt}::timestamptz`;
1583
1674
  parts.push(
1584
1675
  or5(
1585
- lt3(discounts.createdAt, createdAt),
1586
- and8(eq8(discounts.createdAt, createdAt), lt3(discounts.id, id))
1676
+ lt4(discounts.createdAt, createdAt),
1677
+ and8(eq8(discounts.createdAt, createdAt), lt4(discounts.id, id))
1587
1678
  )
1588
1679
  );
1589
1680
  }
@@ -1606,7 +1697,7 @@ async function selectDiscountPage(db, filter) {
1606
1697
  return db.select({
1607
1698
  ...withCounts,
1608
1699
  createdAtKey: sql8`${discounts.createdAt}::text`
1609
- }).from(discounts).where(listConditions(filter)).orderBy(desc2(discounts.createdAt), desc2(discounts.id)).limit(filter.limit);
1700
+ }).from(discounts).where(listConditions(filter)).orderBy(desc3(discounts.createdAt), desc3(discounts.id)).limit(filter.limit);
1610
1701
  }
1611
1702
  async function findDiscountById(db, id, options = {}) {
1612
1703
  const query = db.select(withCounts).from(discounts).where(eq8(discounts.id, id));
@@ -2356,21 +2447,50 @@ var adminDiscountsPlugin = async (app, { db, authorize }) => {
2356
2447
  );
2357
2448
  };
2358
2449
 
2450
+ // src/orders/dev-buyer.ts
2451
+ var DEFAULT_DEV_BUYER = {
2452
+ email: "dev-buyer@example.com",
2453
+ name: "Dev Buyer",
2454
+ address: {
2455
+ line1: "123 Example Street",
2456
+ city: "Springfield",
2457
+ state: "IL",
2458
+ postalCode: "62701",
2459
+ country: "US"
2460
+ }
2461
+ };
2462
+ function resolveDevCompletion(setting, sessionId, firstRate) {
2463
+ const buyer = typeof setting === "function" ? setting(sessionId) : setting;
2464
+ const name = buyer?.name ?? DEFAULT_DEV_BUYER.name;
2465
+ return {
2466
+ email: buyer?.email ?? DEFAULT_DEV_BUYER.email,
2467
+ shippingAddress: { name, ...buyer?.address ?? DEFAULT_DEV_BUYER.address },
2468
+ shipping: firstRate && {
2469
+ label: firstRate.label,
2470
+ amountCents: firstRate.amountCents
2471
+ }
2472
+ };
2473
+ }
2474
+
2359
2475
  // src/orders/dev-checkout.ts
2360
2476
  function assertDevCheckoutAllowed() {
2361
2477
  if (process.env.NODE_ENV === "production") {
2362
2478
  throw new Error("devCheckout is not allowed when NODE_ENV=production");
2363
2479
  }
2364
2480
  }
2365
- var devCheckoutRoutes = async (app, { db, payment, urls, hooks }) => {
2481
+ var devCheckoutRoutes = async (app, { db, payment, urls, hooks, buyer }) => {
2366
2482
  assertDevCheckoutAllowed();
2367
2483
  app.get("/checkout/dev/:sessionId/complete", async (req, reply) => {
2368
2484
  const { sessionId } = req.params;
2369
- if (!payment.sessions.some((s) => s.id === sessionId)) {
2485
+ const session = payment.sessions.find((s) => s.id === sessionId);
2486
+ if (!session) {
2370
2487
  return reply.code(404).send({ error: "SESSION_NOT_FOUND" });
2371
2488
  }
2372
2489
  const expire = req.query.outcome === "expired";
2373
- const { rawBody, signature } = expire ? payment.expired(sessionId) : payment.completed(sessionId);
2490
+ const { rawBody, signature } = expire ? payment.expired(sessionId) : payment.completed(
2491
+ sessionId,
2492
+ resolveDevCompletion(buyer, sessionId, session.shippingRates[0])
2493
+ );
2374
2494
  const event = await payment.verifyWebhook(rawBody, signature);
2375
2495
  await handlePaymentEvent(db, event, hooks, req.log);
2376
2496
  return reply.redirect(expire ? urls.cancel : urls.success(sessionId), 302);
@@ -2429,7 +2549,7 @@ async function previewDiscount(db, request, currency, now = /* @__PURE__ */ new
2429
2549
  }
2430
2550
 
2431
2551
  // src/orders/checkout-service.ts
2432
- import { randomUUID as randomUUID2 } from "crypto";
2552
+ import { randomUUID as randomUUID3 } from "crypto";
2433
2553
  var HOLD_DURATION_MS = 31 * 60 * 1e3;
2434
2554
  var UUID4 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2435
2555
  function mergeItems(items) {
@@ -2504,7 +2624,7 @@ async function createCheckout(db, settings, request) {
2504
2624
  settings.currency
2505
2625
  );
2506
2626
  const shippingRates2 = await listActiveRates(db, settings.currency);
2507
- const orderId = randomUUID2();
2627
+ const orderId = randomUUID3();
2508
2628
  const expiresAt = new Date(Date.now() + HOLD_DURATION_MS);
2509
2629
  const discount = await reserveOrder(db, {
2510
2630
  orderId,
@@ -2642,7 +2762,7 @@ var ordersPlugin = async (app, { db, ...settings }) => {
2642
2762
  };
2643
2763
 
2644
2764
  // src/payment/fake-gateway.ts
2645
- import { createHmac, randomUUID as randomUUID3, timingSafeEqual } from "crypto";
2765
+ import { createHmac, randomUUID as randomUUID4, timingSafeEqual } from "crypto";
2646
2766
  function createFakeGateway({
2647
2767
  secret = "fake-webhook-secret"
2648
2768
  } = {}) {
@@ -2671,7 +2791,7 @@ function createFakeGateway({
2671
2791
  pendingFailure = void 0;
2672
2792
  throw err;
2673
2793
  }
2674
- const id = `fake_cs_${randomUUID3()}`;
2794
+ const id = `fake_cs_${randomUUID4()}`;
2675
2795
  sessions.push({ ...input, id });
2676
2796
  return {
2677
2797
  id,
@@ -2712,7 +2832,7 @@ function createFakeGateway({
2712
2832
  },
2713
2833
  completed(sessionId, details = {}) {
2714
2834
  return sign({
2715
- id: `fake_evt_${randomUUID3()}`,
2835
+ id: `fake_evt_${randomUUID4()}`,
2716
2836
  type: "checkout.completed",
2717
2837
  sessionId,
2718
2838
  ...details
@@ -2720,28 +2840,28 @@ function createFakeGateway({
2720
2840
  },
2721
2841
  asyncPaid(sessionId) {
2722
2842
  return sign({
2723
- id: `fake_evt_${randomUUID3()}`,
2843
+ id: `fake_evt_${randomUUID4()}`,
2724
2844
  type: "checkout.async_paid",
2725
2845
  sessionId
2726
2846
  });
2727
2847
  },
2728
2848
  asyncFailed(sessionId) {
2729
2849
  return sign({
2730
- id: `fake_evt_${randomUUID3()}`,
2850
+ id: `fake_evt_${randomUUID4()}`,
2731
2851
  type: "checkout.async_failed",
2732
2852
  sessionId
2733
2853
  });
2734
2854
  },
2735
2855
  expired(sessionId) {
2736
2856
  return sign({
2737
- id: `fake_evt_${randomUUID3()}`,
2857
+ id: `fake_evt_${randomUUID4()}`,
2738
2858
  type: "checkout.expired",
2739
2859
  sessionId
2740
2860
  });
2741
2861
  },
2742
2862
  refunded(sessionId, refunds2) {
2743
2863
  return sign({
2744
- id: `fake_evt_${randomUUID3()}`,
2864
+ id: `fake_evt_${randomUUID4()}`,
2745
2865
  type: "payment.refunded",
2746
2866
  sessionId,
2747
2867
  refunds: refunds2
@@ -2783,6 +2903,7 @@ var commercePlugin = async (app, {
2783
2903
  });
2784
2904
  }
2785
2905
  if (!payment || !urls) return;
2906
+ await payment.preflight?.();
2786
2907
  await app.register(ordersPlugin, {
2787
2908
  db,
2788
2909
  currency,
@@ -2798,7 +2919,14 @@ var commercePlugin = async (app, {
2798
2919
  payment.useSessionUrl(
2799
2920
  (id) => `${apiUrl ?? ""}${app.prefix}/checkout/dev/${id}/complete`
2800
2921
  );
2801
- await app.register(devCheckoutRoutes, { db, payment, urls, hooks });
2922
+ const buyer = typeof devCheckout === "object" ? devCheckout.buyer : void 0;
2923
+ await app.register(devCheckoutRoutes, {
2924
+ db,
2925
+ payment,
2926
+ urls,
2927
+ hooks,
2928
+ buyer
2929
+ });
2802
2930
  }
2803
2931
  }
2804
2932
  if (admin) {
@@ -2817,6 +2945,19 @@ var commercePlugin = async (app, {
2817
2945
  }
2818
2946
  };
2819
2947
 
2948
+ // src/payment/stripe-loader.ts
2949
+ async function loadStripe(secretKey) {
2950
+ try {
2951
+ const { default: StripeClient } = await import("stripe");
2952
+ return new StripeClient(secretKey);
2953
+ } catch (cause) {
2954
+ throw new Error(
2955
+ '@develemit/commerce-server: STRIPE_SECRET_KEY is set but the optional peer "stripe" is not installed. Run: pnpm add stripe',
2956
+ { cause }
2957
+ );
2958
+ }
2959
+ }
2960
+
2820
2961
  // src/payment/stripe-gateway.ts
2821
2962
  var REFUND_KEY_METADATA = "commerceRefundKey";
2822
2963
  function rejectedRefund(err) {
@@ -2943,10 +3084,6 @@ function createCoupon(stripe, { discount, currency, expiresAt }) {
2943
3084
  name: discount.code
2944
3085
  });
2945
3086
  }
2946
- async function loadStripe(secretKey) {
2947
- const { default: StripeClient } = await import("stripe");
2948
- return new StripeClient(secretKey);
2949
- }
2950
3087
  function createStripeGateway({
2951
3088
  secretKey,
2952
3089
  webhookSecret,
@@ -2955,6 +3092,9 @@ function createStripeGateway({
2955
3092
  let stripe = client ? Promise.resolve(client) : void 0;
2956
3093
  const getStripe = () => stripe ??= loadStripe(secretKey);
2957
3094
  return {
3095
+ async preflight() {
3096
+ await getStripe();
3097
+ },
2958
3098
  async createCheckoutSession(input) {
2959
3099
  const stripeClient = await getStripe();
2960
3100
  const coupon = input.discount && await createCoupon(stripeClient, input);
@@ -3245,12 +3385,13 @@ function resolveKey(dir, key) {
3245
3385
  const target = path2.resolve(root, key);
3246
3386
  return isInside(root, target) ? target : null;
3247
3387
  }
3248
- async function realInside(dir, target) {
3388
+ async function realInside(dir, target, allowRoot = false) {
3249
3389
  const [root, real] = await Promise.all([
3250
3390
  realpath(dir),
3251
3391
  realpath(target)
3252
3392
  ]).catch(() => [null, null]);
3253
- return root && real && isInside(root, real) ? real : null;
3393
+ if (!root || !real) return null;
3394
+ return isInside(root, real) || allowRoot && real === root ? real : null;
3254
3395
  }
3255
3396
  function createDiskStorage(options) {
3256
3397
  const { dir, publicUrl } = options;
@@ -3261,7 +3402,7 @@ function createDiskStorage(options) {
3261
3402
  if (!target)
3262
3403
  throw new Error(`Invalid storage key: ${JSON.stringify(key)}`);
3263
3404
  await mkdir(path2.dirname(target), { recursive: true });
3264
- if (!await realInside(dir, path2.dirname(target)).catch(() => null)) {
3405
+ if (!await realInside(dir, path2.dirname(target), true).catch(() => null)) {
3265
3406
  throw new Error(`Invalid storage key: ${JSON.stringify(key)}`);
3266
3407
  }
3267
3408
  await pipeline(stream, createWriteStream(target));
@@ -3271,7 +3412,7 @@ function createDiskStorage(options) {
3271
3412
  const target = resolveKey(dir, key);
3272
3413
  if (!target)
3273
3414
  throw new Error(`Invalid storage key: ${JSON.stringify(key)}`);
3274
- if (!await realInside(dir, path2.dirname(target)).catch(() => null))
3415
+ if (!await realInside(dir, path2.dirname(target), true).catch(() => null))
3275
3416
  return;
3276
3417
  await rm(target, { force: true });
3277
3418
  }
@@ -3316,7 +3457,10 @@ function checkoutUrls(webUrl, {
3316
3457
  } = {}) {
3317
3458
  const base = webUrl.replace(/\/+$/, "");
3318
3459
  return {
3319
- success: (sessionId) => `${base}${successPath}?session_id=${encodeURIComponent(sessionId)}`,
3460
+ success: (sessionId) => {
3461
+ const id = sessionId === SESSION_ID_PLACEHOLDER ? sessionId : encodeURIComponent(sessionId);
3462
+ return `${base}${successPath}?session_id=${id}`;
3463
+ },
3320
3464
  cancel: `${base}${cancelPath}`
3321
3465
  };
3322
3466
  }