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 +4 -4
- package/CHANGELOG.md +23 -0
- package/DOCS.md +1 -1
- package/ROADMAP.md +14 -0
- package/dist/admin/remote/booking.remote.d.ts +3 -0
- package/dist/admin/remote/booking.remote.js +4 -1
- package/dist/booking/client/index.d.ts +5 -2
- package/dist/booking/client/index.js +5 -1
- package/dist/booking/client/use-booking.svelte.d.ts +0 -1
- package/dist/booking/config.d.ts +20 -0
- package/dist/booking/config.js +1 -0
- package/dist/booking/draft.d.ts +23 -4
- package/dist/booking/server/adjustments.js +5 -2
- package/dist/booking/server/bookings.d.ts +7 -1
- package/dist/booking/server/bookings.js +59 -11
- package/dist/booking/server/http/create-handler.js +4 -2
- package/dist/booking/server/http/portal-handler.js +5 -1
- package/dist/booking/server/portal.d.ts +0 -1
- package/dist/booking/server/portal.js +3 -1
- package/dist/booking/server/price-source.d.ts +73 -0
- package/dist/booking/server/price-source.js +94 -0
- package/dist/db-postgres/schema/booking/bookings.d.ts +17 -0
- package/dist/db-postgres/schema/booking/bookings.js +12 -0
- package/dist/updates/0.37.1/index.d.ts +2 -0
- package/dist/updates/0.37.1/index.js +24 -0
- package/dist/updates/index.js +3 -1
- package/package.json +1 -1
package/API.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# includio-cms — Public API v0.37.
|
|
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;
|
|
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
|
|
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
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
}
|
package/dist/booking/config.d.ts
CHANGED
|
@@ -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;
|
package/dist/booking/config.js
CHANGED
|
@@ -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',
|
package/dist/booking/draft.d.ts
CHANGED
|
@@ -24,8 +24,15 @@ export interface ItemAssignmentInput {
|
|
|
24
24
|
export interface BookingItemInput {
|
|
25
25
|
tripRef: string;
|
|
26
26
|
departureDate: Date | null;
|
|
27
|
-
/**
|
|
28
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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 =
|
|
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 (
|
|
161
|
-
const pi = pricingInputsFromCreate({ customer: customerForCtx, persons: changes.persons ?? [], items:
|
|
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
|
-
|
|
182
|
-
|
|
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 (
|
|
198
|
-
const validationItems =
|
|
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
|
|
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
|
|
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,
|
|
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:
|
|
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:
|
|
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,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
|
+
};
|
package/dist/updates/index.js
CHANGED
|
@@ -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);
|