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.
- package/API.md +57 -7
- package/CHANGELOG.md +42 -0
- package/DOCS.md +1 -1
- package/ROADMAP.md +32 -0
- package/dist/admin/remote/booking.remote.d.ts +3 -0
- package/dist/admin/remote/booking.remote.js +5 -2
- 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/payment-amount.d.ts +23 -0
- package/dist/booking/payment-amount.js +37 -0
- 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 +7 -2
- package/dist/booking/server/payments.d.ts +14 -4
- package/dist/booking/server/payments.js +11 -5
- package/dist/booking/server/portal.d.ts +5 -3
- package/dist/booking/server/portal.js +13 -5
- package/dist/booking/server/price-source.d.ts +73 -0
- package/dist/booking/server/price-source.js +94 -0
- package/dist/components/ui/input-group/input-group-input.svelte.d.ts +1 -1
- package/dist/components/ui/sidebar/sidebar-input.svelte.d.ts +1 -1
- package/dist/core/fields/jsonLd/builders.d.ts +74 -0
- package/dist/core/fields/jsonLd/builders.js +88 -0
- package/dist/core/fields/jsonLd/graph.d.ts +15 -0
- package/dist/core/fields/jsonLd/graph.js +43 -0
- package/dist/core/fields/jsonLd/index.d.ts +2 -0
- package/dist/core/fields/jsonLd/index.js +2 -0
- package/dist/core/fields/resolveSeo.d.ts +1 -1
- package/dist/core/fields/resolveSeo.js +1 -1
- package/dist/core/fields/slugifyFilename.d.ts +7 -0
- package/dist/core/fields/slugifyFilename.js +14 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +3 -0
- package/dist/core/server/fields/utils/fixOrphans.d.ts +15 -3
- package/dist/core/server/fields/utils/fixOrphans.js +30 -7
- package/dist/db-postgres/schema/booking/bookings.d.ts +17 -0
- package/dist/db-postgres/schema/booking/bookings.js +12 -0
- package/dist/files-local/index.js +5 -5
- package/dist/sveltekit/components/json-ld.svelte +12 -0
- package/dist/sveltekit/components/json-ld.svelte.d.ts +6 -0
- package/dist/sveltekit/components/seo.svelte +62 -11
- package/dist/sveltekit/components/seo.svelte.d.ts +10 -0
- package/dist/sveltekit/index.d.ts +2 -0
- package/dist/sveltekit/index.js +2 -0
- package/dist/sveltekit/server/index.d.ts +1 -0
- package/dist/sveltekit/server/index.js +2 -0
- package/dist/sveltekit/server/sitemap.d.ts +19 -0
- package/dist/sveltekit/server/sitemap.js +43 -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/0.37.2/index.d.ts +2 -0
- package/dist/updates/0.37.2/index.js +20 -0
- package/dist/updates/index.js +5 -1
- 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(
|
|
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,
|
|
@@ -65,7 +65,8 @@ export function createPortalHandler() {
|
|
|
65
65
|
}
|
|
66
66
|
try {
|
|
67
67
|
if (action === 'pay') {
|
|
68
|
-
|
|
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
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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,
|
|
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
|
|
131
|
-
*
|
|
132
|
-
*
|
|
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,
|
|
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:
|
|
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
|
-
/**
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
142
|
-
|
|
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,
|
|
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:
|
|
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" | "
|
|
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" | "
|
|
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;
|