@develemit/commerce-server 0.3.0 → 0.6.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 CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  adminProductPatchSchema as adminProductPatchSchema2,
5
5
  adminProductPageSchema,
6
6
  imageOrderInputSchema,
7
+ productImagePatchSchema,
7
8
  shippingRatesInputSchema
8
9
  } from "@develemit/commerce-contracts";
9
10
 
@@ -27,6 +28,9 @@ var InvalidCursorError = class extends Error {
27
28
  }
28
29
  };
29
30
  var PG_TIMESTAMP = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d+)?[+-]\d{2}(:?\d{2})?$/;
31
+ function isCursorTimestamp(at) {
32
+ return PG_TIMESTAMP.test(at) || !Number.isNaN(Date.parse(at));
33
+ }
30
34
  function encodeCursor({ at, key }) {
31
35
  return Buffer.from(`${at}|${key}`).toString("base64url");
32
36
  }
@@ -130,6 +134,8 @@ var orders = pgTable("commerce_orders", {
130
134
  status: orderStatus("status").notNull().default("pending"),
131
135
  email: text("email"),
132
136
  subtotalCents: integer("subtotal_cents").notNull(),
137
+ discountCents: integer("discount_cents").notNull().default(0),
138
+ discountCode: text("discount_code"),
133
139
  shippingCents: integer("shipping_cents").notNull().default(0),
134
140
  shippingLabel: text("shipping_label"),
135
141
  currency: text("currency").notNull(),
@@ -194,6 +200,69 @@ var shippingRates = pgTable(
194
200
  check("commerce_shipping_rates_amount_check", sql`${t.amountCents} >= 0`)
195
201
  ]
196
202
  );
203
+ var discounts = pgTable(
204
+ "commerce_discounts",
205
+ {
206
+ id: uuid("id").primaryKey().defaultRandom(),
207
+ code: text("code").notNull().unique(),
208
+ type: text("type", { enum: ["percent", "fixed"] }).notNull(),
209
+ percentOff: integer("percent_off"),
210
+ amountOffCents: integer("amount_off_cents"),
211
+ currency: text("currency"),
212
+ minSubtotalCents: integer("min_subtotal_cents"),
213
+ startsAt: timestamp("starts_at", { withTimezone: true }),
214
+ endsAt: timestamp("ends_at", { withTimezone: true }),
215
+ maxRedemptions: integer("max_redemptions"),
216
+ active: boolean("active").notNull().default(true),
217
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
218
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
219
+ },
220
+ (t) => [
221
+ check("commerce_discounts_code_check", sql`${t.code} = upper(${t.code})`),
222
+ check(
223
+ "commerce_discounts_percent_check",
224
+ sql`${t.percentOff} is null or (${t.percentOff} between 1 and 100)`
225
+ ),
226
+ check(
227
+ "commerce_discounts_amount_check",
228
+ sql`${t.amountOffCents} is null or ${t.amountOffCents} > 0`
229
+ ),
230
+ check(
231
+ "commerce_discounts_max_redemptions_check",
232
+ sql`${t.maxRedemptions} is null or ${t.maxRedemptions} > 0`
233
+ ),
234
+ check(
235
+ "commerce_discounts_type_fields_check",
236
+ sql`(${t.type} = 'percent' and ${t.percentOff} is not null and ${t.amountOffCents} is null and ${t.currency} is null) or (${t.type} = 'fixed' and ${t.percentOff} is null and ${t.amountOffCents} is not null and ${t.currency} is not null)`
237
+ )
238
+ ]
239
+ );
240
+ var discountRedemptions = pgTable(
241
+ "commerce_discount_redemptions",
242
+ {
243
+ id: uuid("id").primaryKey().defaultRandom(),
244
+ discountId: uuid("discount_id").notNull().references(() => discounts.id, { onDelete: "cascade" }),
245
+ orderId: uuid("order_id").notNull().unique().references(() => orders.id, { onDelete: "cascade" }),
246
+ status: text("status", { enum: ["reserved", "committed", "released"] }).notNull().default("reserved"),
247
+ amountCents: integer("amount_cents").notNull(),
248
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
249
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
250
+ },
251
+ (t) => [
252
+ check(
253
+ "commerce_discount_redemptions_amount_check",
254
+ sql`${t.amountCents} > 0`
255
+ ),
256
+ check(
257
+ "commerce_discount_redemptions_status_check",
258
+ sql`${t.status} in ('reserved', 'committed', 'released')`
259
+ ),
260
+ index("commerce_discount_redemptions_discount_status_idx").on(
261
+ t.discountId,
262
+ t.status
263
+ )
264
+ ]
265
+ );
197
266
  var commerceSchema = {
198
267
  products,
199
268
  productImages,
@@ -205,7 +274,9 @@ var commerceSchema = {
205
274
  orderStatus,
206
275
  orderRefunds,
207
276
  stripeEvents,
208
- shippingRates
277
+ shippingRates,
278
+ discounts,
279
+ discountRedemptions
209
280
  };
210
281
 
211
282
  // src/shipping/repo.ts
@@ -355,6 +426,9 @@ async function deleteImageRow(db, id) {
355
426
  async function setImagePosition(db, id, position) {
356
427
  await db.update(productImages).set({ position }).where(eq2(productImages.id, id));
357
428
  }
429
+ async function setImageAlt(db, id, alt) {
430
+ await db.update(productImages).set({ alt }).where(eq2(productImages.id, id));
431
+ }
358
432
 
359
433
  // src/admin/service.ts
360
434
  import {
@@ -538,6 +612,14 @@ async function removeProductImage(db, storage, productId, imageId) {
538
612
  if (image.storageKey) await storage.delete(image.storageKey);
539
613
  return getAdminProduct(db, productId);
540
614
  }
615
+ async function updateProductImageAlt(db, productId, imageId, alt) {
616
+ const { images } = await requireProduct(db, productId);
617
+ if (!images.some((i) => i.id === imageId)) {
618
+ throw new ProductImageNotFoundError(imageId);
619
+ }
620
+ await setImageAlt(db, imageId, alt);
621
+ return getAdminProduct(db, productId);
622
+ }
541
623
 
542
624
  // src/admin/routes.ts
543
625
  function isZodError(err) {
@@ -598,7 +680,7 @@ var adminPlugin = async (app, { db, currency, authorize, storage }) => {
598
680
  if (!part) throw new InvalidImageError("An image file is required");
599
681
  const bytes = await part.toBuffer();
600
682
  const fields = part.fields;
601
- const alt = typeof fields.alt?.value === "string" ? fields.alt.value : void 0;
683
+ const alt = typeof fields.alt?.value === "string" ? productImagePatchSchema.parse({ alt: fields.alt.value }).alt : void 0;
602
684
  const product = await addProductImage(db, storage, req.params.id, {
603
685
  bytes,
604
686
  alt
@@ -614,6 +696,15 @@ var adminPlugin = async (app, { db, currency, authorize, storage }) => {
614
696
  imageOrderInputSchema.parse(req.body).imageIds
615
697
  )
616
698
  );
699
+ app.patch(
700
+ "/products/:id/images/:imageId",
701
+ async (req) => updateProductImageAlt(
702
+ db,
703
+ req.params.id,
704
+ req.params.imageId,
705
+ productImagePatchSchema.parse(req.body).alt
706
+ )
707
+ );
617
708
  app.delete(
618
709
  "/products/:id/images/:imageId",
619
710
  async (req) => removeProductImage(db, storage, req.params.id, req.params.imageId)
@@ -756,8 +847,8 @@ async function releaseExpiredHolds(db) {
756
847
  eq3(inventoryHolds.status, "active"),
757
848
  lt(inventoryHolds.expiresAt, sql3`now()`)
758
849
  )
759
- ).returning({ id: inventoryHolds.id });
760
- return released.length;
850
+ ).returning({ reference: inventoryHolds.reference });
851
+ return released.map((r) => r.reference);
761
852
  }
762
853
 
763
854
  // src/inventory/types.ts
@@ -812,8 +903,8 @@ async function reserve(db, { reference, items, expiresAt }) {
812
903
  async function release(db, reference) {
813
904
  await releaseHolds(db, reference);
814
905
  }
815
- async function releaseExpired(db) {
816
- return releaseExpiredHolds(db);
906
+ async function releaseExpiredReferences(db) {
907
+ return [...new Set(await releaseExpiredHolds(db))];
817
908
  }
818
909
  async function commitWithin(tx, reference) {
819
910
  const holds = await uncommittedHolds(tx, reference);
@@ -1058,7 +1149,8 @@ function conditions(filter) {
1058
1149
  );
1059
1150
  }
1060
1151
  if (filter.after) {
1061
- const { createdAt, id } = filter.after;
1152
+ const { id } = filter.after;
1153
+ const createdAt = sql5`${filter.after.createdAt}::timestamptz`;
1062
1154
  parts.push(
1063
1155
  or3(
1064
1156
  lt2(orders.createdAt, createdAt),
@@ -1077,10 +1169,11 @@ async function selectOrderPage(db, filter) {
1077
1169
  id: orders.id,
1078
1170
  status: orders.status,
1079
1171
  email: orders.email,
1080
- totalCents: sql5`${orders.subtotalCents} + ${orders.shippingCents}`,
1172
+ totalCents: sql5`${orders.subtotalCents} - ${orders.discountCents} + ${orders.shippingCents}`,
1081
1173
  currency: orders.currency,
1082
1174
  itemCount,
1083
- createdAt: orders.createdAt
1175
+ createdAt: orders.createdAt,
1176
+ createdAtKey: sql5`${orders.createdAt}::text`
1084
1177
  }).from(orders).where(conditions(filter)).orderBy(desc(orders.createdAt), desc(orders.id)).limit(filter.limit);
1085
1178
  }
1086
1179
  async function findOrderRefunds(db, orderId) {
@@ -1099,7 +1192,7 @@ var isRecordedRefund = or4(
1099
1192
  async function lockOrderForRefund(tx, id) {
1100
1193
  const [row] = await tx.select({
1101
1194
  status: orders.status,
1102
- totalCents: sql6`${orders.subtotalCents} + ${orders.shippingCents}`,
1195
+ totalCents: sql6`${orders.subtotalCents} - ${orders.discountCents} + ${orders.shippingCents}`,
1103
1196
  currency: orders.currency,
1104
1197
  checkoutSessionId: orders.checkoutSessionId
1105
1198
  }).from(orders).where(eq6(orders.id, id)).for("update");
@@ -1232,9 +1325,11 @@ async function toSummary(db, order) {
1232
1325
  id: order.id,
1233
1326
  status: order.status,
1234
1327
  subtotalCents: order.subtotalCents,
1328
+ discountCents: order.discountCents,
1329
+ discountCode: order.discountCode,
1235
1330
  shippingCents: order.shippingCents,
1236
1331
  shippingLabel: order.shippingLabel,
1237
- totalCents: order.subtotalCents + order.shippingCents,
1332
+ totalCents: order.subtotalCents - order.discountCents + order.shippingCents,
1238
1333
  currency: order.currency,
1239
1334
  items: items.map((i) => ({
1240
1335
  productId: i.productId,
@@ -1295,9 +1390,9 @@ function isOrderId(value) {
1295
1390
  function decodeOrderCursor(raw) {
1296
1391
  const { at, key } = decodeCursor(
1297
1392
  raw,
1298
- (c) => isOrderId(c.key) && !Number.isNaN(+new Date(c.at))
1393
+ (c) => isOrderId(c.key) && isCursorTimestamp(c.at)
1299
1394
  );
1300
- return { createdAt: new Date(at), id: key };
1395
+ return { createdAt: at, id: key };
1301
1396
  }
1302
1397
  async function listAdminOrders(db, query) {
1303
1398
  const rows = await selectOrderPage(db, {
@@ -1310,8 +1405,16 @@ async function listAdminOrders(db, query) {
1310
1405
  const page = rows.slice(0, query.limit);
1311
1406
  const last = page[page.length - 1];
1312
1407
  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
1408
+ items: page.map((r) => ({
1409
+ id: r.id,
1410
+ status: r.status,
1411
+ email: r.email,
1412
+ totalCents: r.totalCents,
1413
+ currency: r.currency,
1414
+ itemCount: r.itemCount,
1415
+ createdAt: r.createdAt.toISOString()
1416
+ })),
1417
+ nextCursor: rows.length > query.limit && last ? encodeCursor({ at: last.createdAtKey, key: last.id }) : null
1315
1418
  };
1316
1419
  }
1317
1420
  async function getAdminOrder(db, id) {
@@ -1380,6 +1483,193 @@ var RefundOutcomeUnknownError = class extends Error {
1380
1483
  }
1381
1484
  };
1382
1485
 
1486
+ // src/discounts/evaluate.ts
1487
+ function rejection(reason) {
1488
+ return { ok: false, reason };
1489
+ }
1490
+ function discountAmount(discount, subtotalCents) {
1491
+ if (discount.type === "percent") {
1492
+ return Math.floor(subtotalCents * (discount.percentOff ?? 0) / 100);
1493
+ }
1494
+ return Math.min(discount.amountOffCents ?? 0, subtotalCents);
1495
+ }
1496
+ function evaluateDiscount(discount, ctx) {
1497
+ if (!discount) return rejection("not_found");
1498
+ if (!discount.active) return rejection("inactive");
1499
+ if (discount.startsAt && ctx.now < discount.startsAt) {
1500
+ return rejection("not_started");
1501
+ }
1502
+ if (discount.endsAt && ctx.now >= discount.endsAt) {
1503
+ return rejection("expired");
1504
+ }
1505
+ if (discount.type === "fixed" && discount.currency !== ctx.currency) {
1506
+ return rejection("currency");
1507
+ }
1508
+ if (discount.minSubtotalCents !== null && ctx.subtotalCents < discount.minSubtotalCents) {
1509
+ return rejection("min_subtotal");
1510
+ }
1511
+ if (discount.maxRedemptions !== null && ctx.activeRedemptions >= discount.maxRedemptions) {
1512
+ return rejection("exhausted");
1513
+ }
1514
+ return {
1515
+ ok: true,
1516
+ discountCents: discountAmount(discount, ctx.subtotalCents)
1517
+ };
1518
+ }
1519
+
1520
+ // src/discounts/repo.ts
1521
+ import {
1522
+ and as and8,
1523
+ desc as desc2,
1524
+ eq as eq8,
1525
+ getTableColumns,
1526
+ gt as gt4,
1527
+ ilike as ilike3,
1528
+ inArray as inArray4,
1529
+ isNull as isNull2,
1530
+ lt as lt3,
1531
+ lte,
1532
+ or as or5,
1533
+ sql as sql8
1534
+ } from "drizzle-orm";
1535
+ async function findDiscountByCode(db, code, options = {}) {
1536
+ const query = db.select().from(discounts).where(eq8(discounts.code, code));
1537
+ const [row] = await (options.forUpdate ? query.for("update") : query);
1538
+ return row;
1539
+ }
1540
+ async function countActiveRedemptions(db, discountId2) {
1541
+ const [row] = await db.select({ n: sql8`count(*)::int` }).from(discountRedemptions).where(
1542
+ and8(
1543
+ eq8(discountRedemptions.discountId, discountId2),
1544
+ inArray4(discountRedemptions.status, ["reserved", "committed"])
1545
+ )
1546
+ );
1547
+ return row.n;
1548
+ }
1549
+ async function insertRedemption(db, redemption) {
1550
+ await db.insert(discountRedemptions).values(redemption);
1551
+ }
1552
+ async function transitionRedemptions(db, orderIds, from, to) {
1553
+ if (orderIds.length === 0) return;
1554
+ await db.update(discountRedemptions).set({ status: to, updatedAt: sql8`now()` }).where(
1555
+ and8(
1556
+ inArray4(discountRedemptions.orderId, orderIds),
1557
+ inArray4(discountRedemptions.status, from)
1558
+ )
1559
+ );
1560
+ }
1561
+ function escapeLike3(value) {
1562
+ return value.replace(/[\\%_]/g, (c) => `\\${c}`);
1563
+ }
1564
+ function statusCondition(status, now) {
1565
+ const started = or5(isNull2(discounts.startsAt), lte(discounts.startsAt, now));
1566
+ const open = or5(isNull2(discounts.endsAt), gt4(discounts.endsAt, now));
1567
+ if (status === "inactive") return eq8(discounts.active, false);
1568
+ if (status === "scheduled") {
1569
+ return and8(eq8(discounts.active, true), gt4(discounts.startsAt, now));
1570
+ }
1571
+ if (status === "expired") {
1572
+ return and8(eq8(discounts.active, true), lte(discounts.endsAt, now));
1573
+ }
1574
+ return and8(eq8(discounts.active, true), started, open);
1575
+ }
1576
+ function listConditions(filter) {
1577
+ const parts = [];
1578
+ if (filter.status) parts.push(statusCondition(filter.status, filter.now));
1579
+ if (filter.q) parts.push(ilike3(discounts.code, `${escapeLike3(filter.q)}%`));
1580
+ if (filter.after) {
1581
+ const { id } = filter.after;
1582
+ const createdAt = sql8`${filter.after.createdAt}::timestamptz`;
1583
+ parts.push(
1584
+ or5(
1585
+ lt3(discounts.createdAt, createdAt),
1586
+ and8(eq8(discounts.createdAt, createdAt), lt3(discounts.id, id))
1587
+ )
1588
+ );
1589
+ }
1590
+ return and8(...parts);
1591
+ }
1592
+ function redemptionCount(status) {
1593
+ const filter = status ? sql8`and r.status = ${status}` : sql8``;
1594
+ return sql8`(
1595
+ select count(*) from commerce_discount_redemptions r
1596
+ where r.discount_id = commerce_discounts.id ${filter}
1597
+ )::int`;
1598
+ }
1599
+ var withCounts = {
1600
+ ...getTableColumns(discounts),
1601
+ redeemedCount: redemptionCount("committed"),
1602
+ reservedCount: redemptionCount("reserved"),
1603
+ totalRedemptions: redemptionCount()
1604
+ };
1605
+ async function selectDiscountPage(db, filter) {
1606
+ return db.select({
1607
+ ...withCounts,
1608
+ createdAtKey: sql8`${discounts.createdAt}::text`
1609
+ }).from(discounts).where(listConditions(filter)).orderBy(desc2(discounts.createdAt), desc2(discounts.id)).limit(filter.limit);
1610
+ }
1611
+ async function findDiscountById(db, id, options = {}) {
1612
+ const query = db.select(withCounts).from(discounts).where(eq8(discounts.id, id));
1613
+ const [row] = await (options.forUpdate ? query.for("update") : query);
1614
+ return row;
1615
+ }
1616
+ async function insertDiscount(db, values) {
1617
+ const [row] = await db.insert(discounts).values(values).onConflictDoNothing({ target: discounts.code }).returning({ id: discounts.id });
1618
+ return row?.id;
1619
+ }
1620
+ async function updateDiscount(db, id, values) {
1621
+ await db.update(discounts).set({ ...values, updatedAt: sql8`now()` }).where(eq8(discounts.id, id));
1622
+ }
1623
+
1624
+ // src/discounts/redemptions.ts
1625
+ var DiscountRejectedError = class extends Error {
1626
+ constructor(reason) {
1627
+ super(`Discount rejected: ${reason}`);
1628
+ this.reason = reason;
1629
+ this.name = "DiscountRejectedError";
1630
+ }
1631
+ reason;
1632
+ code = "DISCOUNT_INVALID";
1633
+ };
1634
+ async function evaluateForCheckout(tx, { code, subtotalCents, currency, now = /* @__PURE__ */ new Date() }) {
1635
+ const discount = await findDiscountByCode(tx, code, { forUpdate: true });
1636
+ const result = evaluateDiscount(discount, {
1637
+ subtotalCents,
1638
+ currency,
1639
+ now,
1640
+ activeRedemptions: discount ? await countActiveRedemptions(tx, discount.id) : 0
1641
+ });
1642
+ if (!result.ok) {
1643
+ throw new DiscountRejectedError(
1644
+ result.reason === "inactive" ? "not_found" : result.reason
1645
+ );
1646
+ }
1647
+ return {
1648
+ code,
1649
+ discountCents: result.discountCents,
1650
+ discountId: discount.id
1651
+ };
1652
+ }
1653
+ async function reserveRedemption(tx, orderId, applied) {
1654
+ if (applied.discountCents === 0) return;
1655
+ await insertRedemption(tx, {
1656
+ discountId: applied.discountId,
1657
+ orderId,
1658
+ amountCents: applied.discountCents
1659
+ });
1660
+ }
1661
+ async function commitRedemption(db, orderId) {
1662
+ await transitionRedemptions(
1663
+ db,
1664
+ [orderId],
1665
+ ["reserved", "released"],
1666
+ "committed"
1667
+ );
1668
+ }
1669
+ async function releaseRedemptions(db, orderIds) {
1670
+ await transitionRedemptions(db, orderIds, ["reserved"], "released");
1671
+ }
1672
+
1383
1673
  // src/payment/gateway.ts
1384
1674
  var RefundRejectedError = class extends Error {
1385
1675
  definitelyFailed = true;
@@ -1443,6 +1733,7 @@ async function applyGatewayRefunds(tx, order, refunds) {
1443
1733
  }
1444
1734
  if (SETTLES_AS_REFUNDED.has(locked.status) && await sumRefundedCents(tx, order.id) >= locked.totalCents) {
1445
1735
  await setOrderStatus(tx, order.id, "refunded");
1736
+ await releaseRedemptions(tx, [order.id]);
1446
1737
  }
1447
1738
  return { outcome: "processed", hooks };
1448
1739
  }
@@ -1459,6 +1750,7 @@ async function settle(tx, order) {
1459
1750
  };
1460
1751
  }
1461
1752
  await setOrderStatus(tx, order.id, "paid");
1753
+ await commitRedemption(tx, order.id);
1462
1754
  return { outcome: "processed", hooks: [["onOrderPaid", order.id]] };
1463
1755
  }
1464
1756
  async function completed(tx, order, event) {
@@ -1486,6 +1778,7 @@ async function applyToOrder(tx, order, event) {
1486
1778
  if (order.status !== "pending") return ignored;
1487
1779
  await setOrderStatus(tx, order.id, "cancelled");
1488
1780
  await release(tx, order.id);
1781
+ await releaseRedemptions(tx, [order.id]);
1489
1782
  return {
1490
1783
  outcome: "processed",
1491
1784
  hooks: [["onOrderPaymentFailed", order.id]]
@@ -1494,6 +1787,7 @@ async function applyToOrder(tx, order, event) {
1494
1787
  if (order.status !== "pending") return ignored;
1495
1788
  await setOrderStatus(tx, order.id, "expired");
1496
1789
  await release(tx, order.id);
1790
+ await releaseRedemptions(tx, [order.id]);
1497
1791
  return { outcome: "processed", hooks: [["onOrderExpired", order.id]] };
1498
1792
  case "payment.refunded":
1499
1793
  return applyGatewayRefunds(tx, order, event.refunds);
@@ -1572,7 +1866,7 @@ async function handlePaymentEvent(db, event, hooks = {}, log) {
1572
1866
  }
1573
1867
 
1574
1868
  // src/orders/ship-service.ts
1575
- import { eq as eq8, sql as sql8 } from "drizzle-orm";
1869
+ import { eq as eq9, sql as sql9 } from "drizzle-orm";
1576
1870
  var InvalidOrderStateError = class extends Error {
1577
1871
  constructor(status) {
1578
1872
  super(`The order is ${status}`);
@@ -1584,7 +1878,7 @@ var InvalidOrderStateError = class extends Error {
1584
1878
  };
1585
1879
  async function applyShipment(db, id, input) {
1586
1880
  return db.transaction(async (tx) => {
1587
- const [row] = await tx.select({ status: orders.status }).from(orders).where(eq8(orders.id, id)).for("update");
1881
+ const [row] = await tx.select({ status: orders.status }).from(orders).where(eq9(orders.id, id)).for("update");
1588
1882
  if (!row) throw new AdminOrderNotFoundError(id);
1589
1883
  if (row.status !== "paid" && row.status !== "shipped") {
1590
1884
  throw new InvalidOrderStateError(row.status);
@@ -1594,9 +1888,9 @@ async function applyShipment(db, id, input) {
1594
1888
  fulfillmentCarrier: input.carrier ?? null,
1595
1889
  fulfillmentTrackingNumber: input.trackingNumber ?? null,
1596
1890
  fulfillmentTrackingUrl: input.trackingUrl ?? null,
1597
- ...first && { status: "shipped", shippedAt: sql8`now()` },
1598
- updatedAt: sql8`now()`
1599
- }).where(eq8(orders.id, id));
1891
+ ...first && { status: "shipped", shippedAt: sql9`now()` },
1892
+ updatedAt: sql9`now()`
1893
+ }).where(eq9(orders.id, id));
1600
1894
  return first;
1601
1895
  });
1602
1896
  }
@@ -1653,6 +1947,7 @@ async function recordRefund2(db, pending, gatewayRefundId) {
1653
1947
  }
1654
1948
  if (await sumRefundedCents(tx, refund.orderId) >= order.totalCents) {
1655
1949
  await setOrderStatus(tx, refund.orderId, "refunded");
1950
+ await releaseRedemptions(tx, [refund.orderId]);
1656
1951
  }
1657
1952
  return refund;
1658
1953
  });
@@ -1687,10 +1982,10 @@ async function refundOrder(db, gateway, orderId, input, idempotencyKey2, hooks =
1687
1982
  }
1688
1983
 
1689
1984
  // src/orders/resolve-service.ts
1690
- import { eq as eq9, sql as sql9 } from "drizzle-orm";
1985
+ import { eq as eq10, sql as sql10 } from "drizzle-orm";
1691
1986
  async function applyResolution(db, id) {
1692
1987
  await db.transaction(async (tx) => {
1693
- const [row] = await tx.select({ status: orders.status }).from(orders).where(eq9(orders.id, id)).for("update");
1988
+ const [row] = await tx.select({ status: orders.status }).from(orders).where(eq10(orders.id, id)).for("update");
1694
1989
  if (!row) throw new AdminOrderNotFoundError(id);
1695
1990
  if (row.status !== "needs_attention") {
1696
1991
  throw new InvalidOrderStateError(row.status);
@@ -1699,7 +1994,8 @@ async function applyResolution(db, id) {
1699
1994
  if (outcome.result === "unavailable") {
1700
1995
  throw new InsufficientStockError(outcome.shortProductIds);
1701
1996
  }
1702
- await tx.update(orders).set({ status: "paid", updatedAt: sql9`now()` }).where(eq9(orders.id, id));
1997
+ await tx.update(orders).set({ status: "paid", updatedAt: sql10`now()` }).where(eq10(orders.id, id));
1998
+ await commitRedemption(tx, id);
1703
1999
  });
1704
2000
  }
1705
2001
  async function resolveOrder(db, id, hooks = {}, log) {
@@ -1807,18 +2103,335 @@ var adminOrdersPlugin = async (app, { db, authorize, payment, hooks }) => {
1807
2103
  });
1808
2104
  };
1809
2105
 
2106
+ // src/discounts/admin-routes.ts
2107
+ import { adminDiscountListQuerySchema } from "@develemit/commerce-contracts";
2108
+
2109
+ // src/discounts/admin-service.ts
2110
+ import {
2111
+ discountInputSchema,
2112
+ discountPatchSchema
2113
+ } from "@develemit/commerce-contracts";
2114
+ var UUID2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2115
+ var LOCKED_AFTER_USE = [
2116
+ "code",
2117
+ "type",
2118
+ "percentOff",
2119
+ "amountOffCents",
2120
+ "currency"
2121
+ ];
2122
+ var DiscountNotFoundError = class extends Error {
2123
+ code = "DISCOUNT_NOT_FOUND";
2124
+ constructor(id) {
2125
+ super(`No discount: ${id}`);
2126
+ this.name = "DiscountNotFoundError";
2127
+ }
2128
+ };
2129
+ var InvalidDiscountIdError = class extends Error {
2130
+ code = "INVALID_DISCOUNT_ID";
2131
+ constructor() {
2132
+ super("The discount id is malformed");
2133
+ this.name = "InvalidDiscountIdError";
2134
+ }
2135
+ };
2136
+ var DiscountCodeTakenError = class extends Error {
2137
+ code = "DISCOUNT_CODE_TAKEN";
2138
+ constructor(code) {
2139
+ super(`The code ${code} already exists`);
2140
+ this.name = "DiscountCodeTakenError";
2141
+ }
2142
+ };
2143
+ var DiscountInUseError = class extends Error {
2144
+ constructor(fields) {
2145
+ super(`A redeemed discount cannot change ${fields.join(", ")}`);
2146
+ this.fields = fields;
2147
+ this.name = "DiscountInUseError";
2148
+ }
2149
+ fields;
2150
+ code = "DISCOUNT_IN_USE";
2151
+ };
2152
+ var MaxRedemptionsBelowUsageError = class extends Error {
2153
+ constructor(usage) {
2154
+ super(`maxRedemptions cannot be below the ${usage} already in use`);
2155
+ this.usage = usage;
2156
+ this.name = "MaxRedemptionsBelowUsageError";
2157
+ }
2158
+ usage;
2159
+ code = "MAX_REDEMPTIONS_BELOW_USAGE";
2160
+ };
2161
+ var InvalidDiscountError = class extends Error {
2162
+ code = "INVALID_INPUT";
2163
+ constructor(message) {
2164
+ super(message);
2165
+ this.name = "InvalidDiscountError";
2166
+ }
2167
+ };
2168
+ function isDiscountId(value) {
2169
+ return UUID2.test(value);
2170
+ }
2171
+ function parseDate(value) {
2172
+ if (value === void 0 || value === null) return value;
2173
+ const date = new Date(value);
2174
+ if (Number.isNaN(+date)) throw new InvalidDiscountError("Invalid date");
2175
+ return date;
2176
+ }
2177
+ function toDiscount(row) {
2178
+ return {
2179
+ id: row.id,
2180
+ code: row.code,
2181
+ type: row.type,
2182
+ percentOff: row.percentOff,
2183
+ amountOffCents: row.amountOffCents,
2184
+ currency: row.currency,
2185
+ minSubtotalCents: row.minSubtotalCents,
2186
+ startsAt: row.startsAt?.toISOString() ?? null,
2187
+ endsAt: row.endsAt?.toISOString() ?? null,
2188
+ maxRedemptions: row.maxRedemptions,
2189
+ active: row.active,
2190
+ redeemedCount: row.redeemedCount,
2191
+ reservedCount: row.reservedCount,
2192
+ createdAt: row.createdAt.toISOString()
2193
+ };
2194
+ }
2195
+ function decodeDiscountCursor(raw) {
2196
+ const { at, key } = decodeCursor(
2197
+ raw,
2198
+ (c) => isDiscountId(c.key) && isCursorTimestamp(c.at)
2199
+ );
2200
+ return { createdAt: at, id: key };
2201
+ }
2202
+ async function listAdminDiscounts(db, query, now = /* @__PURE__ */ new Date()) {
2203
+ const rows = await selectDiscountPage(db, {
2204
+ status: query.status,
2205
+ q: query.q,
2206
+ now,
2207
+ after: query.cursor ? decodeDiscountCursor(query.cursor) : void 0,
2208
+ limit: query.limit + 1
2209
+ });
2210
+ const page = rows.slice(0, query.limit);
2211
+ const last = page[page.length - 1];
2212
+ return {
2213
+ items: page.map(toDiscount),
2214
+ nextCursor: rows.length > query.limit && last ? encodeCursor({ at: last.createdAtKey, key: last.id }) : null
2215
+ };
2216
+ }
2217
+ async function getAdminDiscount(db, id) {
2218
+ const row = await findDiscountById(db, id);
2219
+ if (!row) throw new DiscountNotFoundError(id);
2220
+ return toDiscount(row);
2221
+ }
2222
+ async function createDiscount(db, input) {
2223
+ const data = discountInputSchema.parse(input);
2224
+ const id = await insertDiscount(db, {
2225
+ code: data.code,
2226
+ type: data.type,
2227
+ percentOff: data.percentOff ?? null,
2228
+ amountOffCents: data.amountOffCents ?? null,
2229
+ currency: data.currency ?? null,
2230
+ minSubtotalCents: data.minSubtotalCents ?? null,
2231
+ startsAt: parseDate(data.startsAt) ?? null,
2232
+ endsAt: parseDate(data.endsAt) ?? null,
2233
+ maxRedemptions: data.maxRedemptions ?? null,
2234
+ active: data.active
2235
+ });
2236
+ if (!id) throw new DiscountCodeTakenError(data.code);
2237
+ return getAdminDiscount(db, id);
2238
+ }
2239
+ function sameValue(a, b) {
2240
+ if (typeof a === "string" && typeof b === "string") {
2241
+ return a.toUpperCase() === b.toUpperCase();
2242
+ }
2243
+ return a === b;
2244
+ }
2245
+ function changedLockedFields(row, patch) {
2246
+ return LOCKED_AFTER_USE.filter(
2247
+ (field) => patch[field] !== void 0 && !sameValue(patch[field], row[field])
2248
+ );
2249
+ }
2250
+ function assertCoherent(row, patch) {
2251
+ const merged = discountInputSchema.safeParse({
2252
+ code: row.code,
2253
+ type: row.type,
2254
+ percentOff: row.percentOff,
2255
+ amountOffCents: row.amountOffCents,
2256
+ currency: row.currency,
2257
+ ...patch
2258
+ });
2259
+ if (!merged.success) {
2260
+ throw new InvalidDiscountError(
2261
+ merged.error.issues[0]?.message ?? "Invalid"
2262
+ );
2263
+ }
2264
+ }
2265
+ async function patchDiscount(db, id, rawPatch) {
2266
+ const patch = discountPatchSchema.parse(rawPatch);
2267
+ return db.transaction(async (tx) => {
2268
+ const row = await findDiscountById(tx, id, { forUpdate: true });
2269
+ if (!row) throw new DiscountNotFoundError(id);
2270
+ const usage = row.redeemedCount + row.reservedCount;
2271
+ const locked = changedLockedFields(row, patch);
2272
+ if (row.totalRedemptions > 0 && locked.length > 0)
2273
+ throw new DiscountInUseError(locked);
2274
+ if (typeof patch.maxRedemptions === "number" && patch.maxRedemptions < usage) {
2275
+ throw new MaxRedemptionsBelowUsageError(usage);
2276
+ }
2277
+ assertCoherent(row, patch);
2278
+ try {
2279
+ await updateDiscount(tx, id, {
2280
+ code: patch.code,
2281
+ type: patch.type,
2282
+ percentOff: patch.percentOff,
2283
+ amountOffCents: patch.amountOffCents,
2284
+ currency: patch.currency,
2285
+ minSubtotalCents: patch.minSubtotalCents,
2286
+ startsAt: parseDate(patch.startsAt),
2287
+ endsAt: parseDate(patch.endsAt),
2288
+ maxRedemptions: patch.maxRedemptions,
2289
+ active: patch.active
2290
+ });
2291
+ } catch (err) {
2292
+ const cause = err.cause;
2293
+ if ((cause ?? err).code === "23505") {
2294
+ throw new DiscountCodeTakenError(patch.code ?? row.code);
2295
+ }
2296
+ throw err;
2297
+ }
2298
+ const updated = await findDiscountById(tx, id);
2299
+ return toDiscount(updated);
2300
+ });
2301
+ }
2302
+
2303
+ // src/discounts/admin-routes.ts
2304
+ var BAD_REQUEST2 = [
2305
+ InvalidCursorError,
2306
+ InvalidDiscountIdError,
2307
+ InvalidDiscountError,
2308
+ MaxRedemptionsBelowUsageError
2309
+ ];
2310
+ var CONFLICT = [DiscountCodeTakenError, DiscountInUseError];
2311
+ function isZodError3(err) {
2312
+ return err instanceof Error && err.name === "ZodError";
2313
+ }
2314
+ function discountId(req) {
2315
+ if (!isDiscountId(req.params.id)) throw new InvalidDiscountIdError();
2316
+ return req.params.id;
2317
+ }
2318
+ var adminDiscountsPlugin = async (app, { db, authorize }) => {
2319
+ app.setErrorHandler((err, _req, reply) => {
2320
+ if (isZodError3(err)) {
2321
+ return reply.code(400).send({ error: "INVALID_INPUT", issues: err.issues });
2322
+ }
2323
+ if (BAD_REQUEST2.some((type) => err instanceof type)) {
2324
+ const { code, message } = err;
2325
+ return reply.code(400).send({ error: code, message });
2326
+ }
2327
+ if (CONFLICT.some((type) => err instanceof type)) {
2328
+ const { code, message } = err;
2329
+ return reply.code(409).send({ error: code, message });
2330
+ }
2331
+ if (err instanceof DiscountNotFoundError) {
2332
+ return reply.code(404).send({ error: err.code, message: err.message });
2333
+ }
2334
+ return reply.send(err);
2335
+ });
2336
+ app.addHook("preHandler", async (req, reply) => {
2337
+ if (!await authorize(req)) {
2338
+ return reply.code(401).send({ error: "UNAUTHORIZED" });
2339
+ }
2340
+ });
2341
+ app.get(
2342
+ "/",
2343
+ async (req) => listAdminDiscounts(db, adminDiscountListQuerySchema.parse(req.query))
2344
+ );
2345
+ app.post("/", async (req, reply) => {
2346
+ const created = await createDiscount(db, req.body ?? {});
2347
+ return reply.code(201).send(created);
2348
+ });
2349
+ app.get(
2350
+ "/:id",
2351
+ async (req) => getAdminDiscount(db, discountId(req))
2352
+ );
2353
+ app.patch(
2354
+ "/:id",
2355
+ async (req) => patchDiscount(db, discountId(req), req.body ?? {})
2356
+ );
2357
+ };
2358
+
2359
+ // src/orders/dev-checkout.ts
2360
+ function assertDevCheckoutAllowed() {
2361
+ if (process.env.NODE_ENV === "production") {
2362
+ throw new Error("devCheckout is not allowed when NODE_ENV=production");
2363
+ }
2364
+ }
2365
+ var devCheckoutRoutes = async (app, { db, payment, urls, hooks }) => {
2366
+ assertDevCheckoutAllowed();
2367
+ app.get("/checkout/dev/:sessionId/complete", async (req, reply) => {
2368
+ const { sessionId } = req.params;
2369
+ if (!payment.sessions.some((s) => s.id === sessionId)) {
2370
+ return reply.code(404).send({ error: "SESSION_NOT_FOUND" });
2371
+ }
2372
+ const expire = req.query.outcome === "expired";
2373
+ const { rawBody, signature } = expire ? payment.expired(sessionId) : payment.completed(sessionId);
2374
+ const event = await payment.verifyWebhook(rawBody, signature);
2375
+ await handlePaymentEvent(db, event, hooks, req.log);
2376
+ return reply.redirect(expire ? urls.cancel : urls.success(sessionId), 302);
2377
+ });
2378
+ };
2379
+
1810
2380
  // src/orders/routes.ts
1811
2381
  import {
1812
2382
  checkoutConflictSchema,
2383
+ discountPreviewRequestSchema,
2384
+ discountPreviewSchema,
2385
+ discountRejectionSchema,
1813
2386
  checkoutRequestSchema,
1814
2387
  checkoutResponseSchema,
1815
2388
  orderSummarySchema
1816
2389
  } from "@develemit/commerce-contracts";
1817
2390
 
2391
+ // src/discounts/service.ts
2392
+ var UUID3 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2393
+ async function availableSubtotal(db, items, currency) {
2394
+ const quantities = /* @__PURE__ */ new Map();
2395
+ for (const { productId, quantity } of items) {
2396
+ quantities.set(productId, (quantities.get(productId) ?? 0) + quantity);
2397
+ }
2398
+ const found = await findPurchasable(
2399
+ db,
2400
+ [...quantities.keys()].filter((id) => UUID3.test(id))
2401
+ );
2402
+ return found.filter((p) => p.currency === currency).reduce((sum, p) => sum + p.priceCents * quantities.get(p.id), 0);
2403
+ }
2404
+ async function previewDiscount(db, request, currency, now = /* @__PURE__ */ new Date()) {
2405
+ const code = request.code.trim().toUpperCase();
2406
+ const discount = await findDiscountByCode(db, code);
2407
+ const subtotalCents = await availableSubtotal(db, request.items, currency);
2408
+ const result = evaluateDiscount(discount, {
2409
+ subtotalCents,
2410
+ currency,
2411
+ now,
2412
+ activeRedemptions: discount ? await countActiveRedemptions(db, discount.id) : 0
2413
+ });
2414
+ if (!result.ok) {
2415
+ return {
2416
+ ok: false,
2417
+ reason: result.reason === "inactive" ? "not_found" : result.reason
2418
+ };
2419
+ }
2420
+ return {
2421
+ ok: true,
2422
+ preview: {
2423
+ code,
2424
+ discountCents: result.discountCents,
2425
+ subtotalCents,
2426
+ currency
2427
+ }
2428
+ };
2429
+ }
2430
+
1818
2431
  // src/orders/checkout-service.ts
1819
2432
  import { randomUUID as randomUUID2 } from "crypto";
1820
2433
  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;
2434
+ var UUID4 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1822
2435
  function mergeItems(items) {
1823
2436
  const merged = /* @__PURE__ */ new Map();
1824
2437
  for (const { productId, quantity } of items) {
@@ -1830,7 +2443,7 @@ async function priceLines(db, requests, currency) {
1830
2443
  const ids = requests.map((r) => r.productId);
1831
2444
  const found = await findPurchasable(
1832
2445
  db,
1833
- ids.filter((id) => UUID2.test(id))
2446
+ ids.filter((id) => UUID4.test(id))
1834
2447
  );
1835
2448
  const byId = new Map(found.map((p) => [p.id, p]));
1836
2449
  const unavailable = ids.filter((id) => byId.get(id)?.currency !== currency);
@@ -1845,19 +2458,27 @@ async function priceLines(db, requests, currency) {
1845
2458
  };
1846
2459
  });
1847
2460
  }
1848
- async function reserveOrder(db, orderId, lines, currency, expiresAt) {
2461
+ async function reserveOrder(db, { orderId, lines, currency, expiresAt, discountCode }) {
2462
+ const subtotalCents = lines.reduce(
2463
+ (sum, l) => sum + l.unitAmountCents * l.quantity,
2464
+ 0
2465
+ );
1849
2466
  try {
1850
- await db.transaction(async (tx) => {
2467
+ return await db.transaction(async (tx) => {
1851
2468
  await reserve(tx, { reference: orderId, items: lines, expiresAt });
2469
+ const discount = discountCode === void 0 ? void 0 : await evaluateForCheckout(tx, {
2470
+ code: discountCode,
2471
+ subtotalCents,
2472
+ currency
2473
+ });
1852
2474
  await insertOrder(
1853
2475
  tx,
1854
2476
  {
1855
2477
  id: orderId,
1856
2478
  currency,
1857
- subtotalCents: lines.reduce(
1858
- (sum, l) => sum + l.unitAmountCents * l.quantity,
1859
- 0
1860
- )
2479
+ subtotalCents,
2480
+ discountCents: discount?.discountCents ?? 0,
2481
+ discountCode: discount?.code ?? null
1861
2482
  },
1862
2483
  lines.map((l) => ({
1863
2484
  productId: l.productId,
@@ -1866,6 +2487,8 @@ async function reserveOrder(db, orderId, lines, currency, expiresAt) {
1866
2487
  quantity: l.quantity
1867
2488
  }))
1868
2489
  );
2490
+ if (discount) await reserveRedemption(tx, orderId, discount);
2491
+ return discount;
1869
2492
  });
1870
2493
  } catch (err) {
1871
2494
  if (err instanceof InsufficientStockError) {
@@ -1883,7 +2506,13 @@ async function createCheckout(db, settings, request) {
1883
2506
  const shippingRates2 = await listActiveRates(db, settings.currency);
1884
2507
  const orderId = randomUUID2();
1885
2508
  const expiresAt = new Date(Date.now() + HOLD_DURATION_MS);
1886
- await reserveOrder(db, orderId, lines, settings.currency, expiresAt);
2509
+ const discount = await reserveOrder(db, {
2510
+ orderId,
2511
+ lines,
2512
+ currency: settings.currency,
2513
+ expiresAt,
2514
+ discountCode: request.discountCode
2515
+ });
1887
2516
  let session;
1888
2517
  try {
1889
2518
  session = await settings.payment.createCheckoutSession({
@@ -1894,10 +2523,17 @@ async function createCheckout(db, settings, request) {
1894
2523
  cancelUrl: settings.urls.cancel,
1895
2524
  shippingCountries: settings.shippingCountries,
1896
2525
  shippingRates: shippingRates2,
1897
- expiresAt
2526
+ expiresAt,
2527
+ ...discount && discount.discountCents > 0 && {
2528
+ discount: {
2529
+ code: discount.code,
2530
+ amountCents: discount.discountCents
2531
+ }
2532
+ }
1898
2533
  });
1899
2534
  } catch (err) {
1900
2535
  await release(db, orderId);
2536
+ await releaseRedemptions(db, [orderId]);
1901
2537
  await setOrderStatus(db, orderId, "cancelled");
1902
2538
  throw new PaymentGatewayError(err);
1903
2539
  }
@@ -1950,6 +2586,14 @@ var ordersPlugin = async (app, { db, ...settings }) => {
1950
2586
  })
1951
2587
  );
1952
2588
  }
2589
+ if (err instanceof DiscountRejectedError) {
2590
+ return reply.code(422).send(
2591
+ discountRejectionSchema.parse({
2592
+ error: err.code,
2593
+ reason: err.reason
2594
+ })
2595
+ );
2596
+ }
1953
2597
  if (err instanceof PaymentGatewayError) {
1954
2598
  req.log.error({ err: err.cause }, err.message);
1955
2599
  return reply.code(502).send({ error: err.code });
@@ -1957,6 +2601,23 @@ var ordersPlugin = async (app, { db, ...settings }) => {
1957
2601
  throw err;
1958
2602
  }
1959
2603
  });
2604
+ app.post("/checkout/discount/preview", async (req, reply) => {
2605
+ const parsed = discountPreviewRequestSchema.safeParse(req.body);
2606
+ if (!parsed.success) {
2607
+ return reply.code(400).send({ error: "INVALID_DISCOUNT_REQUEST" });
2608
+ }
2609
+ reply.header("Cache-Control", "no-store");
2610
+ const result = await previewDiscount(db, parsed.data, settings.currency);
2611
+ if (!result.ok) {
2612
+ return reply.code(422).send(
2613
+ discountRejectionSchema.parse({
2614
+ error: "DISCOUNT_INVALID",
2615
+ reason: result.reason
2616
+ })
2617
+ );
2618
+ }
2619
+ return discountPreviewSchema.parse(result.preview);
2620
+ });
1960
2621
  app.get(
1961
2622
  "/orders/session/:sessionId",
1962
2623
  async (req, reply) => {
@@ -1980,6 +2641,119 @@ var ordersPlugin = async (app, { db, ...settings }) => {
1980
2641
  });
1981
2642
  };
1982
2643
 
2644
+ // src/payment/fake-gateway.ts
2645
+ import { createHmac, randomUUID as randomUUID3, timingSafeEqual } from "crypto";
2646
+ function createFakeGateway({
2647
+ secret = "fake-webhook-secret"
2648
+ } = {}) {
2649
+ const sessions = [];
2650
+ const refunds = [];
2651
+ const refundCalls = [];
2652
+ let pendingFailure;
2653
+ let sessionUrl;
2654
+ let pendingRefundFailure;
2655
+ const hmac = (body) => createHmac("sha256", secret).update(body).digest("hex");
2656
+ function sign(event) {
2657
+ const rawBody = Buffer.from(JSON.stringify(event));
2658
+ return { rawBody, signature: hmac(rawBody) };
2659
+ }
2660
+ return {
2661
+ kind: "fake",
2662
+ useSessionUrl(build) {
2663
+ sessionUrl = build;
2664
+ },
2665
+ sessions,
2666
+ refunds,
2667
+ refundCalls,
2668
+ async createCheckoutSession(input) {
2669
+ if (pendingFailure) {
2670
+ const err = pendingFailure;
2671
+ pendingFailure = void 0;
2672
+ throw err;
2673
+ }
2674
+ const id = `fake_cs_${randomUUID3()}`;
2675
+ sessions.push({ ...input, id });
2676
+ return {
2677
+ id,
2678
+ url: sessionUrl?.(id) ?? input.successUrl.replace(SESSION_ID_PLACEHOLDER, id)
2679
+ };
2680
+ },
2681
+ async verifyWebhook(rawBody, signature) {
2682
+ const expected = Buffer.from(hmac(rawBody));
2683
+ const given = Buffer.from(signature ?? "");
2684
+ if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
2685
+ throw new WebhookSignatureError();
2686
+ }
2687
+ return JSON.parse(rawBody.toString("utf8"));
2688
+ },
2689
+ async refund(input) {
2690
+ refundCalls.push(input);
2691
+ if (pendingRefundFailure) {
2692
+ const err = pendingRefundFailure;
2693
+ pendingRefundFailure = void 0;
2694
+ throw err;
2695
+ }
2696
+ const prior = refunds.find(
2697
+ (r) => r.idempotencyKey === input.idempotencyKey
2698
+ );
2699
+ if (prior) return { gatewayRefundId: prior.gatewayRefundId };
2700
+ const gatewayRefundId = `fake_re_${refunds.length + 1}_${input.sessionId}`;
2701
+ refunds.push({ ...input, gatewayRefundId });
2702
+ return { gatewayRefundId };
2703
+ },
2704
+ failNextSession(error = new Error("Fake gateway failure")) {
2705
+ pendingFailure = error;
2706
+ },
2707
+ failNextRefund(error = new RefundRejectedError("Fake refund failure")) {
2708
+ pendingRefundFailure = error;
2709
+ },
2710
+ failNextRefundUncertain(error = new Error("Fake refund timeout")) {
2711
+ pendingRefundFailure = error;
2712
+ },
2713
+ completed(sessionId, details = {}) {
2714
+ return sign({
2715
+ id: `fake_evt_${randomUUID3()}`,
2716
+ type: "checkout.completed",
2717
+ sessionId,
2718
+ ...details
2719
+ });
2720
+ },
2721
+ asyncPaid(sessionId) {
2722
+ return sign({
2723
+ id: `fake_evt_${randomUUID3()}`,
2724
+ type: "checkout.async_paid",
2725
+ sessionId
2726
+ });
2727
+ },
2728
+ asyncFailed(sessionId) {
2729
+ return sign({
2730
+ id: `fake_evt_${randomUUID3()}`,
2731
+ type: "checkout.async_failed",
2732
+ sessionId
2733
+ });
2734
+ },
2735
+ expired(sessionId) {
2736
+ return sign({
2737
+ id: `fake_evt_${randomUUID3()}`,
2738
+ type: "checkout.expired",
2739
+ sessionId
2740
+ });
2741
+ },
2742
+ refunded(sessionId, refunds2) {
2743
+ return sign({
2744
+ id: `fake_evt_${randomUUID3()}`,
2745
+ type: "payment.refunded",
2746
+ sessionId,
2747
+ refunds: refunds2
2748
+ });
2749
+ },
2750
+ sign
2751
+ };
2752
+ }
2753
+ function isFakeGateway(gateway) {
2754
+ return gateway.kind === "fake";
2755
+ }
2756
+
1983
2757
  // src/plugin.ts
1984
2758
  var commercePlugin = async (app, {
1985
2759
  db,
@@ -1990,7 +2764,8 @@ var commercePlugin = async (app, {
1990
2764
  hooks,
1991
2765
  admin,
1992
2766
  categoryLabels,
1993
- catalogCache
2767
+ catalogCache,
2768
+ devCheckout
1994
2769
  }) => {
1995
2770
  app.get("/health", async () => ({ status: "ok" }));
1996
2771
  await app.register(catalogPlugin, {
@@ -2016,6 +2791,16 @@ var commercePlugin = async (app, {
2016
2791
  shippingCountries,
2017
2792
  hooks
2018
2793
  });
2794
+ if (devCheckout) {
2795
+ assertDevCheckoutAllowed();
2796
+ if (isFakeGateway(payment)) {
2797
+ const apiUrl = typeof devCheckout === "object" ? devCheckout.apiUrl : "";
2798
+ payment.useSessionUrl(
2799
+ (id) => `${apiUrl ?? ""}${app.prefix}/checkout/dev/${id}/complete`
2800
+ );
2801
+ await app.register(devCheckoutRoutes, { db, payment, urls, hooks });
2802
+ }
2803
+ }
2019
2804
  if (admin) {
2020
2805
  await app.register(adminOrdersPlugin, {
2021
2806
  db,
@@ -2024,6 +2809,11 @@ var commercePlugin = async (app, {
2024
2809
  hooks,
2025
2810
  prefix: "/admin/orders"
2026
2811
  });
2812
+ await app.register(adminDiscountsPlugin, {
2813
+ db,
2814
+ authorize: admin.authorize,
2815
+ prefix: "/admin/discounts"
2816
+ });
2027
2817
  }
2028
2818
  };
2029
2819
 
@@ -2143,6 +2933,16 @@ async function refundedEvent(stripe, event) {
2143
2933
  refunds: refunds.filter((refund) => refund.status !== "failed").map(toRefundEvent)
2144
2934
  };
2145
2935
  }
2936
+ function createCoupon(stripe, { discount, currency, expiresAt }) {
2937
+ return stripe.coupons.create({
2938
+ amount_off: discount.amountCents,
2939
+ currency: currency.toLowerCase(),
2940
+ duration: "once",
2941
+ max_redemptions: 1,
2942
+ redeem_by: Math.floor(expiresAt.getTime() / 1e3),
2943
+ name: discount.code
2944
+ });
2945
+ }
2146
2946
  async function loadStripe(secretKey) {
2147
2947
  const { default: StripeClient } = await import("stripe");
2148
2948
  return new StripeClient(secretKey);
@@ -2156,7 +2956,9 @@ function createStripeGateway({
2156
2956
  const getStripe = () => stripe ??= loadStripe(secretKey);
2157
2957
  return {
2158
2958
  async createCheckoutSession(input) {
2159
- const session = await (await getStripe()).checkout.sessions.create({
2959
+ const stripeClient = await getStripe();
2960
+ const coupon = input.discount && await createCoupon(stripeClient, input);
2961
+ const session = await stripeClient.checkout.sessions.create({
2160
2962
  mode: "payment",
2161
2963
  client_reference_id: input.reference,
2162
2964
  metadata: { reference: input.reference },
@@ -2171,6 +2973,7 @@ function createStripeGateway({
2171
2973
  }
2172
2974
  }
2173
2975
  })),
2976
+ ...coupon && { discounts: [{ coupon: coupon.id }] },
2174
2977
  success_url: input.successUrl,
2175
2978
  cancel_url: input.cancelUrl,
2176
2979
  expires_at: Math.floor(input.expiresAt.getTime() / 1e3),
@@ -2234,120 +3037,103 @@ function createStripeGateway({
2234
3037
  };
2235
3038
  }
2236
3039
 
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
3040
  // src/hold-sweeper.ts
3041
+ async function sweepExpiredHolds(db) {
3042
+ await db.transaction(async (tx) => {
3043
+ await releaseRedemptions(tx, await releaseExpiredReferences(tx));
3044
+ });
3045
+ }
2343
3046
  function startHoldSweeper(db, { intervalMs = 6e4, onError } = {}) {
2344
3047
  const timer = setInterval(() => {
2345
- releaseExpired(db).catch((err) => onError?.(err));
3048
+ sweepExpiredHolds(db).catch((err) => onError?.(err));
2346
3049
  }, intervalMs);
2347
3050
  timer.unref();
2348
3051
  return { stop: () => clearInterval(timer) };
2349
3052
  }
2350
3053
 
3054
+ // src/emails/order-lines.ts
3055
+ var defaultLabels = {
3056
+ subtotal: "Subtotal",
3057
+ discount: (code) => code ? `Discount (${code})` : "Discount",
3058
+ shipping: (label) => label ? `Shipping (${label})` : "Shipping",
3059
+ total: "Total"
3060
+ };
3061
+ var escapes = {
3062
+ "&": "&amp;",
3063
+ "<": "&lt;",
3064
+ ">": "&gt;",
3065
+ '"': "&quot;",
3066
+ "'": "&#39;"
3067
+ };
3068
+ function escapeHtml(value) {
3069
+ return value.replace(/[&<>"']/g, (c) => escapes[c]);
3070
+ }
3071
+ function addressLines(order) {
3072
+ const a = order.shippingAddress;
3073
+ if (!a) return void 0;
3074
+ const lines = [
3075
+ a.name,
3076
+ a.line1,
3077
+ a.line2,
3078
+ [a.city, a.state, a.postalCode].filter(Boolean).join(" "),
3079
+ a.country
3080
+ ].filter((l) => Boolean(l));
3081
+ return lines.length ? lines : void 0;
3082
+ }
3083
+ function textBlock(lines) {
3084
+ return lines.map((l) => `${l.label} ${l.amount}`).join("\n");
3085
+ }
3086
+ function htmlRows(lines) {
3087
+ return lines.map(
3088
+ (l) => `<tr><td>${escapeHtml(l.label)}</td><td align="right">${escapeHtml(l.amount)}</td></tr>`
3089
+ ).join("");
3090
+ }
3091
+ function orderEmailLines(order, options = {}) {
3092
+ const locale = options.locale ?? "en-US";
3093
+ const labels = { ...defaultLabels, ...options.labels };
3094
+ const money = (cents) => new Intl.NumberFormat(locale, {
3095
+ style: "currency",
3096
+ currency: order.currency
3097
+ }).format(cents / 100);
3098
+ const items = order.items.map((i) => ({
3099
+ label: `${i.name} \xD7 ${i.quantity}`,
3100
+ amount: money(i.priceCents * i.quantity)
3101
+ }));
3102
+ const subtotal = {
3103
+ label: labels.subtotal,
3104
+ amount: money(order.subtotalCents)
3105
+ };
3106
+ const discount = order.discountCents > 0 ? {
3107
+ label: labels.discount(order.discountCode),
3108
+ amount: `-${money(order.discountCents)}`
3109
+ } : void 0;
3110
+ const shipping = {
3111
+ label: labels.shipping(order.shippingLabel),
3112
+ amount: money(order.shippingCents)
3113
+ };
3114
+ const total = { label: labels.total, amount: money(order.totalCents) };
3115
+ const address = addressLines(order);
3116
+ const totals = [subtotal, ...discount ? [discount] : [], shipping, total];
3117
+ return {
3118
+ items,
3119
+ subtotal,
3120
+ discount,
3121
+ shipping,
3122
+ total,
3123
+ address,
3124
+ text: {
3125
+ items: textBlock(items),
3126
+ totals: textBlock(totals),
3127
+ address: address?.join("\n") ?? ""
3128
+ },
3129
+ html: {
3130
+ items: htmlRows(items),
3131
+ totals: htmlRows(totals),
3132
+ address: address?.map((l) => `<p>${escapeHtml(l)}</p>`).join("") ?? ""
3133
+ }
3134
+ };
3135
+ }
3136
+
2351
3137
  // src/db/migrate.ts
2352
3138
  import path from "path";
2353
3139
  import { fileURLToPath } from "url";
@@ -2366,16 +3152,16 @@ async function runCommerceMigrations(db) {
2366
3152
  }
2367
3153
 
2368
3154
  // src/seed.ts
2369
- import { eq as eq10, sql as sql10 } from "drizzle-orm";
3155
+ import { eq as eq11, sql as sql11 } from "drizzle-orm";
2370
3156
  async function seedProducts(db, items) {
2371
3157
  await db.transaction(async (tx) => {
2372
3158
  for (const { images = [], ...item } of items) {
2373
3159
  const values = { status: "active", ...item };
2374
3160
  const [row] = await tx.insert(products).values(values).onConflictDoUpdate({
2375
3161
  target: products.slug,
2376
- set: { ...values, updatedAt: sql10`now()` }
3162
+ set: { ...values, updatedAt: sql11`now()` }
2377
3163
  }).returning({ id: products.id });
2378
- await tx.delete(productImages).where(eq10(productImages.productId, row.id));
3164
+ await tx.delete(productImages).where(eq11(productImages.productId, row.id));
2379
3165
  if (images.length > 0) {
2380
3166
  await tx.insert(productImages).values(
2381
3167
  images.map((img, position) => ({
@@ -2425,17 +3211,132 @@ function createMemoryStorage(baseUrl = "memory://") {
2425
3211
  }
2426
3212
  };
2427
3213
  }
3214
+
3215
+ // src/storage/disk.ts
3216
+ import { createReadStream, createWriteStream } from "fs";
3217
+ import { mkdir, realpath, rm, stat } from "fs/promises";
3218
+ import path2 from "path";
3219
+ import { pipeline } from "stream/promises";
3220
+ var contentTypes = {
3221
+ ".jpg": "image/jpeg",
3222
+ ".jpeg": "image/jpeg",
3223
+ ".png": "image/png",
3224
+ ".webp": "image/webp",
3225
+ ".gif": "image/gif",
3226
+ ".avif": "image/avif"
3227
+ };
3228
+ function hasTraversalSegment(key) {
3229
+ return key.split(/[\\/]/).some((segment) => segment === "..");
3230
+ }
3231
+ function decodesToTraversal(key) {
3232
+ try {
3233
+ return hasTraversalSegment(decodeURIComponent(key));
3234
+ } catch {
3235
+ return true;
3236
+ }
3237
+ }
3238
+ function isInside(root, target) {
3239
+ return target.startsWith(root + path2.sep);
3240
+ }
3241
+ function resolveKey(dir, key) {
3242
+ if (!key || key.includes("\0") || path2.isAbsolute(key)) return null;
3243
+ if (hasTraversalSegment(key) || decodesToTraversal(key)) return null;
3244
+ const root = path2.resolve(dir);
3245
+ const target = path2.resolve(root, key);
3246
+ return isInside(root, target) ? target : null;
3247
+ }
3248
+ async function realInside(dir, target) {
3249
+ const [root, real] = await Promise.all([
3250
+ realpath(dir),
3251
+ realpath(target)
3252
+ ]).catch(() => [null, null]);
3253
+ return root && real && isInside(root, real) ? real : null;
3254
+ }
3255
+ function createDiskStorage(options) {
3256
+ const { dir, publicUrl } = options;
3257
+ const base = publicUrl.replace(/\/+$/, "");
3258
+ return {
3259
+ async put(key, stream) {
3260
+ const target = resolveKey(dir, key);
3261
+ if (!target)
3262
+ throw new Error(`Invalid storage key: ${JSON.stringify(key)}`);
3263
+ await mkdir(path2.dirname(target), { recursive: true });
3264
+ if (!await realInside(dir, path2.dirname(target)).catch(() => null)) {
3265
+ throw new Error(`Invalid storage key: ${JSON.stringify(key)}`);
3266
+ }
3267
+ await pipeline(stream, createWriteStream(target));
3268
+ return { key, url: `${base}/${key}` };
3269
+ },
3270
+ async delete(key) {
3271
+ const target = resolveKey(dir, key);
3272
+ if (!target)
3273
+ throw new Error(`Invalid storage key: ${JSON.stringify(key)}`);
3274
+ if (!await realInside(dir, path2.dirname(target)).catch(() => null))
3275
+ return;
3276
+ await rm(target, { force: true });
3277
+ }
3278
+ };
3279
+ }
3280
+ async function openStoredFile(dir, key) {
3281
+ const target = resolveKey(dir, key);
3282
+ if (!target) return null;
3283
+ const real = await realInside(dir, target);
3284
+ if (!real) return null;
3285
+ const info = await stat(real).catch(() => null);
3286
+ if (!info?.isFile()) return null;
3287
+ const contentType = contentTypes[path2.extname(real).toLowerCase()];
3288
+ return { stream: createReadStream(real), contentType, size: info.size };
3289
+ }
3290
+
3291
+ // src/payment/resolve.ts
3292
+ function resolvePayment(env = process.env) {
3293
+ const secretKey = env.STRIPE_SECRET_KEY || void 0;
3294
+ const webhookSecret = env.STRIPE_WEBHOOK_SECRET || void 0;
3295
+ if (!secretKey !== !webhookSecret) {
3296
+ throw new Error(
3297
+ "Set STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET together, or neither"
3298
+ );
3299
+ }
3300
+ if (env.PAYMENT_GATEWAY === "stripe" && !secretKey) {
3301
+ throw new Error("PAYMENT_GATEWAY=stripe needs the Stripe keys");
3302
+ }
3303
+ if (env.PAYMENT_GATEWAY === "fake" || !secretKey) {
3304
+ if (env.NODE_ENV === "production") {
3305
+ throw new Error(
3306
+ "The fake payment gateway is not allowed in production; set the Stripe keys"
3307
+ );
3308
+ }
3309
+ return createFakeGateway();
3310
+ }
3311
+ return createStripeGateway({ secretKey, webhookSecret });
3312
+ }
3313
+ function checkoutUrls(webUrl, {
3314
+ successPath = "/checkout/success",
3315
+ cancelPath = "/shop"
3316
+ } = {}) {
3317
+ const base = webUrl.replace(/\/+$/, "");
3318
+ return {
3319
+ success: (sessionId) => `${base}${successPath}?session_id=${encodeURIComponent(sessionId)}`,
3320
+ cancel: `${base}${cancelPath}`
3321
+ };
3322
+ }
2428
3323
  export {
2429
3324
  ProductNotFoundError,
2430
3325
  SESSION_ID_PLACEHOLDER,
2431
3326
  WebhookSignatureError,
2432
3327
  catalogPlugin,
3328
+ checkoutUrls,
2433
3329
  commercePlugin,
2434
3330
  commerceSchema,
3331
+ createDiskStorage,
2435
3332
  createFakeGateway,
2436
3333
  createMemoryStorage,
2437
3334
  createStripeGateway,
3335
+ isFakeGateway,
3336
+ openStoredFile,
3337
+ orderEmailLines,
2438
3338
  ordersPlugin,
3339
+ resolvePayment,
2439
3340
  runCommerceMigrations,
2440
3341
  seedProducts,
2441
3342
  seedShippingRates,