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