includio-cms 0.37.0 → 0.37.1

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 CHANGED
@@ -1,4 +1,4 @@
1
- # includio-cms — Public API v0.37.0
1
+ # includio-cms — Public API v0.37.1
2
2
 
3
3
  > Auto-generated by `scripts/generate-api-md.ts`. Do not edit by hand.
4
4
 
@@ -688,7 +688,7 @@ Tags:
688
688
  - `interface BookingStatusItem`
689
689
  - `type BookingTx = Parameters<Parameters<ReturnType<typeof getBookingDb>['transaction']>[0]>[0]` — Exact drizzle transaction handle, derived from the booking db's `transaction`.
690
690
  - `class BookingValidationError` — Thrown when a booking fails capacity (core) or the client `validateBooking` seam.
691
- - `createBooking(input: CreateBookingInput, opts: { now?: Date } = {}): Promise<{ booking: typeof bookingsTable.$inferSelect; accessToken: string }>` — Create a booking (public self-serve or staff). Validates catalog capacity +
691
+ - `createBooking(input: CreateBookingInput, opts: { now?: Date; actor?: PriceActor } = {}): Promise<{ booking: typeof bookingsTable.$inferSelect; accessToken: string }>` — Create a booking (public self-serve or staff). Validates catalog capacity +
692
692
  - `createBookingHandler(): { POST: RequestHandler }` — Public self-serve booking creation. Mount at `POST /api/booking`.
693
693
  - `createDepositPayment(bookingId: string): Promise<{ redirectUrl: string | null; status: 'redirect' | 'manual' | 'error'...` — Create the deposit payment for a freshly-created booking: ask the first
