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