@develemit/commerce-server 0.6.0 → 0.8.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,16 +72,17 @@ 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),
85
+ publishedAt: timestamp("published_at", { withTimezone: true }),
85
86
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
86
87
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
87
88
  },
@@ -331,17 +332,17 @@ async function setShippingRates(db, rates) {
331
332
  return listShippingRates(db);
332
333
  }
333
334
 
334
- // src/admin/images.ts
335
- import { Readable } from "stream";
336
-
337
335
  // src/admin/repo.ts
338
336
  import {
339
337
  and as and2,
340
338
  asc as asc2,
339
+ desc,
341
340
  eq as eq2,
342
341
  gt,
343
342
  ilike,
344
343
  inArray,
344
+ lt,
345
+ ne,
345
346
  or,
346
347
  sql as sql2
347
348
  } from "drizzle-orm";
@@ -361,9 +362,12 @@ async function withImages(db, rows) {
361
362
  function escapeLike(value) {
362
363
  return value.replace(/[\\%_]/g, (c) => `\\${c}`);
363
364
  }
365
+ var incompleteDraft = sql2`(${products.status} = 'draft' and (${products.name} = '' or ${products.priceCents} is null or ${products.category} is null))`;
364
366
  function pageConditions(filter) {
365
367
  const parts = [];
366
368
  if (filter.status) parts.push(eq2(products.status, filter.status));
369
+ if (filter.excludeArchived) parts.push(ne(products.status, "archived"));
370
+ if (filter.incomplete) parts.push(incompleteDraft);
367
371
  if (filter.stock === "in") parts.push(gt(products.quantity, 0));
368
372
  if (filter.stock === "sold") parts.push(eq2(products.quantity, 0));
369
373
  if (filter.q) parts.push(ilike(products.name, `%${escapeLike(filter.q)}%`));
@@ -371,10 +375,10 @@ function pageConditions(filter) {
371
375
  const createdAt = sql2`${filter.after.createdAt}::timestamptz`;
372
376
  parts.push(
373
377
  or(
374
- gt(products.createdAt, createdAt),
378
+ lt(products.createdAt, createdAt),
375
379
  and2(
376
380
  eq2(products.createdAt, createdAt),
377
- gt(products.slug, filter.after.slug)
381
+ lt(products.slug, filter.after.slug)
378
382
  )
379
383
  )
380
384
  );
@@ -385,7 +389,7 @@ async function selectProductPage(db, filter) {
385
389
  const selected = await db.select({
386
390
  product: products,
387
391
  createdAtKey: sql2`${products.createdAt}::text`
388
- }).from(products).where(pageConditions(filter)).orderBy(asc2(products.createdAt), asc2(products.slug)).limit(filter.limit);
392
+ }).from(products).where(pageConditions(filter)).orderBy(desc(products.createdAt), desc(products.slug)).limit(filter.limit);
389
393
  const records = await withImages(
390
394
  db,
391
395
  selected.map((s) => s.product)
@@ -429,6 +433,26 @@ async function setImagePosition(db, id, position) {
429
433
  async function setImageAlt(db, id, alt) {
430
434
  await db.update(productImages).set({ alt }).where(eq2(productImages.id, id));
431
435
  }
436
+ async function selectProductCounts(db) {
437
+ const [row] = await db.select({
438
+ all: sql2`count(*) filter (where ${products.status} <> 'archived')::int`,
439
+ live: sql2`count(*) filter (where ${products.status} = 'active' and ${products.quantity} > 0)::int`,
440
+ draft: sql2`count(*) filter (where ${products.status} = 'draft')::int`,
441
+ sold: sql2`count(*) filter (where ${products.status} = 'active' and ${products.quantity} = 0)::int`,
442
+ archived: sql2`count(*) filter (where ${products.status} = 'archived')::int`,
443
+ incompleteDrafts: sql2`count(*) filter (where ${products.status} = 'draft' and (${products.name} = '' or ${products.priceCents} is null or ${products.category} is null))::int`
444
+ }).from(products);
445
+ return row;
446
+ }
447
+ async function productHasOrderLines(db, productId) {
448
+ const rows = await db.select({ id: orderItems.id }).from(orderItems).where(eq2(orderItems.productId, productId)).limit(1);
449
+ return rows.length > 0;
450
+ }
451
+ async function deleteProductRows(db, id) {
452
+ await db.delete(inventoryHolds).where(eq2(inventoryHolds.productId, id));
453
+ await db.delete(productImages).where(eq2(productImages.productId, id));
454
+ await db.delete(products).where(eq2(products.id, id));
455
+ }
432
456
 
433
457
  // src/admin/service.ts
434
458
  import {
@@ -436,6 +460,65 @@ import {
436
460
  adminProductListQuerySchema,
437
461
  adminProductPatchSchema
438
462
  } from "@develemit/commerce-contracts";
463
+ import { randomUUID } from "crypto";
464
+
465
+ // src/admin/types.ts
466
+ var ProductImageNotFoundError = class extends Error {
467
+ constructor(imageId) {
468
+ super(`Image not found: ${imageId}`);
469
+ this.imageId = imageId;
470
+ this.name = "ProductImageNotFoundError";
471
+ }
472
+ imageId;
473
+ code = "IMAGE_NOT_FOUND";
474
+ };
475
+ var InvalidImageOrderError = class extends Error {
476
+ code = "INVALID_IMAGE_ORDER";
477
+ constructor() {
478
+ super("imageIds must list every image of the product exactly once");
479
+ this.name = "InvalidImageOrderError";
480
+ }
481
+ };
482
+ var ProductIncompleteError = class extends Error {
483
+ constructor(missing) {
484
+ super(`Product is missing: ${missing.join(", ")}`);
485
+ this.missing = missing;
486
+ this.name = "ProductIncompleteError";
487
+ }
488
+ missing;
489
+ code = "PRODUCT_INCOMPLETE";
490
+ };
491
+ var ProductNotDeletableError = class extends Error {
492
+ constructor(reason) {
493
+ super(
494
+ reason === "published" ? "This product has been live, so it can only be archived" : "This product is on an order, so it can only be archived"
495
+ );
496
+ this.reason = reason;
497
+ this.name = "ProductNotDeletableError";
498
+ }
499
+ reason;
500
+ code = "PRODUCT_NOT_DELETABLE";
501
+ };
502
+
503
+ // src/admin/publish.ts
504
+ function temporarySlug(id) {
505
+ return `draft-${id.slice(0, 8)}`;
506
+ }
507
+ function missingForPublish({ row, images }, patch, requirePhoto) {
508
+ const name = patch.name ?? row.name;
509
+ const priceCents = patch.priceCents ?? row.priceCents;
510
+ const category = patch.category ?? row.category;
511
+ const missing = [];
512
+ if (!name) missing.push("name");
513
+ if (priceCents === null) missing.push("priceCents");
514
+ if (!category) missing.push("category");
515
+ if (requirePhoto && images.length === 0) missing.push("images");
516
+ return missing;
517
+ }
518
+ function assertPublishable(record, patch, requirePhoto) {
519
+ const missing = missingForPublish(record, patch, requirePhoto);
520
+ if (missing.length > 0) throw new ProductIncompleteError(missing);
521
+ }
439
522
 
440
523
  // src/admin/slug.ts
441
524
  function slugify(name) {
@@ -464,6 +547,7 @@ function toAdminProduct({ row, images }) {
464
547
  quantity: row.quantity,
465
548
  status: row.status,
466
549
  featured: row.featured,
550
+ everPublished: row.publishedAt !== null,
467
551
  images: images.map((i) => ({
468
552
  id: i.id,
469
553
  url: i.url,
@@ -485,6 +569,8 @@ async function listAdminProducts(db, rawQuery = {}) {
485
569
  const query = adminProductListQuerySchema.parse(rawQuery);
486
570
  const found = await selectProductPage(db, {
487
571
  status: query.status,
572
+ excludeArchived: query.excludeArchived === "true",
573
+ incomplete: query.incomplete === "true",
488
574
  stock: query.stock,
489
575
  q: query.q,
490
576
  after: query.cursor ? decodeProductCursor(query.cursor) : void 0,
@@ -500,47 +586,70 @@ async function listAdminProducts(db, rawQuery = {}) {
500
586
  async function getAdminProduct(db, id) {
501
587
  return toAdminProduct(await requireProduct(db, id));
502
588
  }
503
- async function createAdminProduct(db, currency, input) {
589
+ async function createAdminProduct(db, currency, input, { requirePhotoToPublish = true } = {}) {
504
590
  const data = adminProductInputSchema.parse(input);
505
- const base = slugify(data.name);
591
+ if (data.status === "active" && requirePhotoToPublish) {
592
+ throw new ProductIncompleteError(["images"]);
593
+ }
594
+ const newId = randomUUID();
595
+ const base = data.name ? slugify(data.name) : temporarySlug(newId);
506
596
  const id = await db.transaction(async (tx) => {
507
597
  const slug = uniqueSlug(base, await slugsStartingWith(tx, base));
508
598
  return insertProduct(tx, {
509
599
  ...data,
600
+ publishedAt: data.status === "active" ? /* @__PURE__ */ new Date() : void 0,
601
+ id: newId,
510
602
  slug,
511
603
  currency: data.currency ?? currency
512
604
  });
513
605
  });
514
606
  return getAdminProduct(db, id);
515
607
  }
516
- async function patchAdminProduct(db, id, input) {
608
+ async function patchAdminProduct(db, id, input, { requirePhotoToPublish = true } = {}) {
517
609
  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);
610
+ const record = await requireProduct(db, id);
611
+ if (fields.status === "active") {
612
+ const photoNeeded = requirePhotoToPublish && record.row.status !== "active";
613
+ assertPublishable(record, fields, photoNeeded);
614
+ }
615
+ const set = sold ? { ...fields, quantity: 0 } : { ...fields };
616
+ if (fields.status === "active" && !record.row.publishedAt) {
617
+ set.publishedAt = /* @__PURE__ */ new Date();
618
+ }
619
+ const renames = fields.name && record.row.slug === temporarySlug(id);
620
+ await db.transaction(async (tx) => {
621
+ if (renames) {
622
+ const base = slugify(fields.name);
623
+ set.slug = uniqueSlug(base, await slugsStartingWith(tx, base));
624
+ }
625
+ if (Object.keys(set).length > 0) await updateProduct(tx, id, set);
626
+ });
521
627
  return getAdminProduct(db, id);
522
628
  }
629
+ async function getAdminProductCounts(db) {
630
+ return selectProductCounts(db);
631
+ }
523
632
 
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";
633
+ // src/admin/delete.ts
634
+ async function deleteAdminProduct(db, storage, id) {
635
+ const { row, images } = await requireProduct(db, id);
636
+ if (row.status !== "draft" || row.publishedAt) {
637
+ throw new ProductNotDeletableError("published");
530
638
  }
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";
639
+ if (await productHasOrderLines(db, id)) {
640
+ throw new ProductNotDeletableError("has_orders");
539
641
  }
540
- };
642
+ await db.transaction((tx) => deleteProductRows(tx, id));
643
+ for (const image of images) {
644
+ if (image.storageKey) await storage.delete(image.storageKey);
645
+ }
646
+ }
647
+
648
+ // src/admin/images.ts
649
+ import { Readable } from "stream";
541
650
 
542
651
  // src/admin/upload.ts
543
- import { randomUUID } from "crypto";
652
+ import { randomUUID as randomUUID2 } from "crypto";
544
653
  import { fileTypeFromBuffer } from "file-type";
545
654
  var MAX_IMAGE_BYTES = 8 * 1024 * 1024;
546
655
  var EXTENSIONS = {
@@ -565,7 +674,7 @@ async function detectImage(bytes) {
565
674
  return { mime: type.mime, ext };
566
675
  }
567
676
  function imageKey(productId, ext) {
568
- return `products/${productId}/${randomUUID()}.${ext}`;
677
+ return `products/${productId}/${randomUUID2()}.${ext}`;
569
678
  }
570
679
 
571
680
  // src/admin/images.ts
@@ -625,7 +734,7 @@ async function updateProductImageAlt(db, productId, imageId, alt) {
625
734
  function isZodError(err) {
626
735
  return err instanceof Error && err.name === "ZodError";
627
736
  }
628
- var adminPlugin = async (app, { db, currency, authorize, storage }) => {
737
+ var adminPlugin = async (app, { db, currency, authorize, storage, requirePhotoToPublish }) => {
629
738
  const { default: multipart } = await import("@fastify/multipart");
630
739
  await app.register(multipart, {
631
740
  limits: { fileSize: MAX_IMAGE_BYTES, files: 1 }
@@ -637,6 +746,20 @@ var adminPlugin = async (app, { db, currency, authorize, storage }) => {
637
746
  if (err instanceof ProductNotFoundError || err instanceof ProductImageNotFoundError) {
638
747
  return reply.code(404).send({ error: err.code, message: err.message });
639
748
  }
749
+ if (err instanceof ProductIncompleteError) {
750
+ return reply.code(422).send({
751
+ error: err.code,
752
+ message: err.message,
753
+ missing: err.missing
754
+ });
755
+ }
756
+ if (err instanceof ProductNotDeletableError) {
757
+ return reply.code(409).send({
758
+ error: err.code,
759
+ message: err.message,
760
+ reason: err.reason
761
+ });
762
+ }
640
763
  if (err instanceof InvalidCursorError || err instanceof InvalidImageError || err instanceof InvalidImageOrderError) {
641
764
  return reply.code(400).send({ error: err.code, message: err.message });
642
765
  }
@@ -656,9 +779,12 @@ var adminPlugin = async (app, { db, currency, authorize, storage }) => {
656
779
  await listAdminProducts(db, req.query)
657
780
  )
658
781
  );
782
+ app.get("/products/counts", async () => getAdminProductCounts(db));
659
783
  app.post("/products", async (req, reply) => {
660
784
  const input = adminProductInputSchema2.parse(req.body);
661
- const created = await createAdminProduct(db, currency, input);
785
+ const created = await createAdminProduct(db, currency, input, {
786
+ requirePhotoToPublish
787
+ });
662
788
  return reply.code(201).send(created);
663
789
  });
664
790
  app.get(
@@ -670,9 +796,17 @@ var adminPlugin = async (app, { db, currency, authorize, storage }) => {
670
796
  async (req) => patchAdminProduct(
671
797
  db,
672
798
  req.params.id,
673
- adminProductPatchSchema2.parse(req.body)
799
+ adminProductPatchSchema2.parse(req.body),
800
+ { requirePhotoToPublish }
674
801
  )
675
802
  );
803
+ app.delete(
804
+ "/products/:id",
805
+ async (req, reply) => {
806
+ await deleteAdminProduct(db, storage, req.params.id);
807
+ return reply.code(204).send();
808
+ }
809
+ );
676
810
  app.post(
677
811
  "/products/:id/images",
678
812
  async (req, reply) => {
@@ -758,7 +892,7 @@ function addCatalogCaching(app, { cacheControl = DEFAULT_CATALOG_CACHE_CONTROL }
758
892
  }
759
893
 
760
894
  // 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";
895
+ 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
896
  async function lockProductQuantities(tx, productIds) {
763
897
  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
898
  return new Map(rows.map((r) => [r.id, r.quantity]));
@@ -845,7 +979,7 @@ async function releaseExpiredHolds(db) {
845
979
  const released = await db.update(inventoryHolds).set({ status: "released" }).where(
846
980
  and3(
847
981
  eq3(inventoryHolds.status, "active"),
848
- lt(inventoryHolds.expiresAt, sql3`now()`)
982
+ lt2(inventoryHolds.expiresAt, sql3`now()`)
849
983
  )
850
984
  ).returning({ reference: inventoryHolds.reference });
851
985
  return released.map((r) => r.reference);
@@ -950,7 +1084,12 @@ async function restock(tx, items) {
950
1084
  }
951
1085
 
952
1086
  // 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";
1087
+ 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";
1088
+ var isListed = and4(
1089
+ eq4(products.status, "active"),
1090
+ isNotNull(products.priceCents),
1091
+ isNotNull(products.category)
1092
+ );
954
1093
  async function attachImages(db, rows, firstOnly = false) {
955
1094
  if (rows.length === 0) return [];
956
1095
  const where = inArray3(
@@ -964,7 +1103,7 @@ async function attachImages(db, rows, firstOnly = false) {
964
1103
  }));
965
1104
  }
966
1105
  async function listActiveProducts(db, filter) {
967
- const conditions2 = [eq4(products.status, "active")];
1106
+ const conditions2 = [isListed];
968
1107
  if (filter.category) conditions2.push(eq4(products.category, filter.category));
969
1108
  if (filter.featured !== void 0)
970
1109
  conditions2.push(eq4(products.featured, filter.featured));
@@ -995,22 +1134,30 @@ async function listActiveProducts(db, filter) {
995
1134
  }));
996
1135
  }
997
1136
  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);
1137
+ const rows = await db.select().from(products).where(and4(eq4(products.slug, slug), isListed)).limit(1);
999
1138
  return (await attachImages(db, rows))[0];
1000
1139
  }
1001
1140
  async function countActiveByCategory(db) {
1002
1141
  const rows = await db.select({
1003
1142
  category: products.category,
1004
1143
  count: sql4`count(*)::int`
1005
- }).from(products).where(eq4(products.status, "active")).groupBy(products.category).orderBy(asc4(products.category));
1006
- return rows;
1144
+ }).from(products).where(isListed).groupBy(products.category).orderBy(asc4(products.category));
1145
+ return rows.flatMap(
1146
+ (r) => r.category === null ? [] : [{ category: r.category, count: r.count }]
1147
+ );
1007
1148
  }
1008
1149
  async function findActiveProductsByIds(db, ids) {
1009
1150
  if (ids.length === 0) return [];
1010
- return db.select().from(products).where(and4(inArray3(products.id, ids), eq4(products.status, "active")));
1151
+ return db.select().from(products).where(and4(inArray3(products.id, ids), isListed));
1011
1152
  }
1012
1153
 
1013
1154
  // src/catalog/service.ts
1155
+ function completeFields(row) {
1156
+ if (row.priceCents === null || row.category === null) {
1157
+ throw new Error(`Active product ${row.id} is missing price or category`);
1158
+ }
1159
+ return { priceCents: row.priceCents, category: row.category };
1160
+ }
1014
1161
  function availabilityOf(quantity, free) {
1015
1162
  if (free > 0) return "available";
1016
1163
  return quantity > 0 ? "reserved" : "sold";
@@ -1024,9 +1171,8 @@ function toProduct({ row, images }, free) {
1024
1171
  description: row.description,
1025
1172
  ...row.materials ? { materials: row.materials } : {},
1026
1173
  ...row.dimensions ? { dimensions: row.dimensions } : {},
1027
- priceCents: row.priceCents,
1174
+ ...completeFields(row),
1028
1175
  currency: row.currency,
1029
- category: row.category,
1030
1176
  images: images.map((i) => ({ url: i.url, alt: i.alt })),
1031
1177
  quantity: freeQty,
1032
1178
  availability: availabilityOf(row.quantity, freeQty),
@@ -1079,7 +1225,7 @@ async function findPurchasable(db, ids) {
1079
1225
  return rows.map((row) => ({
1080
1226
  id: row.id,
1081
1227
  name: row.name,
1082
- priceCents: row.priceCents,
1228
+ priceCents: completeFields(row).priceCents,
1083
1229
  currency: row.currency
1084
1230
  }));
1085
1231
  }
@@ -1126,8 +1272,11 @@ import {
1126
1272
  shipOrderInputSchema
1127
1273
  } from "@develemit/commerce-contracts";
1128
1274
 
1275
+ // src/orders/admin-service.ts
1276
+ import { orderRefSearchTerm } from "@develemit/commerce-contracts";
1277
+
1129
1278
  // 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";
1279
+ 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
1280
  function escapeLike2(value) {
1132
1281
  return value.replace(/[\\%_]/g, (c) => `\\${c}`);
1133
1282
  }
@@ -1153,8 +1302,8 @@ function conditions(filter) {
1153
1302
  const createdAt = sql5`${filter.after.createdAt}::timestamptz`;
1154
1303
  parts.push(
1155
1304
  or3(
1156
- lt2(orders.createdAt, createdAt),
1157
- and5(eq5(orders.createdAt, createdAt), lt2(orders.id, id))
1305
+ lt3(orders.createdAt, createdAt),
1306
+ and5(eq5(orders.createdAt, createdAt), lt3(orders.id, id))
1158
1307
  )
1159
1308
  );
1160
1309
  }
@@ -1174,7 +1323,7 @@ async function selectOrderPage(db, filter) {
1174
1323
  itemCount,
1175
1324
  createdAt: orders.createdAt,
1176
1325
  createdAtKey: sql5`${orders.createdAt}::text`
1177
- }).from(orders).where(conditions(filter)).orderBy(desc(orders.createdAt), desc(orders.id)).limit(filter.limit);
1326
+ }).from(orders).where(conditions(filter)).orderBy(desc2(orders.createdAt), desc2(orders.id)).limit(filter.limit);
1178
1327
  }
1179
1328
  async function findOrderRefunds(db, orderId) {
1180
1329
  return db.select().from(orderRefunds).where(eq5(orderRefunds.orderId, orderId)).orderBy(orderRefunds.createdAt, orderRefunds.id);
@@ -1184,10 +1333,10 @@ async function findOrderRefunds(db, orderId) {
1184
1333
  import { and as and7, asc as asc5, eq as eq7, sql as sql7 } from "drizzle-orm";
1185
1334
 
1186
1335
  // src/orders/refund-repo.ts
1187
- import { and as and6, eq as eq6, isNotNull, isNull, or as or4, sql as sql6 } from "drizzle-orm";
1336
+ import { and as and6, eq as eq6, isNotNull as isNotNull2, isNull, or as or4, sql as sql6 } from "drizzle-orm";
1188
1337
  var isRecordedRefund = or4(
1189
1338
  isNull(orderRefunds.idempotencyKey),
1190
- isNotNull(orderRefunds.gatewayRefundId)
1339
+ isNotNull2(orderRefunds.gatewayRefundId)
1191
1340
  );
1192
1341
  async function lockOrderForRefund(tx, id) {
1193
1342
  const [row] = await tx.select({
@@ -1397,7 +1546,7 @@ function decodeOrderCursor(raw) {
1397
1546
  async function listAdminOrders(db, query) {
1398
1547
  const rows = await selectOrderPage(db, {
1399
1548
  status: query.status,
1400
- q: query.q,
1549
+ q: query.q ? orderRefSearchTerm(query.q) || void 0 : void 0,
1401
1550
  hasPendingRefund: query.hasPendingRefund,
1402
1551
  after: query.cursor ? decodeOrderCursor(query.cursor) : void 0,
1403
1552
  limit: query.limit + 1
@@ -1520,14 +1669,14 @@ function evaluateDiscount(discount, ctx) {
1520
1669
  // src/discounts/repo.ts
1521
1670
  import {
1522
1671
  and as and8,
1523
- desc as desc2,
1672
+ desc as desc3,
1524
1673
  eq as eq8,
1525
1674
  getTableColumns,
1526
1675
  gt as gt4,
1527
1676
  ilike as ilike3,
1528
1677
  inArray as inArray4,
1529
1678
  isNull as isNull2,
1530
- lt as lt3,
1679
+ lt as lt4,
1531
1680
  lte,
1532
1681
  or as or5,
1533
1682
  sql as sql8
@@ -1582,8 +1731,8 @@ function listConditions(filter) {
1582
1731
  const createdAt = sql8`${filter.after.createdAt}::timestamptz`;
1583
1732
  parts.push(
1584
1733
  or5(
1585
- lt3(discounts.createdAt, createdAt),
1586
- and8(eq8(discounts.createdAt, createdAt), lt3(discounts.id, id))
1734
+ lt4(discounts.createdAt, createdAt),
1735
+ and8(eq8(discounts.createdAt, createdAt), lt4(discounts.id, id))
1587
1736
  )
1588
1737
  );
1589
1738
  }
@@ -1606,7 +1755,7 @@ async function selectDiscountPage(db, filter) {
1606
1755
  return db.select({
1607
1756
  ...withCounts,
1608
1757
  createdAtKey: sql8`${discounts.createdAt}::text`
1609
- }).from(discounts).where(listConditions(filter)).orderBy(desc2(discounts.createdAt), desc2(discounts.id)).limit(filter.limit);
1758
+ }).from(discounts).where(listConditions(filter)).orderBy(desc3(discounts.createdAt), desc3(discounts.id)).limit(filter.limit);
1610
1759
  }
1611
1760
  async function findDiscountById(db, id, options = {}) {
1612
1761
  const query = db.select(withCounts).from(discounts).where(eq8(discounts.id, id));
@@ -2356,21 +2505,50 @@ var adminDiscountsPlugin = async (app, { db, authorize }) => {
2356
2505
  );
2357
2506
  };
2358
2507
 
2508
+ // src/orders/dev-buyer.ts
2509
+ var DEFAULT_DEV_BUYER = {
2510
+ email: "dev-buyer@example.com",
2511
+ name: "Dev Buyer",
2512
+ address: {
2513
+ line1: "123 Example Street",
2514
+ city: "Springfield",
2515
+ state: "IL",
2516
+ postalCode: "62701",
2517
+ country: "US"
2518
+ }
2519
+ };
2520
+ function resolveDevCompletion(setting, sessionId, firstRate) {
2521
+ const buyer = typeof setting === "function" ? setting(sessionId) : setting;
2522
+ const name = buyer?.name ?? DEFAULT_DEV_BUYER.name;
2523
+ return {
2524
+ email: buyer?.email ?? DEFAULT_DEV_BUYER.email,
2525
+ shippingAddress: { name, ...buyer?.address ?? DEFAULT_DEV_BUYER.address },
2526
+ shipping: firstRate && {
2527
+ label: firstRate.label,
2528
+ amountCents: firstRate.amountCents
2529
+ }
2530
+ };
2531
+ }
2532
+
2359
2533
  // src/orders/dev-checkout.ts
2360
2534
  function assertDevCheckoutAllowed() {
2361
2535
  if (process.env.NODE_ENV === "production") {
2362
2536
  throw new Error("devCheckout is not allowed when NODE_ENV=production");
2363
2537
  }
2364
2538
  }
2365
- var devCheckoutRoutes = async (app, { db, payment, urls, hooks }) => {
2539
+ var devCheckoutRoutes = async (app, { db, payment, urls, hooks, buyer }) => {
2366
2540
  assertDevCheckoutAllowed();
2367
2541
  app.get("/checkout/dev/:sessionId/complete", async (req, reply) => {
2368
2542
  const { sessionId } = req.params;
2369
- if (!payment.sessions.some((s) => s.id === sessionId)) {
2543
+ const session = payment.sessions.find((s) => s.id === sessionId);
2544
+ if (!session) {
2370
2545
  return reply.code(404).send({ error: "SESSION_NOT_FOUND" });
2371
2546
  }
2372
2547
  const expire = req.query.outcome === "expired";
2373
- const { rawBody, signature } = expire ? payment.expired(sessionId) : payment.completed(sessionId);
2548
+ const { rawBody, signature } = expire ? payment.expired(sessionId) : payment.completed(
2549
+ sessionId,
2550
+ resolveDevCompletion(buyer, sessionId, session.shippingRates[0])
2551
+ );
2374
2552
  const event = await payment.verifyWebhook(rawBody, signature);
2375
2553
  await handlePaymentEvent(db, event, hooks, req.log);
2376
2554
  return reply.redirect(expire ? urls.cancel : urls.success(sessionId), 302);
@@ -2429,7 +2607,7 @@ async function previewDiscount(db, request, currency, now = /* @__PURE__ */ new
2429
2607
  }
2430
2608
 
2431
2609
  // src/orders/checkout-service.ts
2432
- import { randomUUID as randomUUID2 } from "crypto";
2610
+ import { randomUUID as randomUUID3 } from "crypto";
2433
2611
  var HOLD_DURATION_MS = 31 * 60 * 1e3;
2434
2612
  var UUID4 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2435
2613
  function mergeItems(items) {
@@ -2504,7 +2682,7 @@ async function createCheckout(db, settings, request) {
2504
2682
  settings.currency
2505
2683
  );
2506
2684
  const shippingRates2 = await listActiveRates(db, settings.currency);
2507
- const orderId = randomUUID2();
2685
+ const orderId = randomUUID3();
2508
2686
  const expiresAt = new Date(Date.now() + HOLD_DURATION_MS);
2509
2687
  const discount = await reserveOrder(db, {
2510
2688
  orderId,
@@ -2642,7 +2820,7 @@ var ordersPlugin = async (app, { db, ...settings }) => {
2642
2820
  };
2643
2821
 
2644
2822
  // src/payment/fake-gateway.ts
2645
- import { createHmac, randomUUID as randomUUID3, timingSafeEqual } from "crypto";
2823
+ import { createHmac, randomUUID as randomUUID4, timingSafeEqual } from "crypto";
2646
2824
  function createFakeGateway({
2647
2825
  secret = "fake-webhook-secret"
2648
2826
  } = {}) {
@@ -2671,7 +2849,7 @@ function createFakeGateway({
2671
2849
  pendingFailure = void 0;
2672
2850
  throw err;
2673
2851
  }
2674
- const id = `fake_cs_${randomUUID3()}`;
2852
+ const id = `fake_cs_${randomUUID4()}`;
2675
2853
  sessions.push({ ...input, id });
2676
2854
  return {
2677
2855
  id,
@@ -2712,7 +2890,7 @@ function createFakeGateway({
2712
2890
  },
2713
2891
  completed(sessionId, details = {}) {
2714
2892
  return sign({
2715
- id: `fake_evt_${randomUUID3()}`,
2893
+ id: `fake_evt_${randomUUID4()}`,
2716
2894
  type: "checkout.completed",
2717
2895
  sessionId,
2718
2896
  ...details
@@ -2720,28 +2898,28 @@ function createFakeGateway({
2720
2898
  },
2721
2899
  asyncPaid(sessionId) {
2722
2900
  return sign({
2723
- id: `fake_evt_${randomUUID3()}`,
2901
+ id: `fake_evt_${randomUUID4()}`,
2724
2902
  type: "checkout.async_paid",
2725
2903
  sessionId
2726
2904
  });
2727
2905
  },
2728
2906
  asyncFailed(sessionId) {
2729
2907
  return sign({
2730
- id: `fake_evt_${randomUUID3()}`,
2908
+ id: `fake_evt_${randomUUID4()}`,
2731
2909
  type: "checkout.async_failed",
2732
2910
  sessionId
2733
2911
  });
2734
2912
  },
2735
2913
  expired(sessionId) {
2736
2914
  return sign({
2737
- id: `fake_evt_${randomUUID3()}`,
2915
+ id: `fake_evt_${randomUUID4()}`,
2738
2916
  type: "checkout.expired",
2739
2917
  sessionId
2740
2918
  });
2741
2919
  },
2742
2920
  refunded(sessionId, refunds2) {
2743
2921
  return sign({
2744
- id: `fake_evt_${randomUUID3()}`,
2922
+ id: `fake_evt_${randomUUID4()}`,
2745
2923
  type: "payment.refunded",
2746
2924
  sessionId,
2747
2925
  refunds: refunds2
@@ -2783,6 +2961,7 @@ var commercePlugin = async (app, {
2783
2961
  });
2784
2962
  }
2785
2963
  if (!payment || !urls) return;
2964
+ await payment.preflight?.();
2786
2965
  await app.register(ordersPlugin, {
2787
2966
  db,
2788
2967
  currency,
@@ -2798,7 +2977,14 @@ var commercePlugin = async (app, {
2798
2977
  payment.useSessionUrl(
2799
2978
  (id) => `${apiUrl ?? ""}${app.prefix}/checkout/dev/${id}/complete`
2800
2979
  );
2801
- await app.register(devCheckoutRoutes, { db, payment, urls, hooks });
2980
+ const buyer = typeof devCheckout === "object" ? devCheckout.buyer : void 0;
2981
+ await app.register(devCheckoutRoutes, {
2982
+ db,
2983
+ payment,
2984
+ urls,
2985
+ hooks,
2986
+ buyer
2987
+ });
2802
2988
  }
2803
2989
  }
2804
2990
  if (admin) {
@@ -2817,6 +3003,19 @@ var commercePlugin = async (app, {
2817
3003
  }
2818
3004
  };
2819
3005
 
3006
+ // src/payment/stripe-loader.ts
3007
+ async function loadStripe(secretKey) {
3008
+ try {
3009
+ const { default: StripeClient } = await import("stripe");
3010
+ return new StripeClient(secretKey);
3011
+ } catch (cause) {
3012
+ throw new Error(
3013
+ '@develemit/commerce-server: STRIPE_SECRET_KEY is set but the optional peer "stripe" is not installed. Run: pnpm add stripe',
3014
+ { cause }
3015
+ );
3016
+ }
3017
+ }
3018
+
2820
3019
  // src/payment/stripe-gateway.ts
2821
3020
  var REFUND_KEY_METADATA = "commerceRefundKey";
2822
3021
  function rejectedRefund(err) {
@@ -2943,10 +3142,6 @@ function createCoupon(stripe, { discount, currency, expiresAt }) {
2943
3142
  name: discount.code
2944
3143
  });
2945
3144
  }
2946
- async function loadStripe(secretKey) {
2947
- const { default: StripeClient } = await import("stripe");
2948
- return new StripeClient(secretKey);
2949
- }
2950
3145
  function createStripeGateway({
2951
3146
  secretKey,
2952
3147
  webhookSecret,
@@ -2955,6 +3150,9 @@ function createStripeGateway({
2955
3150
  let stripe = client ? Promise.resolve(client) : void 0;
2956
3151
  const getStripe = () => stripe ??= loadStripe(secretKey);
2957
3152
  return {
3153
+ async preflight() {
3154
+ await getStripe();
3155
+ },
2958
3156
  async createCheckoutSession(input) {
2959
3157
  const stripeClient = await getStripe();
2960
3158
  const coupon = input.discount && await createCoupon(stripeClient, input);
@@ -3052,6 +3250,9 @@ function startHoldSweeper(db, { intervalMs = 6e4, onError } = {}) {
3052
3250
  }
3053
3251
 
3054
3252
  // src/emails/order-lines.ts
3253
+ import {
3254
+ formatOrderRef
3255
+ } from "@develemit/commerce-contracts";
3055
3256
  var defaultLabels = {
3056
3257
  subtotal: "Subtotal",
3057
3258
  discount: (code) => code ? `Discount (${code})` : "Discount",
@@ -3115,6 +3316,7 @@ function orderEmailLines(order, options = {}) {
3115
3316
  const address = addressLines(order);
3116
3317
  const totals = [subtotal, ...discount ? [discount] : [], shipping, total];
3117
3318
  return {
3319
+ reference: formatOrderRef(order.id),
3118
3320
  items,
3119
3321
  subtotal,
3120
3322
  discount,
@@ -3245,12 +3447,13 @@ function resolveKey(dir, key) {
3245
3447
  const target = path2.resolve(root, key);
3246
3448
  return isInside(root, target) ? target : null;
3247
3449
  }
3248
- async function realInside(dir, target) {
3450
+ async function realInside(dir, target, allowRoot = false) {
3249
3451
  const [root, real] = await Promise.all([
3250
3452
  realpath(dir),
3251
3453
  realpath(target)
3252
3454
  ]).catch(() => [null, null]);
3253
- return root && real && isInside(root, real) ? real : null;
3455
+ if (!root || !real) return null;
3456
+ return isInside(root, real) || allowRoot && real === root ? real : null;
3254
3457
  }
3255
3458
  function createDiskStorage(options) {
3256
3459
  const { dir, publicUrl } = options;
@@ -3261,7 +3464,7 @@ function createDiskStorage(options) {
3261
3464
  if (!target)
3262
3465
  throw new Error(`Invalid storage key: ${JSON.stringify(key)}`);
3263
3466
  await mkdir(path2.dirname(target), { recursive: true });
3264
- if (!await realInside(dir, path2.dirname(target)).catch(() => null)) {
3467
+ if (!await realInside(dir, path2.dirname(target), true).catch(() => null)) {
3265
3468
  throw new Error(`Invalid storage key: ${JSON.stringify(key)}`);
3266
3469
  }
3267
3470
  await pipeline(stream, createWriteStream(target));
@@ -3271,7 +3474,7 @@ function createDiskStorage(options) {
3271
3474
  const target = resolveKey(dir, key);
3272
3475
  if (!target)
3273
3476
  throw new Error(`Invalid storage key: ${JSON.stringify(key)}`);
3274
- if (!await realInside(dir, path2.dirname(target)).catch(() => null))
3477
+ if (!await realInside(dir, path2.dirname(target), true).catch(() => null))
3275
3478
  return;
3276
3479
  await rm(target, { force: true });
3277
3480
  }
@@ -3316,7 +3519,10 @@ function checkoutUrls(webUrl, {
3316
3519
  } = {}) {
3317
3520
  const base = webUrl.replace(/\/+$/, "");
3318
3521
  return {
3319
- success: (sessionId) => `${base}${successPath}?session_id=${encodeURIComponent(sessionId)}`,
3522
+ success: (sessionId) => {
3523
+ const id = sessionId === SESSION_ID_PLACEHOLDER ? sessionId : encodeURIComponent(sessionId);
3524
+ return `${base}${successPath}?session_id=${id}`;
3525
+ },
3320
3526
  cancel: `${base}${cancelPath}`
3321
3527
  };
3322
3528
  }