@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/README.md +51 -8
- package/dist/index.d.ts +464 -2
- package/dist/index.js +1045 -144
- package/dist/index.js.map +1 -1
- package/docs/admin.md +30 -2
- package/docs/payments.md +29 -1
- package/migrations/0007_discounts.sql +40 -0
- package/migrations/meta/0007_snapshot.json +1028 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +2 -2
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({
|
|
760
|
-
return released.
|
|
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
|
|
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 {
|
|
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) &&
|
|
1393
|
+
(c) => isOrderId(c.key) && isCursorTimestamp(c.at)
|
|
1299
1394
|
);
|
|
1300
|
-
return { createdAt:
|
|
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) => ({
|
|
1314
|
-
|
|
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
|
|
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(
|
|
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:
|
|
1598
|
-
updatedAt:
|
|
1599
|
-
}).where(
|
|
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
|
|
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(
|
|
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:
|
|
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
|
|
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) =>
|
|
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
|
|
1858
|
-
|
|
1859
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
+
"&": "&",
|
|
3063
|
+
"<": "<",
|
|
3064
|
+
">": ">",
|
|
3065
|
+
'"': """,
|
|
3066
|
+
"'": "'"
|
|
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
|
|
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:
|
|
3162
|
+
set: { ...values, updatedAt: sql11`now()` }
|
|
2377
3163
|
}).returning({ id: products.id });
|
|
2378
|
-
await tx.delete(productImages).where(
|
|
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,
|