includio-cms 0.15.2 → 0.15.3

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 (62) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/DOCS.md +137 -2
  3. package/ROADMAP.md +7 -2
  4. package/dist/admin/client/shop/shipping-method-form.svelte +66 -1
  5. package/dist/admin/client/shop/shipping-method-form.svelte.d.ts +8 -0
  6. package/dist/admin/client/shop/shop-order-detail-page.svelte +101 -0
  7. package/dist/admin/remote/shop.remote.d.ts +44 -0
  8. package/dist/admin/remote/shop.remote.js +35 -0
  9. package/dist/cli/index.js +49 -4
  10. package/dist/cli/scaffold/admin.d.ts +9 -2
  11. package/dist/cli/scaffold/admin.js +32 -3
  12. package/dist/db-postgres/schema/shop/order.d.ts +68 -0
  13. package/dist/db-postgres/schema/shop/order.js +4 -0
  14. package/dist/db-postgres/schema/shop/shippingMethod.d.ts +25 -0
  15. package/dist/db-postgres/schema/shop/shippingMethod.js +1 -0
  16. package/dist/shop/adapters/inpost/geowidget.d.ts +27 -0
  17. package/dist/shop/adapters/inpost/geowidget.js +31 -0
  18. package/dist/shop/adapters/inpost/index.d.ts +89 -0
  19. package/dist/shop/adapters/inpost/index.js +156 -0
  20. package/dist/shop/adapters/inpost/payload.d.ts +18 -0
  21. package/dist/shop/adapters/inpost/payload.js +85 -0
  22. package/dist/shop/adapters/inpost/points-api.d.ts +17 -0
  23. package/dist/shop/adapters/inpost/points-api.js +55 -0
  24. package/dist/shop/adapters/inpost/shipx-client.d.ts +56 -0
  25. package/dist/shop/adapters/inpost/shipx-client.js +95 -0
  26. package/dist/shop/adapters/inpost/status-map.d.ts +9 -0
  27. package/dist/shop/adapters/inpost/status-map.js +46 -0
  28. package/dist/shop/adapters/inpost/webhook.d.ts +16 -0
  29. package/dist/shop/adapters/inpost/webhook.js +55 -0
  30. package/dist/shop/client/index.d.ts +5 -0
  31. package/dist/shop/http/carrier-handler.d.ts +12 -0
  32. package/dist/shop/http/carrier-handler.js +45 -0
  33. package/dist/shop/http/carrier-webhook-handler.d.ts +13 -0
  34. package/dist/shop/http/carrier-webhook-handler.js +66 -0
  35. package/dist/shop/http/checkout-handler.js +23 -1
  36. package/dist/shop/http/index.d.ts +3 -0
  37. package/dist/shop/http/index.js +3 -0
  38. package/dist/shop/http/order-handler.js +14 -0
  39. package/dist/shop/http/shipment-label-handler.d.ts +10 -0
  40. package/dist/shop/http/shipment-label-handler.js +53 -0
  41. package/dist/shop/http/shipping-handler.js +3 -0
  42. package/dist/shop/index.d.ts +3 -1
  43. package/dist/shop/index.js +1 -0
  44. package/dist/shop/server/email.js +37 -0
  45. package/dist/shop/server/orders.d.ts +9 -0
  46. package/dist/shop/server/orders.js +48 -0
  47. package/dist/shop/server/shipments.d.ts +33 -0
  48. package/dist/shop/server/shipments.js +145 -0
  49. package/dist/shop/server/shipping.d.ts +2 -1
  50. package/dist/shop/server/shipping.js +9 -0
  51. package/dist/shop/svelte/InpostPicker.svelte +270 -0
  52. package/dist/shop/svelte/InpostPicker.svelte.d.ts +51 -0
  53. package/dist/shop/svelte/OrderStatus.svelte +53 -1
  54. package/dist/shop/svelte/index.d.ts +1 -0
  55. package/dist/shop/svelte/index.js +1 -0
  56. package/dist/shop/svelte/labels.d.ts +5 -0
  57. package/dist/shop/svelte/labels.js +6 -1
  58. package/dist/shop/types.d.ts +49 -1
  59. package/dist/updates/0.15.3/index.d.ts +2 -0
  60. package/dist/updates/0.15.3/index.js +19 -0
  61. package/dist/updates/index.js +2 -1
  62. package/package.json +1 -1