694
694
  - `createInstallmentPayment(bookingId: string, amount: number): Promise<{ paymentLink: string | null }>` — Generate an installment/balance payment link for an arbitrary amount (staff or
@@ -700,12 +700,12 @@ Tags:
700
700
  - `getBookingMaintenanceStatus(): { running: boolean; lastRun: Date | null }`
701
701
  - `listBookingAdjustments(bookingId: string): Promise<BookingAdjustment[]>` — List all adjustments (active + voided) for a booking, newest first.
702
702
  - `pickEmailTemplate(kind: BookingEmailKind): string` — Template file name for a kind (1:1 — kept as a function for future indirection).
703
- - `portalAddTrips(token: string, additions: Array<{ tripRef: string; unitPrice: number; as...): <inferred>` — Self-service dobieranie wycieczki: doczytaj obecny stan, scal z `additions`
703
+ - `portalAddTrips(token: string, additions: Array<{ tripRef: string; assignments: string[] }>): <inferred>` — Self-service dobieranie wycieczki: doczytaj obecny stan, scal z `additions`
704
704
  - `portalEdit(token: string, changes: BookingEditChanges): <inferred>` — Portal self-service edit (actor:'portal' guards). Throws → handler maps to 409/400.
705
705
  - `portalPay(token: string, amount: number): <inferred>` — Portal: create an installment/balance payment link.
706
706
  - `portalUpdateParticipant(token: string, assignmentId: string, data: Record<string, unknown>): <inferred>` — Portal self-service: customer fills their own participant data. Verifies the
707
707
  - `portalView(token: string): <inferred>` — Portal view: booking + items + participants + ledger (no staff data).
708
- - `priceFromExistingRows(tx: BookingTx, bookingId: string, config: ResolvedBookingConfig, now: Date): Promise<ParticipantPriceResult>` — Price a booking from its CURRENTLY persisted people/items/assignments (no
708
+ - `priceFromExistingRows(tx: BookingTx, bookingId: string, config: ResolvedBookingConfig): Promise<ParticipantPriceResult>` — Price a booking from its CURRENTLY persisted people/items/assignments (no
709
709
  - `processDueExpirations(now: Date = new Date()): Promise<{ abandoned: number; forfeited: number }>` — Auto-cancel expired bookings: abandoned drafts (deposit hold lapsed) and
710
710
  - `processDueReminders(now: Date = new Date()): Promise<number>` — Send all due reminders (unsent, `scheduledFor <= now`) via the email adapter,
711
711
  - `readTripCapacity(tripRef: string): Promise<number | null>` — Read a trip's seat capacity from the catalog collection.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,29 @@
3
3
  All notable changes to includio-cms are documented here.
4
4
  Generated from `src/lib/updates/` — do not edit manually.
5
5
 
6
+ ## 0.37.1 — 2026-07-14
7
+
8
+ Dwie łatki cenowe w bookingu. (1) Cena rezerwacji przestaje pochodzić z żądania HTTP — do 0.37.0 publiczne endpointy przyjmowały `unitPrice` prosto z ciała żądania i nikt nie konfrontował go z katalogiem, więc wyprawę dało się zarezerwować za grosz. (2) Data wyceny zostaje zamrożona na rezerwacji — dotąd każde jej dotknięcie (edycja, ale też zaksięgowanie korekty) przeliczało reguły zależne od czasu wg DZISIEJSZEJ daty, przez co early-bird sprzed miesięcy cicho znikał i klient robił się winien pieniądze.
9
+
10
+ ### Added
11
+ - `defineBooking({ resolvePrice })` — serwerowy szew zwracający cenę bazową (dorosłego) pozycji katalogu w groszach. Rdzeń nie zna kształtu katalogu, więc cenę podaje projekt: `resolvePrice: async (tripRef) => Math.round(Number((await resolveEntry({ id: tripRef, status: "published" }))?.price ?? 0) * 100)`.
12
+ - Model zaufania oparty na aktorze: `createBooking(input, { actor })` przyjmuje `public | portal | staff`. Domyślny aktor to `public` (niezaufany) — pominięcie parametru nie otwiera dziury. Zalogowana obsługa (`staff`) nadal może podać cenę ręcznie: korekta i rabat negocjowany telefonicznie zostają możliwe.
13
+ - Typ `PricedCreateInput` wymusza kolejność: rezerwacji nie da się zbudować z pozycji, której nie wyceniła bramka — pilnuje tego kompilator, nie dyscyplina.
14
+
15
+ ### Fixed
16
+ - **Bezpieczeństwo (data wyceny).** Nowa kolumna `bookings.priced_at` zamraża moment wyceny. `editBooking` i `priceFromExistingRows` (ścieżka korekt — `adjustments.ts:65` i `:155`) liczą teraz early-bird na tę datę, nie na `new Date()`. Wcześniej wystarczyło, że obsługa zaksięguje dopłatę ustaloną telefonicznie tydzień przed wylotem, a rabat za wczesną rezerwację przepadał. `priceFromExistingRows` straciło parametr `now` — kompilator nie pozwoli już podać tam zegara.
17
+ - Rezerwacje sprzed 0.37.1 mają `priced_at = NULL` i wyceniają się z `created_at` — dokładnie tym, czym było wtedy `now`. Zero migracji danych, zero zmiany zachowania.
18
+ - **Bezpieczeństwo (ceny).** Trzy publiczne drogi wpuszczały cenę z zewnątrz: tworzenie rezerwacji (`createBookingHandler`), dokładanie wycieczki przez portal (`add-trips`) i portalowa edycja pozycji (`portalEdit` → `editBooking`). Bramka stoi teraz w rdzeniu (`createBooking`/`editBooking`), nie w handlerach, więc każdy przyszły publiczny endpoint jest chroniony domyślnie.
19
+ - Cena pozycji już zapisanej w rezerwacji jest brana z jej `unitPriceSnapshot`, a nie przeliczana z katalogu — dotknięcie rezerwacji przez portal nie podniesie klientowi ceny, gdy cennik w międzyczasie poszedł w górę.
20
+ - Odczyt katalogu w `editBooking` odbywa się przed otwarciem transakcji: szew klienta robi własne I/O, a wołany pod blokadą `FOR UPDATE` trzymałby wiersz rezerwacji i sięgał po drugie połączenie z tej samej puli.
21
+ - Ceny ujemne i niecałkowite (ułamek grosza) są odrzucane — również dla obsługi.
22
+
23
+ ### Breaking
24
+ - Nowa kolumna `bookings.priced_at` — wymagana migracja (`drizzle-kit push`). Nullable, bez backfillu.
25
+ - **`defineBooking({ resolvePrice })` jest teraz wymagany, gdy booking wystawia publiczne endpointy** (`createBookingHandler`, portal). Bez niego rdzeń odmawia wyceny (fail-closed) zamiast po cichu zaufać cenie z żądania. Instalacje używające wyłącznie panelu admina działają bez zmian.
26
+ - `BookingItemInput.unitPrice` jest opcjonalne i **honorowane wyłącznie dla `actor: "staff"`**. Dla klienta końcowego jest ignorowane — cenę ustala katalog.
27
+ - Publiczne API klienta: `createBookingClient().addTrips(token, additions)` oraz `useBooking().addTrips(additions)` nie przyjmują już `unitPrice` w pozycjach (`{ tripRef, assignments }`). Storefronty przekazujące cenę muszą ją usunąć z ładunku — była i tak ignorowana po stronie serwera.
28
+
6
29
  ## 0.37.0 — 2026-06-17
7
30
 
8
31
  Moduły-nakładki + context switcher: shop i booking jako równorzędne, opcjonalne moduły rozszerzające rdzeń CMS (model „WooCommerce na WordPressie"). Zunifikowany rejestr `config.modules`, przełącznik kontekstu w adminie (Treści / Sklep / Rezerwacje) dla czytelniejszego UI, per-moduł dashboard. Shop przepięty na nowy kontrakt `CMSModule` (dowód generyczności #1), booking dodany jako drugi moduł biblioteki z pełnym rdzeniem domenowym (wycena/state machine/księga płatności) — dowód #2. Booking write-flow + portal self-service domknięte: tworzenie (public+staff), zadatek + webhook idempotentny, pełna edycja self-service (recompute/pojemność/„nie poniżej wpłaconego”), maile + przypomnienia, cron bez-infra (in-process loop + secured endpoint), strefa obsługi w adminie, scaffold `--booking`. Plan: `docs/superpowers/plans/2026-06-18-booking-write-flow-portal.md`, spec: `docs/superpowers/specs/2026-06-18-booking-write-flow-portal-design.md`.
package/DOCS.md CHANGED
@@ -1,4 +1,4 @@
1
- # Includio CMS Documentation (v0.37.0)
1
+ # Includio CMS Documentation (v0.37.1)
2
2
 
3
3
  > This file is auto-generated from the docs site. For the latest version, update the package.
4
4
 
package/ROADMAP.md CHANGED
@@ -35,8 +35,22 @@
35
35
  - [x] `[feature]` `[P1]` Slug first-class + SEO v1.0 freeze (0.26.0) — `resolveSeo` public (`includio-cms/core`), `seoFieldDescriptor` SSOT (Zod+TS drift-guard), admin URL un-hardcode (`slugPath.ts` pure resolver, no `seo`-field requirement), `SlugField`/`SeoField`/`SeoFieldData` `@public` v1.0-frozen <!-- files: src/lib/core/fields/slugPath.ts, src/lib/core/fields/seoFieldDescriptor.ts, src/lib/core/fields/resolveSeo.ts, src/lib/types/fields.ts, src/lib/admin/client/collection/collection-entries.svelte, src/lib/updates/0.26.0/ -->
36
36
  - [x] `[feature]` `[P2]` Soft-delete zamówień admin-only (0.34.0) — ukrycie zamówienia z listy bez utraty danych (`deleted_at`/`deleted_by`), kosz + przywracanie, guard `decideOrderDeletion` (safe-statuses + faktura), auto-restore przy płatności, zwolnienie rezerwacji stocku, `requireRole('admin')` <!-- files: src/lib/shop/server/orders.ts, src/lib/db-postgres/schema/shop/order.ts, src/lib/admin/remote/shop.remote.ts, src/lib/admin/client/shop/{shop-order-detail-page,shop-orders-list-page,restore-order-cell}.svelte, src/lib/updates/0.34.0/ -->
37
37
 
38
+ ## 0.37.1 — Bramka cenowa (bezpieczeństwo)
39
+
40
+ - [x] `[fix]` `[breaking]` `[P0]` **Data wyceny zamrożona na rezerwacji** (`bookings.priced_at`). Dotąd cena bazowa była zamrożona, ale data — nie: `editBooking` i `priceFromExistingRows` (ścieżka **korekt**) przeliczały reguły zależne od czasu wg `new Date()`. Obsługa księgowała dopłatę ustaloną telefonicznie tydzień przed wylotem → `daysUntil(anchor, now)` spadało poniżej progu → **early-bird sprzed miesięcy cicho znikał, a klient robił się winien pieniądze**. Bomba tykała sama, bez żadnej zmiany cennika. Rezerwacje sprzed 0.37.1: `priced_at = NULL` → fallback na `created_at` (zero migracji danych). Pełny snapshot **wartości** polityki (`pricing_snapshot`) zostaje w 0.38.0 §4.4 — wymaga świadomej zmiany cennika przez klientkę, więc da się ją uprzedzić.
41
+ - [x] `[fix]` `[breaking]` `[P0]` **Cena rezerwacji przestaje pochodzić z żądania HTTP.** Do 0.37.0 trzy publiczne drogi wpuszczały `unitPrice` z ciała żądania i nikt nie konfrontował go z katalogiem (`create-handler.ts:37`, portalowe `add-trips`, portalowe `edit` → `editBooking`) — wyprawę dało się zarezerwować za grosz. Bramka stoi teraz w rdzeniu (`createBooking`/`editBooking`), oparta na aktorze: `public`/`portal` nie dyktują ceny (nowa pozycja → szew `resolvePrice` z katalogu, istniejąca → jej `unitPriceSnapshot`), `staff` nadal może korygować ręcznie. Domyślny aktor to `public`, więc przyszły publiczny endpoint jest bezpieczny bez pamiętania o niczym. Fail-closed: brak `resolvePrice` = odmowa wyceny.
42
+
38
43
  ## 0.38.0 — Moduły: kontrakt vs polityka
39
44
 
45
+ > **WSTRZYMANE (2026-07-14).** Łuk jest dojrzały, ale klientka nie zobaczy z niego nic, a produktyzacja
46
+ > (`module_settings`, `AdminRouter`, kupony) zwraca się dopiero przy **drugim kliencie z bookingiem**.
47
+ > Wracamy, gdy taki się pojawi — wtedy abstrakcje wyjdą z dwóch realnych przypadków, nie z jednego.
48
+ > Wyjątek już zrobiony: kawałek 3a (jądro reguł) leży zrebase'owany na `feat/pricing-rules-kernel`.
49
+ > Poprawka do wprowadzenia przy powrocie: pozycja o typach wypraw twierdzi, że „moduł ma wnosić je sam" —
50
+ > **to jest błędne**. `tripType` to pojęcie domenowe klienta (klient nr 3 może wynajmować kajaki).
51
+ > Moduł może wnieść MECHANIZM (rejestr typów pól + generator pola `blocks` + adapter + headless front),
52
+ > nie POJĘCIE.
53
+
40
54
  Spec: `docs/superpowers/specs/2026-07-13-moduly-kontrakt-vs-polityka-design.md`. Kolejność i uzasadnienia tam.
41
55
 
42
56
  - [x] `[feature]` `[P0]` Kawałek 3a — jądro reguł w rdzeniu (`core/rules`) + cennik jako kategorie uczestnika; golden testy parytetu ze starym silnikiem. Branch `feat/pricing-rules-kernel`.
@@ -24,6 +24,7 @@ export declare const listBookings: import("@sveltejs/kit").RemoteQueryFunction<{
24
24
  currency: string;
25
25
  customer: Record<string, unknown>;
26
26
  totals: import("../../db-postgres/schema/booking/index.js").BookingTotals;
27
+ pricedAt: Date | null;
27
28
  accessToken: string;
28
29
  staffNotes: string | null;
29
30
  language: string | null;
@@ -60,6 +61,7 @@ export declare const getBooking: import("@sveltejs/kit").RemoteQueryFunction<str
60
61
  currency: string;
61
62
  customer: Record<string, unknown>;
62
63
  totals: import("../../db-postgres/schema/booking/index.js").BookingTotals;
64
+ pricedAt: Date | null;
63
65
  accessToken: string;
64
66
  staffNotes: string | null;
65
67
  language: string | null;
@@ -172,6 +174,7 @@ export declare const editBookingStaff: import("@sveltejs/kit").RemoteCommand<{
172
174
  updatedAt: Date;
173
175
  customer: Record<string, unknown>;
174
176
  totals: import("../../db-postgres/schema/booking/index.js").BookingTotals;
177
+ pricedAt: Date | null;
175
178
  staffNotes: string | null;
176
179
  balanceDueAt: Date | null;
177
180
  holdExpiresAt: Date | null;
@@ -196,7 +196,10 @@ export const listBookingCatalog = query(async () => {
196
196
  /** Staff: create a booking from the admin panel. @public */
197
197
  export const createBookingStaff = command(z.any(), async (input) => {
198
198
  requireAuth();
199
- return createBooking(input);
199
+ // `actor: 'staff'` — zalogowana obsługa może podać cenę ręcznie (korekta, rabat
200
+ // negocjowany telefonicznie). Publiczne ścieżki NIE mają tego prawa: bez tej flagi
201
+ // rdzeń bierze cenę wyłącznie z katalogu (`resolvePrice`).
202
+ return createBooking(input, { actor: 'staff' });
200
203
  });
201
204
  /** Staff: edit a booking's items/customer (full edit power). @public */
202
205
  export const editBookingStaff = command(z.object({ bookingId: z.string().uuid(), changes: z.any() }), async ({ bookingId, changes }) => {
@@ -29,10 +29,13 @@ export declare function createBookingClient(opts?: BookingClientOptions): {
29
29
  updateParticipant: (token: string, participantId: string, data: Record<string, unknown>) => Promise<unknown>;
30
30
  /** Drugi czynnik: weryfikuj numer telefonu; sukces ustawia sesję portalu (cookie). */
31
31
  verifyPhone: (token: string, phone: string) => Promise<unknown>;
32
- /** Self-service: dołóż wycieczki istniejącym osobom (recompute salda). */
32
+ /**
33
+ * Self-service: dołóż wycieczki istniejącym osobom (recompute salda).
34
+ *
35
+ * Cena **nie jest** parametrem — ustala ją serwer z katalogu (`resolvePrice`).
36
+ */
33
37
  addTrips: (token: string, additions: Array<{
34
38
  tripRef: string;
35
- unitPrice: number;
36
39
  assignments: string[];
37
40
  }>) => Promise<unknown>;
38
41
  };
@@ -37,7 +37,11 @@ export function createBookingClient(opts = {}) {
37
37
  updateParticipant: (token, participantId, data) => req(`/api/booking/portal/${token}?action=participant`, jsonInit('POST', { participantId, data })),
38
38
  /** Drugi czynnik: weryfikuj numer telefonu; sukces ustawia sesję portalu (cookie). */
39
39
  verifyPhone: (token, phone) => req(`/api/booking/portal/${token}?action=verify-phone`, jsonInit('POST', { phone })),
40
- /** Self-service: dołóż wycieczki istniejącym osobom (recompute salda). */
40
+ /**
41
+ * Self-service: dołóż wycieczki istniejącym osobom (recompute salda).
42
+ *
43
+ * Cena **nie jest** parametrem — ustala ją serwer z katalogu (`resolvePrice`).
44
+ */
41
45
  addTrips: (token, additions) => req(`/api/booking/portal/${token}?action=add-trips`, jsonInit('POST', { additions }))
42
46
  };
43
47
  }
@@ -19,7 +19,6 @@ export declare function useBooking(token: string, opts?: BookingClientOptions):
19
19
  verifyPhone: (phone: string) => Promise<void>;
20
20
  addTrips: (additions: Array<{
21
21
  tripRef: string;
22
- unitPrice: number;
23
22
  assignments: string[];
24
23
  }>) => Promise<void>;
25
24
  };
@@ -50,6 +50,25 @@ export interface BookingConfig {
50
50
  surfacedCollections?: string[];
51
51
  /** Deposit that secures a reservation, in minor units (default 0). */
52
52
  depositAmount?: number;
53
+ /**
54
+ * **Szew serwerowy: cena bazowa (dorosłego) pozycji katalogu, w groszach.**
55
+ *
56
+ * Rdzeń nie zna kształtu katalogu (`tripRef` jest dla niego nieprzezroczysty), więc
57
+ * cenę musi podać projekt. Wołany **wyłącznie na serwerze** przy każdej rezerwacji
58
+ * składanej przez klienta końcowego — dzięki temu przeglądarka nie ma na cenę wpływu.
59
+ *
60
+ * **Wymagany, gdy booking wystawia publiczne endpointy** (`createBookingHandler`,
61
+ * portal). Bez niego rdzeń odmawia wyceny (fail-closed) zamiast zaufać cenie z żądania.
62
+ * Obsługa w adminie (`actor: 'staff'`) nadal może podać cenę ręcznie — korekta jest
63
+ * zamierzoną funkcją.
64
+ *
65
+ * @example
66
+ * resolvePrice: async (tripRef) => {
67
+ * const entry = await resolveEntry({ id: tripRef, status: 'published' });
68
+ * return Math.round(Number(entry?.price ?? 0) * 100); // PLN → grosze
69
+ * }
70
+ */
71
+ resolvePrice?: (tripRef: string) => number | Promise<number>;
53
72
  /** Discount rules (legacy per-line engine; booking now prices per-participant). */
54
73
  pricing?: PricingConfig;
55
74
  /**
@@ -127,6 +146,7 @@ export interface ResolvedBookingConfig {
127
146
  catalogCollection: string | null;
128
147
  surfacedCollections: string[];
129
148
  depositAmount: number;
149
+ resolvePrice: ((tripRef: string) => number | Promise<number>) | null;
130
150
  pricing: PricingConfig;
131
151
  participantPricing: (() => ParticipantPricingConfig | Promise<ParticipantPricingConfig>) | null;
132
152
  arrivalDateField: string;
@@ -5,6 +5,7 @@ export function resolveBookingConfig(config) {
5
5
  catalogCollection: config.catalogCollection ?? null,
6
6
  surfacedCollections: config.surfacedCollections ?? [],
7
7
  depositAmount: config.depositAmount ?? 0,
8
+ resolvePrice: config.resolvePrice ?? null,
8
9
  pricing: config.pricing ?? { rules: [] },
9
10
  participantPricing: config.participantPricing ?? null,
10
11
  arrivalDateField: config.arrivalDateField ?? 'arrivalDate',
@@ -24,8 +24,15 @@ export interface ItemAssignmentInput {
24
24
  export interface BookingItemInput {
25
25
  tripRef: string;
26
26
  departureDate: Date | null;
27
- /** Unit (adult) price (minor units) snapshotted at booking time. */
28
- unitPrice: number;
27
+ /**
28
+ * Cena bazowa (dorosłego) w groszach, zamrażana na pozycji.
29
+ *
30
+ * **Honorowana wyłącznie dla `actor: 'staff'`** (ręczna korekta w adminie). Dla klienta
31
+ * końcowego (`public`/`portal`) jest ignorowana — cenę ustala rdzeń z katalogu przez szew
32
+ * `resolvePrice`, bo inaczej każdy mógłby zamówić wyprawę za grosz. Pomiń, gdy cena ma
33
+ * pochodzić z katalogu.
34
+ */
35
+ unitPrice?: number;
29
36
  /** Who takes this trip (+ their per-trip data). Count derives from this. */
30
37
  assignments: ItemAssignmentInput[];
31
38
  }
@@ -64,8 +71,20 @@ export interface BookingDraft {
64
71
  balanceDueAt: Date | null;
65
72
  holdExpiresAt: Date;
66
73
  }
74
+ /**
75
+ * Pozycja po przejściu przez bramkę cenową — cena jest już ustalona (z katalogu, ze
76
+ * snapshotu albo od obsługi). Typ istnieje po to, by **nie dało się** zbudować rezerwacji
77
+ * z pozycji, której nikt nie wycenił: kompilator pilnuje, że bramka poszła pierwsza.
78
+ */
79
+ export type PricedBookingItem = BookingItemInput & {
80
+ unitPrice: number;
81
+ };
82
+ /** Ładunek create z pozycjami już wycenionymi. */
83
+ export type PricedCreateInput = Omit<CreateBookingInput, 'items'> & {
84
+ items: PricedBookingItem[];
85
+ };
67
86
  /** Build the per-participant pricing inputs from a create payload. */
68
- export declare function pricingInputsFromCreate(input: CreateBookingInput, identityFields: FieldDef[]): {
87
+ export declare function pricingInputsFromCreate(input: PricedCreateInput, identityFields: FieldDef[]): {
69
88
  participants: PricingParticipant[];
70
89
  trips: PricedTrip[];
71
90
  };
@@ -74,4 +93,4 @@ export declare function pricingInputsFromCreate(input: CreateBookingInput, ident
74
93
  * rows/totals to persist. Prices per-participant (age bands + volume ladder +
75
94
  * early-bird), anchored to the arrival date in `ctx.anchor`.
76
95
  */
77
- export declare function buildBookingDraft(input: CreateBookingInput, config: ResolvedBookingConfig, ctx: DraftPricingContext, now: Date): BookingDraft;
96
+ export declare function buildBookingDraft(input: PricedCreateInput, config: ResolvedBookingConfig, ctx: DraftPricingContext, now: Date): BookingDraft;
@@ -36,7 +36,9 @@ export async function addBookingAdjustment(bookingId, input, opts) {
36
36
  if (!booking)
37
37
  throw new Error(`Booking ${bookingId} not found`);
38
38
  assertEditAllowed(booking.status);
39
- const result = await priceFromExistingRows(tx, bookingId, config, now);
39
+ // Bez `now`: przeliczenie idzie z zamrożonej daty wyceny rezerwacji. Zaksięgowanie
40
+ // dopłaty nie może odebrać klientowi early-birda sprzed miesięcy.
41
+ const result = await priceFromExistingRows(tx, bookingId, config);
40
42
  const active = await tx
41
43
  .select()
42
44
  .from(bookingAdjustmentsTable)
@@ -109,7 +111,8 @@ export async function voidBookingAdjustment(adjustmentId, opts) {
109
111
  .update(bookingAdjustmentsTable)
110
112
  .set({ voidedAt: now, voidedBy: opts.actor })
111
113
  .where(eq(bookingAdjustmentsTable.id, adjustmentId));
112
- const result = await priceFromExistingRows(tx, adj.bookingId, config, now);
114
+ // Wycofanie korekty również z zamrożonej daty wyceny, nie z dzisiejszej.
115
+ const result = await priceFromExistingRows(tx, adj.bookingId, config);
113
116
  const active = await tx
114
117
  .select()
115
118
  .from(bookingAdjustmentsTable)
@@ -3,6 +3,7 @@ import { type CreateBookingInput, type PersonInput, type ItemAssignmentInput } f
3
3
  import { type ParticipantPriceResult } from '../participant-pricing.js';
4
4
  import type { ResolvedBookingConfig } from '../config.js';
5
5
  import { type BookingTx } from './reminders.js';
6
+ import { type PriceActor } from './price-source.js';
6
7
  /** Edit payload: replace people + items (+assignments) and/or customer details. */
7
8
  export interface BookingEditChanges {
8
9
  persons?: PersonInput[];
@@ -22,6 +23,7 @@ export interface BookingEditChanges {
22
23
  */
23
24
  export declare function createBooking(input: CreateBookingInput, opts?: {
24
25
  now?: Date;
26
+ actor?: PriceActor;
25
27
  }): Promise<{
26
28
  booking: typeof bookingsTable.$inferSelect;
27
29
  accessToken: string;
@@ -41,5 +43,9 @@ export declare function editBooking(bookingId: string, changes: BookingEditChang
41
43
  * edit). Shared by adjustment recompute — mirrors the "no items change" branch
42
44
  * of {@link editBooking}. Must run inside a transaction that has locked the
43
45
  * booking row.
46
+ *
47
+ * **Wycenia na zamrożoną datę rezerwacji (`pricedAt`), nie na dziś.** To jest ścieżka
48
+ * korekt: obsługa księguje dopłatę ustaloną telefonicznie, a przeliczenie nie może przy
49
+ * okazji odebrać klientowi rabatu za wczesną rezerwację.
44
50
  */
45
- export declare function priceFromExistingRows(tx: BookingTx, bookingId: string, config: ResolvedBookingConfig, now: Date): Promise<ParticipantPriceResult>;
51
+ export declare function priceFromExistingRows(tx: BookingTx, bookingId: string, config: ResolvedBookingConfig): Promise<ParticipantPriceResult>;
@@ -12,6 +12,18 @@ import { recomputeTotals, assertEditAllowed, assertPortalEditAllowed } from '../
12
12
  import { deriveStatus } from '../state-machine.js';
13
13
  import { runCapacityCheck, runValidateBooking } from './validation.js';
14
14
  import { rescheduleReminders } from './reminders.js';
15
+ import { priceItemsFromCatalog, fetchCatalogPrices, catalogRefsNeeded, pickItemPrices } from './price-source.js';
16
+ /**
17
+ * Data, na którą wycenia się TĘ rezerwację — nigdy „dzisiaj".
18
+ *
19
+ * Reguły zależne od czasu (early-bird) muszą widzieć moment złożenia rezerwacji, inaczej
20
+ * każde późniejsze dotknięcie (edycja, korekta) po cichu odbiera klientowi rabat.
21
+ * `pricedAt` jest NULL dla rezerwacji sprzed 0.37.1 — dla nich `createdAt` jest dokładnie
22
+ * tym, czym było wtedy `now`.
23
+ */
24
+ function pricingDateOf(booking) {
25
+ return booking.pricedAt ?? booking.createdAt;
26
+ }
15
27
  /** Resolve personRef → DB person id; throws on a dangling assignment reference. */
16
28
  function personIdLookup(map, ref) {
17
29
  const id = map.get(ref);
@@ -50,6 +62,9 @@ export async function createBooking(input, opts = {}) {
50
62
  const config = requireBookingConfig();
51
63
  const db = getBookingDb();
52
64
  const now = opts.now ?? new Date();
65
+ // Domyślnie NIEZAUFANY: nowy publiczny endpoint jest bezpieczny, dopóki ktoś jawnie
66
+ // nie napisze `actor: 'staff'`. Zapomnienie tego parametru nie może otworzyć dziury.
67
+ const actor = opts.actor ?? 'public';
53
68
  const validationItems = input.items.map((it) => ({
54
69
  tripRef: it.tripRef,
55
70
  departureDate: it.departureDate,
@@ -62,8 +77,15 @@ export async function createBooking(input, opts = {}) {
62
77
  items: validationItems,
63
78
  customer: input.customer
64
79
  });
80
+ // Cena jest atrybutem katalogu, nie żądania. Wszystkie pozycje są tu nowe (brak
81
+ // snapshotów), więc dla klienta końcowego jedynym źródłem ceny jest szew `resolvePrice`.
82
+ const priced = await priceItemsFromCatalog(actor, input.items, new Map());
83
+ const pricedInput = {
84
+ ...input,
85
+ items: input.items.map((it, i) => ({ ...it, unitPrice: priced[i].unitPrice }))
86
+ };
65
87
  const ctx = await buildPricingContext(config, input.customer, input.items[0]?.tripRef);
66
- const draft = buildBookingDraft(input, config, ctx, now);
88
+ const draft = buildBookingDraft(pricedInput, config, ctx, now);
67
89
  // Unique booking number with collision retry (~25 bits → rare).
68
90
  let number = generateBookingNumber();
69
91
  for (let i = 0; i < 5; i++) {
@@ -84,6 +106,9 @@ export async function createBooking(input, opts = {}) {
84
106
  currency: config.currency,
85
107
  customer: input.customer,
86
108
  totals: draft.totals,
109
+ // Zamrażamy moment wyceny: wszystkie późniejsze przeliczenia (edycja, korekta)
110
+ // liczą early-bird na TĘ datę, nie na dzisiejszą.
111
+ pricedAt: now,
87
112
  balanceDueAt: draft.balanceDueAt,
88
113
  holdExpiresAt: draft.holdExpiresAt,
89
114
  language: input.language ?? null
@@ -139,6 +164,10 @@ export async function editBooking(bookingId, changes, opts) {
139
164
  const config = requireBookingConfig();
140
165
  const db = getBookingDb();
141
166
  const now = opts.now ?? new Date();
167
+ // Katalog odpytujemy PRZED transakcją: szew `resolvePrice` należy do projektu i robi
168
+ // własne I/O. Wołany pod blokadą `FOR UPDATE` trzymałby wiersz rezerwacji przez cudzy
169
+ // odczyt i sięgał po drugie połączenie z tej samej puli — droga do zakleszczenia puli.
170
+ const catalogPrices = await fetchCatalogPrices(catalogRefsNeeded(opts.actor, changes.items ?? []));
142
171
  return db.transaction(async (tx) => {
143
172
  const [booking] = await tx
144
173
  .select()
@@ -151,14 +180,26 @@ export async function editBooking(bookingId, changes, opts) {
151
180
  .select()
152
181
  .from(bookingItemsTable)
153
182
  .where(eq(bookingItemsTable.bookingId, bookingId));
183
+ // Bramka cenowa. Portal nie dyktuje ceny: pozycja już w rezerwacji zachowuje swój
184
+ // zamrożony snapshot (dotknięcie rezerwacji nie może podnieść klientowi ceny, gdy
185
+ // cennik poszedł w górę), pozycja nowa dostaje cenę z katalogu.
186
+ const existingByRef = new Map(existingItems.map((i) => [i.tripRef, i.unitPriceSnapshot]));
187
+ const editItems = changes.items
188
+ ? pickItemPrices({
189
+ actor: opts.actor,
190
+ items: changes.items,
191
+ existingByRef,
192
+ catalogPrices
193
+ }).map((priced, i) => ({ ...changes.items[i], unitPrice: priced.unitPrice }))
194
+ : undefined;
154
195
  const customerForCtx = changes.customer ?? booking.customer;
155
- const tripRefForSchema = changes.items?.[0]?.tripRef ?? existingItems[0]?.tripRef;
196
+ const tripRefForSchema = editItems?.[0]?.tripRef ?? existingItems[0]?.tripRef;
156
197
  const ctx = await buildPricingContext(config, customerForCtx, tripRefForSchema);
157
198
  // Pricing inputs: from the edit when items change, else from existing rows.
158
199
  let participants;
159
200
  let trips;
160
- if (changes.items) {
161
- const pi = pricingInputsFromCreate({ customer: customerForCtx, persons: changes.persons ?? [], items: changes.items }, ctx.identityFields);
201
+ if (editItems) {
202
+ const pi = pricingInputsFromCreate({ customer: customerForCtx, persons: changes.persons ?? [], items: editItems }, ctx.identityFields);
162
203
  participants = pi.participants;
163
204
  trips = pi.trips;
164
205
  }
@@ -178,8 +219,11 @@ export async function editBooking(bookingId, changes, opts) {
178
219
  participants = pi.participants;
179
220
  trips = pi.trips;
180
221
  }
181
- const result = priceByParticipant(participants, trips, ctx.anchor, now, ctx.participantPricing);
182
- const dates = (changes.items ?? existingItems).map((it) => it.departureDate);
222
+ // Wycena idzie z ZAMROŻONEJ daty rezerwacji, nie z `now` — inaczej poprawienie
223
+ // literówki tydzień przed wylotem skasowałoby early-bird sprzed trzech miesięcy.
224
+ // `now` zostaje tam, gdzie naprawdę znaczy „teraz": `updatedAt`, terminy, przypomnienia.
225
+ const result = priceByParticipant(participants, trips, ctx.anchor, pricingDateOf(booking), ctx.participantPricing);
226
+ const dates = (editItems ?? existingItems).map((it) => it.departureDate);
183
227
  const payments = (await tx
184
228
  .select()
185
229
  .from(bookingPaymentsTable)
@@ -194,8 +238,8 @@ export async function editBooking(bookingId, changes, opts) {
194
238
  assertPortalEditAllowed(booking.status, netOf(totals), payments);
195
239
  else
196
240
  assertEditAllowed(booking.status);
197
- if (changes.items) {
198
- const validationItems = changes.items.map((it) => ({
241
+ if (editItems) {
242
+ const validationItems = editItems.map((it) => ({
199
243
  tripRef: it.tripRef,
200
244
  departureDate: it.departureDate,
201
245
  participantsCount: it.assignments.length
@@ -219,7 +263,7 @@ export async function editBooking(bookingId, changes, opts) {
219
263
  .returning();
220
264
  personIdByRef.set(p.ref, person.id);
221
265
  }
222
- for (const it of changes.items) {
266
+ for (const it of editItems) {
223
267
  const [item] = await tx
224
268
  .insert(bookingItemsTable)
225
269
  .values({
@@ -262,8 +306,12 @@ export async function editBooking(bookingId, changes, opts) {
262
306
  * edit). Shared by adjustment recompute — mirrors the "no items change" branch
263
307
  * of {@link editBooking}. Must run inside a transaction that has locked the
264
308
  * booking row.
309
+ *
310
+ * **Wycenia na zamrożoną datę rezerwacji (`pricedAt`), nie na dziś.** To jest ścieżka
311
+ * korekt: obsługa księguje dopłatę ustaloną telefonicznie, a przeliczenie nie może przy
312
+ * okazji odebrać klientowi rabatu za wczesną rezerwację.
265
313
  */
266
- export async function priceFromExistingRows(tx, bookingId, config, now) {
314
+ export async function priceFromExistingRows(tx, bookingId, config) {
267
315
  const [booking] = await tx.select().from(bookingsTable).where(eq(bookingsTable.id, bookingId));
268
316
  if (!booking)
269
317
  throw new Error(`Booking ${bookingId} not found`);
@@ -284,5 +332,5 @@ export async function priceFromExistingRows(tx, bookingId, config, now) {
284
332
  .where(inArray(bookingItemPersonsTable.itemId, itemIds))
285
333
  : [];
286
334
  const pi = buildPricingInputs(persons.map((p) => ({ id: p.id, data: p.data })), existingItems.map((i) => ({ id: i.id, tripRef: i.tripRef, unitPrice: i.unitPriceSnapshot })), assignments.map((a) => ({ itemId: a.itemId, personId: a.personId })), ctx.identityFields);
287
- return priceByParticipant(pi.participants, pi.trips, ctx.anchor, now, ctx.participantPricing);
335
+ return priceByParticipant(pi.participants, pi.trips, ctx.anchor, pricingDateOf(booking), ctx.participantPricing);
288
336
  }
@@ -31,9 +31,11 @@ export function createBookingHandler() {
31
31
  identity: p.identity && typeof p.identity === 'object' ? p.identity : {}
32
32
  }))
33
33
  : [];
34
+ // `unitPrice` z ciała żądania jest ŚWIADOMIE POMIJANY. Cenę ustala rdzeń z katalogu
35
+ // (szew `resolvePrice`) — inaczej każdy mógłby zamówić wyprawę za grosz.
34
36
  const items = body.items.map((it) => ({
35
37
  tripRef: String(it.tripRef),
36
- unitPrice: Number(it.unitPrice) || 0,
38
+ unitPrice: 0, // nadpisywane przez bramkę cenową (`actor: 'public'`)
37
39
  departureDate: it.departureDate ? new Date(it.departureDate) : null,
38
40
  assignments: Array.isArray(it.assignments)
39
41
  ? it.assignments.map((a) => ({
@@ -47,7 +49,7 @@ export function createBookingHandler() {
47
49
  persons,
48
50
  items,
49
51
  language: body.language
50
- });
52
+ }, { actor: 'public' });
51
53
  const payment = await createDepositPayment(booking.id);
52
54
  return json({
53
55
  bookingId: booking.id,
@@ -77,8 +77,12 @@ export function createPortalHandler() {
77
77
  return out ? json(out) : json({ error: 'Not found' }, { status: 404 });
78
78
  }
79
79
  if (action === 'add-trips') {
80
+ // Cena NIE jest częścią żądania — ustala ją rdzeń z katalogu.
80
81
  const additions = Array.isArray(body.additions)
81
- ? body.additions
82
+ ? body.additions.map((a) => ({
83
+ tripRef: String(a.tripRef),
84
+ assignments: Array.isArray(a.assignments) ? a.assignments.map(String) : []
85
+ }))
82
86
  : [];
83
87
  const out = await portalAddTrips(token, additions);
84
88
  return out ? json(out) : json({ error: 'Not found' }, { status: 404 });
@@ -173,7 +173,6 @@ export declare function portalUpdateParticipant(token: string, assignmentId: str
173
173
  */
174
174
  export declare function portalAddTrips(token: string, additions: Array<{
175
175
  tripRef: string;
176
- unitPrice: number;
177
176
  assignments: string[];
178
177
  }>): Promise<{
179
178
  number: string;
@@ -223,10 +223,12 @@ export async function portalAddTrips(token, additions) {
223
223
  }));
224
224
  const arrivalRaw = booking.customer?.arrivalDate;
225
225
  const anchor = arrivalRaw ? new Date(arrivalRaw) : null;
226
+ // Bez `unitPrice`: cenę nowej pozycji ustala bramka cenowa w `editBooking` z katalogu
227
+ // (`actor: 'portal'`). Klient z ważnym tokenem nie może sobie dorzucić wyprawy za grosz.
226
228
  const newItems = additions.map((a) => ({
227
229
  tripRef: a.tripRef,
228
230
  departureDate: anchor,
229
- unitPrice: a.unitPrice,
231
+ unitPrice: 0, // nadpisywane przez bramkę
230
232
  assignments: a.assignments.map((ref) => ({ personRef: ref }))
231
233
  }));
232
234
  await editBooking(booking.id, { persons: personInputs, items: [...existingItems, ...newItems] }, { actor: 'portal' });
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Kto pisze do rezerwacji. Steruje zaufaniem do ceny przyniesionej w żądaniu:
3
+ * `staff` jest zalogowaną obsługą (ręczna korekta ceny w adminie jest zamierzoną
4
+ * funkcją), `public`/`portal` to klient końcowy — jego ceny nie honorujemy nigdy.
5
+ */
6
+ export type PriceActor = 'public' | 'portal' | 'staff';
7
+ /** Cena nie do przyjęcia: brak w katalogu, ujemna albo w ułamku grosza. */
8
+ export declare class PriceSourceError extends Error {
9
+ constructor(message: string);
10
+ }
11
+ /**
12
+ * Ustala cenę bazową każdej pozycji. **Niezmiennik: cena jest atrybutem katalogu,
13
+ * nie żądania.** Bez tego publiczny endpoint przyjmował `unitPrice` prosto z ciała
14
+ * żądania i rezerwację dało się złożyć za grosz.
15
+ *
16
+ * Kolejność źródeł:
17
+ * 1. `staff` z jawną ceną → jego cena (ręczna korekta w adminie),
18
+ * 2. pozycja już w rezerwacji → jej **snapshot** (zamrożony przy zakupie; dotknięcie
19
+ * rezerwacji nie może podnieść klientowi ceny, gdy cennik poszedł w górę),
20
+ * 3. katalog przez szew `resolvePrice`.
21
+ *
22
+ * Czysta funkcja — I/O (odczyt katalogu) robi {@link fetchCatalogPrices}.
23
+ *
24
+ * @throws {PriceSourceError} gdy pozycji nie da się wycenić (fail-closed: brak ceny
25
+ * to odmowa, nigdy „zero").
26
+ */
27
+ export declare function pickItemPrices(input: {
28
+ actor: PriceActor;
29
+ items: Array<{
30
+ tripRef: string;
31
+ unitPrice?: number;
32
+ }>;
33
+ /** `tripRef` → `unitPriceSnapshot` pozycji już zapisanych w tej rezerwacji. */
34
+ existingByRef: Map<string, number>;
35
+ /** `tripRef` → cena bazowa z katalogu (wynik szwu `resolvePrice`). */
36
+ catalogPrices: Map<string, number>;
37
+ }): Array<{
38
+ tripRef: string;
39
+ unitPrice: number;
40
+ }>;
41
+ /**
42
+ * Odpytuje szew `resolvePrice` o cenę bazową podanych pozycji katalogu (równolegle,
43
+ * bez duplikatów). Szew jest **serwerowy** — czyta katalog projektu, więc przeglądarka
44
+ * nie ma na tę cenę wpływu.
45
+ *
46
+ * @throws {PriceSourceError} gdy `resolvePrice` nie jest skonfigurowany. Fail-closed:
47
+ * publiczny booking bez źródła cen musi odmówić, a nie po cichu zaufać żądaniu.
48
+ */
49
+ export declare function fetchCatalogPrices(tripRefs: string[]): Promise<Map<string, number>>;
50
+ /**
51
+ * Pozycje, o których trzeba zapytać katalog. Obsługa podająca cenę jawnie nie wymaga
52
+ * odczytu — dzięki temu instalacja bez publicznego bookingu (sam admin) działa bez
53
+ * skonfigurowanego `resolvePrice`.
54
+ *
55
+ * `existingByRef` bywa nieznane przed wejściem w transakcję (patrz `editBooking`) — wtedy
56
+ * pomiń je, a katalog zostanie odpytany też o pozycje, które ostatecznie wezmą snapshot.
57
+ * Kilka odczytów więcej jest tańsze niż odczyt katalogu pod blokadą wiersza.
58
+ */
59
+ export declare function catalogRefsNeeded(actor: PriceActor, items: Array<{
60
+ tripRef: string;
61
+ unitPrice?: number;
62
+ }>, existingByRef?: Map<string, number>): string[];
63
+ /**
64
+ * Wycenia pozycje: odpytuje katalog **tylko o te, których nie da się wycenić inaczej**,
65
+ * a potem stosuje {@link pickItemPrices}. Wołaj poza transakcją — robi I/O.
66
+ */
67
+ export declare function priceItemsFromCatalog(actor: PriceActor, items: Array<{
68
+ tripRef: string;
69
+ unitPrice?: number;
70
+ }>, existingByRef: Map<string, number>): Promise<Array<{
71
+ tripRef: string;
72
+ unitPrice: number;
73
+ }>>;
@@ -0,0 +1,94 @@
1
+ import { requireBookingConfig } from './db.js';
2
+ /** Cena nie do przyjęcia: brak w katalogu, ujemna albo w ułamku grosza. */
3
+ export class PriceSourceError extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = 'PriceSourceError';
7
+ }
8
+ }
9
+ function assertValidPrice(tripRef, value) {
10
+ if (!Number.isInteger(value) || value < 0) {
11
+ throw new PriceSourceError(`Niepoprawna cena pozycji ${tripRef}: ${value} (oczekiwano nieujemnej liczby całkowitej groszy)`);
12
+ }
13
+ return value;
14
+ }
15
+ /**
16
+ * Ustala cenę bazową każdej pozycji. **Niezmiennik: cena jest atrybutem katalogu,
17
+ * nie żądania.** Bez tego publiczny endpoint przyjmował `unitPrice` prosto z ciała
18
+ * żądania i rezerwację dało się złożyć za grosz.
19
+ *
20
+ * Kolejność źródeł:
21
+ * 1. `staff` z jawną ceną → jego cena (ręczna korekta w adminie),
22
+ * 2. pozycja już w rezerwacji → jej **snapshot** (zamrożony przy zakupie; dotknięcie
23
+ * rezerwacji nie może podnieść klientowi ceny, gdy cennik poszedł w górę),
24
+ * 3. katalog przez szew `resolvePrice`.
25
+ *
26
+ * Czysta funkcja — I/O (odczyt katalogu) robi {@link fetchCatalogPrices}.
27
+ *
28
+ * @throws {PriceSourceError} gdy pozycji nie da się wycenić (fail-closed: brak ceny
29
+ * to odmowa, nigdy „zero").
30
+ */
31
+ export function pickItemPrices(input) {
32
+ const { actor, items, existingByRef, catalogPrices } = input;
33
+ return items.map((it) => {
34
+ if (actor === 'staff' && it.unitPrice !== undefined) {
35
+ return { tripRef: it.tripRef, unitPrice: assertValidPrice(it.tripRef, it.unitPrice) };
36
+ }
37
+ const snapshot = existingByRef.get(it.tripRef);
38
+ if (snapshot !== undefined) {
39
+ return { tripRef: it.tripRef, unitPrice: assertValidPrice(it.tripRef, snapshot) };
40
+ }
41
+ const fromCatalog = catalogPrices.get(it.tripRef);
42
+ if (fromCatalog === undefined) {
43
+ throw new PriceSourceError(`Brak ceny w katalogu dla pozycji ${it.tripRef} — rezerwacji nie da się wycenić`);
44
+ }
45
+ return { tripRef: it.tripRef, unitPrice: assertValidPrice(it.tripRef, fromCatalog) };
46
+ });
47
+ }
48
+ /**
49
+ * Odpytuje szew `resolvePrice` o cenę bazową podanych pozycji katalogu (równolegle,
50
+ * bez duplikatów). Szew jest **serwerowy** — czyta katalog projektu, więc przeglądarka
51
+ * nie ma na tę cenę wpływu.
52
+ *
53
+ * @throws {PriceSourceError} gdy `resolvePrice` nie jest skonfigurowany. Fail-closed:
54
+ * publiczny booking bez źródła cen musi odmówić, a nie po cichu zaufać żądaniu.
55
+ */
56
+ export async function fetchCatalogPrices(tripRefs) {
57
+ const refs = [...new Set(tripRefs)];
58
+ if (refs.length === 0)
59
+ return new Map();
60
+ const { resolvePrice } = requireBookingConfig();
61
+ if (!resolvePrice) {
62
+ throw new PriceSourceError('defineBooking({ resolvePrice }) nie jest skonfigurowany — publiczna rezerwacja nie ma z czego wziąć ceny. ' +
63
+ 'Podaj szew czytający cenę z katalogu, np. ' +
64
+ '`resolvePrice: async (tripRef) => Math.round(Number((await resolveEntry({ id: tripRef, status: "published" }))?.price ?? 0) * 100)`.');
65
+ }
66
+ const prices = await Promise.all(refs.map(async (ref) => [ref, await resolvePrice(ref)]));
67
+ return new Map(prices);
68
+ }
69
+ /**
70
+ * Pozycje, o których trzeba zapytać katalog. Obsługa podająca cenę jawnie nie wymaga
71
+ * odczytu — dzięki temu instalacja bez publicznego bookingu (sam admin) działa bez
72
+ * skonfigurowanego `resolvePrice`.
73
+ *
74
+ * `existingByRef` bywa nieznane przed wejściem w transakcję (patrz `editBooking`) — wtedy
75
+ * pomiń je, a katalog zostanie odpytany też o pozycje, które ostatecznie wezmą snapshot.
76
+ * Kilka odczytów więcej jest tańsze niż odczyt katalogu pod blokadą wiersza.
77
+ */
78
+ export function catalogRefsNeeded(actor, items, existingByRef) {
79
+ return items
80
+ .filter((it) => {
81
+ if (actor === 'staff' && it.unitPrice !== undefined)
82
+ return false;
83
+ return !existingByRef?.has(it.tripRef);
84
+ })
85
+ .map((it) => it.tripRef);
86
+ }
87
+ /**
88
+ * Wycenia pozycje: odpytuje katalog **tylko o te, których nie da się wycenić inaczej**,
89
+ * a potem stosuje {@link pickItemPrices}. Wołaj poza transakcją — robi I/O.
90
+ */
91
+ export async function priceItemsFromCatalog(actor, items, existingByRef) {
92
+ const catalogPrices = await fetchCatalogPrices(catalogRefsNeeded(actor, items, existingByRef));
93
+ return pickItemPrices({ actor, items, existingByRef, catalogPrices });
94
+ }
@@ -132,6 +132,23 @@ export declare const bookingsTable: import("drizzle-orm/pg-core/table", { with:
132
132
  }, {}, {
133
133
  $type: BookingTotals;
134
134
  }>;
135
+ pricedAt: import("drizzle-orm/pg-core", { with: { "resolution-mode": "require" } }).PgColumn<{
136
+ name: "priced_at";
137
+ tableName: "bookings";
138
+ dataType: "date";
139
+ columnType: "PgTimestamp";
140
+ data: Date;
141
+ driverParam: string;
142
+ notNull: false;
143
+ hasDefault: false;
144
+ isPrimaryKey: false;
145
+ isAutoincrement: false;
146
+ hasRuntimeDefault: false;
147
+ enumValues: undefined;
148
+ baseColumn: never;
149
+ identity: undefined;
150
+ generated: undefined;
151
+ }, {}, {}>;
135
152
  accessToken: import("drizzle-orm/pg-core", { with: { "resolution-mode": "require" } }).PgColumn<{
136
153
  name: "access_token";
137
154
  tableName: "bookings";
@@ -22,6 +22,18 @@ export const bookingsTable = pgTable('bookings', {
22
22
  perks: [],
23
23
  appliedRules: []
24
24
  }),
25
+ /**
26
+ * **Moment wyceny — zamrożony.** Każde przeliczenie istniejącej rezerwacji (edycja,
27
+ * korekta) liczy reguły zależne od czasu (early-bird) na tę datę, **nigdy na `new Date()`**.
28
+ *
29
+ * Bez tego cena bazowa była zamrożona, ale data — nie: obsługa księgowała dopłatę
30
+ * tydzień przed wylotem, `daysUntil(anchor, now)` spadało poniżej progu, early-bird
31
+ * cicho znikał i klient robił się winien pieniądze.
32
+ *
33
+ * NULL dla rezerwacji sprzed 0.37.1 ⇒ czytamy `createdAt`, czyli dokładnie to, czym
34
+ * było `now` w chwili ich wyceny (zero zmiany zachowania, brak migracji danych).
35
+ */
36
+ pricedAt: timestamp('priced_at', { withTimezone: true }),
25
37
  /** Unguessable token for the self-service portal (`/rezerwacja/[token]`). */
26
38
  accessToken: uuid('access_token').defaultRandom().notNull(),
27
39
  staffNotes: text('staff_notes'),
@@ -0,0 +1,2 @@
1
+ import type { CmsUpdate } from '../index.js';
2
+ export declare const update: CmsUpdate;
@@ -0,0 +1,24 @@
1
+ export const update = {
2
+ version: '0.37.1',
3
+ date: '2026-07-14',
4
+ description: 'Dwie łatki cenowe w bookingu. (1) Cena rezerwacji przestaje pochodzić z żądania HTTP — do 0.37.0 publiczne endpointy przyjmowały `unitPrice` prosto z ciała żądania i nikt nie konfrontował go z katalogiem, więc wyprawę dało się zarezerwować za grosz. (2) Data wyceny zostaje zamrożona na rezerwacji — dotąd każde jej dotknięcie (edycja, ale też zaksięgowanie korekty) przeliczało reguły zależne od czasu wg DZISIEJSZEJ daty, przez co early-bird sprzed miesięcy cicho znikał i klient robił się winien pieniądze.',
5
+ fixes: [
6
+ '**Bezpieczeństwo (data wyceny).** Nowa kolumna `bookings.priced_at` zamraża moment wyceny. `editBooking` i `priceFromExistingRows` (ścieżka korekt — `adjustments.ts:65` i `:155`) liczą teraz early-bird na tę datę, nie na `new Date()`. Wcześniej wystarczyło, że obsługa zaksięguje dopłatę ustaloną telefonicznie tydzień przed wylotem, a rabat za wczesną rezerwację przepadał. `priceFromExistingRows` straciło parametr `now` — kompilator nie pozwoli już podać tam zegara.',
7
+ 'Rezerwacje sprzed 0.37.1 mają `priced_at = NULL` i wyceniają się z `created_at` — dokładnie tym, czym było wtedy `now`. Zero migracji danych, zero zmiany zachowania.',
8
+ '**Bezpieczeństwo (ceny).** Trzy publiczne drogi wpuszczały cenę z zewnątrz: tworzenie rezerwacji (`createBookingHandler`), dokładanie wycieczki przez portal (`add-trips`) i portalowa edycja pozycji (`portalEdit` → `editBooking`). Bramka stoi teraz w rdzeniu (`createBooking`/`editBooking`), nie w handlerach, więc każdy przyszły publiczny endpoint jest chroniony domyślnie.',
9
+ 'Cena pozycji już zapisanej w rezerwacji jest brana z jej `unitPriceSnapshot`, a nie przeliczana z katalogu — dotknięcie rezerwacji przez portal nie podniesie klientowi ceny, gdy cennik w międzyczasie poszedł w górę.',
10
+ 'Odczyt katalogu w `editBooking` odbywa się przed otwarciem transakcji: szew klienta robi własne I/O, a wołany pod blokadą `FOR UPDATE` trzymałby wiersz rezerwacji i sięgał po drugie połączenie z tej samej puli.',
11
+ 'Ceny ujemne i niecałkowite (ułamek grosza) są odrzucane — również dla obsługi.'
12
+ ],
13
+ features: [
14
+ '`defineBooking({ resolvePrice })` — serwerowy szew zwracający cenę bazową (dorosłego) pozycji katalogu w groszach. Rdzeń nie zna kształtu katalogu, więc cenę podaje projekt: `resolvePrice: async (tripRef) => Math.round(Number((await resolveEntry({ id: tripRef, status: "published" }))?.price ?? 0) * 100)`.',
15
+ 'Model zaufania oparty na aktorze: `createBooking(input, { actor })` przyjmuje `public | portal | staff`. Domyślny aktor to `public` (niezaufany) — pominięcie parametru nie otwiera dziury. Zalogowana obsługa (`staff`) nadal może podać cenę ręcznie: korekta i rabat negocjowany telefonicznie zostają możliwe.',
16
+ 'Typ `PricedCreateInput` wymusza kolejność: rezerwacji nie da się zbudować z pozycji, której nie wyceniła bramka — pilnuje tego kompilator, nie dyscyplina.'
17
+ ],
18
+ breakingChanges: [
19
+ 'Nowa kolumna `bookings.priced_at` — wymagana migracja (`drizzle-kit push`). Nullable, bez backfillu.',
20
+ '**`defineBooking({ resolvePrice })` jest teraz wymagany, gdy booking wystawia publiczne endpointy** (`createBookingHandler`, portal). Bez niego rdzeń odmawia wyceny (fail-closed) zamiast po cichu zaufać cenie z żądania. Instalacje używające wyłącznie panelu admina działają bez zmian.',
21
+ '`BookingItemInput.unitPrice` jest opcjonalne i **honorowane wyłącznie dla `actor: "staff"`**. Dla klienta końcowego jest ignorowane — cenę ustala katalog.',
22
+ 'Publiczne API klienta: `createBookingClient().addTrips(token, additions)` oraz `useBooking().addTrips(additions)` nie przyjmują już `unitPrice` w pozycjach (`{ tripRef, assignments }`). Storefronty przekazujące cenę muszą ją usunąć z ładunku — była i tak ignorowana po stronie serwera.'
23
+ ]
24
+ };
@@ -76,6 +76,7 @@ import { update as update0367 } from './0.36.7/index.js';
76
76
  import { update as update0368 } from './0.36.8/index.js';
77
77
  import { update as update0369 } from './0.36.9/index.js';
78
78
  import { update as update0370 } from './0.37.0/index.js';
79
+ import { update as update0371 } from './0.37.1/index.js';
79
80
  export const updates = [
80
81
  update0065,
81
82
  update0066,
@@ -154,7 +155,8 @@ export const updates = [
154
155
  update0367,
155
156
  update0368,
156
157
  update0369,
157
- update0370
158
+ update0370,
159
+ update0371
158
160
  ];
159
161
  export const getUpdatesFrom = (fromVersion) => {
160
162
  const fromParts = fromVersion.split('.').map(Number);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "includio-cms",
3
- "version": "0.37.0",
3
+ "version": "0.37.1",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",