includio-cms 0.24.0 → 0.25.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/API.md +29 -6
- package/CHANGELOG.md +95 -0
- package/DOCS.md +80 -5
- package/ROADMAP.md +1 -0
- package/dist/admin/client/index.d.ts +3 -0
- package/dist/admin/client/index.js +3 -0
- package/dist/admin/client/shop/coupon-edit-page.svelte +44 -0
- package/dist/admin/client/shop/coupon-edit-page.svelte.d.ts +3 -0
- package/dist/admin/client/shop/coupon-form.svelte +170 -0
- package/dist/admin/client/shop/coupon-form.svelte.d.ts +18 -0
- package/dist/admin/client/shop/coupon-new-page.svelte +25 -0
- package/dist/admin/client/shop/coupon-new-page.svelte.d.ts +18 -0
- package/dist/admin/client/shop/coupons-list-page.svelte +135 -0
- package/dist/admin/client/shop/coupons-list-page.svelte.d.ts +3 -0
- package/dist/admin/client/shop/refund-dialog.svelte +161 -0
- package/dist/admin/client/shop/refund-dialog.svelte.d.ts +11 -0
- package/dist/admin/client/shop/shipping-method-edit-page.svelte +3 -6
- package/dist/admin/client/shop/shipping-method-form.svelte +15 -21
- package/dist/admin/client/shop/shipping-method-new-page.svelte +3 -6
- package/dist/admin/client/shop/shipping-methods-list-page.svelte +6 -6
- package/dist/admin/client/shop/shop-order-detail-page.svelte +107 -27
- package/dist/admin/client/shop/shop-orders-list-page.svelte +49 -11
- package/dist/admin/client/shop/shop-products-list-page.svelte +12 -11
- package/dist/admin/components/layout/lang.d.ts +1 -0
- package/dist/admin/components/layout/lang.js +4 -2
- package/dist/admin/components/layout/layout-renderer.svelte +12 -11
- package/dist/admin/components/layout/nav-breadcrumbs.svelte +3 -5
- package/dist/admin/components/layout/nav-shop.svelte +3 -1
- package/dist/admin/components/layout/nav-user.svelte +6 -4
- package/dist/admin/components/layout/site-header.svelte +11 -5
- package/dist/admin/remote/shop.remote.d.ts +122 -3
- package/dist/admin/remote/shop.remote.js +161 -5
- package/dist/db-postgres/schema/shop/couponRedemptions.d.ts +97 -0
- package/dist/db-postgres/schema/shop/couponRedemptions.js +21 -0
- package/dist/db-postgres/schema/shop/coupons.d.ts +197 -0
- package/dist/db-postgres/schema/shop/coupons.js +18 -0
- package/dist/db-postgres/schema/shop/index.d.ts +4 -0
- package/dist/db-postgres/schema/shop/index.js +4 -0
- package/dist/db-postgres/schema/shop/product.d.ts +17 -0
- package/dist/db-postgres/schema/shop/product.js +2 -0
- package/dist/db-postgres/schema/shop/refunds.d.ts +214 -0
- package/dist/db-postgres/schema/shop/refunds.js +21 -0
- package/dist/db-postgres/schema/shop/webhookEvents.d.ts +183 -0
- package/dist/db-postgres/schema/shop/webhookEvents.js +22 -0
- package/dist/shop/adapters/payu/client.d.ts +9 -0
- package/dist/shop/adapters/payu/client.js +29 -0
- package/dist/shop/adapters/payu/index.js +17 -1
- package/dist/shop/adapters/stripe/index.d.ts +64 -0
- package/dist/shop/adapters/stripe/index.js +169 -0
- package/dist/shop/adapters/stripe/payload.d.ts +38 -0
- package/dist/shop/adapters/stripe/payload.js +90 -0
- package/dist/shop/adapters/stripe/status-map.d.ts +11 -0
- package/dist/shop/adapters/stripe/status-map.js +31 -0
- package/dist/shop/cart/coupon-cookie.d.ts +7 -0
- package/dist/shop/cart/coupon-cookie.js +32 -0
- package/dist/shop/cart/types.d.ts +12 -0
- package/dist/shop/client/index.d.ts +118 -0
- package/dist/shop/client/index.js +39 -1
- package/dist/shop/http/cart-handler.d.ts +8 -0
- package/dist/shop/http/cart-handler.js +60 -1
- package/dist/shop/http/checkout-handler.js +7 -3
- package/dist/shop/http/index.d.ts +1 -1
- package/dist/shop/http/index.js +1 -1
- package/dist/shop/http/retry-payment-handler.js +1 -1
- package/dist/shop/http/webhook-handler.js +19 -1
- package/dist/shop/http/webhook-idempotency.d.ts +16 -0
- package/dist/shop/http/webhook-idempotency.js +51 -0
- package/dist/shop/http/webhook-logic.js +2 -1
- package/dist/shop/index.d.ts +3 -1
- package/dist/shop/index.js +3 -1
- package/dist/shop/pricing.d.ts +15 -0
- package/dist/shop/pricing.js +22 -0
- package/dist/shop/server/cart-hydrate.d.ts +1 -0
- package/dist/shop/server/cart-hydrate.js +58 -10
- package/dist/shop/server/coupons.d.ts +53 -0
- package/dist/shop/server/coupons.js +117 -0
- package/dist/shop/server/email.d.ts +15 -0
- package/dist/shop/server/email.js +46 -3
- package/dist/shop/server/orders.d.ts +1 -0
- package/dist/shop/server/orders.js +120 -54
- package/dist/shop/server/refund.d.ts +32 -0
- package/dist/shop/server/refund.js +140 -0
- package/dist/shop/svelte/InpostPicker.svelte +4 -7
- package/dist/shop/svelte/OrderStatus.svelte +6 -10
- package/dist/shop/svelte/labels.js +4 -2
- package/dist/shop/types.d.ts +41 -1
- package/dist/updates/0.25.0/index.d.ts +2 -0
- package/dist/updates/0.25.0/index.js +89 -0
- package/dist/updates/index.js +64 -1
- package/package.json +6 -1
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { json } from '@sveltejs/kit';
|
|
2
2
|
import { getCMS } from '../../core/cms.js';
|
|
3
3
|
import { addItem, readCartCookie, removeItem, setItemQty, writeCartCookie } from '../cart/cookie.js';
|
|
4
|
+
import { clearCouponCookie, isValidCouponCode, normalizeCouponCode, readCouponCookie, writeCouponCookie } from '../cart/coupon-cookie.js';
|
|
4
5
|
import { hydrateCart } from '../server/cart-hydrate.js';
|
|
6
|
+
import { CouponError, validateCoupon } from '../server/coupons.js';
|
|
5
7
|
import { checkRateLimit, clientKey } from '../rate-limit.js';
|
|
6
8
|
function shopEnabled() {
|
|
7
9
|
try {
|
|
@@ -13,7 +15,13 @@ function shopEnabled() {
|
|
|
13
15
|
}
|
|
14
16
|
async function respondWithCart(cookies) {
|
|
15
17
|
const items = readCartCookie(cookies);
|
|
16
|
-
const
|
|
18
|
+
const couponCode = readCouponCookie(cookies);
|
|
19
|
+
const snapshot = await hydrateCart(items, { couponCode });
|
|
20
|
+
// If coupon failed validation server-side (snapshot.coupon undefined despite cookie),
|
|
21
|
+
// drop the cookie so we don't keep retrying every request.
|
|
22
|
+
if (couponCode && !snapshot.coupon) {
|
|
23
|
+
clearCouponCookie(cookies);
|
|
24
|
+
}
|
|
17
25
|
// If any items were removed during hydration (not-found), purge them from cookie
|
|
18
26
|
const validIds = new Set(snapshot.items.filter((l) => l.issue !== 'not-found').map((l) => l.variantId));
|
|
19
27
|
const cleaned = items.filter((i) => validIds.has(i.variantId));
|
|
@@ -81,8 +89,59 @@ export function createCartHandler() {
|
|
|
81
89
|
}
|
|
82
90
|
else {
|
|
83
91
|
writeCartCookie(cookies, []);
|
|
92
|
+
clearCouponCookie(cookies);
|
|
84
93
|
}
|
|
85
94
|
return respondWithCart(cookies);
|
|
86
95
|
}
|
|
87
96
|
};
|
|
88
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Cart coupon endpoints — POST applies a code, DELETE removes it.
|
|
100
|
+
* Mount at `/api/shop/cart/coupon`.
|
|
101
|
+
*/
|
|
102
|
+
export function createCartCouponHandler() {
|
|
103
|
+
return {
|
|
104
|
+
POST: async ({ request, cookies }) => {
|
|
105
|
+
if (!shopEnabled())
|
|
106
|
+
return json({ error: 'Shop not enabled' }, { status: 404 });
|
|
107
|
+
const rule = getCMS().shopConfig?.rateLimit.checkout ?? { limit: 30, windowSec: 60 };
|
|
108
|
+
const rl = checkRateLimit(clientKey(request, 'coupon-apply'), rule);
|
|
109
|
+
if (!rl.allowed)
|
|
110
|
+
return json({ error: 'Rate limit exceeded' }, { status: 429 });
|
|
111
|
+
const body = (await request.json().catch(() => null));
|
|
112
|
+
if (!body || typeof body.code !== 'string') {
|
|
113
|
+
return json({ error: 'code required' }, { status: 400 });
|
|
114
|
+
}
|
|
115
|
+
const code = normalizeCouponCode(body.code);
|
|
116
|
+
if (!isValidCouponCode(code)) {
|
|
117
|
+
return json({ error: 'invalid_code' }, { status: 400 });
|
|
118
|
+
}
|
|
119
|
+
const items = readCartCookie(cookies);
|
|
120
|
+
const baseSnapshot = await hydrateCart(items);
|
|
121
|
+
if (baseSnapshot.subtotalNet <= 0) {
|
|
122
|
+
return json({ error: 'empty_cart' }, { status: 400 });
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
await validateCoupon({
|
|
126
|
+
code,
|
|
127
|
+
subtotalNet: baseSnapshot.subtotalNet,
|
|
128
|
+
subtotalGross: baseSnapshot.subtotalGross
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
if (err instanceof CouponError) {
|
|
133
|
+
return json({ error: err.code, message: err.message }, { status: 400 });
|
|
134
|
+
}
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
writeCouponCookie(cookies, code);
|
|
138
|
+
return respondWithCart(cookies);
|
|
139
|
+
},
|
|
140
|
+
DELETE: async ({ cookies }) => {
|
|
141
|
+
if (!shopEnabled())
|
|
142
|
+
return json({ error: 'Shop not enabled' }, { status: 404 });
|
|
143
|
+
clearCouponCookie(cookies);
|
|
144
|
+
return respondWithCart(cookies);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { json } from '@sveltejs/kit';
|
|
2
2
|
import { getCMS } from '../../core/cms.js';
|
|
3
3
|
import { readCartCookie, writeCartCookie } from '../cart/cookie.js';
|
|
4
|
+
import { clearCouponCookie, readCouponCookie } from '../cart/coupon-cookie.js';
|
|
4
5
|
import { writeOrderTokenCookie } from '../cart/order-token-cookie.js';
|
|
5
6
|
import { createOrderFromCart, setPaymentProviderRef } from '../server/orders.js';
|
|
6
7
|
import { getShippingMethod } from '../server/shipping.js';
|
|
@@ -89,6 +90,7 @@ export function createCheckoutHandler() {
|
|
|
89
90
|
console.error('[shop] carrier validateSelection failed:', err);
|
|
90
91
|
return json({ error: 'Carrier validation failed.' }, { status: 400 });
|
|
91
92
|
}
|
|
93
|
+
const couponCode = readCouponCookie(cookies);
|
|
92
94
|
try {
|
|
93
95
|
const result = await createOrderFromCart({
|
|
94
96
|
cartItems,
|
|
@@ -101,10 +103,12 @@ export function createCheckoutHandler() {
|
|
|
101
103
|
paymentMethod,
|
|
102
104
|
consents: asConsents(body.consents),
|
|
103
105
|
notes: asString(body.notes, 2000),
|
|
104
|
-
language: asString(body.language, 10)
|
|
106
|
+
language: asString(body.language, 10),
|
|
107
|
+
couponCode: couponCode ?? undefined
|
|
105
108
|
});
|
|
106
|
-
// Clear cart on successful order
|
|
109
|
+
// Clear cart + coupon on successful order
|
|
107
110
|
writeCartCookie(cookies, []);
|
|
111
|
+
clearCouponCookie(cookies);
|
|
108
112
|
// Short-lived cookie for same-browser order view fallback
|
|
109
113
|
writeOrderTokenCookie(cookies, {
|
|
110
114
|
number: result.order.number,
|
|
@@ -153,7 +157,7 @@ export function createCheckoutHandler() {
|
|
|
153
157
|
currency: result.order.currency,
|
|
154
158
|
paymentStatus: paymentResult?.status ?? 'manual',
|
|
155
159
|
requiresPaymentRedirect: requiresRedirect,
|
|
156
|
-
redirectUrl: requiresRedirect ? paymentResult?.redirectUrl ?? null : null
|
|
160
|
+
redirectUrl: requiresRedirect ? (paymentResult?.redirectUrl ?? null) : null
|
|
157
161
|
});
|
|
158
162
|
}
|
|
159
163
|
catch (err) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { createCartHandler } from './cart-handler.js';
|
|
1
|
+
export { createCartHandler, createCartCouponHandler } from './cart-handler.js';
|
|
2
2
|
export { createShippingMethodsHandler } from './shipping-handler.js';
|
|
3
3
|
export { createCheckoutHandler } from './checkout-handler.js';
|
|
4
4
|
export { createOrderHandler } from './order-handler.js';
|
package/dist/shop/http/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { createCartHandler } from './cart-handler.js';
|
|
1
|
+
export { createCartHandler, createCartCouponHandler } from './cart-handler.js';
|
|
2
2
|
export { createShippingMethodsHandler } from './shipping-handler.js';
|
|
3
3
|
export { createCheckoutHandler } from './checkout-handler.js';
|
|
4
4
|
export { createOrderHandler } from './order-handler.js';
|
|
@@ -92,7 +92,7 @@ export function createRetryPaymentHandler() {
|
|
|
92
92
|
status: result.status === 'redirect' ? 'awaitingPayment' : order.status,
|
|
93
93
|
paymentStatus: result.status,
|
|
94
94
|
requiresPaymentRedirect: result.status === 'redirect',
|
|
95
|
-
redirectUrl: result.status === 'redirect' ? result.redirectUrl ?? null : null
|
|
95
|
+
redirectUrl: result.status === 'redirect' ? (result.redirectUrl ?? null) : null
|
|
96
96
|
});
|
|
97
97
|
}
|
|
98
98
|
};
|
|
@@ -4,6 +4,7 @@ import { requireShopConfig } from '../server/db.js';
|
|
|
4
4
|
import { getOrderByNumber, updateOrderStatus } from '../server/orders.js';
|
|
5
5
|
import { checkRateLimit, clientKey } from '../rate-limit.js';
|
|
6
6
|
import { isTerminalStatus, mapEventToStatus } from './webhook-logic.js';
|
|
7
|
+
import { markWebhookEventProcessed, reserveWebhookEvent } from './webhook-idempotency.js';
|
|
7
8
|
function shopEnabled() {
|
|
8
9
|
try {
|
|
9
10
|
return getCMS().shopConfig !== null;
|
|
@@ -42,18 +43,33 @@ export function createPaymentWebhookHandler() {
|
|
|
42
43
|
// Bad signature / malformed body → 400 (don't encourage retries with 200)
|
|
43
44
|
return json({ error: 'Invalid webhook' }, { status: 400 });
|
|
44
45
|
}
|
|
46
|
+
// Idempotency — primary defence: persistent (provider, event_id) log.
|
|
47
|
+
// Falls through (rowId=null, isNew=true) when the adapter does not
|
|
48
|
+
// surface an event_id; in that case we still rely on terminal-status
|
|
49
|
+
// check below.
|
|
50
|
+
const reservation = await reserveWebhookEvent(provider, event);
|
|
51
|
+
if (!reservation.isNew) {
|
|
52
|
+
return json({ received: true, idempotent: true, replay: true });
|
|
53
|
+
}
|
|
45
54
|
const order = await getOrderByNumber(event.orderNumber);
|
|
46
55
|
if (!order) {
|
|
47
56
|
// Unknown order — 200 so the provider stops retrying
|
|
48
57
|
console.warn(`[shop] Webhook for unknown order ${event.orderNumber} (${provider}) — acking.`);
|
|
58
|
+
if (reservation.rowId)
|
|
59
|
+
await markWebhookEventProcessed(reservation.rowId, null);
|
|
49
60
|
return json({ received: true });
|
|
50
61
|
}
|
|
51
|
-
//
|
|
62
|
+
// Secondary idempotency — terminal states are no-ops (covers adapters
|
|
63
|
+
// that didn't surface eventId).
|
|
52
64
|
if (isTerminalStatus(order.status)) {
|
|
65
|
+
if (reservation.rowId)
|
|
66
|
+
await markWebhookEventProcessed(reservation.rowId, order.id);
|
|
53
67
|
return json({ received: true, idempotent: true });
|
|
54
68
|
}
|
|
55
69
|
const targetStatus = mapEventToStatus(event);
|
|
56
70
|
if (!targetStatus) {
|
|
71
|
+
if (reservation.rowId)
|
|
72
|
+
await markWebhookEventProcessed(reservation.rowId, order.id);
|
|
57
73
|
return json({ received: true, noop: true });
|
|
58
74
|
}
|
|
59
75
|
try {
|
|
@@ -67,6 +83,8 @@ export function createPaymentWebhookHandler() {
|
|
|
67
83
|
// Return 500 — provider retry helps
|
|
68
84
|
return json({ error: 'Processing failed' }, { status: 500 });
|
|
69
85
|
}
|
|
86
|
+
if (reservation.rowId)
|
|
87
|
+
await markWebhookEventProcessed(reservation.rowId, order.id);
|
|
70
88
|
return json({ received: true });
|
|
71
89
|
}
|
|
72
90
|
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { PaymentEvent } from '../types.js';
|
|
2
|
+
export interface IdempotencyCheckResult {
|
|
3
|
+
/** True when this is the first time we see this (provider, eventId). */
|
|
4
|
+
isNew: boolean;
|
|
5
|
+
/** Internal id of the inserted/existing log row, when known. */
|
|
6
|
+
rowId: string | null;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Reserve an idempotency slot for a webhook event. Returns `{ isNew: true }`
|
|
10
|
+
* exactly once per (provider, eventId) — concurrent calls race on the unique
|
|
11
|
+
* index and only one wins. When the event has no `eventId`, returns
|
|
12
|
+
* `{ isNew: true, rowId: null }` and the caller MUST fall back to its own
|
|
13
|
+
* idempotency check (e.g. terminal-status on the order).
|
|
14
|
+
*/
|
|
15
|
+
export declare function reserveWebhookEvent(provider: string, event: PaymentEvent): Promise<IdempotencyCheckResult>;
|
|
16
|
+
export declare function markWebhookEventProcessed(rowId: string, orderId?: string | null): Promise<void>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm';
|
|
2
|
+
import { shopWebhookEventsTable } from '../../db-postgres/schema/shop/index.js';
|
|
3
|
+
import { getShopDb } from '../server/db.js';
|
|
4
|
+
/**
|
|
5
|
+
* Reserve an idempotency slot for a webhook event. Returns `{ isNew: true }`
|
|
6
|
+
* exactly once per (provider, eventId) — concurrent calls race on the unique
|
|
7
|
+
* index and only one wins. When the event has no `eventId`, returns
|
|
8
|
+
* `{ isNew: true, rowId: null }` and the caller MUST fall back to its own
|
|
9
|
+
* idempotency check (e.g. terminal-status on the order).
|
|
10
|
+
*/
|
|
11
|
+
export async function reserveWebhookEvent(provider, event) {
|
|
12
|
+
if (!event.eventId)
|
|
13
|
+
return { isNew: true, rowId: null };
|
|
14
|
+
const db = getShopDb();
|
|
15
|
+
try {
|
|
16
|
+
const [row] = await db
|
|
17
|
+
.insert(shopWebhookEventsTable)
|
|
18
|
+
.values({
|
|
19
|
+
provider,
|
|
20
|
+
eventId: event.eventId,
|
|
21
|
+
eventType: event.eventType ?? null,
|
|
22
|
+
orderNumber: event.orderNumber || null,
|
|
23
|
+
raw: event.raw
|
|
24
|
+
})
|
|
25
|
+
.onConflictDoNothing({
|
|
26
|
+
target: [shopWebhookEventsTable.provider, shopWebhookEventsTable.eventId]
|
|
27
|
+
})
|
|
28
|
+
.returning({ id: shopWebhookEventsTable.id });
|
|
29
|
+
if (row)
|
|
30
|
+
return { isNew: true, rowId: row.id };
|
|
31
|
+
return { isNew: false, rowId: null };
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
// Defensive — if the unique index races and DB raises before onConflict
|
|
35
|
+
// kicks in (vendor-specific), treat as duplicate so we don't reprocess.
|
|
36
|
+
console.warn(`[shop] reserveWebhookEvent insert failed for ${provider}:`, err);
|
|
37
|
+
return { isNew: false, rowId: null };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export async function markWebhookEventProcessed(rowId, orderId) {
|
|
41
|
+
if (!rowId)
|
|
42
|
+
return;
|
|
43
|
+
const db = getShopDb();
|
|
44
|
+
await db
|
|
45
|
+
.update(shopWebhookEventsTable)
|
|
46
|
+
.set({
|
|
47
|
+
processedAt: new Date(),
|
|
48
|
+
...(orderId ? { orderId } : {})
|
|
49
|
+
})
|
|
50
|
+
.where(eq(shopWebhookEventsTable.id, rowId));
|
|
51
|
+
}
|
package/dist/shop/index.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ export declare function defineShop(config: ShopConfig): ResolvedShopConfig;
|
|
|
3
3
|
export { manualAdapter } from './adapters/manual/index.js';
|
|
4
4
|
export { payuAdapter } from './adapters/payu/index.js';
|
|
5
5
|
export type { PayuAdapterOptions } from './adapters/payu/index.js';
|
|
6
|
+
export { stripeAdapter } from './adapters/stripe/index.js';
|
|
7
|
+
export type { StripeAdapterOptions } from './adapters/stripe/index.js';
|
|
6
8
|
export { inpostAdapter } from './adapters/inpost/index.js';
|
|
7
9
|
export type { InpostAdapterOptions, InpostSenderAddress, GeowidgetConfigPreset, InpostEnvironment } from './adapters/inpost/index.js';
|
|
8
|
-
export type { ShopConfig, ResolvedShopConfig, Currency, OrderStatus, PaymentAdapter, PaymentCreateContext, CarrierAdapter, CarrierEvent, ShipmentCreateInput, ShipmentCreateResult, ShipmentLabel, ConsentConfig, ShopFeatures, PaymentCreateResult, PaymentEvent, OrderRef, I18nText } from './types.js';
|
|
10
|
+
export type { ShopConfig, ResolvedShopConfig, Currency, OrderStatus, PaymentAdapter, PaymentCreateContext, PaymentRefundInput, PaymentRefundResult, CarrierAdapter, CarrierEvent, ShipmentCreateInput, ShipmentCreateResult, ShipmentLabel, ConsentConfig, ShopFeatures, PaymentCreateResult, PaymentEvent, OrderRef, CouponRef, I18nText } from './types.js';
|
package/dist/shop/index.js
CHANGED
|
@@ -4,7 +4,8 @@ export function defineShop(config) {
|
|
|
4
4
|
features: {
|
|
5
5
|
variants: config.features?.variants ?? false,
|
|
6
6
|
stock: config.features?.stock ?? false,
|
|
7
|
-
accounts: config.features?.accounts ?? false
|
|
7
|
+
accounts: config.features?.accounts ?? false,
|
|
8
|
+
coupons: config.features?.coupons ?? false
|
|
8
9
|
},
|
|
9
10
|
rateLimit: {
|
|
10
11
|
checkout: config.rateLimit?.checkout ?? { limit: 5, windowSec: 60 },
|
|
@@ -17,4 +18,5 @@ export function defineShop(config) {
|
|
|
17
18
|
}
|
|
18
19
|
export { manualAdapter } from './adapters/manual/index.js';
|
|
19
20
|
export { payuAdapter } from './adapters/payu/index.js';
|
|
21
|
+
export { stripeAdapter } from './adapters/stripe/index.js';
|
|
20
22
|
export { inpostAdapter } from './adapters/inpost/index.js';
|
package/dist/shop/pricing.d.ts
CHANGED
|
@@ -17,3 +17,18 @@ export interface Totals {
|
|
|
17
17
|
}
|
|
18
18
|
export declare function sumTotals(lines: Priceable[]): Totals;
|
|
19
19
|
export declare function resolveI18n(value: Record<string, string> | undefined | null, language: string, fallback?: string): string;
|
|
20
|
+
export interface CouponDiscountInput {
|
|
21
|
+
type: 'percent' | 'fixed';
|
|
22
|
+
/** percent: 0-100; fixed: PLN value (precision 20,6). */
|
|
23
|
+
value: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Compute discount in cents (minor units) applied to a net subtotal.
|
|
27
|
+
* - `percent`: subtotalNet × (value / 100), rounded to nearest cent.
|
|
28
|
+
* - `fixed`: PLN value converted to cents, capped at subtotalNet (never negative).
|
|
29
|
+
*
|
|
30
|
+
* Discount is calculated on the net subtotal — VAT is then applied to lines
|
|
31
|
+
* after the discount factor, so the discount appears proportionally on each
|
|
32
|
+
* line. Result is bounded to `[0, subtotalNet]`.
|
|
33
|
+
*/
|
|
34
|
+
export declare function calculateCouponDiscountNet(subtotalNet: number, coupon: CouponDiscountInput): number;
|
package/dist/shop/pricing.js
CHANGED
|
@@ -47,3 +47,25 @@ export function resolveI18n(value, language, fallback) {
|
|
|
47
47
|
const first = Object.values(value)[0];
|
|
48
48
|
return first ?? fallback ?? '';
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Compute discount in cents (minor units) applied to a net subtotal.
|
|
52
|
+
* - `percent`: subtotalNet × (value / 100), rounded to nearest cent.
|
|
53
|
+
* - `fixed`: PLN value converted to cents, capped at subtotalNet (never negative).
|
|
54
|
+
*
|
|
55
|
+
* Discount is calculated on the net subtotal — VAT is then applied to lines
|
|
56
|
+
* after the discount factor, so the discount appears proportionally on each
|
|
57
|
+
* line. Result is bounded to `[0, subtotalNet]`.
|
|
58
|
+
*/
|
|
59
|
+
export function calculateCouponDiscountNet(subtotalNet, coupon) {
|
|
60
|
+
if (subtotalNet <= 0)
|
|
61
|
+
return 0;
|
|
62
|
+
let raw = 0;
|
|
63
|
+
if (coupon.type === 'percent') {
|
|
64
|
+
const pct = Math.max(0, Math.min(100, coupon.value));
|
|
65
|
+
raw = Math.round(subtotalNet * (pct / 100));
|
|
66
|
+
}
|
|
67
|
+
else if (coupon.type === 'fixed') {
|
|
68
|
+
raw = toCents(Math.max(0, coupon.value));
|
|
69
|
+
}
|
|
70
|
+
return Math.max(0, Math.min(raw, subtotalNet));
|
|
71
|
+
}
|
|
@@ -4,7 +4,8 @@ import { entriesTable } from '../../db-postgres/schema/entry.js';
|
|
|
4
4
|
import { entryVersionsTable } from '../../db-postgres/schema/entryVersion.js';
|
|
5
5
|
import { getCMS } from '../../core/cms.js';
|
|
6
6
|
import { getShopDb, requireShopConfig } from './db.js';
|
|
7
|
-
import { grossPlnFromNetPln, toCents } from '../pricing.js';
|
|
7
|
+
import { grossFromNet, grossPlnFromNetPln, toCents } from '../pricing.js';
|
|
8
|
+
import { buildAppliedCoupon, validateCoupon, CouponError } from './coupons.js';
|
|
8
9
|
export async function hydrateCart(items, opts = {}) {
|
|
9
10
|
const shop = requireShopConfig();
|
|
10
11
|
const cms = getCMS();
|
|
@@ -13,6 +14,9 @@ export async function hydrateCart(items, opts = {}) {
|
|
|
13
14
|
return {
|
|
14
15
|
items: [],
|
|
15
16
|
itemCount: 0,
|
|
17
|
+
subtotalNet: 0,
|
|
18
|
+
subtotalGross: 0,
|
|
19
|
+
subtotalVat: 0,
|
|
16
20
|
totalNet: 0,
|
|
17
21
|
totalGross: 0,
|
|
18
22
|
totalVat: 0,
|
|
@@ -27,10 +31,7 @@ export async function hydrateCart(items, opts = {}) {
|
|
|
27
31
|
.where(inArray(shopProductVariantsTable.id, variantIds));
|
|
28
32
|
const productIds = Array.from(new Set(variants.map((v) => v.productId)));
|
|
29
33
|
const products = productIds.length > 0
|
|
30
|
-
? await db
|
|
31
|
-
.select()
|
|
32
|
-
.from(shopProductsTable)
|
|
33
|
-
.where(inArray(shopProductsTable.id, productIds))
|
|
34
|
+
? await db.select().from(shopProductsTable).where(inArray(shopProductsTable.id, productIds))
|
|
34
35
|
: [];
|
|
35
36
|
const entryIds = products.map((p) => p.entryId);
|
|
36
37
|
const entries = entryIds.length > 0
|
|
@@ -144,13 +145,60 @@ export async function hydrateCart(items, opts = {}) {
|
|
|
144
145
|
totalGross += lineGross;
|
|
145
146
|
}
|
|
146
147
|
const itemCount = lines.reduce((sum, l) => sum + l.qty, 0);
|
|
148
|
+
const subtotalNet = totalNet;
|
|
149
|
+
const subtotalGross = totalGross;
|
|
150
|
+
let appliedCoupon;
|
|
151
|
+
let finalLines = lines;
|
|
152
|
+
let finalTotalNet = totalNet;
|
|
153
|
+
let finalTotalGross = totalGross;
|
|
154
|
+
if (opts.couponCode && shop.features.coupons && subtotalNet > 0) {
|
|
155
|
+
try {
|
|
156
|
+
const { row, discountNet } = await validateCoupon({
|
|
157
|
+
code: opts.couponCode,
|
|
158
|
+
subtotalNet,
|
|
159
|
+
subtotalGross
|
|
160
|
+
});
|
|
161
|
+
if (discountNet > 0) {
|
|
162
|
+
const factor = (subtotalNet - discountNet) / subtotalNet;
|
|
163
|
+
finalLines = lines.map((l) => {
|
|
164
|
+
if (!l.available)
|
|
165
|
+
return l;
|
|
166
|
+
const newLineNet = Math.round(l.lineNet * factor);
|
|
167
|
+
const newLineGross = grossFromNet(newLineNet, l.vatRate);
|
|
168
|
+
return {
|
|
169
|
+
...l,
|
|
170
|
+
lineNet: newLineNet,
|
|
171
|
+
lineGross: newLineGross,
|
|
172
|
+
lineVat: newLineGross - newLineNet
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
finalTotalNet = finalLines.reduce((s, l) => s + l.lineNet, 0);
|
|
176
|
+
finalTotalGross = finalLines.reduce((s, l) => s + l.lineGross, 0);
|
|
177
|
+
}
|
|
178
|
+
appliedCoupon = buildAppliedCoupon(row, discountNet, subtotalNet, subtotalGross);
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
if (err instanceof CouponError) {
|
|
182
|
+
// Silently drop invalid coupon — caller can re-validate explicitly
|
|
183
|
+
// via validateCoupon() to surface the reason to the user.
|
|
184
|
+
appliedCoupon = undefined;
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
throw err;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
147
191
|
return {
|
|
148
|
-
items:
|
|
192
|
+
items: finalLines,
|
|
149
193
|
itemCount,
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
194
|
+
subtotalNet,
|
|
195
|
+
subtotalGross,
|
|
196
|
+
subtotalVat: subtotalGross - subtotalNet,
|
|
197
|
+
totalNet: finalTotalNet,
|
|
198
|
+
totalGross: finalTotalGross,
|
|
199
|
+
totalVat: finalTotalGross - finalTotalNet,
|
|
200
|
+
currency: shop.currency,
|
|
201
|
+
...(appliedCoupon ? { coupon: appliedCoupon } : {})
|
|
154
202
|
};
|
|
155
203
|
function makeMissingLine(ref, issue) {
|
|
156
204
|
return {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { shopCouponsTable } from '../../db-postgres/schema/shop/index.js';
|
|
2
|
+
import type { AppliedCoupon } from '../cart/types.js';
|
|
3
|
+
export type ShopCouponRow = typeof shopCouponsTable.$inferSelect;
|
|
4
|
+
export declare class CouponError extends Error {
|
|
5
|
+
readonly code: 'invalid_code' | 'not_found' | 'inactive' | 'expired' | 'exhausted' | 'min_order_not_met' | 'feature_disabled' | 'race_lost';
|
|
6
|
+
constructor(code: 'invalid_code' | 'not_found' | 'inactive' | 'expired' | 'exhausted' | 'min_order_not_met' | 'feature_disabled' | 'race_lost', message: string);
|
|
7
|
+
}
|
|
8
|
+
export interface ValidateCouponInput {
|
|
9
|
+
code: string;
|
|
10
|
+
subtotalNet: number;
|
|
11
|
+
subtotalGross: number;
|
|
12
|
+
}
|
|
13
|
+
export interface ValidatedCoupon {
|
|
14
|
+
row: ShopCouponRow;
|
|
15
|
+
discountNet: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Look up and validate a coupon for a given cart subtotal. Returns row +
|
|
19
|
+
* computed discount or throws `CouponError`. Does NOT mutate `usedCount`.
|
|
20
|
+
*/
|
|
21
|
+
export declare function validateCoupon(input: ValidateCouponInput): Promise<ValidatedCoupon>;
|
|
22
|
+
/**
|
|
23
|
+
* Build an `AppliedCoupon` snapshot from a validated coupon and a discount
|
|
24
|
+
* computed in net. `discountGross` reflects the gross-equivalent (subtotalGross
|
|
25
|
+
* − totalGrossAfterDiscount). For uniform per-line discount factor:
|
|
26
|
+
* factor = (subtotalNet − discountNet) / subtotalNet
|
|
27
|
+
* discountGross = subtotalGross × (1 − factor)
|
|
28
|
+
*/
|
|
29
|
+
export declare function buildAppliedCoupon(row: ShopCouponRow, discountNet: number, subtotalNet: number, subtotalGross: number): AppliedCoupon;
|
|
30
|
+
/**
|
|
31
|
+
* Atomically reserve one redemption slot on a coupon. Returns the updated
|
|
32
|
+
* `usedCount` on success, throws `CouponError('race_lost')` if the slot was
|
|
33
|
+
* taken concurrently. Use inside the checkout transaction.
|
|
34
|
+
*
|
|
35
|
+
* The UPDATE is conditional on `(maxUses IS NULL OR usedCount < maxUses)` so
|
|
36
|
+
* concurrent redemptions race at the DB and the loser fails fast.
|
|
37
|
+
*/
|
|
38
|
+
export declare function reserveCouponSlot(couponId: string): Promise<number>;
|
|
39
|
+
/**
|
|
40
|
+
* Persist the redemption row tying a coupon to an order.
|
|
41
|
+
* UNIQUE(orderId) means a retried checkout for the same order won't double-redeem.
|
|
42
|
+
*/
|
|
43
|
+
export declare function recordCouponRedemption(input: {
|
|
44
|
+
couponId: string;
|
|
45
|
+
orderId: string;
|
|
46
|
+
discountAmount: number;
|
|
47
|
+
}): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Release a previously reserved coupon slot — used to roll back when a
|
|
50
|
+
* checkout fails after `reserveCouponSlot()` succeeded but before the order
|
|
51
|
+
* row commits.
|
|
52
|
+
*/
|
|
53
|
+
export declare function releaseCouponSlot(couponId: string): Promise<void>;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { and, eq, isNull, or, sql } from 'drizzle-orm';
|
|
2
|
+
import { shopCouponRedemptionsTable, shopCouponsTable } from '../../db-postgres/schema/shop/index.js';
|
|
3
|
+
import { getShopDb, requireShopConfig } from './db.js';
|
|
4
|
+
import { calculateCouponDiscountNet } from '../pricing.js';
|
|
5
|
+
import { normalizeCouponCode } from '../cart/coupon-cookie.js';
|
|
6
|
+
export class CouponError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
constructor(code, message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.name = 'CouponError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Look up and validate a coupon for a given cart subtotal. Returns row +
|
|
16
|
+
* computed discount or throws `CouponError`. Does NOT mutate `usedCount`.
|
|
17
|
+
*/
|
|
18
|
+
export async function validateCoupon(input) {
|
|
19
|
+
const shop = requireShopConfig();
|
|
20
|
+
if (!shop.features.coupons) {
|
|
21
|
+
throw new CouponError('feature_disabled', 'Coupons are not enabled for this shop');
|
|
22
|
+
}
|
|
23
|
+
const code = normalizeCouponCode(input.code);
|
|
24
|
+
if (!code)
|
|
25
|
+
throw new CouponError('invalid_code', 'Coupon code is empty');
|
|
26
|
+
const db = getShopDb();
|
|
27
|
+
const [row] = await db.select().from(shopCouponsTable).where(eq(shopCouponsTable.code, code));
|
|
28
|
+
if (!row)
|
|
29
|
+
throw new CouponError('not_found', `Coupon "${code}" does not exist`);
|
|
30
|
+
if (!row.isActive)
|
|
31
|
+
throw new CouponError('inactive', `Coupon "${code}" is not active`);
|
|
32
|
+
if (row.expiresAt && row.expiresAt.getTime() < Date.now()) {
|
|
33
|
+
throw new CouponError('expired', `Coupon "${code}" has expired`);
|
|
34
|
+
}
|
|
35
|
+
if (row.maxUses !== null && row.usedCount >= row.maxUses) {
|
|
36
|
+
throw new CouponError('exhausted', `Coupon "${code}" has no more uses`);
|
|
37
|
+
}
|
|
38
|
+
if (row.minOrderAmount !== null && input.subtotalGross < row.minOrderAmount) {
|
|
39
|
+
throw new CouponError('min_order_not_met', `Coupon "${code}" requires minimum order ${row.minOrderAmount}`);
|
|
40
|
+
}
|
|
41
|
+
const discountNet = calculateCouponDiscountNet(input.subtotalNet, {
|
|
42
|
+
type: row.type,
|
|
43
|
+
value: Number(row.value)
|
|
44
|
+
});
|
|
45
|
+
return { row, discountNet };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Build an `AppliedCoupon` snapshot from a validated coupon and a discount
|
|
49
|
+
* computed in net. `discountGross` reflects the gross-equivalent (subtotalGross
|
|
50
|
+
* − totalGrossAfterDiscount). For uniform per-line discount factor:
|
|
51
|
+
* factor = (subtotalNet − discountNet) / subtotalNet
|
|
52
|
+
* discountGross = subtotalGross × (1 − factor)
|
|
53
|
+
*/
|
|
54
|
+
export function buildAppliedCoupon(row, discountNet, subtotalNet, subtotalGross) {
|
|
55
|
+
const factor = subtotalNet > 0 ? (subtotalNet - discountNet) / subtotalNet : 1;
|
|
56
|
+
const discountGross = subtotalGross - Math.round(subtotalGross * factor);
|
|
57
|
+
return {
|
|
58
|
+
code: row.code,
|
|
59
|
+
type: row.type,
|
|
60
|
+
value: Number(row.value),
|
|
61
|
+
discountNet,
|
|
62
|
+
discountGross
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Atomically reserve one redemption slot on a coupon. Returns the updated
|
|
67
|
+
* `usedCount` on success, throws `CouponError('race_lost')` if the slot was
|
|
68
|
+
* taken concurrently. Use inside the checkout transaction.
|
|
69
|
+
*
|
|
70
|
+
* The UPDATE is conditional on `(maxUses IS NULL OR usedCount < maxUses)` so
|
|
71
|
+
* concurrent redemptions race at the DB and the loser fails fast.
|
|
72
|
+
*/
|
|
73
|
+
export async function reserveCouponSlot(couponId) {
|
|
74
|
+
const db = getShopDb();
|
|
75
|
+
const result = await db
|
|
76
|
+
.update(shopCouponsTable)
|
|
77
|
+
.set({
|
|
78
|
+
usedCount: sql `${shopCouponsTable.usedCount} + 1`,
|
|
79
|
+
updatedAt: new Date()
|
|
80
|
+
})
|
|
81
|
+
.where(and(eq(shopCouponsTable.id, couponId), or(isNull(shopCouponsTable.maxUses), sql `${shopCouponsTable.usedCount} < ${shopCouponsTable.maxUses}`)))
|
|
82
|
+
.returning({ usedCount: shopCouponsTable.usedCount });
|
|
83
|
+
if (result.length === 0) {
|
|
84
|
+
throw new CouponError('exhausted', 'Coupon slot taken concurrently');
|
|
85
|
+
}
|
|
86
|
+
return result[0].usedCount;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Persist the redemption row tying a coupon to an order.
|
|
90
|
+
* UNIQUE(orderId) means a retried checkout for the same order won't double-redeem.
|
|
91
|
+
*/
|
|
92
|
+
export async function recordCouponRedemption(input) {
|
|
93
|
+
const db = getShopDb();
|
|
94
|
+
await db
|
|
95
|
+
.insert(shopCouponRedemptionsTable)
|
|
96
|
+
.values({
|
|
97
|
+
couponId: input.couponId,
|
|
98
|
+
orderId: input.orderId,
|
|
99
|
+
discountAmount: input.discountAmount
|
|
100
|
+
})
|
|
101
|
+
.onConflictDoNothing({ target: shopCouponRedemptionsTable.orderId });
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Release a previously reserved coupon slot — used to roll back when a
|
|
105
|
+
* checkout fails after `reserveCouponSlot()` succeeded but before the order
|
|
106
|
+
* row commits.
|
|
107
|
+
*/
|
|
108
|
+
export async function releaseCouponSlot(couponId) {
|
|
109
|
+
const db = getShopDb();
|
|
110
|
+
await db
|
|
111
|
+
.update(shopCouponsTable)
|
|
112
|
+
.set({
|
|
113
|
+
usedCount: sql `GREATEST(${shopCouponsTable.usedCount} - 1, 0)`,
|
|
114
|
+
updatedAt: new Date()
|
|
115
|
+
})
|
|
116
|
+
.where(eq(shopCouponsTable.id, couponId));
|
|
117
|
+
}
|
|
@@ -1,2 +1,17 @@
|
|
|
1
1
|
import type { OrderStatus } from '../types.js';
|
|
2
2
|
export declare function sendOrderStatusEmail(orderId: string, status: OrderStatus): Promise<void>;
|
|
3
|
+
/**
|
|
4
|
+
* Notify the shop admin that a product crossed its low-stock threshold.
|
|
5
|
+
* Fire-and-forget — silently no-ops when no `shop.adminEmail` or no email
|
|
6
|
+
* adapter is configured. Stays inside email.ts to keep the adapter call
|
|
7
|
+
* site centralised (HTML, escaping, logging behaviour identical to status
|
|
8
|
+
* emails).
|
|
9
|
+
*
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
export declare function sendLowStockEmail(input: {
|
|
13
|
+
productTitle: string;
|
|
14
|
+
variantSku: string | null;
|
|
15
|
+
stock: number;
|
|
16
|
+
threshold: number;
|
|
17
|
+
}): Promise<void>;
|