includio-cms 0.37.0 → 0.37.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/API.md +57 -7
  2. package/CHANGELOG.md +42 -0
  3. package/DOCS.md +1 -1
  4. package/ROADMAP.md +32 -0
  5. package/dist/admin/remote/booking.remote.d.ts +3 -0
  6. package/dist/admin/remote/booking.remote.js +5 -2
  7. package/dist/booking/client/index.d.ts +5 -2
  8. package/dist/booking/client/index.js +5 -1
  9. package/dist/booking/client/use-booking.svelte.d.ts +0 -1
  10. package/dist/booking/config.d.ts +20 -0
  11. package/dist/booking/config.js +1 -0
  12. package/dist/booking/draft.d.ts +23 -4
  13. package/dist/booking/payment-amount.d.ts +23 -0
  14. package/dist/booking/payment-amount.js +37 -0
  15. package/dist/booking/server/adjustments.js +5 -2
  16. package/dist/booking/server/bookings.d.ts +7 -1
  17. package/dist/booking/server/bookings.js +59 -11
  18. package/dist/booking/server/http/create-handler.js +4 -2
  19. package/dist/booking/server/http/portal-handler.js +7 -2
  20. package/dist/booking/server/payments.d.ts +14 -4
  21. package/dist/booking/server/payments.js +11 -5
  22. package/dist/booking/server/portal.d.ts +5 -3
  23. package/dist/booking/server/portal.js +13 -5
  24. package/dist/booking/server/price-source.d.ts +73 -0
  25. package/dist/booking/server/price-source.js +94 -0
  26. package/dist/components/ui/input-group/input-group-input.svelte.d.ts +1 -1
  27. package/dist/components/ui/sidebar/sidebar-input.svelte.d.ts +1 -1
  28. package/dist/core/fields/jsonLd/builders.d.ts +74 -0
  29. package/dist/core/fields/jsonLd/builders.js +88 -0
  30. package/dist/core/fields/jsonLd/graph.d.ts +15 -0
  31. package/dist/core/fields/jsonLd/graph.js +43 -0
  32. package/dist/core/fields/jsonLd/index.d.ts +2 -0
  33. package/dist/core/fields/jsonLd/index.js +2 -0
  34. package/dist/core/fields/resolveSeo.d.ts +1 -1
  35. package/dist/core/fields/resolveSeo.js +1 -1
  36. package/dist/core/fields/slugifyFilename.d.ts +7 -0
  37. package/dist/core/fields/slugifyFilename.js +14 -0
  38. package/dist/core/index.d.ts +2 -0
  39. package/dist/core/index.js +3 -0
  40. package/dist/core/server/fields/utils/fixOrphans.d.ts +15 -3
  41. package/dist/core/server/fields/utils/fixOrphans.js +30 -7
  42. package/dist/db-postgres/schema/booking/bookings.d.ts +17 -0
  43. package/dist/db-postgres/schema/booking/bookings.js +12 -0
  44. package/dist/files-local/index.js +5 -5
  45. package/dist/sveltekit/components/json-ld.svelte +12 -0
  46. package/dist/sveltekit/components/json-ld.svelte.d.ts +6 -0
  47. package/dist/sveltekit/components/seo.svelte +62 -11
  48. package/dist/sveltekit/components/seo.svelte.d.ts +10 -0
  49. package/dist/sveltekit/index.d.ts +2 -0
  50. package/dist/sveltekit/index.js +2 -0
  51. package/dist/sveltekit/server/index.d.ts +1 -0
  52. package/dist/sveltekit/server/index.js +2 -0
  53. package/dist/sveltekit/server/sitemap.d.ts +19 -0
  54. package/dist/sveltekit/server/sitemap.js +43 -0
  55. package/dist/updates/0.37.1/index.d.ts +2 -0
  56. package/dist/updates/0.37.1/index.js +24 -0
  57. package/dist/updates/0.37.2/index.d.ts +2 -0
  58. package/dist/updates/0.37.2/index.js +20 -0
  59. package/dist/updates/index.js +5 -1
  60. package/package.json +1 -1
@@ -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,
@@ -65,7 +65,8 @@ export function createPortalHandler() {
65
65
  }