@@ -0,0 +1,85 @@
1
+ const SIZE_TO_TEMPLATE = {
2
+ A: 'small',
3
+ B: 'medium',
4
+ C: 'large',
5
+ small: 'small',
6
+ medium: 'medium',
7
+ large: 'large'
8
+ };
9
+ export function mapParcelSize(size) {
10
+ if (!size)
11
+ return 'small';
12
+ return SIZE_TO_TEMPLATE[size] ?? 'small';
13
+ }
14
+ export function splitFullName(full) {
15
+ if (!full)
16
+ return {};
17
+ const trimmed = full.trim();
18
+ if (!trimmed)
19
+ return {};
20
+ const parts = trimmed.split(/\s+/);
21
+ if (parts.length === 1)
22
+ return { firstName: parts[0] };
23
+ return { firstName: parts[0], lastName: parts.slice(1).join(' ') };
24
+ }
25
+ /**
26
+ * Build a ShipX `POST /shipments` body for a given order. Locker services attach
27
+ * `custom_attributes.target_point` (paczkomat code from `carrierRef`); courier
28
+ * services use the receiver address from the order's shipping_address instead.
29
+ */
30
+ export function buildShipmentPayload(opts) {
31
+ const { input, sender, additionalServices } = opts;
32
+ const isLocker = input.serviceType.startsWith('inpost_locker_');
33
+ const isCourier = input.serviceType.startsWith('inpost_courier_');
34
+ const names = splitFullName(input.order.customerName);
35
+ const receiver = {
36
+ first_name: names.firstName ?? input.order.customerEmail,
37
+ last_name: names.lastName ?? '—',
38
+ email: input.order.customerEmail,
39
+ phone: input.order.customerPhone ?? undefined
40
+ };
41
+ if (isCourier) {
42
+ const addr = input.shippingAddress ?? {};
43
+ receiver.address = {
44
+ street: addr.street ?? addr.line1 ?? '',
45
+ building_number: addr.buildingNumber ?? addr.building_number ?? '',
46
+ city: addr.city ?? '',
47
+ post_code: addr.postCode ?? addr.post_code ?? addr.zip ?? '',
48
+ country_code: addr.countryCode ?? addr.country_code ?? 'PL'
49
+ };
50
+ }
51
+ const payload = {
52
+ receiver,
53
+ parcels: [{ template: mapParcelSize(input.parcelSize) }],
54
+ service: input.serviceType,
55
+ reference: input.order.number
56
+ };
57
+ if (isLocker && input.carrierRef) {
58
+ payload.custom_attributes = { target_point: input.carrierRef };
59
+ }
60
+ if (sender) {
61
+ payload.sender = {
62
+ name: sender.name,
63
+ company_name: sender.company,
64
+ email: sender.email,
65
+ phone: sender.phone,
66
+ address: {
67
+ street: sender.street,
68
+ building_number: sender.buildingNumber,
69
+ city: sender.city,
70
+ post_code: sender.postCode,
71
+ country_code: sender.countryCode ?? 'PL'
72
+ }
73
+ };
74
+ }
75
+ if (input.insuranceAmount && input.insuranceAmount > 0) {
76
+ payload.insurance = { amount: input.insuranceAmount, currency: 'PLN' };
77
+ }
78
+ if (input.cod && input.cod > 0) {
79
+ payload.cod = { amount: input.cod, currency: 'PLN' };
80
+ }
81
+ if (additionalServices && additionalServices.length > 0) {
82
+ payload.additional_services = additionalServices;
83
+ }
84
+ return payload;
85
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * InPost Points API — public, no auth required. Used to validate that a parcel
3
+ * locker code (e.g. "KRA01M") refers to a real, currently operating point.
4
+ *
5
+ * https://api-shipx-pl.easypack24.net/v1/points/{name}
6
+ */
7
+ export type InpostEnvironment = 'production' | 'sandbox';
8
+ export interface PointsApiOptions {
9
+ environment?: InpostEnvironment;
10
+ fetch?: typeof fetch;
11
+ cacheTtlMs?: number;
12
+ }
13
+ export interface PointsValidator {
14
+ validate(code: string): Promise<boolean>;
15
+ clearCache(): void;
16
+ }
17
+ export declare function createPointsValidator(opts?: PointsApiOptions): PointsValidator;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * InPost Points API — public, no auth required. Used to validate that a parcel
3
+ * locker code (e.g. "KRA01M") refers to a real, currently operating point.
4
+ *
5
+ * https://api-shipx-pl.easypack24.net/v1/points/{name}
6
+ */
7
+ const POINTS_BASE_URL = {
8
+ production: 'https://api-shipx-pl.easypack24.net',
9
+ sandbox: 'https://sandbox-api-shipx-pl.easypack24.net'
10
+ };
11
+ export function createPointsValidator(opts = {}) {
12
+ const env = opts.environment ?? 'production';
13
+ const fetchFn = opts.fetch ?? globalThis.fetch.bind(globalThis);
14
+ const cacheTtl = opts.cacheTtlMs ?? 5 * 60 * 1000;
15
+ const cache = new Map();
16
+ async function validate(code) {
17
+ const trimmed = code.trim();
18
+ if (!trimmed)
19
+ return false;
20
+ const now = Date.now();
21
+ const cached = cache.get(trimmed);
22
+ if (cached && cached.expiresAt > now)
23
+ return cached.value;
24
+ const url = `${POINTS_BASE_URL[env]}/v1/points/${encodeURIComponent(trimmed)}`;
25
+ let ok = false;
26
+ try {
27
+ const res = await fetchFn(url, {
28
+ method: 'GET',
29
+ headers: { Accept: 'application/json' }
30
+ });
31
+ if (res.status === 404) {
32
+ ok = false;
33
+ }
34
+ else if (res.ok) {
35
+ const data = (await res.json());
36
+ ok = data.status === 'Operating';
37
+ }
38
+ else {
39
+ // Transient/server error — fail open to avoid blocking checkout.
40
+ // Skip caching so a retry can succeed.
41
+ return true;
42
+ }
43
+ }
44
+ catch {
45
+ // Network failure — fail open. Don't cache.
46
+ return true;
47
+ }
48
+ cache.set(trimmed, { value: ok, expiresAt: now + cacheTtl });
49
+ return ok;
50
+ }
51
+ function clearCache() {
52
+ cache.clear();
53
+ }
54
+ return { validate, clearCache };
55
+ }
@@ -0,0 +1,56 @@
1
+ import type { InpostEnvironment } from './points-api.js';
2
+ export interface ShipxClientOptions {
3
+ token: string;
4
+ organizationId: string;
5
+ environment?: InpostEnvironment;
6
+ fetch?: typeof fetch;
7
+ }
8
+ export interface ShipxOffer {
9
+ id: number | string;
10
+ carrier_id?: string;
11
+ service_id?: string;
12
+ status?: string;
13
+ rate?: number;
14
+ [key: string]: unknown;
15
+ }
16
+ export interface ShipxShipment {
17
+ id: number | string;
18
+ status: string;
19
+ tracking_number?: string | null;
20
+ created_at?: string;
21
+ selected_offer?: ShipxOffer | null;
22
+ offers?: ShipxOffer[];
23
+ [key: string]: unknown;
24
+ }
25
+ export declare class ShipxApiError extends Error {
26
+ readonly status: number;
27
+ readonly body?: unknown | undefined;
28
+ constructor(message: string, status: number, body?: unknown | undefined);
29
+ }
30
+ export declare class ShipxClient {
31
+ private readonly base;
32
+ private readonly orgPath;
33
+ private readonly headers;
34
+ private readonly fetchFn;
35
+ constructor(opts: ShipxClientOptions);
36
+ createShipment(payload: Record<string, unknown>): Promise<ShipxShipment>;
37
+ getShipment(id: string | number): Promise<ShipxShipment>;
38
+ cancelShipment(id: string | number): Promise<void>;
39
+ /**
40
+ * Confirm/buy a specific offer for the shipment. Required when the account
41
+ * is not configured for fully-automatic offer payment (typical on sandbox).
42
+ * After this, status moves from `offer_selected` → `confirmed`.
43
+ *
44
+ * `offerId` is taken from `shipment.selected_offer.id` (or `offers[0].id`).
45
+ */
46
+ buyShipment(id: string | number, offerId: string | number): Promise<ShipxShipment>;
47
+ getLabel(id: string | number, opts?: {
48
+ format?: 'Pdf' | 'ZebraLP';
49
+ type?: 'A4' | 'A6';
50
+ }): Promise<{
51
+ contentType: string;
52
+ body: Uint8Array;
53
+ }>;
54
+ private request;
55
+ private requestRaw;
56
+ }
@@ -0,0 +1,95 @@
1
+ const SHIPX_BASE_URL = {
2
+ production: 'https://api-shipx-pl.easypack24.net',
3
+ sandbox: 'https://sandbox-api-shipx-pl.easypack24.net'
4
+ };
5
+ export class ShipxApiError extends Error {
6
+ status;
7
+ body;
8
+ constructor(message, status, body) {
9
+ super(message);
10
+ this.status = status;
11
+ this.body = body;
12
+ this.name = 'ShipxApiError';
13
+ }
14
+ }
15
+ export class ShipxClient {
16
+ base;
17
+ orgPath;
18
+ headers;
19
+ fetchFn;
20
+ constructor(opts) {
21
+ if (!opts.token)
22
+ throw new Error('ShipxClient: token required.');
23
+ if (!opts.organizationId)
24
+ throw new Error('ShipxClient: organizationId required.');
25
+ const env = opts.environment ?? 'production';
26
+ this.base = SHIPX_BASE_URL[env];
27
+ this.orgPath = `/v1/organizations/${encodeURIComponent(opts.organizationId)}`;
28
+ this.headers = {
29
+ Authorization: `Bearer ${opts.token}`,
30
+ 'Content-Type': 'application/json',
31
+ Accept: 'application/json'
32
+ };
33
+ this.fetchFn = opts.fetch ?? globalThis.fetch.bind(globalThis);
34
+ }
35
+ async createShipment(payload) {
36
+ return this.request('POST', `${this.orgPath}/shipments`, payload);
37
+ }
38
+ async getShipment(id) {
39
+ return this.request('GET', `/v1/shipments/${encodeURIComponent(String(id))}`);
40
+ }
41
+ async cancelShipment(id) {
42
+ await this.requestRaw('DELETE', `/v1/shipments/${encodeURIComponent(String(id))}`);
43
+ }
44
+ /**
45
+ * Confirm/buy a specific offer for the shipment. Required when the account
46
+ * is not configured for fully-automatic offer payment (typical on sandbox).
47
+ * After this, status moves from `offer_selected` → `confirmed`.
48
+ *
49
+ * `offerId` is taken from `shipment.selected_offer.id` (or `offers[0].id`).
50
+ */
51
+ async buyShipment(id, offerId) {
52
+ return this.request('POST', `/v1/shipments/${encodeURIComponent(String(id))}/buy`, { offer_id: typeof offerId === 'string' ? Number(offerId) : offerId });
53
+ }
54
+ async getLabel(id, opts = {}) {
55
+ const format = opts.format ?? 'Pdf';
56
+ const type = opts.type ?? 'A6';
57
+ const url = `${this.base}/v1/shipments/${encodeURIComponent(String(id))}/label?format=${format}&type=${type}`;
58
+ const res = await this.fetchFn(url, {
59
+ method: 'GET',
60
+ headers: { Authorization: this.headers.Authorization, Accept: 'application/pdf' }
61
+ });
62
+ if (!res.ok) {
63
+ const text = await res.text().catch(() => '');
64
+ throw new ShipxApiError(`ShipX label fetch failed (${res.status}): ${text || res.statusText}`, res.status, text);
65
+ }
66
+ const buf = new Uint8Array(await res.arrayBuffer());
67
+ return { contentType: res.headers.get('content-type') ?? 'application/pdf', body: buf };
68
+ }
69
+ async request(method, path, body) {
70
+ const res = await this.requestRaw(method, path, body);
71
+ if (res.status === 204)
72
+ return undefined;
73
+ const text = await res.text();
74
+ if (!text)
75
+ return undefined;
76
+ try {
77
+ return JSON.parse(text);
78
+ }
79
+ catch {
80
+ throw new ShipxApiError(`ShipX returned non-JSON response (${res.status})`, res.status, text);
81
+ }
82
+ }
83
+ async requestRaw(method, path, body) {
84
+ const res = await this.fetchFn(`${this.base}${path}`, {
85
+ method,
86
+ headers: this.headers,
87
+ body: body ? JSON.stringify(body) : undefined
88
+ });
89
+ if (!res.ok) {
90
+ const errBody = await res.text().catch(() => '');
91
+ throw new ShipxApiError(`ShipX ${method} ${path} failed (${res.status}): ${errBody || res.statusText}`, res.status, errBody);
92
+ }
93
+ return res;
94
+ }
95
+ }
@@ -0,0 +1,9 @@
1
+ import type { CarrierEvent } from '../../types.js';
2
+ /**
3
+ * Map a ShipX shipment status (from webhook payload or polling) to our domain
4
+ * OrderStatus. Returns `unknown` for transient/intermediate states the order
5
+ * lifecycle doesn't track — caller can ignore those.
6
+ *
7
+ * Reference: https://dokumentacja-inpost.atlassian.net/wiki/spaces/PL/pages/28639231
8
+ */
9
+ export declare function mapShipxStatus(status: string): CarrierEvent['status'];
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Map a ShipX shipment status (from webhook payload or polling) to our domain
3
+ * OrderStatus. Returns `unknown` for transient/intermediate states the order
4
+ * lifecycle doesn't track — caller can ignore those.
5
+ *
6
+ * Reference: https://dokumentacja-inpost.atlassian.net/wiki/spaces/PL/pages/28639231
7
+ */
8
+ export function mapShipxStatus(status) {
9
+ switch (status) {
10
+ case 'created':
11
+ case 'offers_prepared':
12
+ case 'offer_selected':
13
+ case 'confirmed':
14
+ case 'dispatched_by_sender_to_pok':
15
+ case 'dispatched_by_sender':
16
+ case 'collected_from_sender':
17
+ case 'taken_by_courier_from_pok':
18
+ return 'preparing';
19
+ case 'taken_by_courier':
20
+ case 'adopted_at_source_branch':
21
+ case 'sent_from_source_branch':
22
+ case 'adopted_at_sorting_center':
23
+ case 'sent_from_sorting_center':
24
+ case 'adopted_at_target_branch':
25
+ case 'out_for_delivery':
26
+ case 'ready_to_pickup':
27
+ case 'pickup_reminder_sent':
28
+ case 'pickup_time_expired':
29
+ case 'oversized':
30
+ case 'redirect_to_box':
31
+ return 'sent';
32
+ case 'delivered':
33
+ return 'done';
34
+ case 'canceled':
35
+ case 'cancelled':
36
+ case 'returned_to_sender':
37
+ case 'rejected':
38
+ case 'avizo':
39
+ case 'undelivered':
40
+ case 'unstored':
41
+ case 'claimed':
42
+ return 'cancelled';
43
+ default:
44
+ return 'unknown';
45
+ }
46
+ }
@@ -0,0 +1,16 @@
1
+ import type { CarrierEvent } from '../../types.js';
2
+ export declare class WebhookSecretMismatchError extends Error {
3
+ constructor();
4
+ }
5
+ export interface ParseWebhookOptions {
6
+ /** Expected secret. When provided, the URL must carry `?secret=...` matching it. */
7
+ secret?: string;
8
+ /** Override query parameter name (default `secret`). */
9
+ secretParam?: string;
10
+ }
11
+ /**
12
+ * Parse a ShipX webhook request into a `CarrierEvent`. When `secret` is set the
13
+ * URL is checked first — mismatch throws `WebhookSecretMismatchError` so the
14
+ * caller can return 401 without parsing the body.
15
+ */
16
+ export declare function parseShipxWebhook(req: Request, opts?: ParseWebhookOptions): Promise<CarrierEvent>;
@@ -0,0 +1,55 @@
1
+ import { mapShipxStatus } from './status-map.js';
2
+ /** Constant-time string compare, returns false on length mismatch. */
3
+ function safeEqual(a, b) {
4
+ if (a.length !== b.length)
5
+ return false;
6
+ let mismatch = 0;
7
+ for (let i = 0; i < a.length; i++) {
8
+ mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
9
+ }
10
+ return mismatch === 0;
11
+ }
12
+ export class WebhookSecretMismatchError extends Error {
13
+ constructor() {
14
+ super('Webhook secret mismatch.');
15
+ this.name = 'WebhookSecretMismatchError';
16
+ }
17
+ }
18
+ /**
19
+ * Parse a ShipX webhook request into a `CarrierEvent`. When `secret` is set the
20
+ * URL is checked first — mismatch throws `WebhookSecretMismatchError` so the
21
+ * caller can return 401 without parsing the body.
22
+ */
23
+ export async function parseShipxWebhook(req, opts = {}) {
24
+ if (opts.secret) {
25
+ const url = new URL(req.url);
26
+ const provided = url.searchParams.get(opts.secretParam ?? 'secret') ?? '';
27
+ if (!safeEqual(provided, opts.secret)) {
28
+ throw new WebhookSecretMismatchError();
29
+ }
30
+ }
31
+ const text = await req.text();
32
+ let body;
33
+ try {
34
+ body = JSON.parse(text);
35
+ }
36
+ catch {
37
+ throw new Error('Webhook body is not valid JSON.');
38
+ }
39
+ const payload = body.payload ?? {
40
+ shipment_id: body.shipment_id,
41
+ status: body.status,
42
+ tracking_number: body.tracking_number ?? null
43
+ };
44
+ const shipmentId = payload.shipment_id;
45
+ const status = payload.status ?? '';
46
+ if (shipmentId === undefined || shipmentId === null) {
47
+ throw new Error('Webhook payload missing shipment_id.');
48
+ }
49
+ return {
50
+ shipmentId: String(shipmentId),
51
+ trackingNumber: payload.tracking_number ?? undefined,
52
+ status: mapShipxStatus(status),
53
+ raw: body
54
+ };
55
+ }
@@ -12,6 +12,9 @@ export interface ShippingMethodPublic {
12
12
  price: number;
13
13
  vatRate: number;
14
14
  carrierType: string;
15
+ carrierConfig: {
16
+ serviceType: string | null;
17
+ } | null;
15
18
  conditions: {
16
19
  freeAbove?: number;
17
20
  } | null;
@@ -61,6 +64,8 @@ export interface OrderDetailResponse {
61
64
  paymentMethod: string | null;
62
65
  carrierType: string | null;
63
66
  carrierRef: string | null;
67
+ trackingNumber: string | null;
68
+ trackingUrl: string | null;
64
69
  language: string | null;
65
70
  createdAt: string;
66
71
  };
@@ -0,0 +1,12 @@
1
+ import { type RequestHandler } from '@sveltejs/kit';
2
+ /**
3
+ * Public endpoint that returns front-end facing carrier widget descriptor for
4
+ * a given carrier id (e.g. `inpost`). The descriptor never contains private
5
+ * credentials — only what the browser needs to render the picker (e.g. public
6
+ * geowidget token + script URL).
7
+ *
8
+ * Mounted at `/api/shop/carriers/[id]`.
9
+ */
10
+ export declare function createCarrierConfigHandler(): {
11
+ GET: RequestHandler;
12
+ };
@@ -0,0 +1,45 @@
1
+ import { json } from '@sveltejs/kit';
2
+ import { getCMS } from '../../core/cms.js';
3
+ function shopEnabled() {
4
+ try {
5
+ return getCMS().shopConfig !== null;
6
+ }
7
+ catch {
8
+ return false;
9
+ }
10
+ }
11
+ /**
12
+ * Public endpoint that returns front-end facing carrier widget descriptor for
13
+ * a given carrier id (e.g. `inpost`). The descriptor never contains private
14
+ * credentials — only what the browser needs to render the picker (e.g. public
15
+ * geowidget token + script URL).
16
+ *
17
+ * Mounted at `/api/shop/carriers/[id]`.
18
+ */
19
+ export function createCarrierConfigHandler() {
20
+ return {
21
+ GET: async ({ params }) => {
22
+ if (!shopEnabled())
23
+ return json({ error: 'Shop not enabled' }, { status: 404 });
24
+ const id = params.id;
25
+ if (!id)
26
+ return json({ error: 'Carrier id required' }, { status: 400 });
27
+ const shop = getCMS().shopConfig;
28
+ const carrier = shop?.carriers.find((c) => c.id === id);
29
+ if (!carrier)
30
+ return json({ error: 'Carrier not found' }, { status: 404 });
31
+ if (!carrier.widget) {
32
+ return json({ id: carrier.id, label: carrier.label ?? null, widget: null });
33
+ }
34
+ return json({
35
+ id: carrier.id,
36
+ label: carrier.label ?? null,
37
+ widget: {
38
+ scriptUrl: carrier.widget.scriptUrl ?? null,
39
+ stylesheetUrl: carrier.widget.stylesheetUrl ?? null,
40
+ config: carrier.widget.config
41
+ }
42
+ });
43
+ }
44
+ };
45
+ }
@@ -0,0 +1,13 @@
1
+ import { type RequestHandler } from '@sveltejs/kit';
2
+ /**
3
+ * Public webhook receiver for carrier events (e.g. ShipX `shipment_status_changed`).
4
+ * Carrier id (`inpost`, ...) is taken from the route param, looked up in
5
+ * `shop.carriers`, and the adapter's `handleWebhook` parses + verifies the
6
+ * request. Successful events are applied to the matching order (tracking +
7
+ * status). Unknown shipments still return 200 to avoid retry storms.
8
+ *
9
+ * Mounted at `POST /api/shop/carriers/[id]/webhook`.
10
+ */
11
+ export declare function createCarrierWebhookHandler(): {
12
+ POST: RequestHandler;
13
+ };
@@ -0,0 +1,66 @@
1
+ import { json } from '@sveltejs/kit';
2
+ import { getCMS } from '../../core/cms.js';
3
+ import { checkRateLimit, clientKey } from '../rate-limit.js';
4
+ import { applyCarrierEvent } from '../server/shipments.js';
5
+ function shopEnabled() {
6
+ try {
7
+ return getCMS().shopConfig !== null;
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ /**
14
+ * Public webhook receiver for carrier events (e.g. ShipX `shipment_status_changed`).
15
+ * Carrier id (`inpost`, ...) is taken from the route param, looked up in
16
+ * `shop.carriers`, and the adapter's `handleWebhook` parses + verifies the
17
+ * request. Successful events are applied to the matching order (tracking +
18
+ * status). Unknown shipments still return 200 to avoid retry storms.
19
+ *
20
+ * Mounted at `POST /api/shop/carriers/[id]/webhook`.
21
+ */
22
+ export function createCarrierWebhookHandler() {
23
+ return {
24
+ POST: async ({ request, params }) => {
25
+ if (!shopEnabled())
26
+ return json({ error: 'Shop not enabled' }, { status: 404 });
27
+ const id = params.id;
28
+ if (!id)
29
+ return json({ error: 'Carrier id required' }, { status: 400 });
30
+ const shop = getCMS().shopConfig;
31
+ const adapter = shop?.carriers.find((c) => c.id === id);
32
+ if (!adapter)
33
+ return json({ error: 'Carrier not found' }, { status: 404 });
34
+ if (!adapter.handleWebhook) {
35
+ return json({ error: 'Carrier does not support webhooks' }, { status: 400 });
36
+ }
37
+ const rule = shop?.rateLimit.webhook ?? { limit: 60, windowSec: 60 };
38
+ const rl = checkRateLimit(clientKey(request, `webhook:${id}`), rule);
39
+ if (!rl.allowed) {
40
+ return json({ error: 'Rate limit exceeded' }, { status: 429 });
41
+ }
42
+ let event;
43
+ try {
44
+ event = await adapter.handleWebhook(request);
45
+ }
46
+ catch (err) {
47
+ const name = err instanceof Error ? err.name : '';
48
+ if (name === 'WebhookSecretMismatchError') {
49
+ return json({ error: 'Unauthorized' }, { status: 401 });
50
+ }
51
+ const message = err instanceof Error ? err.message : 'Webhook parse error';
52
+ console.error(`[shop/carrier:${id}] webhook parse failed:`, err);
53
+ return json({ error: message }, { status: 400 });
54
+ }
55
+ try {
56
+ const result = await applyCarrierEvent(event);
57
+ return json({ ok: true, ...result });
58
+ }
59
+ catch (err) {
60
+ console.error(`[shop/carrier:${id}] applyCarrierEvent failed:`, err);
61
+ const message = err instanceof Error ? err.message : 'Apply event failed';
62
+ return json({ error: message }, { status: 500 });
63
+ }
64
+ }
65
+ };
66
+ }
@@ -3,6 +3,7 @@ import { getCMS } from '../../core/cms.js';
3
3
  import { readCartCookie, writeCartCookie } from '../cart/cookie.js';
4
4
  import { writeOrderTokenCookie } from '../cart/order-token-cookie.js';
5
5
  import { createOrderFromCart, setPaymentProviderRef } from '../server/orders.js';
6
+ import { getShippingMethod } from '../server/shipping.js';
6
7
  import { checkRateLimit, clientKey } from '../rate-limit.js';
7
8
  import { requireShopConfig } from '../server/db.js';
8
9
  function shopEnabled() {
@@ -67,6 +68,27 @@ export function createCheckoutHandler() {
67
68
  if (cartItems.length === 0) {
68
69
  return json({ error: 'Cart is empty' }, { status: 400 });
69
70
  }
71
+ const carrierRef = asString(body.carrierRef, 200);
72
+ // Carrier-side validation (e.g. paczkomat code → InPost Points API)
73
+ try {
74
+ const shippingMethod = await getShippingMethod(shippingMethodId);
75
+ if (shippingMethod && shippingMethod.carrierType !== 'none') {
76
+ const shop = requireShopConfig();
77
+ const carrier = shop.carriers.find((c) => c.id === shippingMethod.carrierType);
78
+ if (carrier?.validateSelection) {
79
+ const ok = await carrier.validateSelection(carrierRef ?? '', {
80
+ serviceType: shippingMethod.carrierConfig?.serviceType
81
+ });
82
+ if (!ok) {
83
+ return json({ error: 'Invalid carrier selection (e.g. paczkomat unavailable).' }, { status: 400 });
84
+ }
85
+ }
86
+ }
87
+ }
88
+ catch (err) {
89
+ console.error('[shop] carrier validateSelection failed:', err);
90
+ return json({ error: 'Carrier validation failed.' }, { status: 400 });
91
+ }
70
92
  try {
71
93
  const result = await createOrderFromCart({
72
94
  cartItems,
@@ -75,7 +97,7 @@ export function createCheckoutHandler() {
75
97
  customerPhone: asString(body.customerPhone, 40),
76
98
  shippingAddress: asStringRecord(body.shippingAddress),
77
99
  shippingMethodId,
78
- carrierRef: asString(body.carrierRef, 200),
100
+ carrierRef,
79
101
  paymentMethod,
80
102
  consents: asConsents(body.consents),
81
103
  notes: asString(body.notes, 2000),
@@ -5,3 +5,6 @@ export { createOrderHandler } from './order-handler.js';
5
5
  export { createPaymentWebhookHandler } from './webhook-handler.js';
6
6
  export { createRefreshPaymentHandler } from './refresh-payment-handler.js';
7
7
  export { createRetryPaymentHandler } from './retry-payment-handler.js';
8
+ export { createCarrierConfigHandler } from './carrier-handler.js';
9
+ export { createCarrierWebhookHandler } from './carrier-webhook-handler.js';
10
+ export { createShipmentLabelHandler } from './shipment-label-handler.js';
@@ -5,3 +5,6 @@ export { createOrderHandler } from './order-handler.js';
5
5
  export { createPaymentWebhookHandler } from './webhook-handler.js';
6
6
  export { createRefreshPaymentHandler } from './refresh-payment-handler.js';
7
7
  export { createRetryPaymentHandler } from './retry-payment-handler.js';
8
+ export { createCarrierConfigHandler } from './carrier-handler.js';
9
+ export { createCarrierWebhookHandler } from './carrier-webhook-handler.js';
10
+ export { createShipmentLabelHandler } from './shipment-label-handler.js';