@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/CHANGELOG.md +112 -0
- package/README.md +33 -2
- package/dist/index.d.ts +14 -4
- package/dist/index.js +223 -79
- package/dist/index.js.map +1 -1
- package/docs/admin.md +38 -11
- package/docs/payments.md +13 -0
- package/migrations/0008_draft_products_incomplete.sql +3 -0
- package/migrations/meta/0008_snapshot.json +1029 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +11 -3
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")
|
|
79
|
+
priceCents: integer("price_cents"),
|
|
80
80
|
currency: text("currency").notNull(),
|
|
81
|
-
category: text("category")
|
|
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
|
-
|
|
380
|
+
lt(products.createdAt, createdAt),
|
|
375
381
|
and2(
|
|
376
382
|
eq2(products.createdAt, createdAt),
|
|
377
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
520
|
-
|
|
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
|
-
|
|
525
|
-
|
|
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}/${
|
|
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
|
-
|
|
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 = [
|
|
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),
|
|
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(
|
|
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),
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1157
|
-
and5(eq5(orders.createdAt, createdAt),
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1586
|
-
and8(eq8(discounts.createdAt, createdAt),
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
|
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_${
|
|
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_${
|
|
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_${
|
|
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_${
|
|
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_${
|
|
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_${
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
}
|