@develemit/commerce-server 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,2291 @@
1
+ // src/admin/routes.ts
2
+ import {
3
+ adminProductInputSchema as adminProductInputSchema2,
4
+ adminProductPatchSchema as adminProductPatchSchema2,
5
+ adminProductSchema,
6
+ imageOrderInputSchema,
7
+ shippingRatesInputSchema
8
+ } from "@develemit/commerce-contracts";
9
+
10
+ // src/catalog/types.ts
11
+ var ProductNotFoundError = class extends Error {
12
+ constructor(slug) {
13
+ super(`Product not found: ${slug}`);
14
+ this.slug = slug;
15
+ this.name = "ProductNotFoundError";
16
+ }
17
+ slug;
18
+ code = "PRODUCT_NOT_FOUND";
19
+ };
20
+
21
+ // src/shipping/repo.ts
22
+ import { and, asc, eq, notInArray } from "drizzle-orm";
23
+
24
+ // src/db/schema.ts
25
+ import { sql } from "drizzle-orm";
26
+ import {
27
+ boolean,
28
+ check,
29
+ index,
30
+ integer,
31
+ jsonb,
32
+ pgEnum,
33
+ pgTable,
34
+ text,
35
+ unique,
36
+ timestamp,
37
+ uuid
38
+ } from "drizzle-orm/pg-core";
39
+ var productStatus = pgEnum("commerce_product_status", [
40
+ "draft",
41
+ "active",
42
+ "archived"
43
+ ]);
44
+ var products = pgTable(
45
+ "commerce_products",
46
+ {
47
+ id: uuid("id").primaryKey().defaultRandom(),
48
+ slug: text("slug").notNull().unique(),
49
+ name: text("name").notNull(),
50
+ description: text("description").notNull().default(""),
51
+ materials: text("materials"),
52
+ dimensions: text("dimensions"),
53
+ priceCents: integer("price_cents").notNull(),
54
+ currency: text("currency").notNull(),
55
+ category: text("category").notNull(),
56
+ quantity: integer("quantity").notNull().default(1),
57
+ status: productStatus("status").notNull().default("draft"),
58
+ featured: boolean("featured").notNull().default(false),
59
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
60
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
61
+ },
62
+ (t) => [check("commerce_products_quantity_check", sql`${t.quantity} >= 0`)]
63
+ );
64
+ var productImages = pgTable("commerce_product_images", {
65
+ id: uuid("id").primaryKey().defaultRandom(),
66
+ productId: uuid("product_id").notNull().references(() => products.id, { onDelete: "cascade" }),
67
+ url: text("url").notNull(),
68
+ alt: text("alt").notNull().default(""),
69
+ storageKey: text("storage_key"),
70
+ position: integer("position").notNull().default(0)
71
+ });
72
+ var holdStatus = pgEnum("commerce_hold_status", [
73
+ "active",
74
+ "released",
75
+ "committed"
76
+ ]);
77
+ var inventoryHolds = pgTable(
78
+ "commerce_inventory_holds",
79
+ {
80
+ id: uuid("id").primaryKey().defaultRandom(),
81
+ productId: uuid("product_id").notNull().references(() => products.id, { onDelete: "cascade" }),
82
+ quantity: integer("quantity").notNull(),
83
+ reference: text("reference").notNull(),
84
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
85
+ status: holdStatus("status").notNull().default("active"),
86
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
87
+ },
88
+ (t) => [
89
+ check("commerce_inventory_holds_quantity_check", sql`${t.quantity} > 0`),
90
+ index("commerce_inventory_holds_reference_idx").on(t.reference),
91
+ index("commerce_inventory_holds_product_status_idx").on(
92
+ t.productId,
93
+ t.status
94
+ )
95
+ ]
96
+ );
97
+ var orderStatus = pgEnum("commerce_order_status", [
98
+ "pending",
99
+ "paid",
100
+ "shipped",
101
+ "cancelled",
102
+ "expired",
103
+ "needs_attention",
104
+ "refunded"
105
+ ]);
106
+ var orders = pgTable("commerce_orders", {
107
+ id: uuid("id").primaryKey(),
108
+ status: orderStatus("status").notNull().default("pending"),
109
+ email: text("email"),
110
+ subtotalCents: integer("subtotal_cents").notNull(),
111
+ shippingCents: integer("shipping_cents").notNull().default(0),
112
+ shippingLabel: text("shipping_label"),
113
+ currency: text("currency").notNull(),
114
+ checkoutSessionId: text("checkout_session_id").unique(),
115
+ shippingAddress: jsonb("shipping_address"),
116
+ fulfillmentCarrier: text("fulfillment_carrier"),
117
+ fulfillmentTrackingNumber: text("fulfillment_tracking_number"),
118
+ fulfillmentTrackingUrl: text("fulfillment_tracking_url"),
119
+ shippedAt: timestamp("shipped_at", { withTimezone: true }),
120
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
121
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
122
+ });
123
+ var orderItems = pgTable("commerce_order_items", {
124
+ id: uuid("id").primaryKey().defaultRandom(),
125
+ orderId: uuid("order_id").notNull().references(() => orders.id, { onDelete: "cascade" }),
126
+ productId: uuid("product_id").notNull(),
127
+ name: text("name").notNull(),
128
+ priceCents: integer("price_cents").notNull(),
129
+ quantity: integer("quantity").notNull()
130
+ });
131
+ var orderRefunds = pgTable(
132
+ "commerce_order_refunds",
133
+ {
134
+ id: uuid("id").primaryKey().defaultRandom(),
135
+ orderId: uuid("order_id").notNull().references(() => orders.id, { onDelete: "cascade" }),
136
+ amountCents: integer("amount_cents").notNull(),
137
+ currency: text("currency").notNull(),
138
+ reason: text("reason"),
139
+ restocked: boolean("restocked").notNull().default(false),
140
+ source: text("source", { enum: ["admin", "gateway"] }).notNull(),
141
+ gatewayRefundId: text("gateway_refund_id").unique(),
142
+ idempotencyKey: text("idempotency_key"),
143
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
144
+ },
145
+ (t) => [
146
+ check("commerce_order_refunds_amount_check", sql`${t.amountCents} > 0`),
147
+ index("commerce_order_refunds_order_idx").on(t.orderId),
148
+ unique("commerce_order_refunds_idempotency_unique").on(
149
+ t.orderId,
150
+ t.idempotencyKey
151
+ )
152
+ ]
153
+ );
154
+ var stripeEvents = pgTable("commerce_stripe_events", {
155
+ id: text("id").primaryKey(),
156
+ type: text("type").notNull(),
157
+ processedAt: timestamp("processed_at", { withTimezone: true }).notNull().defaultNow()
158
+ });
159
+ var shippingRates = pgTable(
160
+ "commerce_shipping_rates",
161
+ {
162
+ id: uuid("id").primaryKey().defaultRandom(),
163
+ label: text("label").notNull().unique(),
164
+ amountCents: integer("amount_cents").notNull(),
165
+ currency: text("currency").notNull(),
166
+ minDays: integer("min_days"),
167
+ maxDays: integer("max_days"),
168
+ active: boolean("active").notNull().default(true),
169
+ sort: integer("sort").notNull().default(0)
170
+ },
171
+ (t) => [
172
+ check("commerce_shipping_rates_amount_check", sql`${t.amountCents} >= 0`)
173
+ ]
174
+ );
175
+ var commerceSchema = {
176
+ products,
177
+ productImages,
178
+ productStatus,
179
+ inventoryHolds,
180
+ holdStatus,
181
+ orders,
182
+ orderItems,
183
+ orderStatus,
184
+ orderRefunds,
185
+ stripeEvents,
186
+ shippingRates
187
+ };
188
+
189
+ // src/shipping/repo.ts
190
+ async function listActiveRates(db, currency) {
191
+ const rows = await db.select().from(shippingRates).where(
192
+ and(eq(shippingRates.active, true), eq(shippingRates.currency, currency))
193
+ ).orderBy(asc(shippingRates.sort), asc(shippingRates.amountCents));
194
+ return rows.map((r) => ({
195
+ label: r.label,
196
+ amountCents: r.amountCents,
197
+ ...r.minDays !== null && { minDays: r.minDays },
198
+ ...r.maxDays !== null && { maxDays: r.maxDays }
199
+ }));
200
+ }
201
+ async function listAllRates(db) {
202
+ return db.select().from(shippingRates).orderBy(asc(shippingRates.sort), asc(shippingRates.label));
203
+ }
204
+ async function replaceRates(tx, rates) {
205
+ const labels = rates.map((r) => r.label);
206
+ await tx.delete(shippingRates).where(
207
+ labels.length > 0 ? notInArray(shippingRates.label, labels) : void 0
208
+ );
209
+ for (const rate of rates) {
210
+ await tx.insert(shippingRates).values(rate).onConflictDoUpdate({
211
+ target: shippingRates.label,
212
+ set: {
213
+ amountCents: rate.amountCents,
214
+ currency: rate.currency,
215
+ minDays: rate.minDays ?? null,
216
+ maxDays: rate.maxDays ?? null,
217
+ active: rate.active ?? true,
218
+ sort: rate.sort ?? 0
219
+ }
220
+ });
221
+ }
222
+ }
223
+
224
+ // src/shipping/service.ts
225
+ async function listShippingRates(db) {
226
+ return (await listAllRates(db)).map((r) => ({
227
+ label: r.label,
228
+ amountCents: r.amountCents,
229
+ currency: r.currency,
230
+ minDays: r.minDays,
231
+ maxDays: r.maxDays,
232
+ active: r.active,
233
+ sort: r.sort
234
+ }));
235
+ }
236
+ async function setShippingRates(db, rates) {
237
+ await db.transaction((tx) => replaceRates(tx, rates));
238
+ return listShippingRates(db);
239
+ }
240
+
241
+ // src/admin/images.ts
242
+ import { Readable } from "stream";
243
+
244
+ // src/admin/repo.ts
245
+ import { asc as asc2, eq as eq2, inArray, sql as sql2 } from "drizzle-orm";
246
+ async function withImages(db, rows) {
247
+ if (rows.length === 0) return [];
248
+ const images = await db.select().from(productImages).where(
249
+ inArray(
250
+ productImages.productId,
251
+ rows.map((r) => r.id)
252
+ )
253
+ ).orderBy(asc2(productImages.position), asc2(productImages.id));
254
+ return rows.map((row) => ({
255
+ row,
256
+ images: images.filter((i) => i.productId === row.id)
257
+ }));
258
+ }
259
+ async function listAllProducts(db) {
260
+ const rows = await db.select().from(products).orderBy(asc2(products.createdAt), asc2(products.slug));
261
+ return withImages(db, rows);
262
+ }
263
+ async function findProductById(db, id) {
264
+ const rows = await db.select().from(products).where(eq2(products.id, id));
265
+ return (await withImages(db, rows))[0];
266
+ }
267
+ async function slugsStartingWith(db, base) {
268
+ const rows = await db.select({ slug: products.slug }).from(products).where(sql2`${products.slug} like ${`${base}%`}`);
269
+ return new Set(rows.map((r) => r.slug));
270
+ }
271
+ async function insertProduct(db, values) {
272
+ const [row] = await db.insert(products).values(values).returning({ id: products.id });
273
+ return row.id;
274
+ }
275
+ async function updateProduct(db, id, set) {
276
+ await db.update(products).set({ ...set, updatedAt: sql2`now()` }).where(eq2(products.id, id));
277
+ }
278
+ async function nextImagePosition(db, productId) {
279
+ const [row] = await db.select({
280
+ next: sql2`coalesce(max(${productImages.position}) + 1, 0)::int`
281
+ }).from(productImages).where(eq2(productImages.productId, productId));
282
+ return row.next;
283
+ }
284
+ async function insertImage(db, values) {
285
+ const [row] = await db.insert(productImages).values(values).returning();
286
+ return row;
287
+ }
288
+ async function deleteImageRow(db, id) {
289
+ await db.delete(productImages).where(eq2(productImages.id, id));
290
+ }
291
+ async function setImagePosition(db, id, position) {
292
+ await db.update(productImages).set({ position }).where(eq2(productImages.id, id));
293
+ }
294
+
295
+ // src/admin/service.ts
296
+ import {
297
+ adminProductInputSchema,
298
+ adminProductPatchSchema
299
+ } from "@develemit/commerce-contracts";
300
+
301
+ // src/admin/slug.ts
302
+ function slugify(name) {
303
+ const slug = name.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
304
+ return slug || "product";
305
+ }
306
+ function uniqueSlug(base, taken) {
307
+ if (!taken.has(base)) return base;
308
+ let n = 2;
309
+ while (taken.has(`${base}-${n}`)) n += 1;
310
+ return `${base}-${n}`;
311
+ }
312
+
313
+ // src/admin/service.ts
314
+ function toAdminProduct({ row, images }) {
315
+ return {
316
+ id: row.id,
317
+ slug: row.slug,
318
+ name: row.name,
319
+ description: row.description,
320
+ materials: row.materials,
321
+ dimensions: row.dimensions,
322
+ priceCents: row.priceCents,
323
+ currency: row.currency,
324
+ category: row.category,
325
+ quantity: row.quantity,
326
+ status: row.status,
327
+ featured: row.featured,
328
+ images: images.map((i) => ({
329
+ id: i.id,
330
+ url: i.url,
331
+ alt: i.alt,
332
+ position: i.position
333
+ }))
334
+ };
335
+ }
336
+ async function requireProduct(db, id) {
337
+ const found = await findProductById(db, id);
338
+ if (!found) throw new ProductNotFoundError(id);
339
+ return found;
340
+ }
341
+ async function listAdminProducts(db) {
342
+ return (await listAllProducts(db)).map(toAdminProduct);
343
+ }
344
+ async function getAdminProduct(db, id) {
345
+ return toAdminProduct(await requireProduct(db, id));
346
+ }
347
+ async function createAdminProduct(db, currency, input) {
348
+ const data = adminProductInputSchema.parse(input);
349
+ const base = slugify(data.name);
350
+ const id = await db.transaction(async (tx) => {
351
+ const slug = uniqueSlug(base, await slugsStartingWith(tx, base));
352
+ return insertProduct(tx, {
353
+ ...data,
354
+ slug,
355
+ currency: data.currency ?? currency
356
+ });
357
+ });
358
+ return getAdminProduct(db, id);
359
+ }
360
+ async function patchAdminProduct(db, id, input) {
361
+ const { sold, ...fields } = adminProductPatchSchema.parse(input);
362
+ await requireProduct(db, id);
363
+ const set = sold ? { ...fields, quantity: 0 } : fields;
364
+ if (Object.keys(set).length > 0) await updateProduct(db, id, set);
365
+ return getAdminProduct(db, id);
366
+ }
367
+
368
+ // src/admin/types.ts
369
+ var ProductImageNotFoundError = class extends Error {
370
+ constructor(imageId) {
371
+ super(`Image not found: ${imageId}`);
372
+ this.imageId = imageId;
373
+ this.name = "ProductImageNotFoundError";
374
+ }
375
+ imageId;
376
+ code = "IMAGE_NOT_FOUND";
377
+ };
378
+ var InvalidImageOrderError = class extends Error {
379
+ code = "INVALID_IMAGE_ORDER";
380
+ constructor() {
381
+ super("imageIds must list every image of the product exactly once");
382
+ this.name = "InvalidImageOrderError";
383
+ }
384
+ };
385
+
386
+ // src/admin/upload.ts
387
+ import { randomUUID } from "crypto";
388
+ import { fileTypeFromBuffer } from "file-type";
389
+ var MAX_IMAGE_BYTES = 8 * 1024 * 1024;
390
+ var EXTENSIONS = {
391
+ "image/jpeg": "jpg",
392
+ "image/png": "png",
393
+ "image/webp": "webp",
394
+ "image/heic": "heic"
395
+ };
396
+ var InvalidImageError = class extends Error {
397
+ code = "INVALID_IMAGE";
398
+ constructor(message) {
399
+ super(message);
400
+ this.name = "InvalidImageError";
401
+ }
402
+ };
403
+ async function detectImage(bytes) {
404
+ const type = await fileTypeFromBuffer(bytes);
405
+ const ext = type && EXTENSIONS[type.mime];
406
+ if (!type || !ext) {
407
+ throw new InvalidImageError("File must be a jpeg, png, webp or heic image");
408
+ }
409
+ return { mime: type.mime, ext };
410
+ }
411
+ function imageKey(productId, ext) {
412
+ return `products/${productId}/${randomUUID()}.${ext}`;
413
+ }
414
+
415
+ // src/admin/images.ts
416
+ async function addProductImage(db, storage, productId, file) {
417
+ await requireProduct(db, productId);
418
+ const { mime, ext } = await detectImage(file.bytes);
419
+ const stored = await storage.put(
420
+ imageKey(productId, ext),
421
+ Readable.from(file.bytes),
422
+ mime
423
+ );
424
+ try {
425
+ await insertImage(db, {
426
+ productId,
427
+ url: stored.url,
428
+ alt: file.alt ?? "",
429
+ storageKey: stored.key,
430
+ position: await nextImagePosition(db, productId)
431
+ });
432
+ } catch (err) {
433
+ await storage.delete(stored.key);
434
+ throw err;
435
+ }
436
+ return getAdminProduct(db, productId);
437
+ }
438
+ async function reorderProductImages(db, productId, imageIds) {
439
+ const { images } = await requireProduct(db, productId);
440
+ const existing = new Set(images.map((i) => i.id));
441
+ if (imageIds.length !== existing.size || new Set(imageIds).size !== imageIds.length || !imageIds.every((id) => existing.has(id))) {
442
+ throw new InvalidImageOrderError();
443
+ }
444
+ await db.transaction(async (tx) => {
445
+ for (const [position, id] of imageIds.entries()) {
446
+ await setImagePosition(tx, id, position);
447
+ }
448
+ });
449
+ return getAdminProduct(db, productId);
450
+ }
451
+ async function removeProductImage(db, storage, productId, imageId) {
452
+ const { images } = await requireProduct(db, productId);
453
+ const image = images.find((i) => i.id === imageId);
454
+ if (!image) throw new ProductImageNotFoundError(imageId);
455
+ await deleteImageRow(db, imageId);
456
+ if (image.storageKey) await storage.delete(image.storageKey);
457
+ return getAdminProduct(db, productId);
458
+ }
459
+
460
+ // src/admin/routes.ts
461
+ function isZodError(err) {
462
+ return err instanceof Error && err.name === "ZodError";
463
+ }
464
+ var adminPlugin = async (app, { db, currency, authorize, storage }) => {
465
+ const { default: multipart } = await import("@fastify/multipart");
466
+ await app.register(multipart, {
467
+ limits: { fileSize: MAX_IMAGE_BYTES, files: 1 }
468
+ });
469
+ app.setErrorHandler((err, _req, reply) => {
470
+ if (isZodError(err)) {
471
+ return reply.code(400).send({ error: "INVALID_INPUT", issues: err.issues });
472
+ }
473
+ if (err instanceof ProductNotFoundError || err instanceof ProductImageNotFoundError) {
474
+ return reply.code(404).send({ error: err.code, message: err.message });
475
+ }
476
+ if (err instanceof InvalidImageError || err instanceof InvalidImageOrderError) {
477
+ return reply.code(400).send({ error: err.code, message: err.message });
478
+ }
479
+ if (err.code === "FST_REQ_FILE_TOO_LARGE") {
480
+ return reply.code(413).send({ error: "IMAGE_TOO_LARGE" });
481
+ }
482
+ return reply.send(err);
483
+ });
484
+ app.addHook("preHandler", async (req, reply) => {
485
+ if (!await authorize(req)) {
486
+ return reply.code(401).send({ error: "UNAUTHORIZED" });
487
+ }
488
+ });
489
+ app.get(
490
+ "/products",
491
+ async () => (await listAdminProducts(db)).map((p) => adminProductSchema.parse(p))
492
+ );
493
+ app.post("/products", async (req, reply) => {
494
+ const input = adminProductInputSchema2.parse(req.body);
495
+ const created = await createAdminProduct(db, currency, input);
496
+ return reply.code(201).send(created);
497
+ });
498
+ app.get(
499
+ "/products/:id",
500
+ async (req) => getAdminProduct(db, req.params.id)
501
+ );
502
+ app.patch(
503
+ "/products/:id",
504
+ async (req) => patchAdminProduct(
505
+ db,
506
+ req.params.id,
507
+ adminProductPatchSchema2.parse(req.body)
508
+ )
509
+ );
510
+ app.post(
511
+ "/products/:id/images",
512
+ async (req, reply) => {
513
+ const part = await req.file();
514
+ if (!part) throw new InvalidImageError("An image file is required");
515
+ const bytes = await part.toBuffer();
516
+ const fields = part.fields;
517
+ const alt = typeof fields.alt?.value === "string" ? fields.alt.value : void 0;
518
+ const product = await addProductImage(db, storage, req.params.id, {
519
+ bytes,
520
+ alt
521
+ });
522
+ return reply.code(201).send(product);
523
+ }
524
+ );
525
+ app.patch(
526
+ "/products/:id/images/order",
527
+ async (req) => reorderProductImages(
528
+ db,
529
+ req.params.id,
530
+ imageOrderInputSchema.parse(req.body).imageIds
531
+ )
532
+ );
533
+ app.delete(
534
+ "/products/:id/images/:imageId",
535
+ async (req) => removeProductImage(db, storage, req.params.id, req.params.imageId)
536
+ );
537
+ app.get("/shipping-rates", async () => listShippingRates(db));
538
+ app.put(
539
+ "/shipping-rates",
540
+ async (req) => setShippingRates(db, shippingRatesInputSchema.parse(req.body))
541
+ );
542
+ };
543
+
544
+ // src/catalog/routes.ts
545
+ import {
546
+ categorySchema,
547
+ productListQuerySchema,
548
+ productSchema
549
+ } from "@develemit/commerce-contracts";
550
+
551
+ // src/inventory/repo.ts
552
+ import { and as and2, asc as asc3, eq as eq3, gt, inArray as inArray2, lt, sql as sql3 } from "drizzle-orm";
553
+ async function lockProductQuantities(tx, productIds) {
554
+ const rows = await tx.select({ id: products.id, quantity: products.quantity }).from(products).where(inArray2(products.id, productIds)).orderBy(asc3(products.id)).for("update");
555
+ return new Map(rows.map((r) => [r.id, r.quantity]));
556
+ }
557
+ async function activeHeldQuantities(db, productIds) {
558
+ if (productIds.length === 0) return /* @__PURE__ */ new Map();
559
+ const rows = await db.select({
560
+ productId: inventoryHolds.productId,
561
+ held: sql3`sum(${inventoryHolds.quantity})::int`
562
+ }).from(inventoryHolds).where(
563
+ and2(
564
+ inArray2(inventoryHolds.productId, productIds),
565
+ eq3(inventoryHolds.status, "active"),
566
+ gt(inventoryHolds.expiresAt, sql3`now()`)
567
+ )
568
+ ).groupBy(inventoryHolds.productId);
569
+ return new Map(rows.map((r) => [r.productId, r.held]));
570
+ }
571
+ async function productQuantities(db, productIds) {
572
+ if (productIds.length === 0) return /* @__PURE__ */ new Map();
573
+ const rows = await db.select({ id: products.id, quantity: products.quantity }).from(products).where(inArray2(products.id, productIds));
574
+ return new Map(rows.map((r) => [r.id, r.quantity]));
575
+ }
576
+ async function insertHolds(tx, reference, items, expiresAt) {
577
+ await tx.insert(inventoryHolds).values(
578
+ items.map((item) => ({
579
+ productId: item.productId,
580
+ quantity: item.quantity,
581
+ reference,
582
+ expiresAt
583
+ }))
584
+ );
585
+ }
586
+ async function releaseHolds(db, reference) {
587
+ await db.update(inventoryHolds).set({ status: "released" }).where(
588
+ and2(
589
+ eq3(inventoryHolds.reference, reference),
590
+ eq3(inventoryHolds.status, "active")
591
+ )
592
+ );
593
+ }
594
+ async function uncommittedHolds(tx, reference) {
595
+ return tx.select({
596
+ productId: inventoryHolds.productId,
597
+ quantity: inventoryHolds.quantity,
598
+ lapsed: sql3`(${inventoryHolds.status} = 'released' or ${inventoryHolds.expiresAt} <= now())`
599
+ }).from(inventoryHolds).where(
600
+ and2(
601
+ eq3(inventoryHolds.reference, reference),
602
+ inArray2(inventoryHolds.status, ["active", "released"])
603
+ )
604
+ );
605
+ }
606
+ async function markHoldsCommitted(tx, reference) {
607
+ await tx.update(inventoryHolds).set({ status: "committed" }).where(
608
+ and2(
609
+ eq3(inventoryHolds.reference, reference),
610
+ inArray2(inventoryHolds.status, ["active", "released"])
611
+ )
612
+ );
613
+ }
614
+ async function extendActiveHolds(db, reference, expiresAt) {
615
+ await db.update(inventoryHolds).set({ expiresAt }).where(
616
+ and2(
617
+ eq3(inventoryHolds.reference, reference),
618
+ eq3(inventoryHolds.status, "active"),
619
+ gt(inventoryHolds.expiresAt, sql3`now()`)
620
+ )
621
+ );
622
+ }
623
+ async function decrementQuantity(tx, productId, quantity) {
624
+ await tx.update(products).set({
625
+ quantity: sql3`greatest(${products.quantity} - ${quantity}, 0)`,
626
+ updatedAt: sql3`now()`
627
+ }).where(eq3(products.id, productId));
628
+ }
629
+ async function incrementQuantity(tx, productId, quantity) {
630
+ await tx.update(products).set({
631
+ quantity: sql3`${products.quantity} + ${quantity}`,
632
+ updatedAt: sql3`now()`
633
+ }).where(eq3(products.id, productId));
634
+ }
635
+ async function releaseExpiredHolds(db) {
636
+ const released = await db.update(inventoryHolds).set({ status: "released" }).where(
637
+ and2(
638
+ eq3(inventoryHolds.status, "active"),
639
+ lt(inventoryHolds.expiresAt, sql3`now()`)
640
+ )
641
+ ).returning({ id: inventoryHolds.id });
642
+ return released.length;
643
+ }
644
+
645
+ // src/inventory/types.ts
646
+ var InsufficientStockError = class extends Error {
647
+ constructor(productIds) {
648
+ super(`Not enough stock for: ${productIds.join(", ")}`);
649
+ this.productIds = productIds;
650
+ this.name = "InsufficientStockError";
651
+ }
652
+ productIds;
653
+ code = "INSUFFICIENT_STOCK";
654
+ };
655
+
656
+ // src/inventory/service.ts
657
+ function mergeRequests(items) {
658
+ const merged = /* @__PURE__ */ new Map();
659
+ for (const item of items) {
660
+ merged.set(
661
+ item.productId,
662
+ (merged.get(item.productId) ?? 0) + item.quantity
663
+ );
664
+ }
665
+ return [...merged].map(([productId, quantity]) => ({ productId, quantity }));
666
+ }
667
+ function freeOf(quantity, held) {
668
+ return Math.max(quantity - held, 0);
669
+ }
670
+ async function freeQuantity(db, productIds) {
671
+ const [quantities, held] = await Promise.all([
672
+ productQuantities(db, productIds),
673
+ activeHeldQuantities(db, productIds)
674
+ ]);
675
+ return new Map(
676
+ [...quantities].map(([id, qty]) => [id, freeOf(qty, held.get(id) ?? 0)])
677
+ );
678
+ }
679
+ async function reserve(db, { reference, items, expiresAt }) {
680
+ const requests = mergeRequests(items);
681
+ const ids = requests.map((r) => r.productId);
682
+ await db.transaction(async (tx) => {
683
+ const quantities = await lockProductQuantities(tx, ids);
684
+ const held = await activeHeldQuantities(tx, ids);
685
+ const short = requests.filter((r) => {
686
+ const qty = quantities.get(r.productId);
687
+ if (qty === void 0) return true;
688
+ return freeOf(qty, held.get(r.productId) ?? 0) < r.quantity;
689
+ }).map((r) => r.productId);
690
+ if (short.length > 0) throw new InsufficientStockError(short);
691
+ await insertHolds(tx, reference, requests, expiresAt);
692
+ });
693
+ }
694
+ async function release(db, reference) {
695
+ await releaseHolds(db, reference);
696
+ }
697
+ async function releaseExpired(db) {
698
+ return releaseExpiredHolds(db);
699
+ }
700
+ async function commitWithin(tx, reference) {
701
+ const holds = await uncommittedHolds(tx, reference);
702
+ if (holds.length === 0) return { result: "committed" };
703
+ const quantities = await lockProductQuantities(
704
+ tx,
705
+ holds.map((h) => h.productId)
706
+ );
707
+ const lapsed = holds.filter((h) => h.lapsed);
708
+ const held = await activeHeldQuantities(
709
+ tx,
710
+ lapsed.map((h) => h.productId)
711
+ );
712
+ const shortProductIds = lapsed.filter(
713
+ (h) => freeOf(quantities.get(h.productId) ?? 0, held.get(h.productId) ?? 0) < h.quantity
714
+ ).map((h) => h.productId);
715
+ if (shortProductIds.length > 0) {
716
+ await releaseHolds(tx, reference);
717
+ return { result: "unavailable", shortProductIds };
718
+ }
719
+ for (const hold of holds) {
720
+ await decrementQuantity(tx, hold.productId, hold.quantity);
721
+ }
722
+ await markHoldsCommitted(tx, reference);
723
+ return { result: "committed" };
724
+ }
725
+ async function commit(db, reference) {
726
+ return (await db.transaction((tx) => commitWithin(tx, reference))).result;
727
+ }
728
+ async function extendHolds(db, reference, expiresAt) {
729
+ await extendActiveHolds(db, reference, expiresAt);
730
+ }
731
+ async function restock(tx, items) {
732
+ const requests = mergeRequests(items);
733
+ if (requests.length === 0) return;
734
+ await lockProductQuantities(
735
+ tx,
736
+ requests.map((r) => r.productId)
737
+ );
738
+ for (const r of requests) {
739
+ await incrementQuantity(tx, r.productId, r.quantity);
740
+ }
741
+ }
742
+
743
+ // src/catalog/repo.ts
744
+ import { and as and3, asc as asc4, eq as eq4, inArray as inArray3, sql as sql4 } from "drizzle-orm";
745
+ async function attachImages(db, rows) {
746
+ if (rows.length === 0) return [];
747
+ const images = await db.select().from(productImages).where(
748
+ inArray3(
749
+ productImages.productId,
750
+ rows.map((r) => r.id)
751
+ )
752
+ ).orderBy(asc4(productImages.position));
753
+ return rows.map((row) => ({
754
+ row,
755
+ images: images.filter((i) => i.productId === row.id)
756
+ }));
757
+ }
758
+ async function listActiveProducts(db, filter) {
759
+ const conditions2 = [eq4(products.status, "active")];
760
+ if (filter.category) conditions2.push(eq4(products.category, filter.category));
761
+ if (filter.featured !== void 0)
762
+ conditions2.push(eq4(products.featured, filter.featured));
763
+ const rows = await db.select().from(products).where(and3(...conditions2)).orderBy(asc4(products.createdAt), asc4(products.slug));
764
+ return attachImages(db, rows);
765
+ }
766
+ async function findActiveProductBySlug(db, slug) {
767
+ const rows = await db.select().from(products).where(and3(eq4(products.slug, slug), eq4(products.status, "active"))).limit(1);
768
+ return (await attachImages(db, rows))[0];
769
+ }
770
+ async function countActiveByCategory(db) {
771
+ const rows = await db.select({
772
+ category: products.category,
773
+ count: sql4`count(*)::int`
774
+ }).from(products).where(eq4(products.status, "active")).groupBy(products.category).orderBy(asc4(products.category));
775
+ return rows;
776
+ }
777
+ async function findActiveProductsByIds(db, ids) {
778
+ if (ids.length === 0) return [];
779
+ return db.select().from(products).where(and3(inArray3(products.id, ids), eq4(products.status, "active")));
780
+ }
781
+
782
+ // src/catalog/service.ts
783
+ function availabilityOf(quantity, free) {
784
+ if (free > 0) return "available";
785
+ return quantity > 0 ? "reserved" : "sold";
786
+ }
787
+ function toProduct({ row, images }, free) {
788
+ const freeQty = free.get(row.id) ?? row.quantity;
789
+ return {
790
+ id: row.id,
791
+ slug: row.slug,
792
+ name: row.name,
793
+ description: row.description,
794
+ ...row.materials ? { materials: row.materials } : {},
795
+ ...row.dimensions ? { dimensions: row.dimensions } : {},
796
+ priceCents: row.priceCents,
797
+ currency: row.currency,
798
+ category: row.category,
799
+ images: images.map((i) => ({ url: i.url, alt: i.alt })),
800
+ quantity: freeQty,
801
+ availability: availabilityOf(row.quantity, freeQty),
802
+ featured: row.featured
803
+ };
804
+ }
805
+ function labelFor(slug, labels) {
806
+ const label = typeof labels === "function" ? labels(slug) : labels?.[slug];
807
+ return label ?? slug;
808
+ }
809
+ async function listProducts(db, filter) {
810
+ const found = await listActiveProducts(db, filter);
811
+ const free = await freeQuantity(
812
+ db,
813
+ found.map((p) => p.row.id)
814
+ );
815
+ return found.map((p) => toProduct(p, free));
816
+ }
817
+ async function getProduct(db, slug) {
818
+ const found = await findActiveProductBySlug(db, slug);
819
+ if (!found) throw new ProductNotFoundError(slug);
820
+ return toProduct(found, await freeQuantity(db, [found.row.id]));
821
+ }
822
+ async function listCategories(db, labels) {
823
+ return (await countActiveByCategory(db)).map((c) => ({
824
+ slug: c.category,
825
+ label: labelFor(c.category, labels),
826
+ count: c.count
827
+ }));
828
+ }
829
+ async function findPurchasable(db, ids) {
830
+ const rows = await findActiveProductsByIds(db, ids);
831
+ return rows.map((row) => ({
832
+ id: row.id,
833
+ name: row.name,
834
+ priceCents: row.priceCents,
835
+ currency: row.currency
836
+ }));
837
+ }
838
+
839
+ // src/catalog/routes.ts
840
+ var catalogPlugin = async (app, { db, categoryLabels }) => {
841
+ app.setErrorHandler((err, _req, reply) => {
842
+ if (err instanceof ProductNotFoundError) {
843
+ return reply.code(404).send({ error: err.code, message: err.message });
844
+ }
845
+ return reply.send(err);
846
+ });
847
+ app.get("/products", async (req, reply) => {
848
+ const raw = req.query;
849
+ const parsed = productListQuerySchema.safeParse({
850
+ category: raw.category || void 0,
851
+ featured: raw.featured === void 0 ? void 0 : raw.featured === "true"
852
+ });
853
+ if (!parsed.success) {
854
+ return reply.code(400).send({ error: "INVALID_QUERY" });
855
+ }
856
+ const products2 = await listProducts(db, parsed.data);
857
+ return products2.map((p) => productSchema.parse(p));
858
+ });
859
+ app.get("/products/:slug", async (req) => {
860
+ return productSchema.parse(await getProduct(db, req.params.slug));
861
+ });
862
+ app.get("/categories", async () => {
863
+ const categories = await listCategories(db, categoryLabels);
864
+ return categories.map((c) => categorySchema.parse(c));
865
+ });
866
+ };
867
+
868
+ // src/orders/admin-routes.ts
869
+ import {
870
+ adminOrderListQuerySchema,
871
+ adminOrderPageSchema,
872
+ refundOrderInputSchema,
873
+ shipOrderInputSchema
874
+ } from "@develemit/commerce-contracts";
875
+
876
+ // src/orders/admin-repo.ts
877
+ import { and as and4, desc, eq as eq5, ilike, lt as lt2, or, sql as sql5 } from "drizzle-orm";
878
+ function escapeLike(value) {
879
+ return value.replace(/[\\%_]/g, (c) => `\\${c}`);
880
+ }
881
+ function conditions(filter) {
882
+ const parts = [];
883
+ if (filter.status) parts.push(eq5(orders.status, filter.status));
884
+ if (filter.hasPendingRefund !== void 0) {
885
+ const pending = sql5`exists (
886
+ select 1 from commerce_order_refunds r
887
+ where r.order_id = commerce_orders.id and r.idempotency_key is not null
888
+ and r.gateway_refund_id is null
889
+ )`;
890
+ parts.push(filter.hasPendingRefund ? pending : sql5`not ${pending}`);
891
+ }
892
+ if (filter.q) {
893
+ const prefix = `${escapeLike(filter.q)}%`;
894
+ parts.push(
895
+ or(ilike(orders.email, prefix), sql5`${orders.id}::text ilike ${prefix}`)
896
+ );
897
+ }
898
+ if (filter.after) {
899
+ const { createdAt, id } = filter.after;
900
+ parts.push(
901
+ or(
902
+ lt2(orders.createdAt, createdAt),
903
+ and4(eq5(orders.createdAt, createdAt), lt2(orders.id, id))
904
+ )
905
+ );
906
+ }
907
+ return and4(...parts);
908
+ }
909
+ async function selectOrderPage(db, filter) {
910
+ const itemCount = sql5`coalesce((
911
+ select sum(i.quantity) from commerce_order_items i
912
+ where i.order_id = commerce_orders.id
913
+ ), 0)::int`;
914
+ return db.select({
915
+ id: orders.id,
916
+ status: orders.status,
917
+ email: orders.email,
918
+ totalCents: sql5`${orders.subtotalCents} + ${orders.shippingCents}`,
919
+ currency: orders.currency,
920
+ itemCount,
921
+ createdAt: orders.createdAt
922
+ }).from(orders).where(conditions(filter)).orderBy(desc(orders.createdAt), desc(orders.id)).limit(filter.limit);
923
+ }
924
+ async function findOrderRefunds(db, orderId) {
925
+ return db.select().from(orderRefunds).where(eq5(orderRefunds.orderId, orderId)).orderBy(orderRefunds.createdAt, orderRefunds.id);
926
+ }
927
+
928
+ // src/orders/repo.ts
929
+ import { and as and6, asc as asc5, eq as eq7, sql as sql7 } from "drizzle-orm";
930
+
931
+ // src/orders/refund-repo.ts
932
+ import { and as and5, eq as eq6, isNotNull, isNull, or as or2, sql as sql6 } from "drizzle-orm";
933
+ var isRecordedRefund = or2(
934
+ isNull(orderRefunds.idempotencyKey),
935
+ isNotNull(orderRefunds.gatewayRefundId)
936
+ );
937
+ async function lockOrderForRefund(tx, id) {
938
+ const [row] = await tx.select({
939
+ status: orders.status,
940
+ totalCents: sql6`${orders.subtotalCents} + ${orders.shippingCents}`,
941
+ currency: orders.currency,
942
+ checkoutSessionId: orders.checkoutSessionId
943
+ }).from(orders).where(eq6(orders.id, id)).for("update");
944
+ return row;
945
+ }
946
+ async function sumReservedCents(tx, orderId) {
947
+ const [row] = await tx.select({
948
+ total: sql6`coalesce(sum(${orderRefunds.amountCents}), 0)::int`
949
+ }).from(orderRefunds).where(eq6(orderRefunds.orderId, orderId));
950
+ return row.total;
951
+ }
952
+ async function findRefundByKey(tx, orderId, idempotencyKey2) {
953
+ const [row] = await tx.select().from(orderRefunds).where(
954
+ and5(
955
+ eq6(orderRefunds.orderId, orderId),
956
+ eq6(orderRefunds.idempotencyKey, idempotencyKey2)
957
+ )
958
+ );
959
+ return row;
960
+ }
961
+ async function insertPendingRefund(tx, refund) {
962
+ const [row] = await tx.insert(orderRefunds).values(refund).returning();
963
+ return row;
964
+ }
965
+ async function deletePendingRefund(db, id) {
966
+ await db.delete(orderRefunds).where(and5(eq6(orderRefunds.id, id), isNull(orderRefunds.gatewayRefundId)));
967
+ }
968
+ async function confirmRefund(tx, pending, gatewayRefundId) {
969
+ const [existing] = await tx.select({ id: orderRefunds.id }).from(orderRefunds).where(eq6(orderRefunds.gatewayRefundId, gatewayRefundId));
970
+ if (existing) {
971
+ await deletePendingRefund(tx, pending.id);
972
+ return void 0;
973
+ }
974
+ const [updated] = await tx.update(orderRefunds).set({ gatewayRefundId }).where(
975
+ and5(
976
+ eq6(orderRefunds.id, pending.id),
977
+ isNull(orderRefunds.gatewayRefundId)
978
+ )
979
+ ).returning();
980
+ if (updated) return updated;
981
+ const [inserted] = await tx.insert(orderRefunds).values({
982
+ orderId: pending.orderId,
983
+ amountCents: pending.amountCents,
984
+ currency: pending.currency,
985
+ reason: pending.reason,
986
+ restocked: pending.restocked,
987
+ source: pending.source,
988
+ idempotencyKey: pending.idempotencyKey,
989
+ gatewayRefundId
990
+ }).onConflictDoNothing().returning();
991
+ return inserted;
992
+ }
993
+ async function findRefundByGatewayId(tx, gatewayRefundId) {
994
+ const [row] = await tx.select().from(orderRefunds).where(eq6(orderRefunds.gatewayRefundId, gatewayRefundId));
995
+ return row;
996
+ }
997
+ async function confirmPendingByKey(tx, orderId, scopedKey, gatewayRefundId) {
998
+ const prefix = `${orderId}:`;
999
+ if (!scopedKey.startsWith(prefix)) return void 0;
1000
+ const [updated] = await tx.update(orderRefunds).set({ gatewayRefundId }).where(
1001
+ and5(
1002
+ eq6(orderRefunds.orderId, orderId),
1003
+ eq6(orderRefunds.idempotencyKey, scopedKey.slice(prefix.length)),
1004
+ isNull(orderRefunds.gatewayRefundId)
1005
+ )
1006
+ ).returning();
1007
+ return updated;
1008
+ }
1009
+ async function insertGatewayRefund(tx, refund) {
1010
+ const [row] = await tx.insert(orderRefunds).values({ ...refund, source: "gateway", restocked: false }).onConflictDoNothing().returning();
1011
+ return row;
1012
+ }
1013
+
1014
+ // src/orders/repo.ts
1015
+ async function insertOrder(db, order, items) {
1016
+ await db.insert(orders).values(order);
1017
+ await db.insert(orderItems).values(items.map((item) => ({ ...item, orderId: order.id })));
1018
+ }
1019
+ async function setCheckoutSession(db, orderId, sessionId) {
1020
+ await db.update(orders).set({ checkoutSessionId: sessionId, updatedAt: sql7`now()` }).where(eq7(orders.id, orderId));
1021
+ }
1022
+ async function setOrderStatus(db, orderId, status) {
1023
+ await db.update(orders).set({ status, updatedAt: sql7`now()` }).where(eq7(orders.id, orderId));
1024
+ }
1025
+ async function saveOrderDetails(db, orderId, details, status) {
1026
+ await db.update(orders).set({
1027
+ ...status && { status },
1028
+ email: details.email ?? null,
1029
+ shippingAddress: details.shippingAddress ?? null,
1030
+ shippingCents: details.shipping?.amountCents ?? 0,
1031
+ shippingLabel: details.shipping?.label ?? null,
1032
+ updatedAt: sql7`now()`
1033
+ }).where(eq7(orders.id, orderId));
1034
+ }
1035
+ async function findOrderBySession(db, sessionId) {
1036
+ const [row] = await db.select().from(orders).where(eq7(orders.checkoutSessionId, sessionId)).limit(1);
1037
+ return row;
1038
+ }
1039
+ async function findOrderById(db, orderId) {
1040
+ const [row] = await db.select().from(orders).where(eq7(orders.id, orderId)).limit(1);
1041
+ return row;
1042
+ }
1043
+ async function findOrderItems(db, orderId) {
1044
+ return db.select().from(orderItems).where(eq7(orderItems.orderId, orderId)).orderBy(asc5(orderItems.name));
1045
+ }
1046
+ async function recordEvent(db, id, type) {
1047
+ const inserted = await db.insert(stripeEvents).values({ id, type }).onConflictDoNothing().returning({ id: stripeEvents.id });
1048
+ return inserted.length > 0;
1049
+ }
1050
+ async function sumRefundedCents(db, orderId) {
1051
+ const [row] = await db.select({
1052
+ total: sql7`coalesce(sum(${orderRefunds.amountCents}), 0)::int`
1053
+ }).from(orderRefunds).where(and6(eq7(orderRefunds.orderId, orderId), isRecordedRefund));
1054
+ return row.total;
1055
+ }
1056
+
1057
+ // src/orders/order-detail.ts
1058
+ function toFulfillment(order) {
1059
+ if (!order.shippedAt) return null;
1060
+ return {
1061
+ carrier: order.fulfillmentCarrier,
1062
+ trackingNumber: order.fulfillmentTrackingNumber,
1063
+ trackingUrl: order.fulfillmentTrackingUrl,
1064
+ shippedAt: order.shippedAt.toISOString()
1065
+ };
1066
+ }
1067
+ async function toSummary(db, order) {
1068
+ const items = await findOrderItems(db, order.id);
1069
+ return {
1070
+ id: order.id,
1071
+ status: order.status,
1072
+ subtotalCents: order.subtotalCents,
1073
+ shippingCents: order.shippingCents,
1074
+ shippingLabel: order.shippingLabel,
1075
+ totalCents: order.subtotalCents + order.shippingCents,
1076
+ currency: order.currency,
1077
+ items: items.map((i) => ({
1078
+ productId: i.productId,
1079
+ name: i.name,
1080
+ priceCents: i.priceCents,
1081
+ quantity: i.quantity
1082
+ })),
1083
+ fulfillment: toFulfillment(order),
1084
+ createdAt: order.createdAt.toISOString()
1085
+ };
1086
+ }
1087
+ async function summaryBySession(db, sessionId) {
1088
+ const order = await findOrderBySession(db, sessionId);
1089
+ return order && toSummary(db, order);
1090
+ }
1091
+ async function detailById(db, orderId) {
1092
+ const order = await findOrderById(db, orderId);
1093
+ if (!order) return void 0;
1094
+ return {
1095
+ ...await toSummary(db, order),
1096
+ email: order.email,
1097
+ shippingAddress: order.shippingAddress ?? null
1098
+ };
1099
+ }
1100
+
1101
+ // src/orders/admin-service.ts
1102
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1103
+ var InvalidCursorError = class extends Error {
1104
+ code = "INVALID_CURSOR";
1105
+ constructor() {
1106
+ super("The cursor is malformed");
1107
+ this.name = "InvalidCursorError";
1108
+ }
1109
+ };
1110
+ var InvalidOrderIdError = class extends Error {
1111
+ code = "INVALID_ORDER_ID";
1112
+ constructor() {
1113
+ super("The order id is malformed");
1114
+ this.name = "InvalidOrderIdError";
1115
+ }
1116
+ };
1117
+ var AdminOrderNotFoundError = class extends Error {
1118
+ constructor(orderId) {
1119
+ super(`No order: ${orderId}`);
1120
+ this.orderId = orderId;
1121
+ this.name = "AdminOrderNotFoundError";
1122
+ }
1123
+ orderId;
1124
+ code = "ORDER_NOT_FOUND";
1125
+ };
1126
+ function toOrderRefund(row) {
1127
+ return {
1128
+ id: row.id,
1129
+ amountCents: row.amountCents,
1130
+ reason: row.reason,
1131
+ restocked: row.restocked,
1132
+ source: row.source,
1133
+ status: row.idempotencyKey && !row.gatewayRefundId ? "pending" : "succeeded",
1134
+ createdAt: row.createdAt.toISOString()
1135
+ };
1136
+ }
1137
+ function isOrderId(value) {
1138
+ return UUID.test(value);
1139
+ }
1140
+ function encodeCursor(createdAt, id) {
1141
+ return Buffer.from(`${createdAt.toISOString()}|${id}`).toString("base64url");
1142
+ }
1143
+ function decodeCursor(cursor) {
1144
+ const [iso, id, ...rest] = Buffer.from(cursor, "base64url").toString("utf8").split("|");
1145
+ const createdAt = new Date(iso ?? "");
1146
+ if (rest.length || !id || !isOrderId(id) || Number.isNaN(+createdAt)) {
1147
+ throw new InvalidCursorError();
1148
+ }
1149
+ return { createdAt, id };
1150
+ }
1151
+ async function listAdminOrders(db, query) {
1152
+ const rows = await selectOrderPage(db, {
1153
+ status: query.status,
1154
+ q: query.q,
1155
+ hasPendingRefund: query.hasPendingRefund,
1156
+ after: query.cursor ? decodeCursor(query.cursor) : void 0,
1157
+ limit: query.limit + 1
1158
+ });
1159
+ const page = rows.slice(0, query.limit);
1160
+ const last = page[page.length - 1];
1161
+ return {
1162
+ items: page.map((r) => ({ ...r, createdAt: r.createdAt.toISOString() })),
1163
+ nextCursor: rows.length > query.limit && last ? encodeCursor(last.createdAt, last.id) : null
1164
+ };
1165
+ }
1166
+ async function getAdminOrder(db, id) {
1167
+ const detail = await detailById(db, id);
1168
+ const row = await findOrderById(db, id);
1169
+ if (!detail || !row) throw new AdminOrderNotFoundError(id);
1170
+ const [refunds, refundedCents] = await Promise.all([
1171
+ findOrderRefunds(db, id),
1172
+ sumRefundedCents(db, id)
1173
+ ]);
1174
+ return {
1175
+ ...detail,
1176
+ refunds: refunds.map(toOrderRefund),
1177
+ refundedCents,
1178
+ checkoutSessionId: row.checkoutSessionId,
1179
+ updatedAt: row.updatedAt.toISOString()
1180
+ };
1181
+ }
1182
+
1183
+ // src/orders/refund-errors.ts
1184
+ var IdempotencyKeyRequiredError = class extends Error {
1185
+ code = "IDEMPOTENCY_KEY_REQUIRED";
1186
+ constructor() {
1187
+ super("An Idempotency-Key header of 1-255 characters is required");
1188
+ this.name = "IdempotencyKeyRequiredError";
1189
+ }
1190
+ };
1191
+ var RefundExceedsBalanceError = class extends Error {
1192
+ constructor(balanceCents) {
1193
+ super(`The refundable balance is ${balanceCents} cents`);
1194
+ this.balanceCents = balanceCents;
1195
+ this.name = "RefundExceedsBalanceError";
1196
+ }
1197
+ balanceCents;
1198
+ code = "REFUND_EXCEEDS_BALANCE";
1199
+ };
1200
+ var RestockRequiresFullRefundError = class extends Error {
1201
+ code = "RESTOCK_REQUIRES_FULL_REFUND";
1202
+ constructor() {
1203
+ super("Restock is only allowed when refunding the remaining balance");
1204
+ this.name = "RestockRequiresFullRefundError";
1205
+ }
1206
+ };
1207
+ var RestockNotApplicableError = class extends Error {
1208
+ code = "RESTOCK_NOT_APPLICABLE";
1209
+ constructor() {
1210
+ super("This order never took stock, so there is nothing to restock");
1211
+ this.name = "RestockNotApplicableError";
1212
+ }
1213
+ };
1214
+ var RefundGatewayError = class extends Error {
1215
+ code = "REFUND_GATEWAY_ERROR";
1216
+ constructor(cause) {
1217
+ super("The payment gateway could not create the refund", { cause });
1218
+ this.name = "RefundGatewayError";
1219
+ }
1220
+ };
1221
+ var RefundOutcomeUnknownError = class extends Error {
1222
+ code = "REFUND_OUTCOME_UNKNOWN";
1223
+ constructor(cause) {
1224
+ super(
1225
+ "The payment gateway did not confirm the refund and it may still have happened. Retry with the same Idempotency-Key or wait for the charge.refunded webhook",
1226
+ { cause }
1227
+ );
1228
+ this.name = "RefundOutcomeUnknownError";
1229
+ }
1230
+ };
1231
+
1232
+ // src/payment/gateway.ts
1233
+ var RefundRejectedError = class extends Error {
1234
+ definitelyFailed = true;
1235
+ constructor(message, options) {
1236
+ super(message, options);
1237
+ this.name = "RefundRejectedError";
1238
+ }
1239
+ };
1240
+ function isDefiniteRefundFailure(err) {
1241
+ return err instanceof RefundRejectedError;
1242
+ }
1243
+ var SESSION_ID_PLACEHOLDER = "{CHECKOUT_SESSION_ID}";
1244
+ var WebhookSignatureError = class extends Error {
1245
+ code = "INVALID_WEBHOOK_SIGNATURE";
1246
+ constructor(message = "Webhook signature verification failed") {
1247
+ super(message);
1248
+ this.name = "WebhookSignatureError";
1249
+ }
1250
+ };
1251
+
1252
+ // src/orders/gateway-refunds.ts
1253
+ var ACCEPTS_REFUNDS = /* @__PURE__ */ new Set([
1254
+ "paid",
1255
+ "shipped",
1256
+ "needs_attention",
1257
+ "refunded"
1258
+ ]);
1259
+ var SETTLES_AS_REFUNDED = /* @__PURE__ */ new Set(["paid", "shipped", "needs_attention"]);
1260
+ async function recordRefund(tx, order, refund) {
1261
+ if (await findRefundByGatewayId(tx, refund.gatewayRefundId)) return void 0;
1262
+ const confirmed = refund.idempotencyKey && await confirmPendingByKey(
1263
+ tx,
1264
+ order.id,
1265
+ refund.idempotencyKey,
1266
+ refund.gatewayRefundId
1267
+ );
1268
+ if (confirmed) {
1269
+ if (confirmed.restocked) {
1270
+ await restock(tx, await findOrderItems(tx, order.id));
1271
+ }
1272
+ return confirmed;
1273
+ }
1274
+ return insertGatewayRefund(tx, {
1275
+ orderId: order.id,
1276
+ amountCents: refund.amountCents,
1277
+ currency: order.currency,
1278
+ gatewayRefundId: refund.gatewayRefundId
1279
+ });
1280
+ }
1281
+ async function applyGatewayRefunds(tx, order, refunds) {
1282
+ const locked = await lockOrderForRefund(tx, order.id);
1283
+ if (!locked || !ACCEPTS_REFUNDS.has(locked.status)) {
1284
+ return { outcome: "ignored" };
1285
+ }
1286
+ const hooks = [];
1287
+ for (const refund of refunds) {
1288
+ const recorded = await recordRefund(tx, order, refund);
1289
+ if (recorded) {
1290
+ hooks.push(["onOrderRefunded", order.id, toOrderRefund(recorded)]);
1291
+ }
1292
+ }
1293
+ if (SETTLES_AS_REFUNDED.has(locked.status) && await sumRefundedCents(tx, order.id) >= locked.totalCents) {
1294
+ await setOrderStatus(tx, order.id, "refunded");
1295
+ }
1296
+ return { outcome: "processed", hooks };
1297
+ }
1298
+
1299
+ // src/orders/apply-event.ts
1300
+ var ignored = { outcome: "ignored" };
1301
+ var ASYNC_PAYMENT_HOLD_MS = 14 * 24 * 60 * 60 * 1e3;
1302
+ async function settle(tx, order) {
1303
+ if (await commit(tx, order.id) === "unavailable") {
1304
+ await setOrderStatus(tx, order.id, "needs_attention");
1305
+ return {
1306
+ outcome: "processed",
1307
+ hooks: [["onOrderNeedsAttention", order.id]]
1308
+ };
1309
+ }
1310
+ await setOrderStatus(tx, order.id, "paid");
1311
+ return { outcome: "processed", hooks: [["onOrderPaid", order.id]] };
1312
+ }
1313
+ async function completed(tx, order, event) {
1314
+ if (["paid", "shipped", "needs_attention"].includes(order.status)) {
1315
+ return ignored;
1316
+ }
1317
+ await saveOrderDetails(tx, order.id, event);
1318
+ if (event.paymentPending) {
1319
+ await extendHolds(
1320
+ tx,
1321
+ order.id,
1322
+ new Date(Date.now() + ASYNC_PAYMENT_HOLD_MS)
1323
+ );
1324
+ return { outcome: "processed" };
1325
+ }
1326
+ return settle(tx, order);
1327
+ }
1328
+ async function applyToOrder(tx, order, event) {
1329
+ switch (event.type) {
1330
+ case "checkout.completed":
1331
+ return completed(tx, order, event);
1332
+ case "checkout.async_paid":
1333
+ return order.status === "pending" ? settle(tx, order) : ignored;
1334
+ case "checkout.async_failed":
1335
+ if (order.status !== "pending") return ignored;
1336
+ await setOrderStatus(tx, order.id, "cancelled");
1337
+ await release(tx, order.id);
1338
+ return {
1339
+ outcome: "processed",
1340
+ hooks: [["onOrderPaymentFailed", order.id]]
1341
+ };
1342
+ case "checkout.expired":
1343
+ if (order.status !== "pending") return ignored;
1344
+ await setOrderStatus(tx, order.id, "expired");
1345
+ await release(tx, order.id);
1346
+ return { outcome: "processed", hooks: [["onOrderExpired", order.id]] };
1347
+ case "payment.refunded":
1348
+ return applyGatewayRefunds(tx, order, event.refunds);
1349
+ }
1350
+ }
1351
+
1352
+ // src/orders/types.ts
1353
+ var CheckoutConflictError = class extends Error {
1354
+ constructor(productIds) {
1355
+ super(`Unavailable products: ${productIds.join(", ")}`);
1356
+ this.productIds = productIds;
1357
+ this.name = "CheckoutConflictError";
1358
+ }
1359
+ productIds;
1360
+ code = "CHECKOUT_CONFLICT";
1361
+ };
1362
+ var PaymentGatewayError = class extends Error {
1363
+ code = "PAYMENT_GATEWAY_ERROR";
1364
+ constructor(cause) {
1365
+ super("The payment gateway could not create a checkout session", {
1366
+ cause
1367
+ });
1368
+ this.name = "PaymentGatewayError";
1369
+ }
1370
+ };
1371
+ var OrderNotFoundError = class extends Error {
1372
+ constructor(sessionId) {
1373
+ super(`No order for checkout session: ${sessionId}`);
1374
+ this.sessionId = sessionId;
1375
+ this.name = "OrderNotFoundError";
1376
+ }
1377
+ sessionId;
1378
+ code = "ORDER_NOT_FOUND";
1379
+ };
1380
+
1381
+ // src/orders/order-service.ts
1382
+ async function getOrderSummary(db, sessionId) {
1383
+ const summary = await summaryBySession(db, sessionId);
1384
+ if (!summary) throw new OrderNotFoundError(sessionId);
1385
+ return summary;
1386
+ }
1387
+ async function applyEvent(db, event) {
1388
+ return db.transaction(async (tx) => {
1389
+ const type = event.type === "ignored" ? event.sourceType : event.type;
1390
+ if (!await recordEvent(tx, event.id, type)) {
1391
+ return { outcome: "duplicate" };
1392
+ }
1393
+ if (event.type === "ignored") return { outcome: "ignored" };
1394
+ const order = await findOrderBySession(tx, event.sessionId);
1395
+ if (!order) return { outcome: "ignored" };
1396
+ return applyToOrder(tx, order, event);
1397
+ });
1398
+ }
1399
+ async function runHook(db, hooks, [name, orderId], log, ...args) {
1400
+ const hook = hooks[name];
1401
+ if (!hook) return;
1402
+ try {
1403
+ const order = await detailById(db, orderId);
1404
+ if (order) await hook.call(hooks, order, ...args);
1405
+ } catch (err) {
1406
+ log?.error({ err }, `${name} hook failed for order ${orderId}`);
1407
+ }
1408
+ }
1409
+ async function handlePaymentEvent(db, event, hooks = {}, log) {
1410
+ const { outcome, hooks: calls = [] } = await applyEvent(db, event);
1411
+ for (const [name, orderId, ...args] of calls) {
1412
+ await runHook(db, hooks, [name, orderId], log, ...args);
1413
+ }
1414
+ if (outcome === "ignored" && event.type === "payment.refunded") {
1415
+ log?.warn?.(
1416
+ { sessionId: event.sessionId },
1417
+ "refund event for an unknown or unrefundable order"
1418
+ );
1419
+ }
1420
+ return outcome;
1421
+ }
1422
+
1423
+ // src/orders/ship-service.ts
1424
+ import { eq as eq8, sql as sql8 } from "drizzle-orm";
1425
+ var InvalidOrderStateError = class extends Error {
1426
+ constructor(status) {
1427
+ super(`The order is ${status}`);
1428
+ this.status = status;
1429
+ this.name = "InvalidOrderStateError";
1430
+ }
1431
+ status;
1432
+ code = "INVALID_ORDER_STATE";
1433
+ };
1434
+ async function applyShipment(db, id, input) {
1435
+ return db.transaction(async (tx) => {
1436
+ const [row] = await tx.select({ status: orders.status }).from(orders).where(eq8(orders.id, id)).for("update");
1437
+ if (!row) throw new AdminOrderNotFoundError(id);
1438
+ if (row.status !== "paid" && row.status !== "shipped") {
1439
+ throw new InvalidOrderStateError(row.status);
1440
+ }
1441
+ const first = row.status === "paid";
1442
+ await tx.update(orders).set({
1443
+ fulfillmentCarrier: input.carrier ?? null,
1444
+ fulfillmentTrackingNumber: input.trackingNumber ?? null,
1445
+ fulfillmentTrackingUrl: input.trackingUrl ?? null,
1446
+ ...first && { status: "shipped", shippedAt: sql8`now()` },
1447
+ updatedAt: sql8`now()`
1448
+ }).where(eq8(orders.id, id));
1449
+ return first;
1450
+ });
1451
+ }
1452
+ async function shipOrder(db, id, input, hooks = {}, log) {
1453
+ const first = await applyShipment(db, id, input);
1454
+ if (first) await runHook(db, hooks, ["onOrderShipped", id], log);
1455
+ return getAdminOrder(db, id);
1456
+ }
1457
+
1458
+ // src/orders/refund-service.ts
1459
+ var REFUNDABLE = /* @__PURE__ */ new Set(["paid", "shipped", "needs_attention"]);
1460
+ async function reserveRefund(db, orderId, input, idempotencyKey2) {
1461
+ return db.transaction(async (tx) => {
1462
+ const order = await lockOrderForRefund(tx, orderId);
1463
+ if (!order) throw new AdminOrderNotFoundError(orderId);
1464
+ const existing = await findRefundByKey(tx, orderId, idempotencyKey2);
1465
+ if (existing?.gatewayRefundId) return { kind: "done" };
1466
+ if (!REFUNDABLE.has(order.status) || !order.checkoutSessionId) {
1467
+ throw new InvalidOrderStateError(order.status);
1468
+ }
1469
+ const sessionId = order.checkoutSessionId;
1470
+ if (existing) return { kind: "pending", refund: existing, sessionId };
1471
+ const balance = order.totalCents - await sumReservedCents(tx, orderId);
1472
+ const amountCents = input.amountCents ?? balance;
1473
+ if (amountCents <= 0 || amountCents > balance) {
1474
+ throw new RefundExceedsBalanceError(balance);
1475
+ }
1476
+ if (input.restock && amountCents !== balance) {
1477
+ throw new RestockRequiresFullRefundError();
1478
+ }
1479
+ if (input.restock && order.status === "needs_attention") {
1480
+ throw new RestockNotApplicableError();
1481
+ }
1482
+ const refund = await insertPendingRefund(tx, {
1483
+ orderId,
1484
+ amountCents,
1485
+ currency: order.currency,
1486
+ reason: input.reason ?? null,
1487
+ restocked: input.restock,
1488
+ source: "admin",
1489
+ idempotencyKey: idempotencyKey2
1490
+ });
1491
+ return { kind: "pending", refund, sessionId };
1492
+ });
1493
+ }
1494
+ async function recordRefund2(db, pending, gatewayRefundId) {
1495
+ return db.transaction(async (tx) => {
1496
+ const order = await lockOrderForRefund(tx, pending.orderId);
1497
+ const refund = await confirmRefund(tx, pending, gatewayRefundId);
1498
+ if (!order || !refund) return void 0;
1499
+ if (refund.restocked) {
1500
+ const items = await findOrderItems(tx, refund.orderId);
1501
+ await restock(tx, items);
1502
+ }
1503
+ if (await sumRefundedCents(tx, refund.orderId) >= order.totalCents) {
1504
+ await setOrderStatus(tx, refund.orderId, "refunded");
1505
+ }
1506
+ return refund;
1507
+ });
1508
+ }
1509
+ async function refundOrder(db, gateway, orderId, input, idempotencyKey2, hooks = {}, log) {
1510
+ const reservation = await reserveRefund(db, orderId, input, idempotencyKey2);
1511
+ if (reservation.kind === "done") return getAdminOrder(db, orderId);
1512
+ const { refund: pending, sessionId } = reservation;
1513
+ let gatewayRefundId;
1514
+ try {
1515
+ ({ gatewayRefundId } = await gateway.refund({
1516
+ sessionId,
1517
+ amountCents: pending.amountCents,
1518
+ idempotencyKey: `${orderId}:${idempotencyKey2}`
1519
+ }));
1520
+ } catch (err) {
1521
+ if (!isDefiniteRefundFailure(err)) throw new RefundOutcomeUnknownError(err);
1522
+ await deletePendingRefund(db, pending.id);
1523
+ throw new RefundGatewayError(err);
1524
+ }
1525
+ const recorded = await recordRefund2(db, pending, gatewayRefundId);
1526
+ if (recorded) {
1527
+ await runHook(
1528
+ db,
1529
+ hooks,
1530
+ ["onOrderRefunded", orderId],
1531
+ log,
1532
+ toOrderRefund(recorded)
1533
+ );
1534
+ }
1535
+ return getAdminOrder(db, orderId);
1536
+ }
1537
+
1538
+ // src/orders/resolve-service.ts
1539
+ import { eq as eq9, sql as sql9 } from "drizzle-orm";
1540
+ async function applyResolution(db, id) {
1541
+ await db.transaction(async (tx) => {
1542
+ const [row] = await tx.select({ status: orders.status }).from(orders).where(eq9(orders.id, id)).for("update");
1543
+ if (!row) throw new AdminOrderNotFoundError(id);
1544
+ if (row.status !== "needs_attention") {
1545
+ throw new InvalidOrderStateError(row.status);
1546
+ }
1547
+ const outcome = await commitWithin(tx, id);
1548
+ if (outcome.result === "unavailable") {
1549
+ throw new InsufficientStockError(outcome.shortProductIds);
1550
+ }
1551
+ await tx.update(orders).set({ status: "paid", updatedAt: sql9`now()` }).where(eq9(orders.id, id));
1552
+ });
1553
+ }
1554
+ async function resolveOrder(db, id, hooks = {}, log) {
1555
+ await applyResolution(db, id);
1556
+ await runHook(db, hooks, ["onOrderPaid", id], log);
1557
+ return getAdminOrder(db, id);
1558
+ }
1559
+
1560
+ // src/orders/admin-routes.ts
1561
+ function idempotencyKey(req) {
1562
+ const key = req.headers["idempotency-key"];
1563
+ if (typeof key !== "string" || key.length === 0 || key.length > 255) {
1564
+ throw new IdempotencyKeyRequiredError();
1565
+ }
1566
+ return key;
1567
+ }
1568
+ var BAD_REQUEST = [
1569
+ InvalidCursorError,
1570
+ InvalidOrderIdError,
1571
+ IdempotencyKeyRequiredError,
1572
+ RestockRequiresFullRefundError,
1573
+ RestockNotApplicableError
1574
+ ];
1575
+ function isZodError2(err) {
1576
+ return err instanceof Error && err.name === "ZodError";
1577
+ }
1578
+ var adminOrdersPlugin = async (app, { db, authorize, payment, hooks }) => {
1579
+ app.setErrorHandler((err, _req, reply) => {
1580
+ if (isZodError2(err)) {
1581
+ return reply.code(400).send({ error: "INVALID_INPUT", issues: err.issues });
1582
+ }
1583
+ if (BAD_REQUEST.some((type) => err instanceof type)) {
1584
+ const { code, message } = err;
1585
+ return reply.code(400).send({ error: code, message });
1586
+ }
1587
+ if (err instanceof RefundExceedsBalanceError) {
1588
+ return reply.code(400).send({
1589
+ error: err.code,
1590
+ message: err.message,
1591
+ balanceCents: err.balanceCents
1592
+ });
1593
+ }
1594
+ if (err instanceof RefundGatewayError || err instanceof RefundOutcomeUnknownError) {
1595
+ return reply.code(502).send({ error: err.code, message: err.message });
1596
+ }
1597
+ if (err instanceof InvalidOrderStateError) {
1598
+ return reply.code(409).send({ error: err.code, message: err.message, status: err.status });
1599
+ }
1600
+ if (err instanceof InsufficientStockError) {
1601
+ return reply.code(409).send({
1602
+ error: "INSUFFICIENT_STOCK",
1603
+ message: err.message,
1604
+ productIds: err.productIds
1605
+ });
1606
+ }
1607
+ if (err instanceof AdminOrderNotFoundError) {
1608
+ return reply.code(404).send({ error: err.code, message: err.message });
1609
+ }
1610
+ return reply.send(err);
1611
+ });
1612
+ app.addHook("preHandler", async (req, reply) => {
1613
+ if (!await authorize(req)) {
1614
+ return reply.code(401).send({ error: "UNAUTHORIZED" });
1615
+ }
1616
+ });
1617
+ app.get(
1618
+ "/",
1619
+ async (req) => adminOrderPageSchema.parse(
1620
+ await listAdminOrders(db, adminOrderListQuerySchema.parse(req.query))
1621
+ )
1622
+ );
1623
+ app.get("/:id", async (req) => {
1624
+ if (!isOrderId(req.params.id)) throw new InvalidOrderIdError();
1625
+ return getAdminOrder(db, req.params.id);
1626
+ });
1627
+ app.post("/:id/ship", async (req) => {
1628
+ if (!isOrderId(req.params.id)) throw new InvalidOrderIdError();
1629
+ const log = req.log;
1630
+ return shipOrder(
1631
+ db,
1632
+ req.params.id,
1633
+ shipOrderInputSchema.parse(req.body ?? {}),
1634
+ hooks,
1635
+ log
1636
+ );
1637
+ });
1638
+ app.post("/:id/resolve", async (req) => {
1639
+ if (!isOrderId(req.params.id)) throw new InvalidOrderIdError();
1640
+ const log = req.log;
1641
+ return resolveOrder(db, req.params.id, hooks, log);
1642
+ });
1643
+ app.post("/:id/refunds", async (req) => {
1644
+ if (!isOrderId(req.params.id)) throw new InvalidOrderIdError();
1645
+ const key = idempotencyKey(req);
1646
+ const log = req.log;
1647
+ return refundOrder(
1648
+ db,
1649
+ payment,
1650
+ req.params.id,
1651
+ refundOrderInputSchema.parse(req.body ?? {}),
1652
+ key,
1653
+ hooks,
1654
+ log
1655
+ );
1656
+ });
1657
+ };
1658
+
1659
+ // src/orders/routes.ts
1660
+ import {
1661
+ checkoutConflictSchema,
1662
+ checkoutRequestSchema,
1663
+ checkoutResponseSchema,
1664
+ orderSummarySchema
1665
+ } from "@develemit/commerce-contracts";
1666
+
1667
+ // src/orders/checkout-service.ts
1668
+ import { randomUUID as randomUUID2 } from "crypto";
1669
+ var HOLD_DURATION_MS = 31 * 60 * 1e3;
1670
+ var UUID2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1671
+ function mergeItems(items) {
1672
+ const merged = /* @__PURE__ */ new Map();
1673
+ for (const { productId, quantity } of items) {
1674
+ merged.set(productId, (merged.get(productId) ?? 0) + quantity);
1675
+ }
1676
+ return [...merged].map(([productId, quantity]) => ({ productId, quantity }));
1677
+ }
1678
+ async function priceLines(db, requests, currency) {
1679
+ const ids = requests.map((r) => r.productId);
1680
+ const found = await findPurchasable(
1681
+ db,
1682
+ ids.filter((id) => UUID2.test(id))
1683
+ );
1684
+ const byId = new Map(found.map((p) => [p.id, p]));
1685
+ const unavailable = ids.filter((id) => byId.get(id)?.currency !== currency);
1686
+ if (unavailable.length > 0) throw new CheckoutConflictError(unavailable);
1687
+ return requests.map(({ productId, quantity }) => {
1688
+ const product = byId.get(productId);
1689
+ return {
1690
+ productId,
1691
+ name: product.name,
1692
+ unitAmountCents: product.priceCents,
1693
+ quantity
1694
+ };
1695
+ });
1696
+ }
1697
+ async function reserveOrder(db, orderId, lines, currency, expiresAt) {
1698
+ try {
1699
+ await db.transaction(async (tx) => {
1700
+ await reserve(tx, { reference: orderId, items: lines, expiresAt });
1701
+ await insertOrder(
1702
+ tx,
1703
+ {
1704
+ id: orderId,
1705
+ currency,
1706
+ subtotalCents: lines.reduce(
1707
+ (sum, l) => sum + l.unitAmountCents * l.quantity,
1708
+ 0
1709
+ )
1710
+ },
1711
+ lines.map((l) => ({
1712
+ productId: l.productId,
1713
+ name: l.name,
1714
+ priceCents: l.unitAmountCents,
1715
+ quantity: l.quantity
1716
+ }))
1717
+ );
1718
+ });
1719
+ } catch (err) {
1720
+ if (err instanceof InsufficientStockError) {
1721
+ throw new CheckoutConflictError(err.productIds);
1722
+ }
1723
+ throw err;
1724
+ }
1725
+ }
1726
+ async function createCheckout(db, settings, request) {
1727
+ const lines = await priceLines(
1728
+ db,
1729
+ mergeItems(request.items),
1730
+ settings.currency
1731
+ );
1732
+ const shippingRates2 = await listActiveRates(db, settings.currency);
1733
+ const orderId = randomUUID2();
1734
+ const expiresAt = new Date(Date.now() + HOLD_DURATION_MS);
1735
+ await reserveOrder(db, orderId, lines, settings.currency, expiresAt);
1736
+ let session;
1737
+ try {
1738
+ session = await settings.payment.createCheckoutSession({
1739
+ reference: orderId,
1740
+ currency: settings.currency,
1741
+ lines,
1742
+ successUrl: settings.urls.success(SESSION_ID_PLACEHOLDER),
1743
+ cancelUrl: settings.urls.cancel,
1744
+ shippingCountries: settings.shippingCountries,
1745
+ shippingRates: shippingRates2,
1746
+ expiresAt
1747
+ });
1748
+ } catch (err) {
1749
+ await release(db, orderId);
1750
+ await setOrderStatus(db, orderId, "cancelled");
1751
+ throw new PaymentGatewayError(err);
1752
+ }
1753
+ await setCheckoutSession(db, orderId, session.id);
1754
+ return { url: session.url };
1755
+ }
1756
+
1757
+ // src/orders/webhook-routes.ts
1758
+ var webhookRoutes = async (app, { db, payment, hooks }) => {
1759
+ app.removeAllContentTypeParsers();
1760
+ app.addContentTypeParser(
1761
+ "*",
1762
+ { parseAs: "buffer" },
1763
+ (_req, body, done) => done(null, body)
1764
+ );
1765
+ app.post("/stripe", async (req, reply) => {
1766
+ const header = req.headers["stripe-signature"];
1767
+ const signature = typeof header === "string" ? header : void 0;
1768
+ const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
1769
+ let event;
1770
+ try {
1771
+ event = await payment.verifyWebhook(rawBody, signature);
1772
+ } catch (err) {
1773
+ if (err instanceof WebhookSignatureError) {
1774
+ return reply.code(400).send({ error: err.code });
1775
+ }
1776
+ throw err;
1777
+ }
1778
+ const outcome = await handlePaymentEvent(db, event, hooks, req.log);
1779
+ return { received: true, outcome };
1780
+ });
1781
+ };
1782
+
1783
+ // src/orders/routes.ts
1784
+ var ordersPlugin = async (app, { db, ...settings }) => {
1785
+ app.post("/checkout", async (req, reply) => {
1786
+ const parsed = checkoutRequestSchema.safeParse(req.body);
1787
+ if (!parsed.success) {
1788
+ return reply.code(400).send({ error: "INVALID_CHECKOUT_REQUEST" });
1789
+ }
1790
+ try {
1791
+ return checkoutResponseSchema.parse(
1792
+ await createCheckout(db, settings, parsed.data)
1793
+ );
1794
+ } catch (err) {
1795
+ if (err instanceof CheckoutConflictError) {
1796
+ return reply.code(409).send(
1797
+ checkoutConflictSchema.parse({
1798
+ unavailableProductIds: err.productIds
1799
+ })
1800
+ );
1801
+ }
1802
+ if (err instanceof PaymentGatewayError) {
1803
+ req.log.error({ err: err.cause }, err.message);
1804
+ return reply.code(502).send({ error: err.code });
1805
+ }
1806
+ throw err;
1807
+ }
1808
+ });
1809
+ app.get(
1810
+ "/orders/session/:sessionId",
1811
+ async (req, reply) => {
1812
+ try {
1813
+ return orderSummarySchema.parse(
1814
+ await getOrderSummary(db, req.params.sessionId)
1815
+ );
1816
+ } catch (err) {
1817
+ if (err instanceof OrderNotFoundError) {
1818
+ return reply.code(404).send({ error: err.code });
1819
+ }
1820
+ throw err;
1821
+ }
1822
+ }
1823
+ );
1824
+ await app.register(webhookRoutes, {
1825
+ db,
1826
+ payment: settings.payment,
1827
+ hooks: settings.hooks,
1828
+ prefix: "/orders/webhooks"
1829
+ });
1830
+ };
1831
+
1832
+ // src/plugin.ts
1833
+ var commercePlugin = async (app, {
1834
+ db,
1835
+ currency,
1836
+ payment,
1837
+ urls,
1838
+ shippingCountries = ["US"],
1839
+ hooks,
1840
+ admin,
1841
+ categoryLabels
1842
+ }) => {
1843
+ app.get("/health", async () => ({ status: "ok" }));
1844
+ await app.register(catalogPlugin, {
1845
+ db,
1846
+ categoryLabels,
1847
+ prefix: "/catalog"
1848
+ });
1849
+ if (admin) {
1850
+ await app.register(adminPlugin, {
1851
+ db,
1852
+ currency,
1853
+ ...admin,
1854
+ prefix: "/admin"
1855
+ });
1856
+ }
1857
+ if (!payment || !urls) return;
1858
+ await app.register(ordersPlugin, {
1859
+ db,
1860
+ currency,
1861
+ payment,
1862
+ urls,
1863
+ shippingCountries,
1864
+ hooks
1865
+ });
1866
+ if (admin) {
1867
+ await app.register(adminOrdersPlugin, {
1868
+ db,
1869
+ authorize: admin.authorize,
1870
+ payment,
1871
+ hooks,
1872
+ prefix: "/admin/orders"
1873
+ });
1874
+ }
1875
+ };
1876
+
1877
+ // src/payment/stripe-gateway.ts
1878
+ var REFUND_KEY_METADATA = "commerceRefundKey";
1879
+ function rejectedRefund(err) {
1880
+ if (err instanceof RefundRejectedError) return err;
1881
+ const status = err?.statusCode;
1882
+ if (typeof status === "number" && status >= 400 && status < 500) {
1883
+ return new RefundRejectedError(err.message, { cause: err });
1884
+ }
1885
+ return err;
1886
+ }
1887
+ function toShippingAddress(name, address) {
1888
+ if (!address) return void 0;
1889
+ const fields = {
1890
+ name: name ?? void 0,
1891
+ line1: address.line1 ?? void 0,
1892
+ line2: address.line2 ?? void 0,
1893
+ city: address.city ?? void 0,
1894
+ state: address.state ?? void 0,
1895
+ postalCode: address.postal_code ?? void 0,
1896
+ country: address.country ?? void 0
1897
+ };
1898
+ return Object.fromEntries(
1899
+ Object.entries(fields).filter(([, v]) => v !== void 0)
1900
+ );
1901
+ }
1902
+ function toShippingOption(rate, currency) {
1903
+ const days = (value) => ({ unit: "business_day", value });
1904
+ return {
1905
+ shipping_rate_data: {
1906
+ type: "fixed_amount",
1907
+ display_name: rate.label,
1908
+ fixed_amount: { amount: rate.amountCents, currency },
1909
+ ...rate.minDays !== void 0 && rate.maxDays !== void 0 && {
1910
+ delivery_estimate: {
1911
+ minimum: days(rate.minDays),
1912
+ maximum: days(rate.maxDays)
1913
+ }
1914
+ }
1915
+ }
1916
+ };
1917
+ }
1918
+ function toChosenShipping(session) {
1919
+ const cost = session.shipping_cost;
1920
+ const rate = cost?.shipping_rate;
1921
+ if (!cost || !rate || typeof rate === "string") return void 0;
1922
+ return {
1923
+ label: rate.display_name ?? "Shipping",
1924
+ amountCents: cost.amount_total
1925
+ };
1926
+ }
1927
+ function toPaymentEvent(event, expanded) {
1928
+ if (event.type === "checkout.session.completed") {
1929
+ const session = event.data.object;
1930
+ const shipping = session.collected_information?.shipping_details;
1931
+ return {
1932
+ id: event.id,
1933
+ type: "checkout.completed",
1934
+ sessionId: session.id,
1935
+ email: session.customer_details?.email ?? void 0,
1936
+ shippingAddress: toShippingAddress(shipping?.name, shipping?.address),
1937
+ shipping: expanded && toChosenShipping(expanded),
1938
+ paymentPending: session.payment_status === "unpaid"
1939
+ };
1940
+ }
1941
+ if (event.type === "checkout.session.async_payment_succeeded") {
1942
+ return {
1943
+ id: event.id,
1944
+ type: "checkout.async_paid",
1945
+ sessionId: event.data.object.id
1946
+ };
1947
+ }
1948
+ if (event.type === "checkout.session.async_payment_failed") {
1949
+ return {
1950
+ id: event.id,
1951
+ type: "checkout.async_failed",
1952
+ sessionId: event.data.object.id
1953
+ };
1954
+ }
1955
+ if (event.type === "checkout.session.expired") {
1956
+ return {
1957
+ id: event.id,
1958
+ type: "checkout.expired",
1959
+ sessionId: event.data.object.id
1960
+ };
1961
+ }
1962
+ return { id: event.id, type: "ignored", sourceType: event.type };
1963
+ }
1964
+ function toRefundEvent(refund) {
1965
+ return {
1966
+ gatewayRefundId: refund.id,
1967
+ amountCents: refund.amount,
1968
+ idempotencyKey: refund.metadata?.[REFUND_KEY_METADATA] || void 0
1969
+ };
1970
+ }
1971
+ async function refundedEvent(stripe, event) {
1972
+ const charge = event.data.object;
1973
+ const intent = charge.payment_intent;
1974
+ const ignored2 = {
1975
+ id: event.id,
1976
+ type: "ignored",
1977
+ sourceType: event.type
1978
+ };
1979
+ if (!intent) return ignored2;
1980
+ const { data: sessions } = await stripe.checkout.sessions.list({
1981
+ payment_intent: typeof intent === "string" ? intent : intent.id,
1982
+ limit: 1
1983
+ });
1984
+ if (sessions.length === 0) return ignored2;
1985
+ const refunds = charge.refunds?.data ?? (await stripe.refunds.list({ charge: charge.id, limit: 100 })).data;
1986
+ return {
1987
+ id: event.id,
1988
+ type: "payment.refunded",
1989
+ sessionId: sessions[0].id,
1990
+ refunds: refunds.filter((refund) => refund.status !== "failed").map(toRefundEvent)
1991
+ };
1992
+ }
1993
+ async function loadStripe(secretKey) {
1994
+ const { default: StripeClient } = await import("stripe");
1995
+ return new StripeClient(secretKey);
1996
+ }
1997
+ function createStripeGateway({
1998
+ secretKey,
1999
+ webhookSecret,
2000
+ client
2001
+ }) {
2002
+ let stripe = client ? Promise.resolve(client) : void 0;
2003
+ const getStripe = () => stripe ??= loadStripe(secretKey);
2004
+ return {
2005
+ async createCheckoutSession(input) {
2006
+ const session = await (await getStripe()).checkout.sessions.create({
2007
+ mode: "payment",
2008
+ client_reference_id: input.reference,
2009
+ metadata: { reference: input.reference },
2010
+ line_items: input.lines.map((line) => ({
2011
+ quantity: line.quantity,
2012
+ price_data: {
2013
+ currency: input.currency.toLowerCase(),
2014
+ unit_amount: line.unitAmountCents,
2015
+ product_data: {
2016
+ name: line.name,
2017
+ metadata: { productId: line.productId }
2018
+ }
2019
+ }
2020
+ })),
2021
+ success_url: input.successUrl,
2022
+ cancel_url: input.cancelUrl,
2023
+ expires_at: Math.floor(input.expiresAt.getTime() / 1e3),
2024
+ ...input.shippingRates.length > 0 && {
2025
+ shipping_options: input.shippingRates.map(
2026
+ (rate) => toShippingOption(rate, input.currency.toLowerCase())
2027
+ )
2028
+ },
2029
+ shipping_address_collection: {
2030
+ allowed_countries: input.shippingCountries
2031
+ }
2032
+ });
2033
+ if (!session.url) throw new Error("Stripe returned no checkout URL");
2034
+ return { id: session.id, url: session.url };
2035
+ },
2036
+ async verifyWebhook(rawBody, signature) {
2037
+ const stripeClient = await getStripe();
2038
+ let event;
2039
+ try {
2040
+ event = stripeClient.webhooks.constructEvent(
2041
+ rawBody,
2042
+ signature ?? "",
2043
+ webhookSecret
2044
+ );
2045
+ } catch (err) {
2046
+ throw new WebhookSignatureError(err.message);
2047
+ }
2048
+ if (event.type === "charge.refunded") {
2049
+ return refundedEvent(stripeClient, event);
2050
+ }
2051
+ if (event.type !== "checkout.session.completed") {
2052
+ return toPaymentEvent(event);
2053
+ }
2054
+ const session = await stripeClient.checkout.sessions.retrieve(
2055
+ event.data.object.id,
2056
+ { expand: ["shipping_cost.shipping_rate"] }
2057
+ );
2058
+ return toPaymentEvent(event, session);
2059
+ },
2060
+ async refund({ sessionId, amountCents, idempotencyKey: idempotencyKey2 }) {
2061
+ try {
2062
+ const stripeClient = await getStripe();
2063
+ const session = await stripeClient.checkout.sessions.retrieve(sessionId);
2064
+ const intent = session.payment_intent;
2065
+ if (!intent) {
2066
+ throw new RefundRejectedError(`Session ${sessionId} has no payment`);
2067
+ }
2068
+ const refund = await stripeClient.refunds.create(
2069
+ {
2070
+ payment_intent: typeof intent === "string" ? intent : intent.id,
2071
+ amount: amountCents,
2072
+ metadata: { [REFUND_KEY_METADATA]: idempotencyKey2 }
2073
+ },
2074
+ { idempotencyKey: idempotencyKey2 }
2075
+ );
2076
+ return { gatewayRefundId: refund.id };
2077
+ } catch (err) {
2078
+ throw rejectedRefund(err);
2079
+ }
2080
+ }
2081
+ };
2082
+ }
2083
+
2084
+ // src/payment/fake-gateway.ts
2085
+ import { createHmac, randomUUID as randomUUID3, timingSafeEqual } from "crypto";
2086
+ function createFakeGateway({
2087
+ secret = "fake-webhook-secret"
2088
+ } = {}) {
2089
+ const sessions = [];
2090
+ const refunds = [];
2091
+ const refundCalls = [];
2092
+ let pendingFailure;
2093
+ let pendingRefundFailure;
2094
+ const hmac = (body) => createHmac("sha256", secret).update(body).digest("hex");
2095
+ function sign(event) {
2096
+ const rawBody = Buffer.from(JSON.stringify(event));
2097
+ return { rawBody, signature: hmac(rawBody) };
2098
+ }
2099
+ return {
2100
+ sessions,
2101
+ refunds,
2102
+ refundCalls,
2103
+ async createCheckoutSession(input) {
2104
+ if (pendingFailure) {
2105
+ const err = pendingFailure;
2106
+ pendingFailure = void 0;
2107
+ throw err;
2108
+ }
2109
+ const id = `fake_cs_${randomUUID3()}`;
2110
+ sessions.push({ ...input, id });
2111
+ return {
2112
+ id,
2113
+ url: input.successUrl.replace(SESSION_ID_PLACEHOLDER, id)
2114
+ };
2115
+ },
2116
+ async verifyWebhook(rawBody, signature) {
2117
+ const expected = Buffer.from(hmac(rawBody));
2118
+ const given = Buffer.from(signature ?? "");
2119
+ if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
2120
+ throw new WebhookSignatureError();
2121
+ }
2122
+ return JSON.parse(rawBody.toString("utf8"));
2123
+ },
2124
+ async refund(input) {
2125
+ refundCalls.push(input);
2126
+ if (pendingRefundFailure) {
2127
+ const err = pendingRefundFailure;
2128
+ pendingRefundFailure = void 0;
2129
+ throw err;
2130
+ }
2131
+ const prior = refunds.find(
2132
+ (r) => r.idempotencyKey === input.idempotencyKey
2133
+ );
2134
+ if (prior) return { gatewayRefundId: prior.gatewayRefundId };
2135
+ const gatewayRefundId = `fake_re_${refunds.length + 1}_${input.sessionId}`;
2136
+ refunds.push({ ...input, gatewayRefundId });
2137
+ return { gatewayRefundId };
2138
+ },
2139
+ failNextSession(error = new Error("Fake gateway failure")) {
2140
+ pendingFailure = error;
2141
+ },
2142
+ failNextRefund(error = new RefundRejectedError("Fake refund failure")) {
2143
+ pendingRefundFailure = error;
2144
+ },
2145
+ failNextRefundUncertain(error = new Error("Fake refund timeout")) {
2146
+ pendingRefundFailure = error;
2147
+ },
2148
+ completed(sessionId, details = {}) {
2149
+ return sign({
2150
+ id: `fake_evt_${randomUUID3()}`,
2151
+ type: "checkout.completed",
2152
+ sessionId,
2153
+ ...details
2154
+ });
2155
+ },
2156
+ asyncPaid(sessionId) {
2157
+ return sign({
2158
+ id: `fake_evt_${randomUUID3()}`,
2159
+ type: "checkout.async_paid",
2160
+ sessionId
2161
+ });
2162
+ },
2163
+ asyncFailed(sessionId) {
2164
+ return sign({
2165
+ id: `fake_evt_${randomUUID3()}`,
2166
+ type: "checkout.async_failed",
2167
+ sessionId
2168
+ });
2169
+ },
2170
+ expired(sessionId) {
2171
+ return sign({
2172
+ id: `fake_evt_${randomUUID3()}`,
2173
+ type: "checkout.expired",
2174
+ sessionId
2175
+ });
2176
+ },
2177
+ refunded(sessionId, refunds2) {
2178
+ return sign({
2179
+ id: `fake_evt_${randomUUID3()}`,
2180
+ type: "payment.refunded",
2181
+ sessionId,
2182
+ refunds: refunds2
2183
+ });
2184
+ },
2185
+ sign
2186
+ };
2187
+ }
2188
+
2189
+ // src/hold-sweeper.ts
2190
+ function startHoldSweeper(db, { intervalMs = 6e4, onError } = {}) {
2191
+ const timer = setInterval(() => {
2192
+ releaseExpired(db).catch((err) => onError?.(err));
2193
+ }, intervalMs);
2194
+ timer.unref();
2195
+ return { stop: () => clearInterval(timer) };
2196
+ }
2197
+
2198
+ // src/db/migrate.ts
2199
+ import path from "path";
2200
+ import { fileURLToPath } from "url";
2201
+ import { migrate } from "drizzle-orm/node-postgres/migrator";
2202
+ var here = path.dirname(fileURLToPath(import.meta.url));
2203
+ function resolveMigrationsFolder() {
2204
+ const fromSrc = path.resolve(here, "../../migrations");
2205
+ const fromDist = path.resolve(here, "../migrations");
2206
+ return here.endsWith(path.join("src", "db")) ? fromSrc : fromDist;
2207
+ }
2208
+ async function runCommerceMigrations(db) {
2209
+ await migrate(db, {
2210
+ migrationsFolder: resolveMigrationsFolder(),
2211
+ migrationsTable: "__commerce_migrations"
2212
+ });
2213
+ }
2214
+
2215
+ // src/seed.ts
2216
+ import { eq as eq10, sql as sql10 } from "drizzle-orm";
2217
+ async function seedProducts(db, items) {
2218
+ await db.transaction(async (tx) => {
2219
+ for (const { images = [], ...item } of items) {
2220
+ const values = { status: "active", ...item };
2221
+ const [row] = await tx.insert(products).values(values).onConflictDoUpdate({
2222
+ target: products.slug,
2223
+ set: { ...values, updatedAt: sql10`now()` }
2224
+ }).returning({ id: products.id });
2225
+ await tx.delete(productImages).where(eq10(productImages.productId, row.id));
2226
+ if (images.length > 0) {
2227
+ await tx.insert(productImages).values(
2228
+ images.map((img, position) => ({
2229
+ productId: row.id,
2230
+ url: img.url,
2231
+ alt: img.alt ?? "",
2232
+ position
2233
+ }))
2234
+ );
2235
+ }
2236
+ }
2237
+ });
2238
+ }
2239
+
2240
+ // src/shipping/seed.ts
2241
+ async function seedShippingRates(db, rates) {
2242
+ await db.transaction(async (tx) => {
2243
+ for (const rate of rates) {
2244
+ await tx.insert(shippingRates).values(rate).onConflictDoUpdate({
2245
+ target: shippingRates.label,
2246
+ set: {
2247
+ amountCents: rate.amountCents,
2248
+ currency: rate.currency,
2249
+ minDays: rate.minDays ?? null,
2250
+ maxDays: rate.maxDays ?? null,
2251
+ active: rate.active ?? true,
2252
+ sort: rate.sort ?? 0
2253
+ }
2254
+ });
2255
+ }
2256
+ });
2257
+ }
2258
+
2259
+ // src/storage/memory.ts
2260
+ function createMemoryStorage(baseUrl = "memory://") {
2261
+ const files = /* @__PURE__ */ new Map();
2262
+ return {
2263
+ files,
2264
+ async put(key, stream, contentType) {
2265
+ const chunks = [];
2266
+ for await (const chunk of stream) chunks.push(Buffer.from(chunk));
2267
+ files.set(key, { body: Buffer.concat(chunks), contentType });
2268
+ return { key, url: `${baseUrl}${key}` };
2269
+ },
2270
+ async delete(key) {
2271
+ files.delete(key);
2272
+ }
2273
+ };
2274
+ }
2275
+ export {
2276
+ ProductNotFoundError,
2277
+ SESSION_ID_PLACEHOLDER,
2278
+ WebhookSignatureError,
2279
+ catalogPlugin,
2280
+ commercePlugin,
2281
+ commerceSchema,
2282
+ createFakeGateway,
2283
+ createMemoryStorage,
2284
+ createStripeGateway,
2285
+ ordersPlugin,
2286
+ runCommerceMigrations,
2287
+ seedProducts,
2288
+ seedShippingRates,
2289
+ startHoldSweeper
2290
+ };
2291
+ //# sourceMappingURL=index.js.map