@voyantjs/storefront 0.47.0 → 0.50.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.
@@ -1 +1 @@
1
- {"version":3,"file":"routes-public.d.ts","sourceRoot":"","sources":["../src/routes-public.ts"],"names":[],"mappings":"AAIA,OAAO,EAGL,KAAK,wBAAwB,EAC9B,MAAM,cAAc,CAAA;AAYrB,KAAK,GAAG,GAAG;IACT,SAAS,EAAE;QACT,EAAE,EAAE,OAAO,CAAA;KACZ,CAAA;CACF,CAAA;AAED,wBAAgB,4BAA4B,CAAC,OAAO,CAAC,EAAE,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0BAyI9E;AAED,MAAM,MAAM,sBAAsB,GAAG,UAAU,CAAC,OAAO,4BAA4B,CAAC,CAAA"}
1
+ {"version":3,"file":"routes-public.d.ts","sourceRoot":"","sources":["../src/routes-public.ts"],"names":[],"mappings":"AAKA,OAAO,EAIL,KAAK,cAAc,EACnB,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAA;AAIvB,OAAO,EAGL,KAAK,wBAAwB,EAC9B,MAAM,cAAc,CAAA;AAiBrB,KAAK,GAAG,GAAG;IACT,QAAQ,EAAE,cAAc,CAAA;IACxB,SAAS,EAAE;QACT,MAAM,CAAC,EAAE,MAAM,CAAA;KAChB,GAAG,eAAe,CAAA;CACpB,CAAA;AA+CD,wBAAgB,4BAA4B,CAAC,OAAO,CAAC,EAAE,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0BAuP9E;AAED,MAAM,MAAM,sBAAsB,GAAG,UAAU,CAAC,OAAO,4BAA4B,CAAC,CAAA"}
@@ -1,20 +1,90 @@
1
- import { parseJsonBody, parseQuery } from "@voyantjs/hono";
1
+ import { checkoutCapabilityActions, checkoutCapabilityCookie, issueCheckoutCapability, } from "@voyantjs/bookings/checkout-capability";
2
+ import { idempotencyKey, parseJsonBody, parseQuery, } from "@voyantjs/hono";
2
3
  import { Hono } from "hono";
3
4
  import { createStorefrontService, } from "./service.js";
4
- import { storefrontDepartureListQuerySchema, storefrontDeparturePricePreviewInputSchema, storefrontOfferApplyInputSchema, storefrontOfferRedeemInputSchema, storefrontProductAvailabilitySummaryQuerySchema, storefrontProductExtensionsQuerySchema, storefrontPromotionalOfferListQuerySchema, } from "./validation.js";
5
+ import { storefrontBookingSessionBootstrapInputSchema, storefrontDepartureListQuerySchema, storefrontDeparturePricePreviewInputSchema, storefrontLeadIntakeInputSchema, storefrontNewsletterSubscribeInputSchema, storefrontOfferApplyInputSchema, storefrontOfferRedeemInputSchema, storefrontProductAvailabilitySummaryQuerySchema, storefrontProductExtensionsQuerySchema, storefrontPromotionalOfferListQuerySchema, } from "./validation.js";
5
6
  import { storefrontTransportEligibilityInputSchema } from "./validation-transport-eligibility.js";
