@develemit/commerce-server 0.4.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
@@ -28,6 +28,9 @@ var InvalidCursorError = class extends Error {
28
28
  }
29
29
  };
30
30
  var PG_TIMESTAMP = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d+)?[+-]\d{2}(:?\d{2})?$/;
31
+ function isCursorTimestamp(at) {
32
+ return PG_TIMESTAMP.test(at) || !Number.isNaN(Date.parse(at));
33
+ }
31
34
  function encodeCursor({ at, key }) {
32
35
  return Buffer.from(`${at}|${key}`).toString("base64url");
33
36
  }
@@ -69,13 +72,13 @@ var products = pgTable(
69
72
  {
70
73
  id: uuid("id").primaryKey().defaultRandom(),
71
74
  slug: text("slug").notNull().unique(),
72
- name: text("name").notNull(),
75
+ name: text("name").notNull().default(""),
73
76
  description: text("description").notNull().default(""),
74
77
  materials: text("materials"),
75
78
  dimensions: text("dimensions"),
76
- priceCents: integer("price_cents").notNull(),
79
+ priceCents: integer("price_cents"),
77
80
  currency: text("currency").notNull(),
78
- category: text("category").notNull(),
81
+ category: text("category"),
79
82
  quantity: integer("quantity").notNull().default(1),
80
83
  status: productStatus("status").notNull().default("draft"),
81
84
  featured: boolean("featured").notNull().default(false),
@@ -335,10 +338,13 @@ import { Readable } from "stream";
335
338
  import {
336
339
  and as and2,
337
340
  asc as asc2,
341
+ desc,
338
342
  eq as eq2,
339
343
  gt,
340
344
  ilike,
341
345
  inArray,
346
+ lt,
347
+ ne,
342
348
  or,
343
349
  sql as sql2
344
350
  } from "drizzle-orm";
@@ -358,9 +364,12 @@ async function withImages(db, rows) {
358
364
  function escapeLike(value) {
359
365
  return value.replace(/[\\%_]/g, (c) => `\\${c}`);
360
366
  }
367
+ var incompleteDraft = sql2`(${products.status} = 'draft' and (${products.name} = '' or ${products.priceCents} is null or ${products.category} is null))`;
361
368
  function pageConditions(filter) {
362
369
  const parts = [];
363
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);
364
373
  if (filter.stock === "in") parts.push(gt(products.quantity, 0));
365
374
  if (filter.stock === "sold") parts.push(eq2(products.quantity, 0));
366
375
  if (filter.q) parts.push(ilike(products.name, `%${escapeLike(filter.q)}%`));
@@ -368,10 +377,10 @@ function pageConditions(filter) {
368
377
  const createdAt = sql2`${filter.after.createdAt}::timestamptz`;
369
378
  parts.push(
370
379
  or(
371
- gt(products.createdAt, createdAt),
380
+ lt(products.createdAt, createdAt),
372
381
  and2(
373
382
  eq2(products.createdAt, createdAt),
374
- gt(products.slug, filter.after.slug)
383
+ lt(products.slug, filter.after.slug)
375
384
  )
376
385
  )
377
386
  );
@@ -382,7 +391,7 @@ async function selectProductPage(db, filter) {
382
391
  const selected = await db.select({
383
392
  product: products,
384
393
  createdAtKey: sql2`${products.createdAt}::text`
385
- }).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);
386
395
  const records = await withImages(
387
396
  db,
388
397
  selected.map((s) => s.product)
@@ -426,6 +435,17 @@ async function setImagePosition(db, id, position) {
426
435
  async function setImageAlt(db, id, alt) {
427
436
  await db.update(productImages).set({ alt }).where(eq2(productImages.id, id));
428
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
+ }
429
449
 
430
450
  // src/admin/service.ts
431
451
  import {
@@ -433,6 +453,54 @@ import {
433
453
  adminProductListQuerySchema,
434
454
  adminProductPatchSchema
435
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
+ }
436
504
 
437
505
  // src/admin/slug.ts
438
506
  function slugify(name) {
@@ -482,6 +550,8 @@ async function listAdminProducts(db, rawQuery = {}) {
482
550
  const query = adminProductListQuerySchema.parse(rawQuery);
483
551
  const found = await selectProductPage(db, {
484
552
  status: query.status,
553
+ excludeArchived: query.excludeArchived === "true",
554
+ incomplete: query.incomplete === "true",
485
555
  stock: query.stock,
486
556
  q: query.q,
487
557
  after: query.cursor ? decodeProductCursor(query.cursor) : void 0,
@@ -497,47 +567,48 @@ async function listAdminProducts(db, rawQuery = {}) {
497
567
  async function getAdminProduct(db, id) {
498
568
  return toAdminProduct(await requireProduct(db, id));
499
569
  }
500
- async function createAdminProduct(db, currency, input) {
570
+ async function createAdminProduct(db, currency, input, { requirePhotoToPublish = true } = {}) {
501
571
  const data = adminProductInputSchema.parse(input);
502
- 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);
503
577
  const id = await db.transaction(async (tx) => {
504
578
  const slug = uniqueSlug(base, await slugsStartingWith(tx, base));
505
579
  return insertProduct(tx, {
506
580
  ...data,
581
+ id: newId,
507
582
  slug,
508
583
  currency: data.currency ?? currency
509
584
  });
510
585
  });
511
586
  return getAdminProduct(db, id);
512
587
  }
513
- async function patchAdminProduct(db, id, input) {
588
+ async function patchAdminProduct(db, id, input, { requirePhotoToPublish = true } = {}) {
514
589
  const { sold, ...fields } = adminProductPatchSchema.parse(input);
515
- await requireProduct(db, id);
516
- const set = sold ? { ...fields, quantity: 0 } : fields;
517
- 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
+ });
518
604
  return getAdminProduct(db, id);
519
605
  }
520
-
521
- // src/admin/types.ts
522
- var ProductImageNotFoundError = class extends Error {
523
- constructor(imageId) {
524
- super(`Image not found: ${imageId}`);
525
- this.imageId = imageId;
526
- this.name = "ProductImageNotFoundError";
527
- }
528
- imageId;
529
- code = "IMAGE_NOT_FOUND";
530
- };
531
- var InvalidImageOrderError = class extends Error {
532
- code = "INVALID_IMAGE_ORDER";
533
- constructor() {
534
- super("imageIds must list every image of the product exactly once");
535
- this.name = "InvalidImageOrderError";
536
- }
537
- };
606
+ async function getAdminProductCounts(db) {
607
+ return selectProductCounts(db);
608
+ }
538
609
 
539
610
  // src/admin/upload.ts
540
- import { randomUUID } from "crypto";
611
+ import { randomUUID as randomUUID2 } from "crypto";
541
612
  import { fileTypeFromBuffer } from "file-type";
542
613
  var MAX_IMAGE_BYTES = 8 * 1024 * 1024;
543
614
  var EXTENSIONS = {
@@ -562,7 +633,7 @@ async function detectImage(bytes) {
562
633
  return { mime: type.mime, ext };
563
634
  }
564
635
  function imageKey(productId, ext) {
565
- return `products/${productId}/${randomUUID()}.${ext}`;
636
+ return `products/${productId}/${randomUUID2()}.${ext}`;
566
637
  }
567
638
 
568
639
  // src/admin/images.ts
@@ -622,7 +693,7 @@ async function updateProductImageAlt(db, productId, imageId, alt) {
622
693
  function isZodError(err) {
623
694
  return err instanceof Error && err.name === "ZodError";
624
695
  }
625
- var adminPlugin = async (app, { db, currency, authorize, storage }) => {
696
+ var adminPlugin = async (app, { db, currency, authorize, storage, requirePhotoToPublish }) => {
626
697
  const { default: multipart } = await import("@fastify/multipart");
627
698
  await app.register(multipart, {
628
699
  limits: { fileSize: MAX_IMAGE_BYTES, files: 1 }
@@ -634,6 +705,13 @@ var adminPlugin = async (app, { db, currency, authorize, storage }) => {
634
705
  if (err instanceof ProductNotFoundError || err instanceof ProductImageNotFoundError) {
635
706
  return reply.code(404).send({ error: err.code, message: err.message });
636
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
+ }
637
715
  if (err instanceof InvalidCursorError || err instanceof InvalidImageError || err instanceof InvalidImageOrderError) {
638
716
  return reply.code(400).send({ error: err.code, message: err.message });
639
717
  }
@@ -653,9 +731,12 @@ var adminPlugin = async (app, { db, currency, authorize, storage }) => {
653
731
  await listAdminProducts(db, req.query)
654
732
  )
655
733
  );
734
+ app.get("/products/counts", async () => getAdminProductCounts(db));
656
735
  app.post("/products", async (req, reply) => {
657
736
  const input = adminProductInputSchema2.parse(req.body);
658
- const created = await createAdminProduct(db, currency, input);
737
+ const created = await createAdminProduct(db, currency, input, {
738
+ requirePhotoToPublish
739
+ });
659
740
  return reply.code(201).send(created);
660
741
  });
661
742
  app.get(
@@ -667,7 +748,8 @@ var adminPlugin = async (app, { db, currency, authorize, storage }) => {
667
748
  async (req) => patchAdminProduct(
668
749
  db,
669
750
  req.params.id,
670
- adminProductPatchSchema2.parse(req.body)
751
+ adminProductPatchSchema2.parse(req.body),
752
+ { requirePhotoToPublish }
671
753
  )
672
754
  );
673
755
  app.post(
@@ -677,7 +759,7 @@ var adminPlugin = async (app, { db, currency, authorize, storage }) => {
677
759
  if (!part) throw new InvalidImageError("An image file is required");
678
760
  const bytes = await part.toBuffer();
679
761
  const fields = part.fields;
680
- const alt = typeof fields.alt?.value === "string" ? fields.alt.value : void 0;
762
+ const alt = typeof fields.alt?.value === "string" ? productImagePatchSchema.parse({ alt: fields.alt.value }).alt : void 0;
681
763
  const product = await addProductImage(db, storage, req.params.id, {
682
764
  bytes,
683
765
  alt
@@ -755,7 +837,7 @@ function addCatalogCaching(app, { cacheControl = DEFAULT_CATALOG_CACHE_CONTROL }
755
837
  }
756
838
 
757
839
  // src/inventory/repo.ts
758
- 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";
759
841
  async function lockProductQuantities(tx, productIds) {
760
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");
761
843
  return new Map(rows.map((r) => [r.id, r.quantity]));
@@ -842,7 +924,7 @@ async function releaseExpiredHolds(db) {
842
924
  const released = await db.update(inventoryHolds).set({ status: "released" }).where(
843
925
  and3(
844
926
  eq3(inventoryHolds.status, "active"),
845
- lt(inventoryHolds.expiresAt, sql3`now()`)
927
+ lt2(inventoryHolds.expiresAt, sql3`now()`)
846
928
  )
847
929
  ).returning({ reference: inventoryHolds.reference });
848
930
  return released.map((r) => r.reference);
@@ -947,7 +1029,12 @@ async function restock(tx, items) {
947
1029
  }
948
1030
 
949
1031
  // src/catalog/repo.ts
950
- 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
+ );
951
1038
  async function attachImages(db, rows, firstOnly = false) {
952
1039
  if (rows.length === 0) return [];
953
1040
  const where = inArray3(
@@ -961,7 +1048,7 @@ async function attachImages(db, rows, firstOnly = false) {
961
1048
  }));
962
1049
  }
963
1050
  async function listActiveProducts(db, filter) {
964
- const conditions2 = [eq4(products.status, "active")];
1051
+ const conditions2 = [isListed];
965
1052
  if (filter.category) conditions2.push(eq4(products.category, filter.category));
966
1053
  if (filter.featured !== void 0)
967
1054
  conditions2.push(eq4(products.featured, filter.featured));
@@ -992,22 +1079,30 @@ async function listActiveProducts(db, filter) {
992
1079
  }));
993
1080
  }
994
1081
  async function findActiveProductBySlug(db, slug) {
995
- 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);
996
1083
  return (await attachImages(db, rows))[0];
997
1084
  }
998
1085
  async function countActiveByCategory(db) {
999
1086
  const rows = await db.select({
1000
1087
  category: products.category,
1001
1088
  count: sql4`count(*)::int`
1002
- }).from(products).where(eq4(products.status, "active")).groupBy(products.category).orderBy(asc4(products.category));
1003
- 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
+ );
1004
1093
  }
1005
1094
  async function findActiveProductsByIds(db, ids) {
1006
1095
  if (ids.length === 0) return [];
1007
- 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));
1008
1097
  }
1009
1098
 
1010
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
+ }
1011
1106
  function availabilityOf(quantity, free) {
1012
1107
  if (free > 0) return "available";
1013
1108
  return quantity > 0 ? "reserved" : "sold";
@@ -1021,9 +1116,8 @@ function toProduct({ row, images }, free) {
1021
1116
  description: row.description,
1022
1117
  ...row.materials ? { materials: row.materials } : {},
1023
1118
  ...row.dimensions ? { dimensions: row.dimensions } : {},
1024
- priceCents: row.priceCents,
1119
+ ...completeFields(row),
1025
1120
  currency: row.currency,
1026
- category: row.category,
1027
1121
  images: images.map((i) => ({ url: i.url, alt: i.alt })),
1028
1122
  quantity: freeQty,
1029
1123
  availability: availabilityOf(row.quantity, freeQty),
@@ -1076,7 +1170,7 @@ async function findPurchasable(db, ids) {
1076
1170
  return rows.map((row) => ({
1077
1171
  id: row.id,
1078
1172
  name: row.name,
1079
- priceCents: row.priceCents,
1173
+ priceCents: completeFields(row).priceCents,
1080
1174
  currency: row.currency
1081
1175
  }));
1082
1176
  }
@@ -1124,7 +1218,7 @@ import {
1124
1218
  } from "@develemit/commerce-contracts";
1125
1219
 
1126
1220
  // src/orders/admin-repo.ts
1127
- 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";
1128
1222
  function escapeLike2(value) {
1129
1223
  return value.replace(/[\\%_]/g, (c) => `\\${c}`);
1130
1224
  }
@@ -1146,11 +1240,12 @@ function conditions(filter) {
1146
1240
  );
1147
1241
  }
1148
1242
  if (filter.after) {
1149
- const { createdAt, id } = filter.after;
1243
+ const { id } = filter.after;
1244
+ const createdAt = sql5`${filter.after.createdAt}::timestamptz`;
1150
1245
  parts.push(
1151
1246
  or3(
1152
- lt2(orders.createdAt, createdAt),
1153
- and5(eq5(orders.createdAt, createdAt), lt2(orders.id, id))
1247
+ lt3(orders.createdAt, createdAt),
1248
+ and5(eq5(orders.createdAt, createdAt), lt3(orders.id, id))
1154
1249
  )
1155
1250
  );
1156
1251
  }
@@ -1168,8 +1263,9 @@ async function selectOrderPage(db, filter) {
1168
1263
  totalCents: sql5`${orders.subtotalCents} - ${orders.discountCents} + ${orders.shippingCents}`,
1169
1264
  currency: orders.currency,
1170
1265
  itemCount,
1171
- createdAt: orders.createdAt
1172
- }).from(orders).where(conditions(filter)).orderBy(desc(orders.createdAt), desc(orders.id)).limit(filter.limit);
1266
+ createdAt: orders.createdAt,
1267
+ createdAtKey: sql5`${orders.createdAt}::text`
1268
+ }).from(orders).where(conditions(filter)).orderBy(desc2(orders.createdAt), desc2(orders.id)).limit(filter.limit);
1173
1269
  }
1174
1270
  async function findOrderRefunds(db, orderId) {
1175
1271
  return db.select().from(orderRefunds).where(eq5(orderRefunds.orderId, orderId)).orderBy(orderRefunds.createdAt, orderRefunds.id);
@@ -1179,10 +1275,10 @@ async function findOrderRefunds(db, orderId) {
1179
1275
  import { and as and7, asc as asc5, eq as eq7, sql as sql7 } from "drizzle-orm";
1180
1276
 
1181
1277
  // src/orders/refund-repo.ts
1182
- 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";
1183
1279
  var isRecordedRefund = or4(
1184
1280
  isNull(orderRefunds.idempotencyKey),
1185
- isNotNull(orderRefunds.gatewayRefundId)
1281
+ isNotNull2(orderRefunds.gatewayRefundId)
1186
1282
  );
1187
1283
  async function lockOrderForRefund(tx, id) {
1188
1284
  const [row] = await tx.select({
@@ -1385,9 +1481,9 @@ function isOrderId(value) {
1385
1481
  function decodeOrderCursor(raw) {
1386
1482
  const { at, key } = decodeCursor(
1387
1483
  raw,
1388
- (c) => isOrderId(c.key) && !Number.isNaN(+new Date(c.at))
1484
+ (c) => isOrderId(c.key) && isCursorTimestamp(c.at)
1389
1485
  );
1390
- return { createdAt: new Date(at), id: key };
1486
+ return { createdAt: at, id: key };
1391
1487
  }
1392
1488
  async function listAdminOrders(db, query) {
1393
1489
  const rows = await selectOrderPage(db, {
@@ -1400,8 +1496,16 @@ async function listAdminOrders(db, query) {
1400
1496
  const page = rows.slice(0, query.limit);
1401
1497
  const last = page[page.length - 1];
1402
1498
  return {
1403
- items: page.map((r) => ({ ...r, createdAt: r.createdAt.toISOString() })),
1404
- nextCursor: rows.length > query.limit && last ? encodeCursor({ at: last.createdAt.toISOString(), key: last.id }) : null
1499
+ items: page.map((r) => ({
1500
+ id: r.id,
1501
+ status: r.status,
1502
+ email: r.email,
1503
+ totalCents: r.totalCents,
1504
+ currency: r.currency,
1505
+ itemCount: r.itemCount,
1506
+ createdAt: r.createdAt.toISOString()
1507
+ })),
1508
+ nextCursor: rows.length > query.limit && last ? encodeCursor({ at: last.createdAtKey, key: last.id }) : null
1405
1509
  };
1406
1510
  }
1407
1511
  async function getAdminOrder(db, id) {
@@ -1507,14 +1611,14 @@ function evaluateDiscount(discount, ctx) {
1507
1611
  // src/discounts/repo.ts
1508
1612
  import {
1509
1613
  and as and8,
1510
- desc as desc2,
1614
+ desc as desc3,
1511
1615
  eq as eq8,
1512
1616
  getTableColumns,
1513
1617
  gt as gt4,
1514
1618
  ilike as ilike3,
1515
1619
  inArray as inArray4,
1516
1620
  isNull as isNull2,
1517
- lt as lt3,
1621
+ lt as lt4,
1518
1622
  lte,
1519
1623
  or as or5,
1520
1624
  sql as sql8
@@ -1565,11 +1669,12 @@ function listConditions(filter) {
1565
1669
  if (filter.status) parts.push(statusCondition(filter.status, filter.now));
1566
1670
  if (filter.q) parts.push(ilike3(discounts.code, `${escapeLike3(filter.q)}%`));
1567
1671
  if (filter.after) {
1568
- const { createdAt, id } = filter.after;
1672
+ const { id } = filter.after;
1673
+ const createdAt = sql8`${filter.after.createdAt}::timestamptz`;
1569
1674
  parts.push(
1570
1675
  or5(
1571
- lt3(discounts.createdAt, createdAt),
1572
- and8(eq8(discounts.createdAt, createdAt), lt3(discounts.id, id))
1676
+ lt4(discounts.createdAt, createdAt),
1677
+ and8(eq8(discounts.createdAt, createdAt), lt4(discounts.id, id))
1573
1678
  )
1574
1679
  );
1575
1680
  }
@@ -1589,7 +1694,10 @@ var withCounts = {
1589
1694
  totalRedemptions: redemptionCount()
1590
1695
  };
1591
1696
  async function selectDiscountPage(db, filter) {
1592
- return db.select(withCounts).from(discounts).where(listConditions(filter)).orderBy(desc2(discounts.createdAt), desc2(discounts.id)).limit(filter.limit);
1697
+ return db.select({
1698
+ ...withCounts,
1699
+ createdAtKey: sql8`${discounts.createdAt}::text`
1700
+ }).from(discounts).where(listConditions(filter)).orderBy(desc3(discounts.createdAt), desc3(discounts.id)).limit(filter.limit);
1593
1701
  }
1594
1702
  async function findDiscountById(db, id, options = {}) {
1595
1703
  const query = db.select(withCounts).from(discounts).where(eq8(discounts.id, id));
@@ -2178,9 +2286,9 @@ function toDiscount(row) {
2178
2286
  function decodeDiscountCursor(raw) {
2179
2287
  const { at, key } = decodeCursor(
2180
2288
  raw,
2181
- (c) => isDiscountId(c.key) && !Number.isNaN(+new Date(c.at))
2289
+ (c) => isDiscountId(c.key) && isCursorTimestamp(c.at)
2182
2290
  );
2183
- return { createdAt: new Date(at), id: key };
2291
+ return { createdAt: at, id: key };
2184
2292
  }
2185
2293
  async function listAdminDiscounts(db, query, now = /* @__PURE__ */ new Date()) {
2186
2294
  const rows = await selectDiscountPage(db, {
@@ -2194,7 +2302,7 @@ async function listAdminDiscounts(db, query, now = /* @__PURE__ */ new Date()) {
2194
2302
  const last = page[page.length - 1];
2195
2303
  return {
2196
2304
  items: page.map(toDiscount),
2197
- nextCursor: rows.length > query.limit && last ? encodeCursor({ at: last.createdAt.toISOString(), key: last.id }) : null
2305
+ nextCursor: rows.length > query.limit && last ? encodeCursor({ at: last.createdAtKey, key: last.id }) : null
2198
2306
  };
2199
2307
  }
2200
2308
  async function getAdminDiscount(db, id) {
@@ -2219,9 +2327,15 @@ async function createDiscount(db, input) {
2219
2327
  if (!id) throw new DiscountCodeTakenError(data.code);
2220
2328
  return getAdminDiscount(db, id);
2221
2329
  }
2330
+ function sameValue(a, b) {
2331
+ if (typeof a === "string" && typeof b === "string") {
2332
+ return a.toUpperCase() === b.toUpperCase();
2333
+ }
2334
+ return a === b;
2335
+ }
2222
2336
  function changedLockedFields(row, patch) {
2223
2337
  return LOCKED_AFTER_USE.filter(
2224
- (field) => patch[field] !== void 0 && patch[field] !== row[field]
2338
+ (field) => patch[field] !== void 0 && !sameValue(patch[field], row[field])
2225
2339
  );
2226
2340
  }
2227
2341
  function assertCoherent(row, patch) {
@@ -2333,6 +2447,56 @@ var adminDiscountsPlugin = async (app, { db, authorize }) => {
2333
2447
  );
2334
2448
  };
2335
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
+
2475
+ // src/orders/dev-checkout.ts
2476
+ function assertDevCheckoutAllowed() {
2477
+ if (process.env.NODE_ENV === "production") {
2478
+ throw new Error("devCheckout is not allowed when NODE_ENV=production");
2479
+ }
2480
+ }
2481
+ var devCheckoutRoutes = async (app, { db, payment, urls, hooks, buyer }) => {
2482
+ assertDevCheckoutAllowed();
2483
+ app.get("/checkout/dev/:sessionId/complete", async (req, reply) => {
2484
+ const { sessionId } = req.params;
2485
+ const session = payment.sessions.find((s) => s.id === sessionId);
2486
+ if (!session) {
2487
+ return reply.code(404).send({ error: "SESSION_NOT_FOUND" });
2488
+ }
2489
+ const expire = req.query.outcome === "expired";
2490
+ const { rawBody, signature } = expire ? payment.expired(sessionId) : payment.completed(
2491
+ sessionId,
2492
+ resolveDevCompletion(buyer, sessionId, session.shippingRates[0])
2493
+ );
2494
+ const event = await payment.verifyWebhook(rawBody, signature);
2495
+ await handlePaymentEvent(db, event, hooks, req.log);
2496
+ return reply.redirect(expire ? urls.cancel : urls.success(sessionId), 302);
2497
+ });
2498
+ };
2499
+
2336
2500
  // src/orders/routes.ts
2337
2501
  import {
2338
2502
  checkoutConflictSchema,
@@ -2385,7 +2549,7 @@ async function previewDiscount(db, request, currency, now = /* @__PURE__ */ new
2385
2549
  }
2386
2550
 
2387
2551
  // src/orders/checkout-service.ts
2388
- import { randomUUID as randomUUID2 } from "crypto";
2552
+ import { randomUUID as randomUUID3 } from "crypto";
2389
2553
  var HOLD_DURATION_MS = 31 * 60 * 1e3;
2390
2554
  var UUID4 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2391
2555
  function mergeItems(items) {
@@ -2460,7 +2624,7 @@ async function createCheckout(db, settings, request) {
2460
2624
  settings.currency
2461
2625
  );
2462
2626
  const shippingRates2 = await listActiveRates(db, settings.currency);
2463
- const orderId = randomUUID2();
2627
+ const orderId = randomUUID3();
2464
2628
  const expiresAt = new Date(Date.now() + HOLD_DURATION_MS);
2465
2629
  const discount = await reserveOrder(db, {
2466
2630
  orderId,
@@ -2597,6 +2761,119 @@ var ordersPlugin = async (app, { db, ...settings }) => {
2597
2761
  });
2598
2762
  };
2599
2763
 
2764
+ // src/payment/fake-gateway.ts
2765
+ import { createHmac, randomUUID as randomUUID4, timingSafeEqual } from "crypto";
2766
+ function createFakeGateway({
2767
+ secret = "fake-webhook-secret"
2768
+ } = {}) {
2769
+ const sessions = [];
2770
+ const refunds = [];
2771
+ const refundCalls = [];
2772
+ let pendingFailure;
2773
+ let sessionUrl;
2774
+ let pendingRefundFailure;
2775
+ const hmac = (body) => createHmac("sha256", secret).update(body).digest("hex");
2776
+ function sign(event) {
2777
+ const rawBody = Buffer.from(JSON.stringify(event));
2778
+ return { rawBody, signature: hmac(rawBody) };
2779
+ }
2780
+ return {
2781
+ kind: "fake",
2782
+ useSessionUrl(build) {
2783
+ sessionUrl = build;
2784
+ },
2785
+ sessions,
2786
+ refunds,
2787
+ refundCalls,
2788
+ async createCheckoutSession(input) {
2789
+ if (pendingFailure) {
2790
+ const err = pendingFailure;
2791
+ pendingFailure = void 0;
2792
+ throw err;
2793
+ }
2794
+ const id = `fake_cs_${randomUUID4()}`;
2795
+ sessions.push({ ...input, id });
2796
+ return {
2797
+ id,
2798
+ url: sessionUrl?.(id) ?? input.successUrl.replace(SESSION_ID_PLACEHOLDER, id)
2799
+ };
2800
+ },
2801
+ async verifyWebhook(rawBody, signature) {
2802
+ const expected = Buffer.from(hmac(rawBody));
2803
+ const given = Buffer.from(signature ?? "");
2804
+ if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
2805
+ throw new WebhookSignatureError();
2806
+ }
2807
+ return JSON.parse(rawBody.toString("utf8"));
2808
+ },
2809
+ async refund(input) {
2810
+ refundCalls.push(input);
2811
+ if (pendingRefundFailure) {
2812
+ const err = pendingRefundFailure;
2813
+ pendingRefundFailure = void 0;
2814
+ throw err;
2815
+ }
2816
+ const prior = refunds.find(
2817
+ (r) => r.idempotencyKey === input.idempotencyKey
2818
+ );
2819
+ if (prior) return { gatewayRefundId: prior.gatewayRefundId };
2820
+ const gatewayRefundId = `fake_re_${refunds.length + 1}_${input.sessionId}`;
2821
+ refunds.push({ ...input, gatewayRefundId });
2822
+ return { gatewayRefundId };
2823
+ },
2824
+ failNextSession(error = new Error("Fake gateway failure")) {
2825
+ pendingFailure = error;
2826
+ },
2827
+ failNextRefund(error = new RefundRejectedError("Fake refund failure")) {
2828
+ pendingRefundFailure = error;
2829
+ },
2830
+ failNextRefundUncertain(error = new Error("Fake refund timeout")) {
2831
+ pendingRefundFailure = error;
2832
+ },
2833
+ completed(sessionId, details = {}) {
2834
+ return sign({
2835
+ id: `fake_evt_${randomUUID4()}`,
2836
+ type: "checkout.completed",
2837
+ sessionId,
2838
+ ...details
2839
+ });
2840
+ },
2841
+ asyncPaid(sessionId) {
2842
+ return sign({
2843
+ id: `fake_evt_${randomUUID4()}`,
2844
+ type: "checkout.async_paid",
2845
+ sessionId
2846
+ });
2847
+ },
2848
+ asyncFailed(sessionId) {
2849
+ return sign({
2850
+ id: `fake_evt_${randomUUID4()}`,
2851
+ type: "checkout.async_failed",
2852
+ sessionId
2853
+ });
2854
+ },
2855
+ expired(sessionId) {
2856
+ return sign({
2857
+ id: `fake_evt_${randomUUID4()}`,
2858
+ type: "checkout.expired",
2859
+ sessionId
2860
+ });
2861
+ },
2862
+ refunded(sessionId, refunds2) {
2863
+ return sign({
2864
+ id: `fake_evt_${randomUUID4()}`,
2865
+ type: "payment.refunded",
2866
+ sessionId,
2867
+ refunds: refunds2
2868
+ });
2869
+ },
2870
+ sign
2871
+ };
2872
+ }
2873
+ function isFakeGateway(gateway) {
2874
+ return gateway.kind === "fake";
2875
+ }
2876
+
2600
2877
  // src/plugin.ts
2601
2878
  var commercePlugin = async (app, {
2602
2879
  db,
@@ -2607,7 +2884,8 @@ var commercePlugin = async (app, {
2607
2884
  hooks,
2608
2885
  admin,
2609
2886
  categoryLabels,
2610
- catalogCache
2887
+ catalogCache,
2888
+ devCheckout
2611
2889
  }) => {
2612
2890
  app.get("/health", async () => ({ status: "ok" }));
2613
2891
  await app.register(catalogPlugin, {
@@ -2625,6 +2903,7 @@ var commercePlugin = async (app, {
2625
2903
  });
2626
2904
  }
2627
2905
  if (!payment || !urls) return;
2906
+ await payment.preflight?.();
2628
2907
  await app.register(ordersPlugin, {
2629
2908
  db,
2630
2909
  currency,
@@ -2633,6 +2912,23 @@ var commercePlugin = async (app, {
2633
2912
  shippingCountries,
2634
2913
  hooks
2635
2914
  });
2915
+ if (devCheckout) {
2916
+ assertDevCheckoutAllowed();
2917
+ if (isFakeGateway(payment)) {
2918
+ const apiUrl = typeof devCheckout === "object" ? devCheckout.apiUrl : "";
2919
+ payment.useSessionUrl(
2920
+ (id) => `${apiUrl ?? ""}${app.prefix}/checkout/dev/${id}/complete`
2921
+ );
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
+ });
2930
+ }
2931
+ }
2636
2932
  if (admin) {
2637
2933
  await app.register(adminOrdersPlugin, {
2638
2934
  db,
@@ -2649,6 +2945,19 @@ var commercePlugin = async (app, {
2649
2945
  }
2650
2946
  };
2651
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
+
2652
2961
  // src/payment/stripe-gateway.ts
2653
2962
  var REFUND_KEY_METADATA = "commerceRefundKey";
2654
2963
  function rejectedRefund(err) {
@@ -2765,19 +3074,16 @@ async function refundedEvent(stripe, event) {
2765
3074
  refunds: refunds.filter((refund) => refund.status !== "failed").map(toRefundEvent)
2766
3075
  };
2767
3076
  }
2768
- function createCoupon(stripe, { discount, currency }) {
3077
+ function createCoupon(stripe, { discount, currency, expiresAt }) {
2769
3078
  return stripe.coupons.create({
2770
3079
  amount_off: discount.amountCents,
2771
3080
  currency: currency.toLowerCase(),
2772
3081
  duration: "once",
2773
3082
  max_redemptions: 1,
3083
+ redeem_by: Math.floor(expiresAt.getTime() / 1e3),
2774
3084
  name: discount.code
2775
3085
  });
2776
3086
  }
2777
- async function loadStripe(secretKey) {
2778
- const { default: StripeClient } = await import("stripe");
2779
- return new StripeClient(secretKey);
2780
- }
2781
3087
  function createStripeGateway({
2782
3088
  secretKey,
2783
3089
  webhookSecret,
@@ -2786,6 +3092,9 @@ function createStripeGateway({
2786
3092
  let stripe = client ? Promise.resolve(client) : void 0;
2787
3093
  const getStripe = () => stripe ??= loadStripe(secretKey);
2788
3094
  return {
3095
+ async preflight() {
3096
+ await getStripe();
3097
+ },
2789
3098
  async createCheckoutSession(input) {
2790
3099
  const stripeClient = await getStripe();
2791
3100
  const coupon = input.discount && await createCoupon(stripeClient, input);
@@ -2868,111 +3177,6 @@ function createStripeGateway({
2868
3177
  };
2869
3178
  }
2870
3179
 
2871
- // src/payment/fake-gateway.ts
2872
- import { createHmac, randomUUID as randomUUID3, timingSafeEqual } from "crypto";
2873
- function createFakeGateway({
2874
- secret = "fake-webhook-secret"
2875
- } = {}) {
2876
- const sessions = [];
2877
- const refunds = [];
2878
- const refundCalls = [];
2879
- let pendingFailure;
2880
- let pendingRefundFailure;
2881
- const hmac = (body) => createHmac("sha256", secret).update(body).digest("hex");
2882
- function sign(event) {
2883
- const rawBody = Buffer.from(JSON.stringify(event));
2884
- return { rawBody, signature: hmac(rawBody) };
2885
- }
2886
- return {
2887
- sessions,
2888
- refunds,
2889
- refundCalls,
2890
- async createCheckoutSession(input) {
2891
- if (pendingFailure) {
2892
- const err = pendingFailure;
2893
- pendingFailure = void 0;
2894
- throw err;
2895
- }
2896
- const id = `fake_cs_${randomUUID3()}`;
2897
- sessions.push({ ...input, id });
2898
- return {
2899
- id,
2900
- url: input.successUrl.replace(SESSION_ID_PLACEHOLDER, id)
2901
- };
2902
- },
2903
- async verifyWebhook(rawBody, signature) {
2904
- const expected = Buffer.from(hmac(rawBody));
2905
- const given = Buffer.from(signature ?? "");
2906
- if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
2907
- throw new WebhookSignatureError();
2908
- }
2909
- return JSON.parse(rawBody.toString("utf8"));
2910
- },
2911
- async refund(input) {
2912
- refundCalls.push(input);
2913
- if (pendingRefundFailure) {
2914
- const err = pendingRefundFailure;
2915
- pendingRefundFailure = void 0;
2916
- throw err;
2917
- }
2918
- const prior = refunds.find(
2919
- (r) => r.idempotencyKey === input.idempotencyKey
2920
- );
2921
- if (prior) return { gatewayRefundId: prior.gatewayRefundId };
2922
- const gatewayRefundId = `fake_re_${refunds.length + 1}_${input.sessionId}`;
2923
- refunds.push({ ...input, gatewayRefundId });
2924
- return { gatewayRefundId };
2925
- },
2926
- failNextSession(error = new Error("Fake gateway failure")) {
2927
- pendingFailure = error;
2928
- },
2929
- failNextRefund(error = new RefundRejectedError("Fake refund failure")) {
2930
- pendingRefundFailure = error;
2931
- },
2932
- failNextRefundUncertain(error = new Error("Fake refund timeout")) {
2933
- pendingRefundFailure = error;
2934
- },
2935
- completed(sessionId, details = {}) {
2936
- return sign({
2937
- id: `fake_evt_${randomUUID3()}`,
2938
- type: "checkout.completed",
2939
- sessionId,
2940
- ...details
2941
- });
2942
- },
2943
- asyncPaid(sessionId) {
2944
- return sign({
2945
- id: `fake_evt_${randomUUID3()}`,
2946
- type: "checkout.async_paid",
2947
- sessionId
2948
- });
2949
- },
2950
- asyncFailed(sessionId) {
2951
- return sign({
2952
- id: `fake_evt_${randomUUID3()}`,
2953
- type: "checkout.async_failed",
2954
- sessionId
2955
- });
2956
- },
2957
- expired(sessionId) {
2958
- return sign({
2959
- id: `fake_evt_${randomUUID3()}`,
2960
- type: "checkout.expired",
2961
- sessionId
2962
- });
2963
- },
2964
- refunded(sessionId, refunds2) {
2965
- return sign({
2966
- id: `fake_evt_${randomUUID3()}`,
2967
- type: "payment.refunded",
2968
- sessionId,
2969
- refunds: refunds2
2970
- });
2971
- },
2972
- sign
2973
- };
2974
- }
2975
-
2976
3180
  // src/hold-sweeper.ts
2977
3181
  async function sweepExpiredHolds(db) {
2978
3182
  await db.transaction(async (tx) => {
@@ -2987,6 +3191,89 @@ function startHoldSweeper(db, { intervalMs = 6e4, onError } = {}) {
2987
3191
  return { stop: () => clearInterval(timer) };
2988
3192
  }
2989
3193
 
3194
+ // src/emails/order-lines.ts
3195
+ var defaultLabels = {
3196
+ subtotal: "Subtotal",
3197
+ discount: (code) => code ? `Discount (${code})` : "Discount",
3198
+ shipping: (label) => label ? `Shipping (${label})` : "Shipping",
3199
+ total: "Total"
3200
+ };
3201
+ var escapes = {
3202
+ "&": "&amp;",
3203
+ "<": "&lt;",
3204
+ ">": "&gt;",
3205
+ '"': "&quot;",
3206
+ "'": "&#39;"
3207
+ };
3208
+ function escapeHtml(value) {
3209
+ return value.replace(/[&<>"']/g, (c) => escapes[c]);
3210
+ }
3211
+ function addressLines(order) {
3212
+ const a = order.shippingAddress;
3213
+ if (!a) return void 0;
3214
+ const lines = [
3215
+ a.name,
3216
+ a.line1,
3217
+ a.line2,
3218
+ [a.city, a.state, a.postalCode].filter(Boolean).join(" "),
3219
+ a.country
3220
+ ].filter((l) => Boolean(l));
3221
+ return lines.length ? lines : void 0;
3222
+ }
3223
+ function textBlock(lines) {
3224
+ return lines.map((l) => `${l.label} ${l.amount}`).join("\n");
3225
+ }
3226
+ function htmlRows(lines) {
3227
+ return lines.map(
3228
+ (l) => `<tr><td>${escapeHtml(l.label)}</td><td align="right">${escapeHtml(l.amount)}</td></tr>`
3229
+ ).join("");
3230
+ }
3231
+ function orderEmailLines(order, options = {}) {
3232
+ const locale = options.locale ?? "en-US";
3233
+ const labels = { ...defaultLabels, ...options.labels };
3234
+ const money = (cents) => new Intl.NumberFormat(locale, {
3235
+ style: "currency",
3236
+ currency: order.currency
3237
+ }).format(cents / 100);
3238
+ const items = order.items.map((i) => ({
3239
+ label: `${i.name} \xD7 ${i.quantity}`,
3240
+ amount: money(i.priceCents * i.quantity)
3241
+ }));
3242
+ const subtotal = {
3243
+ label: labels.subtotal,
3244
+ amount: money(order.subtotalCents)
3245
+ };
3246
+ const discount = order.discountCents > 0 ? {
3247
+ label: labels.discount(order.discountCode),
3248
+ amount: `-${money(order.discountCents)}`
3249
+ } : void 0;
3250
+ const shipping = {
3251
+ label: labels.shipping(order.shippingLabel),
3252
+ amount: money(order.shippingCents)
3253
+ };
3254
+ const total = { label: labels.total, amount: money(order.totalCents) };
3255
+ const address = addressLines(order);
3256
+ const totals = [subtotal, ...discount ? [discount] : [], shipping, total];
3257
+ return {
3258
+ items,
3259
+ subtotal,
3260
+ discount,
3261
+ shipping,
3262
+ total,
3263
+ address,
3264
+ text: {
3265
+ items: textBlock(items),
3266
+ totals: textBlock(totals),
3267
+ address: address?.join("\n") ?? ""
3268
+ },
3269
+ html: {
3270
+ items: htmlRows(items),
3271
+ totals: htmlRows(totals),
3272
+ address: address?.map((l) => `<p>${escapeHtml(l)}</p>`).join("") ?? ""
3273
+ }
3274
+ };
3275
+ }
3276
+
2990
3277
  // src/db/migrate.ts
2991
3278
  import path from "path";
2992
3279
  import { fileURLToPath } from "url";
@@ -3064,17 +3351,136 @@ function createMemoryStorage(baseUrl = "memory://") {
3064
3351
  }
3065
3352
  };
3066
3353
  }
3354
+
3355
+ // src/storage/disk.ts
3356
+ import { createReadStream, createWriteStream } from "fs";
3357
+ import { mkdir, realpath, rm, stat } from "fs/promises";
3358
+ import path2 from "path";
3359
+ import { pipeline } from "stream/promises";
3360
+ var contentTypes = {
3361
+ ".jpg": "image/jpeg",
3362
+ ".jpeg": "image/jpeg",
3363
+ ".png": "image/png",
3364
+ ".webp": "image/webp",
3365
+ ".gif": "image/gif",
3366
+ ".avif": "image/avif"
3367
+ };
3368
+ function hasTraversalSegment(key) {
3369
+ return key.split(/[\\/]/).some((segment) => segment === "..");
3370
+ }
3371
+ function decodesToTraversal(key) {
3372
+ try {
3373
+ return hasTraversalSegment(decodeURIComponent(key));
3374
+ } catch {
3375
+ return true;
3376
+ }
3377
+ }
3378
+ function isInside(root, target) {
3379
+ return target.startsWith(root + path2.sep);
3380
+ }
3381
+ function resolveKey(dir, key) {
3382
+ if (!key || key.includes("\0") || path2.isAbsolute(key)) return null;
3383
+ if (hasTraversalSegment(key) || decodesToTraversal(key)) return null;
3384
+ const root = path2.resolve(dir);
3385
+ const target = path2.resolve(root, key);
3386
+ return isInside(root, target) ? target : null;
3387
+ }
3388
+ async function realInside(dir, target, allowRoot = false) {
3389
+ const [root, real] = await Promise.all([
3390
+ realpath(dir),
3391
+ realpath(target)
3392
+ ]).catch(() => [null, null]);
3393
+ if (!root || !real) return null;
3394
+ return isInside(root, real) || allowRoot && real === root ? real : null;
3395
+ }
3396
+ function createDiskStorage(options) {
3397
+ const { dir, publicUrl } = options;
3398
+ const base = publicUrl.replace(/\/+$/, "");
3399
+ return {
3400
+ async put(key, stream) {
3401
+ const target = resolveKey(dir, key);
3402
+ if (!target)
3403
+ throw new Error(`Invalid storage key: ${JSON.stringify(key)}`);
3404
+ await mkdir(path2.dirname(target), { recursive: true });
3405
+ if (!await realInside(dir, path2.dirname(target), true).catch(() => null)) {
3406
+ throw new Error(`Invalid storage key: ${JSON.stringify(key)}`);
3407
+ }
3408
+ await pipeline(stream, createWriteStream(target));
3409
+ return { key, url: `${base}/${key}` };
3410
+ },
3411
+ async delete(key) {
3412
+ const target = resolveKey(dir, key);
3413
+ if (!target)
3414
+ throw new Error(`Invalid storage key: ${JSON.stringify(key)}`);
3415
+ if (!await realInside(dir, path2.dirname(target), true).catch(() => null))
3416
+ return;
3417
+ await rm(target, { force: true });
3418
+ }
3419
+ };
3420
+ }
3421
+ async function openStoredFile(dir, key) {
3422
+ const target = resolveKey(dir, key);
3423
+ if (!target) return null;
3424
+ const real = await realInside(dir, target);
3425
+ if (!real) return null;
3426
+ const info = await stat(real).catch(() => null);
3427
+ if (!info?.isFile()) return null;
3428
+ const contentType = contentTypes[path2.extname(real).toLowerCase()];
3429
+ return { stream: createReadStream(real), contentType, size: info.size };
3430
+ }
3431
+
3432
+ // src/payment/resolve.ts
3433
+ function resolvePayment(env = process.env) {
3434
+ const secretKey = env.STRIPE_SECRET_KEY || void 0;
3435
+ const webhookSecret = env.STRIPE_WEBHOOK_SECRET || void 0;
3436
+ if (!secretKey !== !webhookSecret) {
3437
+ throw new Error(
3438
+ "Set STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET together, or neither"
3439
+ );
3440
+ }
3441
+ if (env.PAYMENT_GATEWAY === "stripe" && !secretKey) {
3442
+ throw new Error("PAYMENT_GATEWAY=stripe needs the Stripe keys");
3443
+ }
3444
+ if (env.PAYMENT_GATEWAY === "fake" || !secretKey) {
3445
+ if (env.NODE_ENV === "production") {
3446
+ throw new Error(
3447
+ "The fake payment gateway is not allowed in production; set the Stripe keys"
3448
+ );
3449
+ }
3450
+ return createFakeGateway();
3451
+ }
3452
+ return createStripeGateway({ secretKey, webhookSecret });
3453
+ }
3454
+ function checkoutUrls(webUrl, {
3455
+ successPath = "/checkout/success",
3456
+ cancelPath = "/shop"
3457
+ } = {}) {
3458
+ const base = webUrl.replace(/\/+$/, "");
3459
+ return {
3460
+ success: (sessionId) => {
3461
+ const id = sessionId === SESSION_ID_PLACEHOLDER ? sessionId : encodeURIComponent(sessionId);
3462
+ return `${base}${successPath}?session_id=${id}`;
3463
+ },
3464
+ cancel: `${base}${cancelPath}`
3465
+ };
3466
+ }
3067
3467
  export {
3068
3468
  ProductNotFoundError,
3069
3469
  SESSION_ID_PLACEHOLDER,
3070
3470
  WebhookSignatureError,
3071
3471
  catalogPlugin,
3472
+ checkoutUrls,
3072
3473
  commercePlugin,
3073
3474
  commerceSchema,
3475
+ createDiskStorage,
3074
3476
  createFakeGateway,
3075
3477
  createMemoryStorage,
3076
3478
  createStripeGateway,
3479
+ isFakeGateway,
3480
+ openStoredFile,
3481
+ orderEmailLines,
3077
3482
  ordersPlugin,
3483
+ resolvePayment,
3078
3484
  runCommerceMigrations,
3079
3485
  seedProducts,
3080
3486
  seedShippingRates,