@develemit/commerce-server 0.4.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 +27 -0
- package/dist/index.d.ts +71 -2
- package/dist/index.js +382 -120
- package/dist/index.js.map +1 -1
- package/docs/admin.md +5 -2
- package/docs/payments.md +18 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -28,6 +28,9 @@ var InvalidCursorError = class extends Error {
|
|
|
28
28
|
}
|
|
29
29
|
};
|
|
30
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
|
+
}
|
|
31
34
|
function encodeCursor({ at, key }) {
|
|
32
35
|
return Buffer.from(`${at}|${key}`).toString("base64url");
|
|
33
36
|
}
|
|
@@ -677,7 +680,7 @@ var adminPlugin = async (app, { db, currency, authorize, storage }) => {
|
|
|
677
680
|
if (!part) throw new InvalidImageError("An image file is required");
|
|
678
681
|
const bytes = await part.toBuffer();
|
|
679
682
|
const fields = part.fields;
|
|
680
|
-
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;
|
|
681
684
|
const product = await addProductImage(db, storage, req.params.id, {
|
|
682
685
|
bytes,
|
|
683
686
|
alt
|
|
@@ -1146,7 +1149,8 @@ function conditions(filter) {
|
|
|
1146
1149
|
);
|
|
1147
1150
|
}
|
|
1148
1151
|
if (filter.after) {
|
|
1149
|
-
const {
|
|
1152
|
+
const { id } = filter.after;
|
|
1153
|
+
const createdAt = sql5`${filter.after.createdAt}::timestamptz`;
|
|
1150
1154
|
parts.push(
|
|
1151
1155
|
or3(
|
|
1152
1156
|
lt2(orders.createdAt, createdAt),
|
|
@@ -1168,7 +1172,8 @@ async function selectOrderPage(db, filter) {
|
|
|
1168
1172
|
totalCents: sql5`${orders.subtotalCents} - ${orders.discountCents} + ${orders.shippingCents}`,
|
|
1169
1173
|
currency: orders.currency,
|
|
1170
1174
|
itemCount,
|
|
1171
|
-
createdAt: orders.createdAt
|
|
1175
|
+
createdAt: orders.createdAt,
|
|
1176
|
+
createdAtKey: sql5`${orders.createdAt}::text`
|
|
1172
1177
|
}).from(orders).where(conditions(filter)).orderBy(desc(orders.createdAt), desc(orders.id)).limit(filter.limit);
|
|
1173
1178
|
}
|
|
1174
1179
|
async function findOrderRefunds(db, orderId) {
|
|
@@ -1385,9 +1390,9 @@ function isOrderId(value) {
|
|
|
1385
1390
|
function decodeOrderCursor(raw) {
|
|
1386
1391
|
const { at, key } = decodeCursor(
|
|
1387
1392
|
raw,
|
|
1388
|
-
(c) => isOrderId(c.key) &&
|
|
1393
|
+
(c) => isOrderId(c.key) && isCursorTimestamp(c.at)
|
|
1389
1394
|
);
|
|
1390
|
-
return { createdAt:
|
|
1395
|
+
return { createdAt: at, id: key };
|
|
1391
1396
|
}
|
|
1392
1397
|
async function listAdminOrders(db, query) {
|
|
1393
1398
|
const rows = await selectOrderPage(db, {
|
|
@@ -1400,8 +1405,16 @@ async function listAdminOrders(db, query) {
|
|
|
1400
1405
|
const page = rows.slice(0, query.limit);
|
|
1401
1406
|
const last = page[page.length - 1];
|
|
1402
1407
|
return {
|
|
1403
|
-
items: page.map((r) => ({
|
|
1404
|
-
|
|
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
|
|
1405
1418
|
};
|
|
1406
1419
|
}
|
|
1407
1420
|
async function getAdminOrder(db, id) {
|
|
@@ -1565,7 +1578,8 @@ function listConditions(filter) {
|
|
|
1565
1578
|
if (filter.status) parts.push(statusCondition(filter.status, filter.now));
|
|
1566
1579
|
if (filter.q) parts.push(ilike3(discounts.code, `${escapeLike3(filter.q)}%`));
|
|
1567
1580
|
if (filter.after) {
|
|
1568
|
-
const {
|
|
1581
|
+
const { id } = filter.after;
|
|
1582
|
+
const createdAt = sql8`${filter.after.createdAt}::timestamptz`;
|
|
1569
1583
|
parts.push(
|
|
1570
1584
|
or5(
|
|
1571
1585
|
lt3(discounts.createdAt, createdAt),
|
|
@@ -1589,7 +1603,10 @@ var withCounts = {
|
|
|
1589
1603
|
totalRedemptions: redemptionCount()
|
|
1590
1604
|
};
|
|
1591
1605
|
async function selectDiscountPage(db, filter) {
|
|
1592
|
-
return db.select(
|
|
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);
|
|
1593
1610
|
}
|
|
1594
1611
|
async function findDiscountById(db, id, options = {}) {
|
|
1595
1612
|
const query = db.select(withCounts).from(discounts).where(eq8(discounts.id, id));
|
|
@@ -2178,9 +2195,9 @@ function toDiscount(row) {
|
|
|
2178
2195
|
function decodeDiscountCursor(raw) {
|
|
2179
2196
|
const { at, key } = decodeCursor(
|
|
2180
2197
|
raw,
|
|
2181
|
-
(c) => isDiscountId(c.key) &&
|
|
2198
|
+
(c) => isDiscountId(c.key) && isCursorTimestamp(c.at)
|
|
2182
2199
|
);
|
|
2183
|
-
return { createdAt:
|
|
2200
|
+
return { createdAt: at, id: key };
|
|
2184
2201
|
}
|
|
2185
2202
|
async function listAdminDiscounts(db, query, now = /* @__PURE__ */ new Date()) {
|
|
2186
2203
|
const rows = await selectDiscountPage(db, {
|
|
@@ -2194,7 +2211,7 @@ async function listAdminDiscounts(db, query, now = /* @__PURE__ */ new Date()) {
|
|
|
2194
2211
|
const last = page[page.length - 1];
|
|
2195
2212
|
return {
|
|
2196
2213
|
items: page.map(toDiscount),
|
|
2197
|
-
nextCursor: rows.length > query.limit && last ? encodeCursor({ at: last.
|
|
2214
|
+
nextCursor: rows.length > query.limit && last ? encodeCursor({ at: last.createdAtKey, key: last.id }) : null
|
|
2198
2215
|
};
|
|
2199
2216
|
}
|
|
2200
2217
|
async function getAdminDiscount(db, id) {
|
|
@@ -2219,9 +2236,15 @@ async function createDiscount(db, input) {
|
|
|
2219
2236
|
if (!id) throw new DiscountCodeTakenError(data.code);
|
|
2220
2237
|
return getAdminDiscount(db, id);
|
|
2221
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
|
+
}
|
|
2222
2245
|
function changedLockedFields(row, patch) {
|
|
2223
2246
|
return LOCKED_AFTER_USE.filter(
|
|
2224
|
-
(field) => patch[field] !== void 0 && patch[field]
|
|
2247
|
+
(field) => patch[field] !== void 0 && !sameValue(patch[field], row[field])
|
|
2225
2248
|
);
|
|
2226
2249
|
}
|
|
2227
2250
|
function assertCoherent(row, patch) {
|
|
@@ -2333,6 +2356,27 @@ var adminDiscountsPlugin = async (app, { db, authorize }) => {
|
|
|
2333
2356
|
);
|
|
2334
2357
|
};
|
|
2335
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
|
+
|
|
2336
2380
|
// src/orders/routes.ts
|
|
2337
2381
|
import {
|
|
2338
2382
|
checkoutConflictSchema,
|
|
@@ -2597,6 +2641,119 @@ var ordersPlugin = async (app, { db, ...settings }) => {
|
|
|
2597
2641
|
});
|
|
2598
2642
|
};
|
|
2599
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
|
+
|
|
2600
2757
|
// src/plugin.ts
|
|
2601
2758
|
var commercePlugin = async (app, {
|
|
2602
2759
|
db,
|
|
@@ -2607,7 +2764,8 @@ var commercePlugin = async (app, {
|
|
|
2607
2764
|
hooks,
|
|
2608
2765
|
admin,
|
|
2609
2766
|
categoryLabels,
|
|
2610
|
-
catalogCache
|
|
2767
|
+
catalogCache,
|
|
2768
|
+
devCheckout
|
|
2611
2769
|
}) => {
|
|
2612
2770
|
app.get("/health", async () => ({ status: "ok" }));
|
|
2613
2771
|
await app.register(catalogPlugin, {
|
|
@@ -2633,6 +2791,16 @@ var commercePlugin = async (app, {
|
|
|
2633
2791
|
shippingCountries,
|
|
2634
2792
|
hooks
|
|
2635
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
|
+
}
|
|
2636
2804
|
if (admin) {
|
|
2637
2805
|
await app.register(adminOrdersPlugin, {
|
|
2638
2806
|
db,
|
|
@@ -2765,12 +2933,13 @@ async function refundedEvent(stripe, event) {
|
|
|
2765
2933
|
refunds: refunds.filter((refund) => refund.status !== "failed").map(toRefundEvent)
|
|
2766
2934
|
};
|
|
2767
2935
|
}
|
|
2768
|
-
function createCoupon(stripe, { discount, currency }) {
|
|
2936
|
+
function createCoupon(stripe, { discount, currency, expiresAt }) {
|
|
2769
2937
|
return stripe.coupons.create({
|
|
2770
2938
|
amount_off: discount.amountCents,
|
|
2771
2939
|
currency: currency.toLowerCase(),
|
|
2772
2940
|
duration: "once",
|
|
2773
2941
|
max_redemptions: 1,
|
|
2942
|
+
redeem_by: Math.floor(expiresAt.getTime() / 1e3),
|
|
2774
2943
|
name: discount.code
|
|
2775
2944
|
});
|
|
2776
2945
|
}
|
|
@@ -2868,111 +3037,6 @@ function createStripeGateway({
|
|
|
2868
3037
|
};
|
|
2869
3038
|
}
|
|
2870
3039
|
|
|
2871
|
-
// src/payment/fake-gateway.ts
|
|
2872
|
-
import { createHmac, randomUUID as randomUUID3, timingSafeEqual } from "crypto";
|
|
2873
|
-
function createFakeGateway({
|
|
2874
|
-
secret = "fake-webhook-secret"
|
|
2875
|
-
} = {}) {
|
|
2876
|
-
const sessions = [];
|
|
2877
|
-
const refunds = [];
|
|
2878
|
-
const refundCalls = [];
|
|
2879
|
-
let pendingFailure;
|
|
2880
|
-
let pendingRefundFailure;
|
|
2881
|
-
const hmac = (body) => createHmac("sha256", secret).update(body).digest("hex");
|
|
2882
|
-
function sign(event) {
|
|
2883
|
-
const rawBody = Buffer.from(JSON.stringify(event));
|
|
2884
|
-
return { rawBody, signature: hmac(rawBody) };
|
|
2885
|
-
}
|
|
2886
|
-
return {
|
|
2887
|
-
sessions,
|
|
2888
|
-
refunds,
|
|
2889
|
-
refundCalls,
|
|
2890
|
-
async createCheckoutSession(input) {
|
|
2891
|
-
if (pendingFailure) {
|
|
2892
|
-
const err = pendingFailure;
|
|
2893
|
-
pendingFailure = void 0;
|
|
2894
|
-
throw err;
|
|
2895
|
-
}
|
|
2896
|
-
const id = `fake_cs_${randomUUID3()}`;
|
|
2897
|
-
sessions.push({ ...input, id });
|
|
2898
|
-
return {
|
|
2899
|
-
id,
|
|
2900
|
-
url: input.successUrl.replace(SESSION_ID_PLACEHOLDER, id)
|
|
2901
|
-
};
|
|
2902
|
-
},
|
|
2903
|
-
async verifyWebhook(rawBody, signature) {
|
|
2904
|
-
const expected = Buffer.from(hmac(rawBody));
|
|
2905
|
-
const given = Buffer.from(signature ?? "");
|
|
2906
|
-
if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
|
|
2907
|
-
throw new WebhookSignatureError();
|
|
2908
|
-
}
|
|
2909
|
-
return JSON.parse(rawBody.toString("utf8"));
|
|
2910
|
-
},
|
|
2911
|
-
async refund(input) {
|
|
2912
|
-
refundCalls.push(input);
|
|
2913
|
-
if (pendingRefundFailure) {
|
|
2914
|
-
const err = pendingRefundFailure;
|
|
2915
|
-
pendingRefundFailure = void 0;
|
|
2916
|
-
throw err;
|
|
2917
|
-
}
|
|
2918
|
-
const prior = refunds.find(
|
|
2919
|
-
(r) => r.idempotencyKey === input.idempotencyKey
|
|
2920
|
-
);
|
|
2921
|
-
if (prior) return { gatewayRefundId: prior.gatewayRefundId };
|
|
2922
|
-
const gatewayRefundId = `fake_re_${refunds.length + 1}_${input.sessionId}`;
|
|
2923
|
-
refunds.push({ ...input, gatewayRefundId });
|
|
2924
|
-
return { gatewayRefundId };
|
|
2925
|
-
},
|
|
2926
|
-
failNextSession(error = new Error("Fake gateway failure")) {
|
|
2927
|
-
pendingFailure = error;
|
|
2928
|
-
},
|
|
2929
|
-
failNextRefund(error = new RefundRejectedError("Fake refund failure")) {
|
|
2930
|
-
pendingRefundFailure = error;
|
|
2931
|
-
},
|
|
2932
|
-
failNextRefundUncertain(error = new Error("Fake refund timeout")) {
|
|
2933
|
-
pendingRefundFailure = error;
|
|
2934
|
-
},
|
|
2935
|
-
completed(sessionId, details = {}) {
|
|
2936
|
-
return sign({
|
|
2937
|
-
id: `fake_evt_${randomUUID3()}`,
|
|
2938
|
-
type: "checkout.completed",
|
|
2939
|
-
sessionId,
|
|
2940
|
-
...details
|
|
2941
|
-
});
|
|
2942
|
-
},
|
|
2943
|
-
asyncPaid(sessionId) {
|
|
2944
|
-
return sign({
|
|
2945
|
-
id: `fake_evt_${randomUUID3()}`,
|
|
2946
|
-
type: "checkout.async_paid",
|
|
2947
|
-
sessionId
|
|
2948
|
-
});
|
|
2949
|
-
},
|
|
2950
|
-
asyncFailed(sessionId) {
|
|
2951
|
-
return sign({
|
|
2952
|
-
id: `fake_evt_${randomUUID3()}`,
|
|
2953
|
-
type: "checkout.async_failed",
|
|
2954
|
-
sessionId
|
|
2955
|
-
});
|
|
2956
|
-
},
|
|
2957
|
-
expired(sessionId) {
|
|
2958
|
-
return sign({
|
|
2959
|
-
id: `fake_evt_${randomUUID3()}`,
|
|
2960
|
-
type: "checkout.expired",
|
|
2961
|
-
sessionId
|
|
2962
|
-
});
|
|
2963
|
-
},
|
|
2964
|
-
refunded(sessionId, refunds2) {
|
|
2965
|
-
return sign({
|
|
2966
|
-
id: `fake_evt_${randomUUID3()}`,
|
|
2967
|
-
type: "payment.refunded",
|
|
2968
|
-
sessionId,
|
|
2969
|
-
refunds: refunds2
|
|
2970
|
-
});
|
|
2971
|
-
},
|
|
2972
|
-
sign
|
|
2973
|
-
};
|
|
2974
|
-
}
|
|
2975
|
-
|
|
2976
3040
|
// src/hold-sweeper.ts
|
|
2977
3041
|
async function sweepExpiredHolds(db) {
|
|
2978
3042
|
await db.transaction(async (tx) => {
|
|
@@ -2987,6 +3051,89 @@ function startHoldSweeper(db, { intervalMs = 6e4, onError } = {}) {
|
|
|
2987
3051
|
return { stop: () => clearInterval(timer) };
|
|
2988
3052
|
}
|
|
2989
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
|
+
|
|
2990
3137
|
// src/db/migrate.ts
|
|
2991
3138
|
import path from "path";
|
|
2992
3139
|
import { fileURLToPath } from "url";
|
|
@@ -3064,17 +3211,132 @@ function createMemoryStorage(baseUrl = "memory://") {
|
|
|
3064
3211
|
}
|
|
3065
3212
|
};
|
|
3066
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
|
+
}
|
|
3067
3323
|
export {
|
|
3068
3324
|
ProductNotFoundError,
|
|
3069
3325
|
SESSION_ID_PLACEHOLDER,
|
|
3070
3326
|
WebhookSignatureError,
|
|
3071
3327
|
catalogPlugin,
|
|
3328
|
+
checkoutUrls,
|
|
3072
3329
|
commercePlugin,
|
|
3073
3330
|
commerceSchema,
|
|
3331
|
+
createDiskStorage,
|
|
3074
3332
|
createFakeGateway,
|
|
3075
3333
|
createMemoryStorage,
|
|
3076
3334
|
createStripeGateway,
|
|
3335
|
+
isFakeGateway,
|
|
3336
|
+
openStoredFile,
|
|
3337
|
+
orderEmailLines,
|
|
3077
3338
|
ordersPlugin,
|
|
3339
|
+
resolvePayment,
|
|
3078
3340
|
runCommerceMigrations,
|
|
3079
3341
|
seedProducts,
|
|
3080
3342
|
seedShippingRates,
|