7
+ function getRuntimeEnv(c) {
8
+ const processEnv = globalThis.process?.env ?? {};
9
+ return {
10
+ ...processEnv,
11
+ ...(c.env ?? {}),
12
+ };
13
+ }
14
+ function sessionConflictError(status) {
15
+ switch (status) {
16
+ case "insufficient_capacity":
17
+ return "Insufficient slot capacity";
18
+ case "slot_unavailable":
19
+ return "Availability slot is not bookable";
20
+ case "pricing_unavailable":
21
+ return "Pricing is not available for the selected booking session items";
22
+ case "stale_quote":
23
+ return "Booking session quote is stale";
24
+ case "invalid_slot":
25
+ return "Booking session slot does not match the requested departure";
26
+ default:
27
+ return "Unable to bootstrap booking session";
28
+ }
29
+ }
30
+ function attachCheckoutCapability(session, issued) {
31
+ return {
32
+ ...session,
33
+ checkoutCapability: {
34
+ token: issued.token,
35
+ expiresAt: issued.expiresAt.toISOString(),
36
+ actions: [...checkoutCapabilityActions],
37
+ },
38
+ };
39
+ }
6
40
  export function createStorefrontPublicRoutes(options) {
7
41
  const storefrontService = createStorefrontService(options);
8
42
  function getRequestContext(c) {
9
43
  return {
10
44
  db: c.get("db"),
45
+ eventBus: c.get("eventBus"),
11
46
  env: c.env,
12
47
  context: c,
13
48
  };
14
49
  }
50
+ async function runIntakeGuard(input) {
51
+ const decision = await storefrontService.checkIntakeGuard(input);
52
+ if (!decision || decision.allowed)
53
+ return null;
54
+ return {
55
+ status: decision.status ?? 403,
56
+ error: decision.error ?? "Storefront intake rejected",
57
+ };
58
+ }
15
59
  return new Hono()
16
60
  .get("/settings", async (c) => {
17
61
  return c.json({ data: await storefrontService.resolveSettings(getRequestContext(c)) });
62
+ })
63
+ .post("/leads", async (c) => {
64
+ const context = getRequestContext(c);
65
+ const body = await parseJsonBody(c, storefrontLeadIntakeInputSchema);
66
+ const rejected = await runIntakeGuard({ kind: "lead", body, context });
67
+ if (rejected)
68
+ return c.json({ error: rejected.error }, rejected.status);
69
+ return c.json({
70
+ data: await storefrontService.createLead({
71
+ body,
72
+ context,
73
+ }),
74
+ }, 201);
75
+ })
76
+ .post("/newsletter/subscribe", async (c) => {
77
+ const context = getRequestContext(c);
78
+ const body = await parseJsonBody(c, storefrontNewsletterSubscribeInputSchema);
79
+ const rejected = await runIntakeGuard({ kind: "newsletter", body, context });
80
+ if (rejected)
81
+ return c.json({ error: rejected.error }, rejected.status);
82
+ return c.json({
83
+ data: await storefrontService.subscribeNewsletter({
84
+ body,
85
+ context,
86
+ }),
87
+ }, 202);
18
88
  })
19
89
  .get("/departures/:departureId", async (c) => {
20
90
  const departure = await storefrontService.getDeparture(c.get("db"), c.req.param("departureId"));
@@ -26,10 +96,41 @@ export function createStorefrontPublicRoutes(options) {
26
96
  return c.json(await storefrontService.listProductDepartures(c.get("db"), c.req.param("productId"), await parseQuery(c, storefrontDepartureListQuerySchema)));
27
97
  })
28
98
  .post("/departures/:departureId/price", async (c) => {
29
- const preview = await storefrontService.previewDeparturePrice(c.get("db"), c.req.param("departureId"), await parseJsonBody(c, storefrontDeparturePricePreviewInputSchema));
99
+ const preview = await storefrontService.previewDeparturePrice(c.get("db"), c.req.param("departureId"), await parseJsonBody(c, storefrontDeparturePricePreviewInputSchema), getRequestContext(c));
30
100
  return preview
31
101
  ? c.json({ data: preview })
32
102
  : c.json({ error: "Storefront departure not found" }, 404);
103
+ })
104
+ .post("/bookings/sessions/bootstrap", idempotencyKey({ scope: "POST /v1/public/bookings/sessions/bootstrap" }), async (c) => {
105
+ const result = await storefrontService.bootstrapBookingSession(getRequestContext(c), await parseJsonBody(c, storefrontBookingSessionBootstrapInputSchema), c.get("userId"));
106
+ if (result.status === "departure_not_found") {
107
+ return c.json({ error: "Storefront departure not found" }, 404);
108
+ }
109
+ if (result.status === "slot_not_found") {
110
+ return c.json({ error: "Availability slot not found" }, 404);
111
+ }
112
+ if (result.status !== "ok") {
113
+ return c.json({
114
+ error: sessionConflictError(result.status),
115
+ ...(result.status === "stale_quote" && "repricing" in result
116
+ ? { data: { repricing: result.repricing } }
117
+ : {}),
118
+ }, result.status === "invalid_slot" ? 400 : 409);
119
+ }
120
+ if (!("bootstrap" in result)) {
121
+ return c.json({ error: "Unable to bootstrap booking session" }, 409);
122
+ }
123
+ const { bootstrap } = result;
124
+ const capability = await issueCheckoutCapability(bootstrap.session.sessionId, getRuntimeEnv(c));
125
+ c.header("Set-Cookie", checkoutCapabilityCookie(capability.token, capability.expiresAt), {
126
+ append: true,
127
+ });
128
+ return c.json({
129
+ data: {
130
+ ...bootstrap,
131
+ session: attachCheckoutCapability(bootstrap.session, capability),
132
+ },
133
+ }, 201);
33
134
  })
34
135
  .post("/departures/:departureId/eligibility", async (c) => {
35
136
  return c.json({
@@ -0,0 +1,227 @@
1
+ import { type PaymentPolicy } from "@voyantjs/finance";
2
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
3
+ import type { StorefrontBookingSessionBootstrapInput } from "./validation.js";
4
+ export interface StorefrontBootstrapRequestContext {
5
+ db: PostgresJsDatabase;
6
+ env?: unknown;
7
+ context?: unknown;
8
+ }
9
+ export interface StorefrontBookingSessionBootstrapOptions {
10
+ paymentPolicy?: PaymentPolicy;
11
+ resolvePaymentPolicy?: (input: StorefrontBookingSessionBootstrapInput & StorefrontBootstrapRequestContext) => Promise<PaymentPolicy | null | undefined> | PaymentPolicy | null | undefined;
12
+ today?: Date;
13
+ }
14
+ export declare function bootstrapStorefrontBookingSession(context: StorefrontBootstrapRequestContext, input: StorefrontBookingSessionBootstrapInput, options: StorefrontBookingSessionBootstrapOptions | undefined, userId?: string): Promise<{
15
+ status: Exclude<string, "ok">;
16
+ } | {
17
+ status: "stale_quote";
18
+ repricing: {
19
+ originalQuote: {
20
+ currencyCode: string;
21
+ totalSellAmountCents: number;
22
+ quotedAt?: string | null | undefined;
23
+ expiresAt?: string | null | undefined;
24
+ };
25
+ current: {
26
+ sessionId: string;
27
+ catalogId: string | null;
28
+ currencyCode: string;
29
+ totalSellAmountCents: number;
30
+ items: {
31
+ inputIndex: number;
32
+ itemId: string;
33
+ title: string;
34
+ productId: string | null;
35
+ optionId: string | null;
36
+ optionUnitId: string | null;
37
+ optionUnitName: string | null;
38
+ optionUnitType: string | null;
39
+ pricingCategoryId: string | null;
40
+ quantity: number;
41
+ pricingMode: string;
42
+ unitSellAmountCents: number | null;
43
+ totalSellAmountCents: number | null;
44
+ warnings: string[];
45
+ }[];
46
+ warnings: never[];
47
+ appliedToSession: boolean;
48
+ };
49
+ deltaAmountCents: number;
50
+ staleQuote: boolean;
51
+ };
52
+ bootstrap?: undefined;
53
+ } | {
54
+ status: "ok";
55
+ bootstrap: {
56
+ session: {
57
+ sessionId: string;
58
+ bookingNumber: string;
59
+ status: "completed" | "cancelled" | "draft" | "on_hold" | "awaiting_payment" | "confirmed" | "in_progress" | "expired";
60
+ externalBookingRef: string | null;
61
+ communicationLanguage: string | null;
62
+ sellCurrency: string;
63
+ sellAmountCents: number | null;
64
+ startDate: string | null;
65
+ endDate: string | null;
66
+ pax: number | null;
67
+ holdExpiresAt: string | null;
68
+ confirmedAt: string | null;
69
+ expiredAt: string | null;
70
+ cancelledAt: string | null;
71
+ completedAt: string | null;
72
+ travelers: {
73
+ id: string;
74
+ participantType: "other" | "traveler" | "occupant";
75
+ travelerCategory: "child" | "other" | "adult" | "infant" | "senior" | null;
76
+ firstName: string;
77
+ lastName: string;
78
+ email: string | null;
79
+ phone: string | null;
80
+ preferredLanguage: string | null;
81
+ specialRequests: string | null;
82
+ isPrimary: boolean;
83
+ notes: string | null;
84
+ }[];
85
+ items: {
86
+ id: string;
87
+ title: string;
88
+ description: string | null;
89
+ itemType: "service" | "other" | "unit" | "extra" | "fee" | "tax" | "discount" | "adjustment" | "accommodation" | "transport";
90
+ status: "cancelled" | "draft" | "on_hold" | "confirmed" | "expired" | "fulfilled";
91
+ serviceDate: string | null;
92
+ startsAt: string | null;
93
+ endsAt: string | null;
94
+ quantity: number;
95
+ sellCurrency: string;
96
+ unitSellAmountCents: number | null;
97
+ totalSellAmountCents: number | null;
98
+ costCurrency: string | null;
99
+ unitCostAmountCents: number | null;
100
+ totalCostAmountCents: number | null;
101
+ notes: string | null;
102
+ productId: string | null;
103
+ optionId: string | null;
104
+ optionUnitId: string | null;
105
+ pricingCategoryId: string | null;
106
+ travelerLinks: {
107
+ id: string;
108
+ travelerId: string;
109
+ role: string;
110
+ isPrimary: boolean;
111
+ }[];
112
+ }[];
113
+ allocations: {
114
+ id: string;
115
+ bookingItemId: string;
116
+ productId: string | null;
117
+ optionId: string | null;
118
+ optionUnitId: string | null;
119
+ pricingCategoryId: string | null;
120
+ availabilitySlotId: string | null;
121
+ quantity: number;
122
+ allocationType: "resource" | "pickup" | "unit";
123
+ status: "cancelled" | "confirmed" | "expired" | "fulfilled" | "held" | "released";
124
+ holdExpiresAt: string | null;
125
+ confirmedAt: string | null;
126
+ releasedAt: string | null;
127
+ }[];
128
+ checklist: {
129
+ hasTravelers: boolean;
130
+ hasPrimaryTraveler: boolean;
131
+ hasItems: boolean;
132
+ hasAllocations: boolean;
133
+ readyForConfirmation: boolean;
134
+ };
135
+ state: {
136
+ sessionId: string;
137
+ stateKey: "wizard";
138
+ currentStep: string | null;
139
+ completedSteps: string[];
140
+ payload: Record<string, unknown>;
141
+ version: number;
142
+ createdAt: string;
143
+ updatedAt: string;
144
+ } | null;
145
+ };
146
+ paymentPlan: {
147
+ source: "storefront_default";
148
+ depositKind: import("@voyantjs/finance").DepositKind;
149
+ depositPercent: number | null;
150
+ depositAmountCents: number | null;
151
+ requiresFullPayment: boolean;
152
+ };
153
+ paymentSchedule: {
154
+ id: string;
155
+ scheduleType: "other" | "deposit" | "installment" | "balance" | "hold";
156
+ status: "pending" | "cancelled" | "expired" | "paid" | "due" | "waived";
157
+ dueDate: string;
158
+ currency: string;
159
+ amountCents: number;
160
+ notes: string | null;
161
+ }[];
162
+ repricing: {
163
+ originalQuote: {
164
+ currencyCode: string;
165
+ totalSellAmountCents: number;
166
+ quotedAt?: string | null | undefined;
167
+ expiresAt?: string | null | undefined;
168
+ };
169
+ current: {
170
+ sessionId: string;
171
+ items: {
172
+ itemId: string;
173
+ title: string;
174
+ productId: string | null;
175
+ optionId: string | null;
176
+ optionUnitId: string | null;
177
+ optionUnitName: string | null;
178
+ optionUnitType: string | null;
179
+ pricingCategoryId: string | null;
180
+ quantity: number;
181
+ pricingMode: string;
182
+ unitSellAmountCents: number | null;
183
+ totalSellAmountCents: number | null;
184
+ warnings: string[];
185
+ }[];
186
+ catalogId: string | null;
187
+ currencyCode: string;
188
+ totalSellAmountCents: number;
189
+ warnings: never[];
190
+ appliedToSession: boolean;
191
+ };
192
+ deltaAmountCents: number;
193
+ staleQuote: boolean;
194
+ };
195
+ availability: {
196
+ departureId: string;
197
+ slotId: string;
198
+ productId: string;
199
+ optionId: string | null;
200
+ dateLocal: string;
201
+ startsAt: string | null;
202
+ endsAt: string | null;
203
+ timezone: string;
204
+ status: "cancelled" | "open" | "closed" | "sold_out";
205
+ capacity: number | null;
206
+ remaining: number | null;
207
+ };
208
+ allocation: {
209
+ id: string;
210
+ bookingItemId: string;
211
+ productId: string | null;
212
+ optionId: string | null;
213
+ optionUnitId: string | null;
214
+ pricingCategoryId: string | null;
215
+ availabilitySlotId: string | null;
216
+ quantity: number;
217
+ allocationType: "resource" | "pickup" | "unit";
218
+ status: "cancelled" | "confirmed" | "expired" | "fulfilled" | "held" | "released";
219
+ holdExpiresAt: string | null;
220
+ confirmedAt: string | null;
221
+ releasedAt: string | null;
222
+ }[];
223
+ currency: string;
224
+ };
225
+ repricing?: undefined;
226
+ }>;
227
+ //# sourceMappingURL=service-booking-session-bootstrap.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-booking-session-bootstrap.d.ts","sourceRoot":"","sources":["../src/service-booking-session-bootstrap.ts"],"names":[],"mappings":"AAEA,OAAO,EAIL,KAAK,aAAa,EACnB,MAAM,mBAAmB,CAAA;AAE1B,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAEjE,OAAO,KAAK,EAAE,sCAAsC,EAAE,MAAM,iBAAiB,CAAA;AAE7E,MAAM,WAAW,iCAAiC;IAChD,EAAE,EAAE,kBAAkB,CAAA;IACtB,GAAG,CAAC,EAAE,OAAO,CAAA;IACb,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,WAAW,wCAAwC;IACvD,aAAa,CAAC,EAAE,aAAa,CAAA;IAC7B,oBAAoB,CAAC,EAAE,CACrB,KAAK,EAAE,sCAAsC,GAAG,iCAAiC,KAC9E,OAAO,CAAC,aAAa,GAAG,IAAI,GAAG,SAAS,CAAC,GAAG,aAAa,GAAG,IAAI,GAAG,SAAS,CAAA;IACjF,KAAK,CAAC,EAAE,IAAI,CAAA;CACb;AA0PD,wBAAsB,iCAAiC,CACrD,OAAO,EAAE,iCAAiC,EAC1C,KAAK,EAAE,sCAAsC,EAC7C,OAAO,EAAE,wCAAwC,GAAG,SAAS,EAC7D,MAAM,CAAC,EAAE,MAAM;;;;;;;;;;;;;;;;;4BA/JD,MAAM;wBACV,MAAM;uBACP,MAAM;2BACF,MAAM,GAAG,IAAI;0BACd,MAAM,GAAG,IAAI;8BACT,MAAM,GAAG,IAAI;gCACX,MAAM,GAAG,IAAI;gCACb,MAAM,GAAG,IAAI;mCACV,MAAM,GAAG,IAAI;0BACtB,MAAM;6BACH,MAAM;qCACE,MAAM,GAAG,IAAI;sCACZ,MAAM,GAAG,IAAI;0BACzB,MAAM,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2BAXX,MAAM;+BACF,MAAM,GAAG,IAAI;8BACd,MAAM,GAAG,IAAI;kCACT,MAAM,GAAG,IAAI;oCACX,MAAM,GAAG,IAAI;oCACb,MAAM,GAAG,IAAI;uCACV,MAAM,GAAG,IAAI;8BACtB,MAAM;iCACH,MAAM;yCACE,MAAM,GAAG,IAAI;0CACZ,MAAM,GAAG,IAAI;8BACzB,MAAM,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuTrB"}
@@ -0,0 +1,297 @@
1
+ import { availabilitySlots } from "@voyantjs/availability/schema";
2
+ import { publicBookingsService, resolveSessionPricingSnapshot } from "@voyantjs/bookings";
3
+ import { computePaymentSchedule, financeService, noDepositPolicy, } from "@voyantjs/finance";
4
+ import { eq } from "drizzle-orm";
5
+ function normalizeDate(value) {
6
+ if (!value) {
7
+ return null;
8
+ }
9
+ return value instanceof Date ? value.toISOString().slice(0, 10) : value;
10
+ }
11
+ function normalizeDateTime(value) {
12
+ if (!value) {
13
+ return null;
14
+ }
15
+ return value instanceof Date ? value.toISOString() : value;
16
+ }
17
+ function isExpired(value, now) {
18
+ if (!value) {
19
+ return false;
20
+ }
21
+ const expiresAt = new Date(value);
22
+ return Number.isNaN(expiresAt.getTime()) || expiresAt.getTime() <= now.getTime();
23
+ }
24
+ function resolveTierAmount(tiers, quantity, fallbackAmount) {
25
+ const tier = tiers.find((candidate) => quantity >= candidate.minQuantity &&
26
+ (candidate.maxQuantity === null || quantity <= candidate.maxQuantity));
27
+ return tier?.sellAmountCents ?? fallbackAmount;
28
+ }
29
+ function computeLineTotal(pricingMode, unitSellAmountCents, quantity, fallbackAmount) {
30
+ switch (pricingMode) {
31
+ case "free":
32
+ case "included":
33
+ return 0;
34
+ case "on_request":
35
+ return null;
36
+ case "per_unit":
37
+ case "per_person":
38
+ return unitSellAmountCents === null ? null : unitSellAmountCents * quantity;
39
+ default:
40
+ return unitSellAmountCents ?? fallbackAmount;
41
+ }
42
+ }
43
+ function serializePaymentPlan(policy, requiresFullPayment) {
44
+ return {
45
+ source: "storefront_default",
46
+ depositKind: policy.deposit.kind,
47
+ depositPercent: policy.deposit.kind === "percent" ? (policy.deposit.percent ?? 0) : null,
48
+ depositAmountCents: policy.deposit.kind === "fixed_cents" ? (policy.deposit.amountCents ?? 0) : null,
49
+ requiresFullPayment,
50
+ };
51
+ }
52
+ async function resolveAvailabilitySlot(db, slotId) {
53
+ const [slot] = await db
54
+ .select()
55
+ .from(availabilitySlots)
56
+ .where(eq(availabilitySlots.id, slotId))
57
+ .limit(1);
58
+ return slot ?? null;
59
+ }
60
+ async function previewBootstrapPricing(db, input, slot) {
61
+ const pricedItems = [];
62
+ let resolvedCatalogId = input.catalogId ?? null;
63
+ let resolvedCurrency = input.session.sellCurrency;
64
+ for (const [index, item] of input.session.items.entries()) {
65
+ const productId = item.productId ?? slot.productId;
66
+ const optionId = item.optionId ?? slot.optionId ?? undefined;
67
+ if (!productId) {
68
+ return { status: "pricing_unavailable" };
69
+ }
70
+ const snapshot = await resolveSessionPricingSnapshot(db, productId, {
71
+ catalogId: input.catalogId,
72
+ optionId,
73
+ });
74
+ if (!snapshot) {
75
+ return { status: "pricing_unavailable" };
76
+ }
77
+ resolvedCatalogId = snapshot.catalog.id;
78
+ resolvedCurrency = snapshot.catalog.currencyCode ?? input.session.sellCurrency;
79
+ const option = snapshot.options.find((candidate) => candidate.id === optionId) ?? snapshot.options[0] ?? null;
80
+ if (!option) {
81
+ return { status: "pricing_unavailable" };
82
+ }
83
+ const rule = snapshot.rules.find((candidate) => candidate.optionId === option.id && candidate.isDefault) ??
84
+ snapshot.rules.find((candidate) => candidate.optionId === option.id) ??
85
+ null;
86
+ if (!rule) {
87
+ return { status: "pricing_unavailable" };
88
+ }
89
+ const selectedUnitId = item.optionUnitId ?? null;
90
+ const pricingCategoryId = item.pricingCategoryId ?? null;
91
+ const ruleUnitPrices = snapshot.unitPrices.filter((candidate) => candidate.optionPriceRuleId === rule.id);
92
+ const unitPriceCandidates = ruleUnitPrices.filter((candidate) => {
93
+ if (selectedUnitId && candidate.unitId !== selectedUnitId) {
94
+ return false;
95
+ }
96
+ if (pricingCategoryId && candidate.pricingCategoryId !== pricingCategoryId) {
97
+ return false;
98
+ }
99
+ if (candidate.minQuantity !== null && item.quantity < candidate.minQuantity) {
100
+ return false;
101
+ }
102
+ if (candidate.maxQuantity !== null && item.quantity > candidate.maxQuantity) {
103
+ return false;
104
+ }
105
+ return true;
106
+ });
107
+ const fallbackUnitPrice = !pricingCategoryId && !selectedUnitId
108
+ ? (ruleUnitPrices.find((candidate) => candidate.pricingCategoryId === null &&
109
+ (candidate.minQuantity === null || item.quantity >= candidate.minQuantity) &&
110
+ (candidate.maxQuantity === null || item.quantity <= candidate.maxQuantity)) ?? null)
111
+ : null;
112
+ const unitPrice = unitPriceCandidates[0] ?? fallbackUnitPrice;
113
+ if ((selectedUnitId || ruleUnitPrices.length > 0) &&
114
+ !unitPrice &&
115
+ rule.pricingMode !== "per_booking") {
116
+ return { status: "pricing_unavailable" };
117
+ }
118
+ const unitSellAmountCents = unitPrice
119
+ ? resolveTierAmount(unitPrice.tiers, item.quantity, unitPrice.sellAmountCents)
120
+ : rule.baseSellAmountCents;
121
+ const pricingMode = unitPrice?.pricingMode ?? rule.pricingMode;
122
+ const totalSellAmountCents = computeLineTotal(pricingMode, unitSellAmountCents, item.quantity, rule.baseSellAmountCents);
123
+ if (totalSellAmountCents === null) {
124
+ return { status: "pricing_unavailable" };
125
+ }
126
+ pricedItems.push({
127
+ inputIndex: index,
128
+ itemId: `input:${index}`,
129
+ title: item.title,
130
+ productId,
131
+ optionId: option.id,
132
+ optionUnitId: selectedUnitId,
133
+ optionUnitName: unitPrice?.unitName ?? null,
134
+ optionUnitType: unitPrice?.unitType ?? null,
135
+ pricingCategoryId,
136
+ quantity: item.quantity,
137
+ pricingMode,
138
+ unitSellAmountCents,
139
+ totalSellAmountCents,
140
+ warnings: [],
141
+ });
142
+ }
143
+ return {
144
+ status: "ok",
145
+ pricing: {
146
+ sessionId: "pending",
147
+ catalogId: resolvedCatalogId,
148
+ currencyCode: resolvedCurrency,
149
+ totalSellAmountCents: pricedItems.reduce((total, item) => total + (item.totalSellAmountCents ?? 0), 0),
150
+ items: pricedItems,
151
+ warnings: [],
152
+ appliedToSession: true,
153
+ },
154
+ };
155
+ }
156
+ async function resolveBootstrapPaymentPolicy(input, context, options) {
157
+ return ((await options?.resolvePaymentPolicy?.({
158
+ ...input,
159
+ ...context,
160
+ })) ??
161
+ options?.paymentPolicy ??
162
+ noDepositPolicy);
163
+ }
164
+ export async function bootstrapStorefrontBookingSession(context, input, options, userId) {
165
+ const now = options?.today ?? new Date();
166
+ if (isExpired(input.quote.expiresAt, now)) {
167
+ return { status: "stale_quote" };
168
+ }
169
+ const [departure, slot] = await Promise.all([
170
+ resolveAvailabilitySlot(context.db, input.departureId),
171
+ resolveAvailabilitySlot(context.db, input.slotId),
172
+ ]);
173
+ if (!departure) {
174
+ return { status: "departure_not_found" };
175
+ }
176
+ if (!slot) {
177
+ return { status: "slot_not_found" };
178
+ }
179
+ if (departure.id !== slot.id) {
180
+ return { status: "invalid_slot" };
181
+ }
182
+ const mismatchedItem = input.session.items.find((item) => item.availabilitySlotId !== input.slotId ||
183
+ (item.productId !== undefined &&
184
+ item.productId !== null &&
185
+ item.productId !== slot.productId) ||
186
+ (slot.optionId !== null &&
187
+ item.optionId !== undefined &&
188
+ item.optionId !== null &&
189
+ item.optionId !== slot.optionId));
190
+ if (mismatchedItem) {
191
+ return { status: "invalid_slot" };
192
+ }
193
+ const preview = await previewBootstrapPricing(context.db, input, {
194
+ productId: slot.productId,
195
+ optionId: slot.optionId ?? null,
196
+ });
197
+ if (preview.status !== "ok") {
198
+ return preview;
199
+ }
200
+ if (preview.pricing.currencyCode !== input.quote.currencyCode ||
201
+ preview.pricing.totalSellAmountCents !== input.quote.totalSellAmountCents) {
202
+ return {
203
+ status: "stale_quote",
204
+ repricing: {
205
+ originalQuote: input.quote,
206
+ current: preview.pricing,
207
+ deltaAmountCents: preview.pricing.totalSellAmountCents - input.quote.totalSellAmountCents,
208
+ staleQuote: true,
209
+ },
210
+ };
211
+ }
212
+ const createResult = await publicBookingsService.createSession(context.db, {
213
+ ...input.session,
214
+ sellCurrency: preview.pricing.currencyCode,
215
+ sellAmountCents: preview.pricing.totalSellAmountCents,
216
+ items: input.session.items.map((item, index) => {
217
+ const pricedItem = preview.pricing.items[index];
218
+ return {
219
+ ...item,
220
+ sellCurrency: preview.pricing.currencyCode,
221
+ productId: pricedItem?.productId ?? item.productId,
222
+ optionId: pricedItem?.optionId ?? item.optionId,
223
+ optionUnitId: pricedItem?.optionUnitId ?? item.optionUnitId,
224
+ pricingCategoryId: pricedItem?.pricingCategoryId ?? item.pricingCategoryId,
225
+ unitSellAmountCents: pricedItem?.unitSellAmountCents ?? item.unitSellAmountCents,
226
+ totalSellAmountCents: pricedItem?.totalSellAmountCents ?? item.totalSellAmountCents,
227
+ };
228
+ }),
229
+ }, userId);
230
+ if (createResult.status !== "ok") {
231
+ return createResult;
232
+ }
233
+ if (!("session" in createResult)) {
234
+ return { status: "not_found" };
235
+ }
236
+ const createdSession = createResult.session;
237
+ const policy = await resolveBootstrapPaymentPolicy(input, context, options);
238
+ const computedSchedule = computePaymentSchedule({
239
+ totalCents: createdSession.sellAmountCents ?? preview.pricing.totalSellAmountCents,
240
+ currency: createdSession.sellCurrency,
241
+ departureDate: slot.dateLocal ?? normalizeDate(slot.startsAt),
242
+ today: now,
243
+ }, policy);
244
+ const persistedSchedule = (await financeService.applyComputedPaymentSchedule(context.db, createdSession.sessionId, computedSchedule)) ?? [];
245
+ const [slotAfter] = await context.db
246
+ .select()
247
+ .from(availabilitySlots)
248
+ .where(eq(availabilitySlots.id, input.slotId))
249
+ .limit(1);
250
+ const availability = slotAfter ?? slot;
251
+ const currentItems = preview.pricing.items.map(({ inputIndex: _inputIndex, ...item }, index) => ({
252
+ ...item,
253
+ itemId: createdSession.items[index]?.id ?? item.itemId,
254
+ }));
255
+ const current = {
256
+ ...preview.pricing,
257
+ sessionId: createdSession.sessionId,
258
+ items: currentItems,
259
+ };
260
+ return {
261
+ status: "ok",
262
+ bootstrap: {
263
+ session: createdSession,
264
+ paymentPlan: serializePaymentPlan(policy, computedSchedule.length === 1 && computedSchedule[0]?.scheduleType === "full"),
265
+ paymentSchedule: persistedSchedule.map((schedule) => ({
266
+ id: schedule.id,
267
+ scheduleType: schedule.scheduleType,
268
+ status: schedule.status,
269
+ dueDate: normalizeDate(schedule.dueDate),
270
+ currency: schedule.currency,
271
+ amountCents: schedule.amountCents,
272
+ notes: schedule.notes ?? null,
273
+ })),
274
+ repricing: {
275
+ originalQuote: input.quote,
276
+ current,
277
+ deltaAmountCents: 0,
278
+ staleQuote: false,
279
+ },
280
+ availability: {
281
+ departureId: input.departureId,
282
+ slotId: input.slotId,
283
+ productId: availability.productId,
284
+ optionId: availability.optionId ?? null,
285
+ dateLocal: availability.dateLocal ?? null,
286
+ startsAt: normalizeDateTime(availability.startsAt),
287
+ endsAt: normalizeDateTime(availability.endsAt),
288
+ timezone: availability.timezone,
289
+ status: availability.status,
290
+ capacity: availability.initialPax ?? null,
291
+ remaining: availability.unlimited ? null : (availability.remainingPax ?? null),
292
+ },
293
+ allocation: createdSession.allocations,
294
+ currency: createdSession.sellCurrency,
295
+ },
296
+ };
297
+ }