66
66
  try {
67
67
  if (action === 'pay') {
68
- const out = await portalPay(token, Number(body.amount) || 0);
68
+ // Kwota NIE pochodzi z żądania — serwer obciąża saldo (bramka kwotowa w rdzeniu).
69
+ const out = await portalPay(token);
69
70
  return out ? json(out) : json({ error: 'Not found' }, { status: 404 });
70
71
  }
71
72
  if (action === 'edit') {
@@ -77,8 +78,12 @@ export function createPortalHandler() {
77
78
  return out ? json(out) : json({ error: 'Not found' }, { status: 404 });
78
79
  }
79
80
  if (action === 'add-trips') {
81
+ // Cena NIE jest częścią żądania — ustala ją rdzeń z katalogu.
80
82
  const additions = Array.isArray(body.additions)
81
- ? body.additions
83
+ ? body.additions.map((a) => ({
84
+ tripRef: String(a.tripRef),
85
+ assignments: Array.isArray(a.assignments) ? a.assignments.map(String) : []
86
+ }))
82
87
  : [];
83
88
  const out = await portalAddTrips(token, additions);
84
89
  return out ? json(out) : json({ error: 'Not found' }, { status: 404 });
@@ -1,5 +1,6 @@
1
1
  import type { PaymentEvent } from '../../shop/types.js';
2
2
  import type { PaymentKind } from '../ledger.js';
3
+ import { type PaymentActor } from '../payment-amount.js';
3
4
  /**
4
5
  * Create the deposit payment for a freshly-created booking: ask the first
5
6
  * configured adapter for a payment (charging only the deposit amount via an
@@ -22,12 +23,21 @@ export declare function applyWebhookPayment(event: PaymentEvent): Promise<{
22
23
  bookingId: string | null;
23
24
  kind: PaymentKind | null;
24
25
  }>;
26
+ export interface InstallmentPaymentOptions {
27
+ /** Trust level of the caller. `portal` cannot dictate the amount; `staff` can. */
28
+ actor: PaymentActor;
29
+ /** Required for `staff` (positive minor units). Ignored for `portal` (server charges the balance). */
30
+ amount?: number;
31
+ }
25
32
  /**
26
- * Generate an installment/balance payment link for an arbitrary amount (staff or
27
- * portal). Records a pending ledger row keyed by the provider ref; the webhook
28
- * settles it later.
33
+ * Generate an installment/balance payment link. Records a pending ledger row keyed
34
+ * by the provider ref; the webhook settles it later.
35
+ *
36
+ * The charged amount is gated by {@link resolveInstallmentAmount}: for `actor:'portal'`
37
+ * the browser cannot dictate it — the server charges the outstanding `totals.balance`
38
+ * (fail-closed when nothing is due); `actor:'staff'` may bill a custom `amount`.
29
39
  */
30
- export declare function createInstallmentPayment(bookingId: string, amount: number): Promise<{
40
+ export declare function createInstallmentPayment(bookingId: string, opts: InstallmentPaymentOptions): Promise<{
31
41
  paymentLink: string | null;
32
42
  }>;
33
43
  /** Record a manual cash payment (staff). Settles immediately and recomputes status. */
@@ -6,6 +6,7 @@ import { applyPaymentEvent } from '../webhook-logic.js';
6
6
  import { deriveStatus } from '../state-machine.js';
7
7
  import { netOf } from '../totals.js';
8
8
  import { buildPaymentContext } from '../payment-context.js';
9
+ import { resolveInstallmentAmount } from '../payment-amount.js';
9
10
  /**
10
11
  * Create the deposit payment for a freshly-created booking: ask the first
11
12
  * configured adapter for a payment (charging only the deposit amount via an
@@ -127,11 +128,14 @@ export async function applyWebhookPayment(event) {
127
128
  });
128
129
  }
129
130
  /**
130
- * Generate an installment/balance payment link for an arbitrary amount (staff or
131
- * portal). Records a pending ledger row keyed by the provider ref; the webhook
132
- * settles it later.
131
+ * Generate an installment/balance payment link. Records a pending ledger row keyed
132
+ * by the provider ref; the webhook settles it later.
133
+ *
134
+ * The charged amount is gated by {@link resolveInstallmentAmount}: for `actor:'portal'`
135
+ * the browser cannot dictate it — the server charges the outstanding `totals.balance`
136
+ * (fail-closed when nothing is due); `actor:'staff'` may bill a custom `amount`.
133
137
  */
134
- export async function createInstallmentPayment(bookingId, amount) {
138
+ export async function createInstallmentPayment(bookingId, opts) {
135
139
  const config = requireBookingConfig();
136
140
  const db = getBookingDb();
137
141
  const [booking] = await db.select().from(bookingsTable).where(eq(bookingsTable.id, bookingId));
@@ -140,6 +144,8 @@ export async function createInstallmentPayment(bookingId, amount) {
140
144
  const adapter = config.payment[0];
141
145
  if (!adapter)
142
146
  throw new Error('No payment adapter configured');
147
+ // Amount gate (payment-side twin of the 0.37.1 price gate).
148
+ const amount = resolveInstallmentAmount(booking.totals.balance, opts.actor, opts.amount);
143
149
  const ctx = {
144
150
  ...buildPaymentContext({
145
151
  accessToken: booking.accessToken,
@@ -165,7 +171,7 @@ export async function createInstallmentPayment(bookingId, amount) {
165
171
  status: 'pending',
166
172
  providerRef: result.providerRef ?? null,
167
173
  paymentLink: result.redirectUrl ?? null,
168
- createdBy: 'staff'
174
+ createdBy: opts.actor
169
175
  });
170
176
  return { paymentLink: result.redirectUrl ?? null };
171
177
  }
@@ -123,8 +123,11 @@ export declare function portalEdit(token: string, changes: BookingEditChanges):
123
123
  }[];
124
124
  ledger: import("../ledger.js").LedgerSummary;
125
125
  } | null>;
126
- /** Portal: create an installment/balance payment link. */
127
- export declare function portalPay(token: string, amount: number): Promise<{
126
+ /**
127
+ * Portal: create a balance payment link. The amount is NOT taken from the request —
128
+ * the server charges the outstanding `totals.balance` (see {@link createInstallmentPayment}).
129
+ */
130
+ export declare function portalPay(token: string): Promise<{
128
131
  paymentLink: string | null;
129
132
  } | null>;
130
133
  /**
@@ -173,7 +176,6 @@ export declare function portalUpdateParticipant(token: string, assignmentId: str
173
176
  */
174
177
  export declare function portalAddTrips(token: string, additions: Array<{
175
178
  tripRef: string;
176
- unitPrice: number;
177
179
  assignments: string[];
178
180
  }>): Promise<{
179
181
  number: string;
@@ -2,6 +2,7 @@ import { and, eq, inArray, isNull } from 'drizzle-orm';
2
2
  import { getBookingDb } from './db.js';
3
3
  import { bookingsTable, bookingItemsTable, bookingPersonsTable, bookingItemPersonsTable, bookingPaymentsTable } from '../../db-postgres/schema/booking/index.js';
4
4
  import { ledgerSummary } from '../ledger.js';
5
+ import { netOf } from '../totals.js';
5
6
  import { editBooking } from './bookings.js';
6
7
  import { createInstallmentPayment } from './payments.js';
7
8
  import { resolveItemSchema, saveParticipantData } from './participants.js';
@@ -29,7 +30,9 @@ export function toPortalView(booking, items, participants, payments, schemaByIte
29
30
  kind: p.kind,
30
31
  paidAt: p.paidAt ?? null
31
32
  })),
32
- ledger: ledgerSummary(booking.totals.gross, payments)
33
+ // Ledger balance must fold in discounts/surcharges — feed NET (like `totals.balance`),
34
+ // not the pre-discount `gross`, so `ledger.balance === totals.balance`.
35
+ ledger: ledgerSummary(netOf(booking.totals), payments)
33
36
  };
34
37
  }
35
38
  /** Resolve a booking by its unguessable portal access token (active only). */
@@ -138,12 +141,15 @@ export async function portalEdit(token, changes) {
138
141
  await editBooking(booking.id, changes, { actor: 'portal' });
139
142
  return portalView(token);
140
143
  }
141
- /** Portal: create an installment/balance payment link. */
142
- export async function portalPay(token, amount) {
144
+ /**
145
+ * Portal: create a balance payment link. The amount is NOT taken from the request —
146
+ * the server charges the outstanding `totals.balance` (see {@link createInstallmentPayment}).
147
+ */
148
+ export async function portalPay(token) {
143
149
  const booking = await resolveByToken(token);
144
150
  if (!booking)
145
151
  return null;
146
- return createInstallmentPayment(booking.id, amount);
152
+ return createInstallmentPayment(booking.id, { actor: 'portal' });
147
153
  }
148
154
  /**
149
155
  * Portal self-service: customer fills their own participant data. Verifies the
@@ -223,10 +229,12 @@ export async function portalAddTrips(token, additions) {
223
229
  }));
224
230
  const arrivalRaw = booking.customer?.arrivalDate;
225
231
  const anchor = arrivalRaw ? new Date(arrivalRaw) : null;
232
+ // Bez `unitPrice`: cenę nowej pozycji ustala bramka cenowa w `editBooking` z katalogu
233
+ // (`actor: 'portal'`). Klient z ważnym tokenem nie może sobie dorzucić wyprawy za grosz.
226
234
  const newItems = additions.map((a) => ({
227
235
  tripRef: a.tripRef,
228
236
  departureDate: anchor,
229
- unitPrice: a.unitPrice,
237
+ unitPrice: 0, // nadpisywane przez bramkę
230
238
  assignments: a.assignments.map((ref) => ({ personRef: ref }))
231
239
  }));
232
240
  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
+ }
@@ -2,7 +2,7 @@ declare const InputGroupInput: import("svelte").Component<(Omit<import("svelte/e
2
2
  type: "file";
3
3
  files?: FileList;
4
4
  } | {
5
- type?: "number" | "image" | "url" | "text" | "date" | "search" | "radio" | "hidden" | "email" | "password" | (string & {}) | "reset" | "color" | "button" | "checkbox" | "tel" | "time" | "month" | "submit" | "datetime-local" | "range" | "week";
5
+ type?: "number" | "image" | "url" | "text" | "date" | "search" | "radio" | "email" | "hidden" | "password" | (string & {}) | "reset" | "color" | "button" | "checkbox" | "tel" | "time" | "month" | "submit" | "datetime-local" | "range" | "week";
6
6
  files?: undefined;
7
7
  })) & {
8
8
  ref?: HTMLElement | null | undefined;
@@ -2,7 +2,7 @@ declare const SidebarInput: import("svelte").Component<(Omit<import("svelte/elem
2
2
  type: "file";
3
3
  files?: FileList;
4
4
  } | {
5
- type?: "number" | "image" | "url" | "text" | "date" | "search" | "radio" | "hidden" | "email" | "password" | (string & {}) | "reset" | "color" | "button" | "checkbox" | "tel" | "time" | "month" | "submit" | "datetime-local" | "range" | "week";
5
+ type?: "number" | "image" | "url" | "text" | "date" | "search" | "radio" | "email" | "hidden" | "password" | (string & {}) | "reset" | "color" | "button" | "checkbox" | "tel" | "time" | "month" | "submit" | "datetime-local" | "range" | "week";
6
6
  files?: undefined;
7
7
  })) & {
8
8
  ref?: HTMLElement | null | undefined;
@@ -0,0 +1,74 @@
1
+ import type { MediaFile } from '../../../types/media.js';
2
+ import { type JsonLdNode } from './graph.js';
3
+ export interface OrganizationInput {
4
+ name: string;
5
+ url?: string;
6
+ logo?: string | MediaFile;
7
+ sameAs?: string[];
8
+ description?: string;
9
+ }
10
+ export declare function organization(input: OrganizationInput): JsonLdNode;
11
+ export interface PostalAddressInput {
12
+ streetAddress?: string;
13
+ addressLocality?: string;
14
+ postalCode?: string;
15
+ addressRegion?: string;
16
+ addressCountry?: string;
17
+ }
18
+ export interface GeoInput {
19
+ latitude?: number;
20
+ longitude?: number;
21
+ }
22
+ export interface LocalBusinessInput extends OrganizationInput {
23
+ telephone?: string;
24
+ email?: string;
25
+ image?: string | MediaFile;
26
+ address?: PostalAddressInput;
27
+ geo?: GeoInput;
28
+ openingHours?: string | string[];
29
+ priceRange?: string;
30
+ }
31
+ export declare function localBusiness(input: LocalBusinessInput): JsonLdNode;
32
+ export interface WebSiteInput {
33
+ name: string;
34
+ url: string;
35
+ description?: string;
36
+ inLanguage?: string;
37
+ /** e.g. https://site/search?q={search_term_string} */
38
+ searchUrlTemplate?: string;
39
+ }
40
+ export declare function website(input: WebSiteInput): JsonLdNode;
41
+ export interface WebPageInput {
42
+ name: string;
43
+ url: string;
44
+ description?: string;
45
+ inLanguage?: string;
46
+ isPartOf?: string;
47
+ }
48
+ export declare function webPage(input: WebPageInput): JsonLdNode;
49
+ export declare function breadcrumbList(items: Array<{
50
+ name: string;
51
+ url: string;
52
+ }>): JsonLdNode;
53
+ export interface ArticleInput {
54
+ headline: string;
55
+ url?: string;
56
+ image?: string | MediaFile;
57
+ description?: string;
58
+ datePublished?: string;
59
+ dateModified?: string;
60
+ author?: string | {
61
+ name: string;
62
+ url?: string;
63
+ };
64
+ publisher?: OrganizationInput;
65
+ inLanguage?: string;
66
+ section?: string;
67
+ keywords?: string;
68
+ }
69
+ export declare function article(input: ArticleInput): JsonLdNode;
70
+ export declare function blogPosting(input: ArticleInput): JsonLdNode;
71
+ export declare function faqPage(items: Array<{
72
+ question: string;
73
+ answer: string;
74
+ }>): JsonLdNode;