@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/CHANGELOG.md +112 -0
- package/README.md +60 -2
- package/dist/index.d.ts +84 -5
- package/dist/index.js +588 -182
- package/dist/index.js.map +1 -1
- package/docs/admin.md +44 -14
- package/docs/payments.md +31 -1
- 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
|
@@ -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")
|
|
79
|
+
priceCents: integer("price_cents"),
|
|
77
80
|
currency: text("currency").notNull(),
|
|
78
|
-
category: text("category")
|
|
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
|
-
|
|
380
|
+
lt(products.createdAt, createdAt),
|
|
372
381
|
and2(
|
|
373
382
|
eq2(products.createdAt, createdAt),
|
|
374
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
517
|
-
|
|
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
|
-
|
|
522
|
-
|
|
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}/${
|
|
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
|
-
|
|
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 = [
|
|
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),
|
|
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(
|
|
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),
|
|
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
|
-
|
|
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
|
|
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 {
|
|
1243
|
+
const { id } = filter.after;
|
|
1244
|
+
const createdAt = sql5`${filter.after.createdAt}::timestamptz`;
|
|
1150
1245
|
parts.push(
|
|
1151
1246
|
or3(
|
|
1152
|
-
|
|
1153
|
-
and5(eq5(orders.createdAt, createdAt),
|
|
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
|
-
|
|
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
|
-
|
|
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) &&
|
|
1484
|
+
(c) => isOrderId(c.key) && isCursorTimestamp(c.at)
|
|
1389
1485
|
);
|
|
1390
|
-
return { createdAt:
|
|
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) => ({
|
|
1404
|
-
|
|
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
|
|
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
|
|
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 {
|
|
1672
|
+
const { id } = filter.after;
|
|
1673
|
+
const createdAt = sql8`${filter.after.createdAt}::timestamptz`;
|
|
1569
1674
|
parts.push(
|
|
1570
1675
|
or5(
|
|
1571
|
-
|
|
1572
|
-
and8(eq8(discounts.createdAt, createdAt),
|
|
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(
|
|
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) &&
|
|
2289
|
+
(c) => isDiscountId(c.key) && isCursorTimestamp(c.at)
|
|
2182
2290
|
);
|
|
2183
|
-
return { createdAt:
|
|
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.
|
|
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]
|
|
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
|
|
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 =
|
|
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
|
+
"&": "&",
|
|
3203
|
+
"<": "<",
|
|
3204
|
+
">": ">",
|
|
3205
|
+
'"': """,
|
|
3206
|
+
"'": "'"
|
|
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,
|