includio-cms 0.36.8 → 0.37.0
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 +179 -3
- package/CHANGELOG.md +64 -0
- package/DOCS.md +28 -3
- package/ROADMAP.md +18 -0
- package/dist/admin/client/booking/booking-dashboard.svelte +40 -0
- package/dist/admin/client/booking/booking-dashboard.svelte.d.ts +3 -0
- package/dist/admin/client/booking/customer-panel.svelte +102 -0
- package/dist/admin/client/booking/customer-panel.svelte.d.ts +11 -0
- package/dist/admin/client/booking/customer-view.d.ts +52 -0
- package/dist/admin/client/booking/customer-view.js +134 -0
- package/dist/admin/client/booking/participant-data-panel.svelte +330 -0
- package/dist/admin/client/booking/participant-data-panel.svelte.d.ts +22 -0
- package/dist/admin/client/booking/reservation-detail-page.svelte +637 -0
- package/dist/admin/client/booking/reservation-detail-page.svelte.d.ts +3 -0
- package/dist/admin/client/booking/reservations-page.svelte +221 -0
- package/dist/admin/client/booking/reservations-page.svelte.d.ts +3 -0
- package/dist/admin/client/collection/collection-entries.svelte +21 -2
- package/dist/admin/client/collection/entry-link.svelte +6 -4
- package/dist/admin/client/collection/entry-link.svelte.d.ts +3 -1
- package/dist/admin/client/collection/row-actions.svelte +12 -4
- package/dist/admin/client/collection/row-actions.svelte.d.ts +1 -0
- package/dist/admin/client/collection/status-badge.svelte +39 -3
- package/dist/admin/client/collection/status-badge.svelte.d.ts +5 -3
- package/dist/admin/client/entry/entry.svelte +26 -9
- package/dist/admin/client/entry/hybrid/hybrid-preview.svelte +73 -10
- package/dist/admin/client/entry/hybrid/hybrid-preview.svelte.d.ts +6 -5
- package/dist/admin/client/entry/preview-views-context.svelte.d.ts +22 -0
- package/dist/admin/client/entry/preview-views-context.svelte.js +58 -0
- package/dist/admin/client/entry/preview-views.d.ts +27 -0
- package/dist/admin/client/entry/preview-views.js +33 -0
- package/dist/admin/client/index.d.ts +3 -0
- package/dist/admin/client/index.js +3 -0
- package/dist/admin/client/modules/dashboard-registry.d.ts +7 -0
- package/dist/admin/client/modules/dashboard-registry.js +11 -0
- package/dist/admin/client/modules/module-dashboard-page.svelte +26 -0
- package/dist/admin/client/modules/module-dashboard-page.svelte.d.ts +6 -0
- package/dist/admin/client/modules/stat-card.svelte +22 -0
- package/dist/admin/client/modules/stat-card.svelte.d.ts +10 -0
- package/dist/admin/client/shop/shop-dashboard.svelte +29 -0
- package/dist/admin/client/shop/shop-dashboard.svelte.d.ts +3 -0
- package/dist/admin/components/fields/relation-field.svelte +11 -2
- package/dist/admin/components/icons/icon-map.js +8 -0
- package/dist/admin/components/layout/app-sidebar.svelte +45 -14
- package/dist/admin/components/layout/context-switcher.svelte +90 -0
- package/dist/admin/components/layout/context-switcher.svelte.d.ts +3 -0
- package/dist/admin/components/layout/lang.d.ts +4 -0
- package/dist/admin/components/layout/lang.js +8 -0
- package/dist/admin/components/layout/layout-renderer.svelte +2 -1
- package/dist/admin/components/layout/layout-tabs.svelte +14 -0
- package/dist/admin/components/layout/layout-tabs.svelte.d.ts +2 -0
- package/dist/admin/components/layout/nav-collections.svelte +16 -9
- package/dist/admin/components/layout/nav-collections.svelte.d.ts +5 -1
- package/dist/admin/components/layout/nav-global.svelte +45 -0
- package/dist/admin/components/layout/nav-global.svelte.d.ts +3 -0
- package/dist/admin/components/layout/nav-main.svelte +8 -32
- package/dist/admin/components/layout/nav-main.svelte.d.ts +6 -1
- package/dist/admin/components/layout/nav-module.svelte +57 -0
- package/dist/admin/components/layout/nav-module.svelte.d.ts +7 -0
- package/dist/admin/components/layout/nav-section.svelte +1 -1
- package/dist/admin/components/layout/nav-section.svelte.d.ts +1 -1
- package/dist/admin/components/layout/nav-singletons.svelte +14 -7
- package/dist/admin/components/layout/nav-singletons.svelte.d.ts +5 -1
- package/dist/admin/remote/booking.remote.d.ts +244 -0
- package/dist/admin/remote/booking.remote.js +283 -0
- package/dist/admin/remote/entry.remote.d.ts +3 -0
- package/dist/admin/remote/entry.remote.js +14 -3
- package/dist/admin/remote/index.d.ts +2 -0
- package/dist/admin/remote/index.js +2 -0
- package/dist/admin/remote/modules.remote.d.ts +10 -0
- package/dist/admin/remote/modules.remote.js +14 -0
- package/dist/admin/state/active-context-resolve.d.ts +37 -0
- package/dist/admin/state/active-context-resolve.js +66 -0
- package/dist/admin/state/active-context.svelte.d.ts +8 -0
- package/dist/admin/state/active-context.svelte.js +27 -0
- package/dist/booking/adjustments.d.ts +33 -0
- package/dist/booking/adjustments.js +26 -0
- package/dist/booking/capacity.d.ts +12 -0
- package/dist/booking/capacity.js +10 -0
- package/dist/booking/client/index.d.ts +39 -0
- package/dist/booking/client/index.js +44 -0
- package/dist/booking/client/use-booking.svelte.d.ts +25 -0
- package/dist/booking/client/use-booking.svelte.js +56 -0
- package/dist/booking/config.d.ts +156 -0
- package/dist/booking/config.js +36 -0
- package/dist/booking/draft.d.ts +77 -0
- package/dist/booking/draft.js +67 -0
- package/dist/booking/expiry-logic.d.ts +30 -0
- package/dist/booking/expiry-logic.js +33 -0
- package/dist/booking/index.d.ts +18 -0
- package/dist/booking/index.js +52 -0
- package/dist/booking/ledger.d.ts +46 -0
- package/dist/booking/ledger.js +60 -0
- package/dist/booking/number.d.ts +2 -0
- package/dist/booking/number.js +11 -0
- package/dist/booking/participant-fields/access.d.ts +10 -0
- package/dist/booking/participant-fields/access.js +14 -0
- package/dist/booking/participant-fields/age.d.ts +12 -0
- package/dist/booking/participant-fields/age.js +28 -0
- package/dist/booking/participant-fields/completeness.d.ts +8 -0
- package/dist/booking/participant-fields/completeness.js +13 -0
- package/dist/booking/participant-fields/group.d.ts +6 -0
- package/dist/booking/participant-fields/group.js +15 -0
- package/dist/booking/participant-fields/index.d.ts +6 -0
- package/dist/booking/participant-fields/index.js +6 -0
- package/dist/booking/participant-fields/types.d.ts +50 -0
- package/dist/booking/participant-fields/types.js +5 -0
- package/dist/booking/participant-fields/validate.d.ts +7 -0
- package/dist/booking/participant-fields/validate.js +44 -0
- package/dist/booking/participant-pricing.d.ts +93 -0
- package/dist/booking/participant-pricing.js +91 -0
- package/dist/booking/participants-view.d.ts +32 -0
- package/dist/booking/participants-view.js +22 -0
- package/dist/booking/payment-context.d.ts +19 -0
- package/dist/booking/payment-context.js +29 -0
- package/dist/booking/pricing-inputs.d.ts +28 -0
- package/dist/booking/pricing-inputs.js +35 -0
- package/dist/booking/pricing.d.ts +86 -0
- package/dist/booking/pricing.js +125 -0
- package/dist/booking/recompute.d.ts +22 -0
- package/dist/booking/recompute.js +31 -0
- package/dist/booking/reminders-schedule.d.ts +11 -0
- package/dist/booking/reminders-schedule.js +28 -0
- package/dist/booking/server/adjustments.d.ts +27 -0
- package/dist/booking/server/adjustments.js +136 -0
- package/dist/booking/server/bookings.d.ts +45 -0
- package/dist/booking/server/bookings.js +288 -0
- package/dist/booking/server/catalog.d.ts +9 -0
- package/dist/booking/server/catalog.js +20 -0
- package/dist/booking/server/db.d.ts +7 -0
- package/dist/booking/server/db.js +18 -0
- package/dist/booking/server/email.d.ts +23 -0
- package/dist/booking/server/email.js +59 -0
- package/dist/booking/server/emailTemplateRegistry.d.ts +29 -0
- package/dist/booking/server/emailTemplateRegistry.js +125 -0
- package/dist/booking/server/http/create-handler.d.ts +5 -0
- package/dist/booking/server/http/create-handler.js +68 -0
- package/dist/booking/server/http/index.d.ts +4 -0
- package/dist/booking/server/http/index.js +4 -0
- package/dist/booking/server/http/maintenance-handler.d.ts +9 -0
- package/dist/booking/server/http/maintenance-handler.js +36 -0
- package/dist/booking/server/http/portal-handler.d.ts +13 -0
- package/dist/booking/server/http/portal-handler.js +99 -0
- package/dist/booking/server/http/webhook-handler.d.ts +9 -0
- package/dist/booking/server/http/webhook-handler.js +39 -0
- package/dist/booking/server/index.d.ts +12 -0
- package/dist/booking/server/index.js +14 -0
- package/dist/booking/server/maintenance.d.ts +28 -0
- package/dist/booking/server/maintenance.js +134 -0
- package/dist/booking/server/participants.d.ts +26 -0
- package/dist/booking/server/participants.js +92 -0
- package/dist/booking/server/payments.d.ts +34 -0
- package/dist/booking/server/payments.js +207 -0
- package/dist/booking/server/portal-session.d.ts +16 -0
- package/dist/booking/server/portal-session.js +42 -0
- package/dist/booking/server/portal.d.ts +207 -0
- package/dist/booking/server/portal.js +234 -0
- package/dist/booking/server/refunds.d.ts +7 -0
- package/dist/booking/server/refunds.js +58 -0
- package/dist/booking/server/reminders.d.ts +9 -0
- package/dist/booking/server/reminders.js +24 -0
- package/dist/booking/server/validation.d.ts +17 -0
- package/dist/booking/server/validation.js +55 -0
- package/dist/booking/state-machine.d.ts +41 -0
- package/dist/booking/state-machine.js +73 -0
- package/dist/booking/templates/_partials/footer.en.html +4 -0
- package/dist/booking/templates/_partials/footer.pl.html +4 -0
- package/dist/booking/templates/_partials/header.en.html +4 -0
- package/dist/booking/templates/_partials/header.pl.html +4 -0
- package/dist/booking/templates/balance-due.en.html +10 -0
- package/dist/booking/templates/balance-due.pl.html +10 -0
- package/dist/booking/templates/balance-overdue-cancelled.en.html +5 -0
- package/dist/booking/templates/balance-overdue-cancelled.pl.html +5 -0
- package/dist/booking/templates/booking-received.en.html +9 -0
- package/dist/booking/templates/booking-received.pl.html +9 -0
- package/dist/booking/templates/deposit-paid.en.html +6 -0
- package/dist/booking/templates/deposit-paid.pl.html +6 -0
- package/dist/booking/templates/payment-link.en.html +5 -0
- package/dist/booking/templates/payment-link.pl.html +5 -0
- package/dist/booking/templates/payment-received.en.html +10 -0
- package/dist/booking/templates/payment-received.pl.html +10 -0
- package/dist/booking/totals.d.ts +38 -0
- package/dist/booking/totals.js +61 -0
- package/dist/booking/webhook-logic.d.ts +28 -0
- package/dist/booking/webhook-logic.js +50 -0
- package/dist/cli/create-user.d.ts +7 -1
- package/dist/cli/create-user.js +23 -15
- package/dist/cli/index.js +55 -7
- package/dist/cli/scaffold/admin.d.ts +6 -0
- package/dist/cli/scaffold/admin.js +5 -3
- package/dist/cli/scaffold/booking.d.ts +12 -0
- package/dist/cli/scaffold/booking.js +103 -0
- package/dist/core/cms.d.ts +24 -9
- package/dist/core/cms.js +72 -34
- package/dist/core/config/validatePreviews.d.ts +12 -0
- package/dist/core/config/validatePreviews.js +90 -0
- package/dist/core/fields/dotPath.d.ts +4 -0
- package/dist/core/fields/dotPath.js +18 -0
- package/dist/core/fields/slugPath.d.ts +9 -1
- package/dist/core/fields/slugPath.js +16 -2
- package/dist/core/fields/slugify.d.ts +3 -0
- package/dist/core/fields/slugify.js +4 -0
- package/dist/core/server/entries/operations/duplicate.d.ts +10 -0
- package/dist/core/server/entries/operations/duplicate.js +73 -0
- package/dist/core/server/entries/operations/slugNormalize.d.ts +8 -0
- package/dist/core/server/entries/operations/slugNormalize.js +20 -0
- package/dist/core/server/entries/operations/slugUniqueness.d.ts +17 -0
- package/dist/core/server/entries/operations/slugUniqueness.js +39 -0
- package/dist/core/server/fields/resolveRelationFields.d.ts +11 -2
- package/dist/core/server/fields/resolveRelationFields.js +12 -3
- package/dist/core/server/generator/generator.js +7 -2
- package/dist/db-postgres/schema/booking/bookingAdjustments.d.ts +186 -0
- package/dist/db-postgres/schema/booking/bookingAdjustments.js +22 -0
- package/dist/db-postgres/schema/booking/bookingItemPersons.d.ts +117 -0
- package/dist/db-postgres/schema/booking/bookingItemPersons.js +22 -0
- package/dist/db-postgres/schema/booking/bookingItems.d.ts +181 -0
- package/dist/db-postgres/schema/booking/bookingItems.js +22 -0
- package/dist/db-postgres/schema/booking/bookingPayments.d.ts +204 -0
- package/dist/db-postgres/schema/booking/bookingPayments.js +25 -0
- package/dist/db-postgres/schema/booking/bookingPersons.d.ts +99 -0
- package/dist/db-postgres/schema/booking/bookingPersons.js +17 -0
- package/dist/db-postgres/schema/booking/bookingReminders.d.ts +131 -0
- package/dist/db-postgres/schema/booking/bookingReminders.js +18 -0
- package/dist/db-postgres/schema/booking/bookings.d.ts +307 -0
- package/dist/db-postgres/schema/booking/bookings.js +40 -0
- package/dist/db-postgres/schema/booking/index.d.ts +7 -0
- package/dist/db-postgres/schema/booking/index.js +7 -0
- package/dist/db-postgres/schema/index.d.ts +1 -0
- package/dist/db-postgres/schema/index.js +1 -0
- package/dist/db-postgres/schema-booking.d.ts +1 -0
- package/dist/db-postgres/schema-booking.js +1 -0
- package/dist/server/security/rate-limit-handler.d.ts +19 -0
- package/dist/server/security/rate-limit-handler.js +34 -0
- package/dist/shop/adapters/payu/client.js +3 -1
- package/dist/shop/adapters/payu/index.js +37 -17
- package/dist/shop/adapters/payu/payload.d.ts +6 -0
- package/dist/shop/adapters/payu/payload.js +1 -1
- package/dist/shop/adapters/stripe/index.js +36 -21
- package/dist/shop/index.d.ts +11 -2
- package/dist/shop/index.js +43 -1
- package/dist/shop/rate-limit.d.ts +1 -9
- package/dist/shop/rate-limit.js +3 -28
- package/dist/shop/types.d.ts +19 -0
- package/dist/sveltekit/config.d.ts +4 -0
- package/dist/sveltekit/config.js +6 -0
- package/dist/types/cms.d.ts +15 -1
- package/dist/types/cms.schema.d.ts +96 -2
- package/dist/types/cms.schema.js +84 -1
- package/dist/types/config.d.ts +33 -0
- package/dist/types/layout.d.ts +6 -0
- package/dist/types/modules.d.ts +133 -0
- package/dist/types/modules.js +27 -0
- package/dist/updates/0.36.9/index.d.ts +2 -0
- package/dist/updates/0.36.9/index.js +12 -0
- package/dist/updates/0.37.0/index.d.ts +2 -0
- package/dist/updates/0.37.0/index.js +52 -0
- package/dist/updates/index.js +5 -1
- package/package.json +19 -1
- package/dist/admin/components/layout/nav-shop.svelte +0 -33
- package/dist/admin/components/layout/nav-shop.svelte.d.ts +0 -3
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type AdjustmentKind, type BookingAdjustment } from '../adjustments.js';
|
|
2
|
+
export interface AddAdjustmentInput {
|
|
3
|
+
kind: AdjustmentKind;
|
|
4
|
+
/** UI-entered POSITIVE magnitude (minor units). */
|
|
5
|
+
magnitude: number;
|
|
6
|
+
label: string;
|
|
7
|
+
reason?: string | null;
|
|
8
|
+
}
|
|
9
|
+
/** List all adjustments (active + voided) for a booking, newest first. */
|
|
10
|
+
export declare function listBookingAdjustments(bookingId: string): Promise<BookingAdjustment[]>;
|
|
11
|
+
/**
|
|
12
|
+
* Add a manual adjustment (surcharge/discount) and recompute the booking totals
|
|
13
|
+
* + derived status in one transaction. Rejects an adjustment that would push the
|
|
14
|
+
* net owed below zero. `amount` is stored signed (surcharge > 0, discount < 0).
|
|
15
|
+
*/
|
|
16
|
+
export declare function addBookingAdjustment(bookingId: string, input: AddAdjustmentInput, opts: {
|
|
17
|
+
actor: string;
|
|
18
|
+
now?: Date;
|
|
19
|
+
}): Promise<BookingAdjustment>;
|
|
20
|
+
/**
|
|
21
|
+
* Soft-void an adjustment (kept for audit) and recompute the booking totals +
|
|
22
|
+
* status. Throws if the adjustment is missing or already voided.
|
|
23
|
+
*/
|
|
24
|
+
export declare function voidBookingAdjustment(adjustmentId: string, opts: {
|
|
25
|
+
actor: string;
|
|
26
|
+
now?: Date;
|
|
27
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { and, desc, eq, isNull } from 'drizzle-orm';
|
|
2
|
+
import { getBookingDb, requireBookingConfig } from './db.js';
|
|
3
|
+
import { bookingsTable, bookingPaymentsTable, bookingAdjustmentsTable } from '../../db-postgres/schema/booking/index.js';
|
|
4
|
+
import { signedAmount, sumAdjustments, assertAdjustmentMagnitude } from '../adjustments.js';
|
|
5
|
+
import { recomputeTotals, assertEditAllowed } from '../recompute.js';
|
|
6
|
+
import { netOf } from '../totals.js';
|
|
7
|
+
import { deriveStatus } from '../state-machine.js';
|
|
8
|
+
import { priceFromExistingRows } from './bookings.js';
|
|
9
|
+
/** List all adjustments (active + voided) for a booking, newest first. */
|
|
10
|
+
export async function listBookingAdjustments(bookingId) {
|
|
11
|
+
const db = getBookingDb();
|
|
12
|
+
const rows = await db
|
|
13
|
+
.select()
|
|
14
|
+
.from(bookingAdjustmentsTable)
|
|
15
|
+
.where(eq(bookingAdjustmentsTable.bookingId, bookingId))
|
|
16
|
+
.orderBy(desc(bookingAdjustmentsTable.createdAt));
|
|
17
|
+
return rows;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Add a manual adjustment (surcharge/discount) and recompute the booking totals
|
|
21
|
+
* + derived status in one transaction. Rejects an adjustment that would push the
|
|
22
|
+
* net owed below zero. `amount` is stored signed (surcharge > 0, discount < 0).
|
|
23
|
+
*/
|
|
24
|
+
export async function addBookingAdjustment(bookingId, input, opts) {
|
|
25
|
+
assertAdjustmentMagnitude(input.magnitude);
|
|
26
|
+
const config = requireBookingConfig();
|
|
27
|
+
const db = getBookingDb();
|
|
28
|
+
const now = opts.now ?? new Date();
|
|
29
|
+
const amount = signedAmount(input.kind, input.magnitude);
|
|
30
|
+
return db.transaction(async (tx) => {
|
|
31
|
+
const [booking] = await tx
|
|
32
|
+
.select()
|
|
33
|
+
.from(bookingsTable)
|
|
34
|
+
.where(eq(bookingsTable.id, bookingId))
|
|
35
|
+
.for('update');
|
|
36
|
+
if (!booking)
|
|
37
|
+
throw new Error(`Booking ${bookingId} not found`);
|
|
38
|
+
assertEditAllowed(booking.status);
|
|
39
|
+
const result = await priceFromExistingRows(tx, bookingId, config, now);
|
|
40
|
+
const active = await tx
|
|
41
|
+
.select()
|
|
42
|
+
.from(bookingAdjustmentsTable)
|
|
43
|
+
.where(and(eq(bookingAdjustmentsTable.bookingId, bookingId), isNull(bookingAdjustmentsTable.voidedAt)));
|
|
44
|
+
const prospective = sumAdjustments([...active, { amount, voidedAt: null }]);
|
|
45
|
+
const payments = (await tx
|
|
46
|
+
.select()
|
|
47
|
+
.from(bookingPaymentsTable)
|
|
48
|
+
.where(eq(bookingPaymentsTable.bookingId, bookingId))).map((p) => ({ amount: p.amount, status: p.status, kind: p.kind }));
|
|
49
|
+
const totals = recomputeTotals(result, prospective, payments);
|
|
50
|
+
// Raw (unclamped) net — netOf() floors at 0, so guard on the raw value.
|
|
51
|
+
const rawNet = totals.gross - totals.discount - totals.manualDiscount + totals.surcharge;
|
|
52
|
+
if (rawNet < 0) {
|
|
53
|
+
throw new Error('Adjustment would make the booking total negative');
|
|
54
|
+
}
|
|
55
|
+
const [row] = await tx
|
|
56
|
+
.insert(bookingAdjustmentsTable)
|
|
57
|
+
.values({
|
|
58
|
+
bookingId,
|
|
59
|
+
kind: input.kind,
|
|
60
|
+
amount,
|
|
61
|
+
label: input.label,
|
|
62
|
+
reason: input.reason ?? null,
|
|
63
|
+
createdBy: opts.actor
|
|
64
|
+
})
|
|
65
|
+
.returning();
|
|
66
|
+
await tx
|
|
67
|
+
.update(bookingsTable)
|
|
68
|
+
.set({
|
|
69
|
+
totals,
|
|
70
|
+
status: deriveStatus({
|
|
71
|
+
gross: netOf(totals),
|
|
72
|
+
paid: totals.paid,
|
|
73
|
+
depositRequired: config.depositAmount,
|
|
74
|
+
current: booking.status
|
|
75
|
+
}),
|
|
76
|
+
updatedAt: now
|
|
77
|
+
})
|
|
78
|
+
.where(eq(bookingsTable.id, bookingId));
|
|
79
|
+
return row;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Soft-void an adjustment (kept for audit) and recompute the booking totals +
|
|
84
|
+
* status. Throws if the adjustment is missing or already voided.
|
|
85
|
+
*/
|
|
86
|
+
export async function voidBookingAdjustment(adjustmentId, opts) {
|
|
87
|
+
const config = requireBookingConfig();
|
|
88
|
+
const db = getBookingDb();
|
|
89
|
+
const now = opts.now ?? new Date();
|
|
90
|
+
await db.transaction(async (tx) => {
|
|
91
|
+
const [adj] = await tx
|
|
92
|
+
.select()
|
|
93
|
+
.from(bookingAdjustmentsTable)
|
|
94
|
+
.where(eq(bookingAdjustmentsTable.id, adjustmentId))
|
|
95
|
+
.for('update');
|
|
96
|
+
if (!adj)
|
|
97
|
+
throw new Error('Adjustment not found');
|
|
98
|
+
if (adj.voidedAt)
|
|
99
|
+
throw new Error('Adjustment already voided');
|
|
100
|
+
const [booking] = await tx
|
|
101
|
+
.select()
|
|
102
|
+
.from(bookingsTable)
|
|
103
|
+
.where(eq(bookingsTable.id, adj.bookingId))
|
|
104
|
+
.for('update');
|
|
105
|
+
if (!booking)
|
|
106
|
+
throw new Error('Booking not found');
|
|
107
|
+
assertEditAllowed(booking.status);
|
|
108
|
+
await tx
|
|
109
|
+
.update(bookingAdjustmentsTable)
|
|
110
|
+
.set({ voidedAt: now, voidedBy: opts.actor })
|
|
111
|
+
.where(eq(bookingAdjustmentsTable.id, adjustmentId));
|
|
112
|
+
const result = await priceFromExistingRows(tx, adj.bookingId, config, now);
|
|
113
|
+
const active = await tx
|
|
114
|
+
.select()
|
|
115
|
+
.from(bookingAdjustmentsTable)
|
|
116
|
+
.where(and(eq(bookingAdjustmentsTable.bookingId, adj.bookingId), isNull(bookingAdjustmentsTable.voidedAt)));
|
|
117
|
+
const payments = (await tx
|
|
118
|
+
.select()
|
|
119
|
+
.from(bookingPaymentsTable)
|
|
120
|
+
.where(eq(bookingPaymentsTable.bookingId, adj.bookingId))).map((p) => ({ amount: p.amount, status: p.status, kind: p.kind }));
|
|
121
|
+
const totals = recomputeTotals(result, sumAdjustments(active), payments);
|
|
122
|
+
await tx
|
|
123
|
+
.update(bookingsTable)
|
|
124
|
+
.set({
|
|
125
|
+
totals,
|
|
126
|
+
status: deriveStatus({
|
|
127
|
+
gross: netOf(totals),
|
|
128
|
+
paid: totals.paid,
|
|
129
|
+
depositRequired: config.depositAmount,
|
|
130
|
+
current: booking.status
|
|
131
|
+
}),
|
|
132
|
+
updatedAt: now
|
|
133
|
+
})
|
|
134
|
+
.where(eq(bookingsTable.id, adj.bookingId));
|
|
135
|
+
});
|
|
136
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { bookingsTable } from '../../db-postgres/schema/booking/index.js';
|
|
2
|
+
import { type CreateBookingInput, type PersonInput, type ItemAssignmentInput } from '../draft.js';
|
|
3
|
+
import { type ParticipantPriceResult } from '../participant-pricing.js';
|
|
4
|
+
import type { ResolvedBookingConfig } from '../config.js';
|
|
5
|
+
import { type BookingTx } from './reminders.js';
|
|
6
|
+
/** Edit payload: replace people + items (+assignments) and/or customer details. */
|
|
7
|
+
export interface BookingEditChanges {
|
|
8
|
+
persons?: PersonInput[];
|
|
9
|
+
items?: Array<{
|
|
10
|
+
tripRef: string;
|
|
11
|
+
departureDate: Date | null;
|
|
12
|
+
unitPrice: number;
|
|
13
|
+
assignments: ItemAssignmentInput[];
|
|
14
|
+
}>;
|
|
15
|
+
customer?: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Create a booking (public self-serve or staff). Validates catalog capacity +
|
|
19
|
+
* the client seam, prices the draft per-participant, and persists booking +
|
|
20
|
+
* people + items + assignments + reminders in one transaction. Status starts
|
|
21
|
+
* `draft`; the deposit payment (and its webhook) drives the status forward.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createBooking(input: CreateBookingInput, opts?: {
|
|
24
|
+
now?: Date;
|
|
25
|
+
}): Promise<{
|
|
26
|
+
booking: typeof bookingsTable.$inferSelect;
|
|
27
|
+
accessToken: string;
|
|
28
|
+
}>;
|
|
29
|
+
/**
|
|
30
|
+
* Edit a booking (staff or portal). Recomputes totals per-participant + derived
|
|
31
|
+
* status, enforces the actor's guards (`portal` blocks dropping below paid),
|
|
32
|
+
* re-runs capacity + validate seam when items change, replaces people/items/
|
|
33
|
+
* assignments, and reschedules reminders on a departure change. One transaction.
|
|
34
|
+
*/
|
|
35
|
+
export declare function editBooking(bookingId: string, changes: BookingEditChanges, opts: {
|
|
36
|
+
actor: 'staff' | 'portal';
|
|
37
|
+
now?: Date;
|
|
38
|
+
}): Promise<typeof bookingsTable.$inferSelect>;
|
|
39
|
+
/**
|
|
40
|
+
* Price a booking from its CURRENTLY persisted people/items/assignments (no
|
|
41
|
+
* edit). Shared by adjustment recompute — mirrors the "no items change" branch
|
|
42
|
+
* of {@link editBooking}. Must run inside a transaction that has locked the
|
|
43
|
+
* booking row.
|
|
44
|
+
*/
|
|
45
|
+
export declare function priceFromExistingRows(tx: BookingTx, bookingId: string, config: ResolvedBookingConfig, now: Date): Promise<ParticipantPriceResult>;
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { and, eq, inArray, isNull } from 'drizzle-orm';
|
|
2
|
+
import { getBookingDb, requireBookingConfig } from './db.js';
|
|
3
|
+
import { bookingsTable, bookingItemsTable, bookingPersonsTable, bookingItemPersonsTable, bookingPaymentsTable, bookingRemindersTable, bookingAdjustmentsTable } from '../../db-postgres/schema/booking/index.js';
|
|
4
|
+
import { buildBookingDraft, pricingInputsFromCreate } from '../draft.js';
|
|
5
|
+
import { buildPricingInputs } from '../pricing-inputs.js';
|
|
6
|
+
import { priceByParticipant } from '../participant-pricing.js';
|
|
7
|
+
import { grossByTripRef, netOf } from '../totals.js';
|
|
8
|
+
import { sumAdjustments } from '../adjustments.js';
|
|
9
|
+
import { resolveItemSchema } from './participants.js';
|
|
10
|
+
import { generateBookingNumber } from '../number.js';
|
|
11
|
+
import { recomputeTotals, assertEditAllowed, assertPortalEditAllowed } from '../recompute.js';
|
|
12
|
+
import { deriveStatus } from '../state-machine.js';
|
|
13
|
+
import { runCapacityCheck, runValidateBooking } from './validation.js';
|
|
14
|
+
import { rescheduleReminders } from './reminders.js';
|
|
15
|
+
/** Resolve personRef → DB person id; throws on a dangling assignment reference. */
|
|
16
|
+
function personIdLookup(map, ref) {
|
|
17
|
+
const id = map.get(ref);
|
|
18
|
+
if (!id)
|
|
19
|
+
throw new Error(`Assignment references unknown personRef "${ref}"`);
|
|
20
|
+
return id;
|
|
21
|
+
}
|
|
22
|
+
/** Parse the arrival-date anchor from a customer field value (Date or ISO string). */
|
|
23
|
+
function parseAnchor(raw) {
|
|
24
|
+
if (raw instanceof Date)
|
|
25
|
+
return raw;
|
|
26
|
+
if (typeof raw === 'string' && !Number.isNaN(Date.parse(raw)))
|
|
27
|
+
return new Date(raw);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the per-call pricing context: the editable participant-pricing config
|
|
32
|
+
* (from the client resolver, e.g. globalSettings), the arrival-date anchor from
|
|
33
|
+
* the customer, and the identity field schema (to locate the dob key).
|
|
34
|
+
*/
|
|
35
|
+
async function buildPricingContext(config, customer, tripRefForSchema) {
|
|
36
|
+
const participantPricing = config.participantPricing
|
|
37
|
+
? await config.participantPricing()
|
|
38
|
+
: { ageBands: [], ladder: [], earlyBird: null };
|
|
39
|
+
const anchor = parseAnchor(customer[config.arrivalDateField]);
|
|
40
|
+
const identityFields = tripRefForSchema ? await resolveItemSchema(tripRefForSchema) : [];
|
|
41
|
+
return { participantPricing, anchor, identityFields };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Create a booking (public self-serve or staff). Validates catalog capacity +
|
|
45
|
+
* the client seam, prices the draft per-participant, and persists booking +
|
|
46
|
+
* people + items + assignments + reminders in one transaction. Status starts
|
|
47
|
+
* `draft`; the deposit payment (and its webhook) drives the status forward.
|
|
48
|
+
*/
|
|
49
|
+
export async function createBooking(input, opts = {}) {
|
|
50
|
+
const config = requireBookingConfig();
|
|
51
|
+
const db = getBookingDb();
|
|
52
|
+
const now = opts.now ?? new Date();
|
|
53
|
+
const validationItems = input.items.map((it) => ({
|
|
54
|
+
tripRef: it.tripRef,
|
|
55
|
+
departureDate: it.departureDate,
|
|
56
|
+
participantsCount: it.assignments.length
|
|
57
|
+
}));
|
|
58
|
+
await runCapacityCheck(validationItems);
|
|
59
|
+
await runValidateBooking({
|
|
60
|
+
operation: 'create',
|
|
61
|
+
bookingId: null,
|
|
62
|
+
items: validationItems,
|
|
63
|
+
customer: input.customer
|
|
64
|
+
});
|
|
65
|
+
const ctx = await buildPricingContext(config, input.customer, input.items[0]?.tripRef);
|
|
66
|
+
const draft = buildBookingDraft(input, config, ctx, now);
|
|
67
|
+
// Unique booking number with collision retry (~25 bits → rare).
|
|
68
|
+
let number = generateBookingNumber();
|
|
69
|
+
for (let i = 0; i < 5; i++) {
|
|
70
|
+
const hit = await db
|
|
71
|
+
.select({ id: bookingsTable.id })
|
|
72
|
+
.from(bookingsTable)
|
|
73
|
+
.where(eq(bookingsTable.number, number));
|
|
74
|
+
if (hit.length === 0)
|
|
75
|
+
break;
|
|
76
|
+
number = generateBookingNumber();
|
|
77
|
+
}
|
|
78
|
+
return db.transaction(async (tx) => {
|
|
79
|
+
const [booking] = await tx
|
|
80
|
+
.insert(bookingsTable)
|
|
81
|
+
.values({
|
|
82
|
+
number,
|
|
83
|
+
status: 'draft',
|
|
84
|
+
currency: config.currency,
|
|
85
|
+
customer: input.customer,
|
|
86
|
+
totals: draft.totals,
|
|
87
|
+
balanceDueAt: draft.balanceDueAt,
|
|
88
|
+
holdExpiresAt: draft.holdExpiresAt,
|
|
89
|
+
language: input.language ?? null
|
|
90
|
+
})
|
|
91
|
+
.returning();
|
|
92
|
+
const personIdByRef = new Map();
|
|
93
|
+
for (const p of draft.persons) {
|
|
94
|
+
const [person] = await tx
|
|
95
|
+
.insert(bookingPersonsTable)
|
|
96
|
+
.values({ bookingId: booking.id, data: p.identity })
|
|
97
|
+
.returning();
|
|
98
|
+
personIdByRef.set(p.ref, person.id);
|
|
99
|
+
}
|
|
100
|
+
for (const it of draft.items) {
|
|
101
|
+
const [item] = await tx
|
|
102
|
+
.insert(bookingItemsTable)
|
|
103
|
+
.values({
|
|
104
|
+
bookingId: booking.id,
|
|
105
|
+
tripRef: it.tripRef,
|
|
106
|
+
departureDate: it.departureDate,
|
|
107
|
+
participantsCount: it.participantsCount,
|
|
108
|
+
unitPriceSnapshot: it.unitPriceSnapshot,
|
|
109
|
+
subtotalSnapshot: it.subtotalSnapshot,
|
|
110
|
+
appliedDiscounts: it.appliedDiscounts
|
|
111
|
+
})
|
|
112
|
+
.returning();
|
|
113
|
+
if (it.assignments.length > 0) {
|
|
114
|
+
await tx.insert(bookingItemPersonsTable).values(it.assignments.map((a) => ({
|
|
115
|
+
itemId: item.id,
|
|
116
|
+
personId: personIdLookup(personIdByRef, a.personRef),
|
|
117
|
+
data: a.data ?? {}
|
|
118
|
+
})));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (draft.reminders.length > 0) {
|
|
122
|
+
await tx.insert(bookingRemindersTable).values(draft.reminders.map((r) => ({
|
|
123
|
+
bookingId: booking.id,
|
|
124
|
+
type: r.type,
|
|
125
|
+
templateKey: r.templateKey,
|
|
126
|
+
scheduledFor: r.scheduledFor
|
|
127
|
+
})));
|
|
128
|
+
}
|
|
129
|
+
return { booking, accessToken: booking.accessToken };
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Edit a booking (staff or portal). Recomputes totals per-participant + derived
|
|
134
|
+
* status, enforces the actor's guards (`portal` blocks dropping below paid),
|
|
135
|
+
* re-runs capacity + validate seam when items change, replaces people/items/
|
|
136
|
+
* assignments, and reschedules reminders on a departure change. One transaction.
|
|
137
|
+
*/
|
|
138
|
+
export async function editBooking(bookingId, changes, opts) {
|
|
139
|
+
const config = requireBookingConfig();
|
|
140
|
+
const db = getBookingDb();
|
|
141
|
+
const now = opts.now ?? new Date();
|
|
142
|
+
return db.transaction(async (tx) => {
|
|
143
|
+
const [booking] = await tx
|
|
144
|
+
.select()
|
|
145
|
+
.from(bookingsTable)
|
|
146
|
+
.where(eq(bookingsTable.id, bookingId))
|
|
147
|
+
.for('update');
|
|
148
|
+
if (!booking)
|
|
149
|
+
throw new Error(`Booking ${bookingId} not found`);
|
|
150
|
+
const existingItems = await tx
|
|
151
|
+
.select()
|
|
152
|
+
.from(bookingItemsTable)
|
|
153
|
+
.where(eq(bookingItemsTable.bookingId, bookingId));
|
|
154
|
+
const customerForCtx = changes.customer ?? booking.customer;
|
|
155
|
+
const tripRefForSchema = changes.items?.[0]?.tripRef ?? existingItems[0]?.tripRef;
|
|
156
|
+
const ctx = await buildPricingContext(config, customerForCtx, tripRefForSchema);
|
|
157
|
+
// Pricing inputs: from the edit when items change, else from existing rows.
|
|
158
|
+
let participants;
|
|
159
|
+
let trips;
|
|
160
|
+
if (changes.items) {
|
|
161
|
+
const pi = pricingInputsFromCreate({ customer: customerForCtx, persons: changes.persons ?? [], items: changes.items }, ctx.identityFields);
|
|
162
|
+
participants = pi.participants;
|
|
163
|
+
trips = pi.trips;
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
const existingItemIds = existingItems.map((i) => i.id);
|
|
167
|
+
const persons = await tx
|
|
168
|
+
.select()
|
|
169
|
+
.from(bookingPersonsTable)
|
|
170
|
+
.where(eq(bookingPersonsTable.bookingId, bookingId));
|
|
171
|
+
const assignments = existingItemIds.length
|
|
172
|
+
? await tx
|
|
173
|
+
.select()
|
|
174
|
+
.from(bookingItemPersonsTable)
|
|
175
|
+
.where(inArray(bookingItemPersonsTable.itemId, existingItemIds))
|
|
176
|
+
: [];
|
|
177
|
+
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);
|
|
178
|
+
participants = pi.participants;
|
|
179
|
+
trips = pi.trips;
|
|
180
|
+
}
|
|
181
|
+
const result = priceByParticipant(participants, trips, ctx.anchor, now, ctx.participantPricing);
|
|
182
|
+
const dates = (changes.items ?? existingItems).map((it) => it.departureDate);
|
|
183
|
+
const payments = (await tx
|
|
184
|
+
.select()
|
|
185
|
+
.from(bookingPaymentsTable)
|
|
186
|
+
.where(eq(bookingPaymentsTable.bookingId, bookingId))).map((p) => ({ amount: p.amount, status: p.status, kind: p.kind }));
|
|
187
|
+
const adjustments = await tx
|
|
188
|
+
.select()
|
|
189
|
+
.from(bookingAdjustmentsTable)
|
|
190
|
+
.where(and(eq(bookingAdjustmentsTable.bookingId, bookingId), isNull(bookingAdjustmentsTable.voidedAt)));
|
|
191
|
+
const sums = sumAdjustments(adjustments);
|
|
192
|
+
const totals = recomputeTotals(result, sums, payments);
|
|
193
|
+
if (opts.actor === 'portal')
|
|
194
|
+
assertPortalEditAllowed(booking.status, netOf(totals), payments);
|
|
195
|
+
else
|
|
196
|
+
assertEditAllowed(booking.status);
|
|
197
|
+
if (changes.items) {
|
|
198
|
+
const validationItems = changes.items.map((it) => ({
|
|
199
|
+
tripRef: it.tripRef,
|
|
200
|
+
departureDate: it.departureDate,
|
|
201
|
+
participantsCount: it.assignments.length
|
|
202
|
+
}));
|
|
203
|
+
await runCapacityCheck(validationItems, bookingId);
|
|
204
|
+
await runValidateBooking({
|
|
205
|
+
operation: 'edit',
|
|
206
|
+
bookingId,
|
|
207
|
+
items: validationItems,
|
|
208
|
+
customer: customerForCtx
|
|
209
|
+
});
|
|
210
|
+
const subtotalByRef = grossByTripRef(result);
|
|
211
|
+
// Replace people + items + assignments (item delete cascades assignments).
|
|
212
|
+
await tx.delete(bookingItemsTable).where(eq(bookingItemsTable.bookingId, bookingId));
|
|
213
|
+
await tx.delete(bookingPersonsTable).where(eq(bookingPersonsTable.bookingId, bookingId));
|
|
214
|
+
const personIdByRef = new Map();
|
|
215
|
+
for (const p of changes.persons ?? []) {
|
|
216
|
+
const [person] = await tx
|
|
217
|
+
.insert(bookingPersonsTable)
|
|
218
|
+
.values({ bookingId, data: p.identity })
|
|
219
|
+
.returning();
|
|
220
|
+
personIdByRef.set(p.ref, person.id);
|
|
221
|
+
}
|
|
222
|
+
for (const it of changes.items) {
|
|
223
|
+
const [item] = await tx
|
|
224
|
+
.insert(bookingItemsTable)
|
|
225
|
+
.values({
|
|
226
|
+
bookingId,
|
|
227
|
+
tripRef: it.tripRef,
|
|
228
|
+
departureDate: it.departureDate,
|
|
229
|
+
participantsCount: it.assignments.length,
|
|
230
|
+
unitPriceSnapshot: it.unitPrice,
|
|
231
|
+
subtotalSnapshot: subtotalByRef.get(it.tripRef) ?? null,
|
|
232
|
+
appliedDiscounts: totals.appliedRules.filter((r) => r.targetRef === it.tripRef)
|
|
233
|
+
})
|
|
234
|
+
.returning();
|
|
235
|
+
if (it.assignments.length > 0) {
|
|
236
|
+
await tx.insert(bookingItemPersonsTable).values(it.assignments.map((a) => ({
|
|
237
|
+
itemId: item.id,
|
|
238
|
+
personId: personIdLookup(personIdByRef, a.personRef),
|
|
239
|
+
data: a.data ?? {}
|
|
240
|
+
})));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const status = deriveStatus({
|
|
245
|
+
gross: netOf(totals),
|
|
246
|
+
paid: totals.paid,
|
|
247
|
+
depositRequired: config.depositAmount,
|
|
248
|
+
current: booking.status
|
|
249
|
+
});
|
|
250
|
+
const [updated] = await tx
|
|
251
|
+
.update(bookingsTable)
|
|
252
|
+
.set({ totals, status, customer: customerForCtx, updatedAt: now })
|
|
253
|
+
.where(eq(bookingsTable.id, bookingId))
|
|
254
|
+
.returning();
|
|
255
|
+
if (changes.items)
|
|
256
|
+
await rescheduleReminders(tx, bookingId, dates);
|
|
257
|
+
return updated;
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Price a booking from its CURRENTLY persisted people/items/assignments (no
|
|
262
|
+
* edit). Shared by adjustment recompute — mirrors the "no items change" branch
|
|
263
|
+
* of {@link editBooking}. Must run inside a transaction that has locked the
|
|
264
|
+
* booking row.
|
|
265
|
+
*/
|
|
266
|
+
export async function priceFromExistingRows(tx, bookingId, config, now) {
|
|
267
|
+
const [booking] = await tx.select().from(bookingsTable).where(eq(bookingsTable.id, bookingId));
|
|
268
|
+
if (!booking)
|
|
269
|
+
throw new Error(`Booking ${bookingId} not found`);
|
|
270
|
+
const existingItems = await tx
|
|
271
|
+
.select()
|
|
272
|
+
.from(bookingItemsTable)
|
|
273
|
+
.where(eq(bookingItemsTable.bookingId, bookingId));
|
|
274
|
+
const ctx = await buildPricingContext(config, booking.customer, existingItems[0]?.tripRef);
|
|
275
|
+
const persons = await tx
|
|
276
|
+
.select()
|
|
277
|
+
.from(bookingPersonsTable)
|
|
278
|
+
.where(eq(bookingPersonsTable.bookingId, bookingId));
|
|
279
|
+
const itemIds = existingItems.map((i) => i.id);
|
|
280
|
+
const assignments = itemIds.length
|
|
281
|
+
? await tx
|
|
282
|
+
.select()
|
|
283
|
+
.from(bookingItemPersonsTable)
|
|
284
|
+
.where(inArray(bookingItemPersonsTable.itemId, itemIds))
|
|
285
|
+
: [];
|
|
286
|
+
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);
|
|
288
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read a trip's seat capacity from the catalog collection.
|
|
3
|
+
*
|
|
4
|
+
* Contract: `tripRef` is the catalog **entry id** (`Entry._id`). The client
|
|
5
|
+
* storefront stores the resolved trip's `_id` on each booking item. Returns
|
|
6
|
+
* `null` (treated as unlimited) when no `capacityField` is configured, the entry
|
|
7
|
+
* is missing/unpublished, or the field is absent/non-numeric.
|
|
8
|
+
*/
|
|
9
|
+
export declare function readTripCapacity(tripRef: string): Promise<number | null>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { resolveEntry } from '../../core/server/entries/operations/resolveEntry.js';
|
|
2
|
+
import { requireBookingConfig } from './db.js';
|
|
3
|
+
/**
|
|
4
|
+
* Read a trip's seat capacity from the catalog collection.
|
|
5
|
+
*
|
|
6
|
+
* Contract: `tripRef` is the catalog **entry id** (`Entry._id`). The client
|
|
7
|
+
* storefront stores the resolved trip's `_id` on each booking item. Returns
|
|
8
|
+
* `null` (treated as unlimited) when no `capacityField` is configured, the entry
|
|
9
|
+
* is missing/unpublished, or the field is absent/non-numeric.
|
|
10
|
+
*/
|
|
11
|
+
export async function readTripCapacity(tripRef) {
|
|
12
|
+
const config = requireBookingConfig();
|
|
13
|
+
if (!config.capacityField)
|
|
14
|
+
return null;
|
|
15
|
+
const entry = await resolveEntry({ id: tripRef, status: 'published' });
|
|
16
|
+
if (!entry)
|
|
17
|
+
return null;
|
|
18
|
+
const raw = entry[config.capacityField];
|
|
19
|
+
return typeof raw === 'number' ? raw : null;
|
|
20
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ResolvedBookingConfig } from '../index.js';
|
|
2
|
+
/** Returns the Drizzle handle, asserting the postgres adapter is in use. */
|
|
3
|
+
export declare function getBookingDb(): import("drizzle-orm/postgres-js/driver", { with: { "resolution-mode": "require" } }).PostgresJsDatabase<typeof import("../../db-postgres/schema/index.js")> & {
|
|
4
|
+
$client: import("postgres").Sql<{}>;
|
|
5
|
+
};
|
|
6
|
+
/** Returns the resolved booking config, throwing when the module is not registered. */
|
|
7
|
+
export declare function requireBookingConfig(): ResolvedBookingConfig;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { getCMS } from '../../core/cms.js';
|
|
2
|
+
/** Returns the Drizzle handle, asserting the postgres adapter is in use. */
|
|
3
|
+
export function getBookingDb() {
|
|
4
|
+
const cms = getCMS();
|
|
5
|
+
const adapter = cms.databaseAdapter;
|
|
6
|
+
if (!adapter._drizzle) {
|
|
7
|
+
throw new Error('Booking requires the db-postgres adapter (Drizzle instance missing).');
|
|
8
|
+
}
|
|
9
|
+
return adapter._drizzle;
|
|
10
|
+
}
|
|
11
|
+
/** Returns the resolved booking config, throwing when the module is not registered. */
|
|
12
|
+
export function requireBookingConfig() {
|
|
13
|
+
const cfg = getCMS().getModuleConfig('booking');
|
|
14
|
+
if (!cfg) {
|
|
15
|
+
throw new Error('Booking is not configured. Add `defineBooking({...})` to `modules` in your CMS config.');
|
|
16
|
+
}
|
|
17
|
+
return cfg;
|
|
18
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** The transactional emails the booking module can send. */
|
|
2
|
+
export type BookingEmailKind = 'booking-received' | 'deposit-paid' | 'payment-received' | 'payment-link' | 'balance-due' | 'balance-overdue-cancelled';
|
|
3
|
+
/** Template file name for a kind (1:1 — kept as a function for future indirection). */
|
|
4
|
+
export declare function pickEmailTemplate(kind: BookingEmailKind): string;
|
|
5
|
+
/** Localised subject for a kind, falling back to English. */
|
|
6
|
+
export declare function subjectFor(kind: BookingEmailKind, lang: string): string;
|
|
7
|
+
/** Render a booking email's HTML body for the given language + context. */
|
|
8
|
+
export declare function renderBookingEmail(kind: BookingEmailKind, lang: string, context: Record<string, unknown>): Promise<string>;
|
|
9
|
+
/** Pure render-context builder for a booking email. @internal */
|
|
10
|
+
export declare function buildEmailContext(booking: {
|
|
11
|
+
accessToken: string;
|
|
12
|
+
customer: Record<string, unknown>;
|
|
13
|
+
} & Record<string, unknown>, config: {
|
|
14
|
+
currency: string;
|
|
15
|
+
brand: string | null;
|
|
16
|
+
portalUrlTemplate: string | null;
|
|
17
|
+
}, now: Date, extra?: Record<string, unknown>): Record<string, unknown>;
|
|
18
|
+
/**
|
|
19
|
+
* Send a transactional booking email to the booker. No-op when no email adapter
|
|
20
|
+
* is configured, the booking is missing, or it has no customer email. `extra` is
|
|
21
|
+
* merged into the render context (e.g. `{ amount, paymentLink }` for payment-link).
|
|
22
|
+
*/
|
|
23
|
+
export declare function sendBookingEmail(bookingId: string, kind: BookingEmailKind, extra?: Record<string, unknown>): Promise<void>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm';
|
|
2
|
+
import { getCMS } from '../../core/cms.js';
|
|
3
|
+
import { getBookingDb, requireBookingConfig } from './db.js';
|
|
4
|
+
import { bookingsTable } from '../../db-postgres/schema/booking/index.js';
|
|
5
|
+
import { renderEmailTemplate } from './emailTemplateRegistry.js';
|
|
6
|
+
/** Template file name for a kind (1:1 — kept as a function for future indirection). */
|
|
7
|
+
export function pickEmailTemplate(kind) {
|
|
8
|
+
return kind;
|
|
9
|
+
}
|
|
10
|
+
const SUBJECTS = {
|
|
11
|
+
'booking-received': { pl: 'Rezerwacja przyjęta', en: 'Booking received' },
|
|
12
|
+
'deposit-paid': { pl: 'Zadatek zaksięgowany', en: 'Deposit received' },
|
|
13
|
+
'payment-received': { pl: 'Płatność zaksięgowana', en: 'Payment received' },
|
|
14
|
+
'payment-link': { pl: 'Link do płatności', en: 'Payment link' },
|
|
15
|
+
'balance-due': { pl: 'Przypomnienie o dopłacie', en: 'Balance reminder' },
|
|
16
|
+
'balance-overdue-cancelled': { pl: 'Rezerwacja anulowana', en: 'Booking cancelled' }
|
|
17
|
+
};
|
|
18
|
+
/** Localised subject for a kind, falling back to English. */
|
|
19
|
+
export function subjectFor(kind, lang) {
|
|
20
|
+
return SUBJECTS[kind][lang] ?? SUBJECTS[kind].en;
|
|
21
|
+
}
|
|
22
|
+
/** Render a booking email's HTML body for the given language + context. */
|
|
23
|
+
export async function renderBookingEmail(kind, lang, context) {
|
|
24
|
+
return renderEmailTemplate(pickEmailTemplate(kind), lang, context);
|
|
25
|
+
}
|
|
26
|
+
/** Pure render-context builder for a booking email. @internal */
|
|
27
|
+
export function buildEmailContext(booking, config, now, extra = {}) {
|
|
28
|
+
const portalUrl = config.portalUrlTemplate
|
|
29
|
+
? config.portalUrlTemplate.replace('{token}', booking.accessToken)
|
|
30
|
+
: undefined;
|
|
31
|
+
return { booking, currency: config.currency, brand: config.brand, portalUrl, now, ...extra };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Send a transactional booking email to the booker. No-op when no email adapter
|
|
35
|
+
* is configured, the booking is missing, or it has no customer email. `extra` is
|
|
36
|
+
* merged into the render context (e.g. `{ amount, paymentLink }` for payment-link).
|
|
37
|
+
*/
|
|
38
|
+
export async function sendBookingEmail(bookingId, kind, extra = {}) {
|
|
39
|
+
const cms = getCMS();
|
|
40
|
+
const email = cms.emailAdapter;
|
|
41
|
+
if (!email)
|
|
42
|
+
return;
|
|
43
|
+
const config = requireBookingConfig();
|
|
44
|
+
const db = getBookingDb();
|
|
45
|
+
const [booking] = await db.select().from(bookingsTable).where(eq(bookingsTable.id, bookingId));
|
|
46
|
+
if (!booking)
|
|
47
|
+
return;
|
|
48
|
+
const lang = booking.language ?? cms.languages[0] ?? 'pl';
|
|
49
|
+
const to = String(booking.customer.email ?? '');
|
|
50
|
+
if (!to)
|
|
51
|
+
return;
|
|
52
|
+
const context = buildEmailContext(booking, {
|
|
53
|
+
currency: config.currency,
|
|
54
|
+
brand: config.brand,
|
|
55
|
+
portalUrlTemplate: config.portalUrlTemplate
|
|
56
|
+
}, new Date(), extra);
|
|
57
|
+
const html = await renderBookingEmail(kind, lang, context);
|
|
58
|
+
await email.sendMail({ to, subject: subjectFor(kind, lang), html });
|
|
59
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Booking email template registry — file-based Handlebars with project override
|
|
3
|
+
* → package default → fallback-lang resolution. Self-contained (no shop / no
|
|
4
|
+
* CMS-singleton auto-fetch) to keep booking independent of the shop module.
|
|
5
|
+
*
|
|
6
|
+
* @internal — exported for testing; not part of the public API.
|
|
7
|
+
*/
|
|
8
|
+
type Lang = string;
|
|
9
|
+
/** Long pl-PL (or given locale) date; '' for null/invalid. @internal */
|
|
10
|
+
export declare function formatBookingDate(value: unknown, locale?: string): string;
|
|
11
|
+
/** Whole days from `now` to `value`, floored at 0; '' for invalid. @internal */
|
|
12
|
+
export declare function daysUntilDate(value: unknown, now: unknown): string;
|
|
13
|
+
/**
|
|
14
|
+
* Register `header`/`footer` partials for a render. Re-registers on every call
|
|
15
|
+
* (cheap — source is cached in prod) so booking's partials always win over the
|
|
16
|
+
* shop module's identically-named partials right before a booking render.
|
|
17
|
+
* @internal
|
|
18
|
+
*/
|
|
19
|
+
export declare function registerBookingPartials(projectDir: string, lang: Lang, fallbackLang: Lang): void;
|
|
20
|
+
/**
|
|
21
|
+
* Render a booking email template by name + language. Project overrides live in
|
|
22
|
+
* `<projectDir>/<name>.<lang>.html` (default `src/emails/booking`); package
|
|
23
|
+
* defaults ship under `booking/templates`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function renderEmailTemplate(name: string, lang: Lang, context: Record<string, unknown>, opts?: {
|
|
26
|
+
projectDir?: string;
|
|
27
|
+
fallbackLang?: Lang;
|
|
28
|
+
}): string;
|
|
29
|
+
export {};
|