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.
Files changed (90) hide show
  1. package/API.md +29 -6
  2. package/CHANGELOG.md +95 -0
  3. package/DOCS.md +80 -5
  4. package/ROADMAP.md +1 -0
  5. package/dist/admin/client/index.d.ts +3 -0
  6. package/dist/admin/client/index.js +3 -0
  7. package/dist/admin/client/shop/coupon-edit-page.svelte +44 -0
  8. package/dist/admin/client/shop/coupon-edit-page.svelte.d.ts +3 -0
  9. package/dist/admin/client/shop/coupon-form.svelte +170 -0
  10. package/dist/admin/client/shop/coupon-form.svelte.d.ts +18 -0
  11. package/dist/admin/client/shop/coupon-new-page.svelte +25 -0
  12. package/dist/admin/client/shop/coupon-new-page.svelte.d.ts +18 -0
  13. package/dist/admin/client/shop/coupons-list-page.svelte +135 -0
  14. package/dist/admin/client/shop/coupons-list-page.svelte.d.ts +3 -0
  15. package/dist/admin/client/shop/refund-dialog.svelte +161 -0
  16. package/dist/admin/client/shop/refund-dialog.svelte.d.ts +11 -0
  17. package/dist/admin/client/shop/shipping-method-edit-page.svelte +3 -6
  18. package/dist/admin/client/shop/shipping-method-form.svelte +15 -21
  19. package/dist/admin/client/shop/shipping-method-new-page.svelte +3 -6
  20. package/dist/admin/client/shop/shipping-methods-list-page.svelte +6 -6
  21. package/dist/admin/client/shop/shop-order-detail-page.svelte +107 -27
  22. package/dist/admin/client/shop/shop-orders-list-page.svelte +49 -11
  23. package/dist/admin/client/shop/shop-products-list-page.svelte +12 -11
  24. package/dist/admin/components/layout/lang.d.ts +1 -0
  25. package/dist/admin/components/layout/lang.js +4 -2
  26. package/dist/admin/components/layout/layout-renderer.svelte +12 -11
  27. package/dist/admin/components/layout/nav-breadcrumbs.svelte +3 -5
  28. package/dist/admin/components/layout/nav-shop.svelte +3 -1
  29. package/dist/admin/components/layout/nav-user.svelte +6 -4
  30. package/dist/admin/components/layout/site-header.svelte +11 -5
  31. package/dist/admin/remote/shop.remote.d.ts +122 -3
  32. package/dist/admin/remote/shop.remote.js +161 -5
  33. package/dist/db-postgres/schema/shop/couponRedemptions.d.ts +97 -0
  34. package/dist/db-postgres/schema/shop/couponRedemptions.js +21 -0
  35. package/dist/db-postgres/schema/shop/coupons.d.ts +197 -0
  36. package/dist/db-postgres/schema/shop/coupons.js +18 -0
  37. package/dist/db-postgres/schema/shop/index.d.ts +4 -0
  38. package/dist/db-postgres/schema/shop/index.js +4 -0
  39. package/dist/db-postgres/schema/shop/product.d.ts +17 -0
  40. package/dist/db-postgres/schema/shop/product.js +2 -0
  41. package/dist/db-postgres/schema/shop/refunds.d.ts +214 -0
  42. package/dist/db-postgres/schema/shop/refunds.js +21 -0
  43. package/dist/db-postgres/schema/shop/webhookEvents.d.ts +183 -0
  44. package/dist/db-postgres/schema/shop/webhookEvents.js +22 -0
  45. package/dist/shop/adapters/payu/client.d.ts +9 -0
  46. package/dist/shop/adapters/payu/client.js +29 -0
  47. package/dist/shop/adapters/payu/index.js +17 -1
  48. package/dist/shop/adapters/stripe/index.d.ts +64 -0
  49. package/dist/shop/adapters/stripe/index.js +169 -0
  50. package/dist/shop/adapters/stripe/payload.d.ts +38 -0
  51. package/dist/shop/adapters/stripe/payload.js +90 -0
  52. package/dist/shop/adapters/stripe/status-map.d.ts +11 -0
  53. package/dist/shop/adapters/stripe/status-map.js +31 -0
  54. package/dist/shop/cart/coupon-cookie.d.ts +7 -0
  55. package/dist/shop/cart/coupon-cookie.js +32 -0
  56. package/dist/shop/cart/types.d.ts +12 -0
  57. package/dist/shop/client/index.d.ts +118 -0
  58. package/dist/shop/client/index.js +39 -1
  59. package/dist/shop/http/cart-handler.d.ts +8 -0
  60. package/dist/shop/http/cart-handler.js +60 -1
  61. package/dist/shop/http/checkout-handler.js +7 -3
  62. package/dist/shop/http/index.d.ts +1 -1
  63. package/dist/shop/http/index.js +1 -1
  64. package/dist/shop/http/retry-payment-handler.js +1 -1
  65. package/dist/shop/http/webhook-handler.js +19 -1
  66. package/dist/shop/http/webhook-idempotency.d.ts +16 -0
  67. package/dist/shop/http/webhook-idempotency.js +51 -0
  68. package/dist/shop/http/webhook-logic.js +2 -1
  69. package/dist/shop/index.d.ts +3 -1
  70. package/dist/shop/index.js +3 -1
  71. package/dist/shop/pricing.d.ts +15 -0
  72. package/dist/shop/pricing.js +22 -0
  73. package/dist/shop/server/cart-hydrate.d.ts +1 -0
  74. package/dist/shop/server/cart-hydrate.js +58 -10
  75. package/dist/shop/server/coupons.d.ts +53 -0
  76. package/dist/shop/server/coupons.js +117 -0
  77. package/dist/shop/server/email.d.ts +15 -0
  78. package/dist/shop/server/email.js +46 -3
  79. package/dist/shop/server/orders.d.ts +1 -0
  80. package/dist/shop/server/orders.js +120 -54
  81. package/dist/shop/server/refund.d.ts +32 -0
  82. package/dist/shop/server/refund.js +140 -0
  83. package/dist/shop/svelte/InpostPicker.svelte +4 -7
  84. package/dist/shop/svelte/OrderStatus.svelte +6 -10
  85. package/dist/shop/svelte/labels.js +4 -2
  86. package/dist/shop/types.d.ts +41 -1
  87. package/dist/updates/0.25.0/index.d.ts +2 -0
  88. package/dist/updates/0.25.0/index.js +89 -0
  89. package/dist/updates/index.js +64 -1
  90. 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 snapshot = await hydrateCart(items);
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';
@@ -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
- // Idempotency — terminal states are no-ops
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
+ }
@@ -2,7 +2,8 @@ export const TERMINAL_ORDER_STATUSES = new Set([
2
2
  'paid',
3
3
  'paymentRejected',
4
4
  'cancelled',
5
- 'done'
5
+ 'done',
6
+ 'refunded'
6
7
  ]);
7
8
  export function isTerminalStatus(status) {
8
9
  return TERMINAL_ORDER_STATUSES.has(status);
@@ -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';
@@ -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';
@@ -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;
@@ -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
+ }
@@ -1,4 +1,5 @@
1
1
  import type { CartItemRef, CartSnapshot } from '../cart/types.js';
2
2
  export declare function hydrateCart(items: CartItemRef[], opts?: {
3
3
  language?: string;
4
+ couponCode?: string | null;
4
5
  }): Promise<CartSnapshot>;
@@ -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: lines,
192
+ items: finalLines,
149
193
  itemCount,
150
- totalNet,
151
- totalGross,
152
- totalVat: totalGross - totalNet,
153
- currency: shop.currency
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>;