@voyant-travel/trips 0.110.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +38 -0
  3. package/dist/catalog-component-adapter.d.ts +16 -0
  4. package/dist/catalog-component-adapter.d.ts.map +1 -0
  5. package/dist/catalog-component-adapter.js +34 -0
  6. package/dist/cruise-extension.d.ts +48 -0
  7. package/dist/cruise-extension.d.ts.map +1 -0
  8. package/dist/cruise-extension.js +66 -0
  9. package/dist/index.d.ts +24 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +31 -0
  12. package/dist/mcp-contract.d.ts +79 -0
  13. package/dist/mcp-contract.d.ts.map +1 -0
  14. package/dist/mcp-contract.js +21 -0
  15. package/dist/mcp-registry.d.ts +48 -0
  16. package/dist/mcp-registry.d.ts.map +1 -0
  17. package/dist/mcp-registry.js +88 -0
  18. package/dist/mcp-tools.d.ts +157 -0
  19. package/dist/mcp-tools.d.ts.map +1 -0
  20. package/dist/mcp-tools.js +109 -0
  21. package/dist/routes.d.ts +2068 -0
  22. package/dist/routes.d.ts.map +1 -0
  23. package/dist/routes.js +280 -0
  24. package/dist/schema.d.ts +1897 -0
  25. package/dist/schema.d.ts.map +1 -0
  26. package/dist/schema.js +255 -0
  27. package/dist/service-cancellation.d.ts +6 -0
  28. package/dist/service-cancellation.d.ts.map +1 -0
  29. package/dist/service-cancellation.js +251 -0
  30. package/dist/service-checkout.d.ts +6 -0
  31. package/dist/service-checkout.d.ts.map +1 -0
  32. package/dist/service-checkout.js +328 -0
  33. package/dist/service-helpers.d.ts +17 -0
  34. package/dist/service-helpers.d.ts.map +1 -0
  35. package/dist/service-helpers.js +161 -0
  36. package/dist/service-internals.d.ts +11 -0
  37. package/dist/service-internals.d.ts.map +1 -0
  38. package/dist/service-internals.js +72 -0
  39. package/dist/service-pricing.d.ts +8 -0
  40. package/dist/service-pricing.d.ts.map +1 -0
  41. package/dist/service-pricing.js +142 -0
  42. package/dist/service-reservation.d.ts +5 -0
  43. package/dist/service-reservation.d.ts.map +1 -0
  44. package/dist/service-reservation.js +493 -0
  45. package/dist/service-snapshots.d.ts +8 -0
  46. package/dist/service-snapshots.d.ts.map +1 -0
  47. package/dist/service-snapshots.js +115 -0
  48. package/dist/service-trips.d.ts +14 -0
  49. package/dist/service-trips.d.ts.map +1 -0
  50. package/dist/service-trips.js +378 -0
  51. package/dist/service-types.d.ts +285 -0
  52. package/dist/service-types.d.ts.map +1 -0
  53. package/dist/service-types.js +6 -0
  54. package/dist/service.d.ts +38 -0
  55. package/dist/service.d.ts.map +1 -0
  56. package/dist/service.js +40 -0
  57. package/dist/traveler-party-validation.d.ts +3 -0
  58. package/dist/traveler-party-validation.d.ts.map +1 -0
  59. package/dist/traveler-party-validation.js +68 -0
  60. package/dist/validation.d.ts +449 -0
  61. package/dist/validation.d.ts.map +1 -0
  62. package/dist/validation.js +261 -0
  63. package/package.json +83 -0
@@ -0,0 +1,142 @@
1
+ import { eq } from "drizzle-orm";
2
+ import { isCatalogBackedTripComponent, toBookingDraftV1 } from "./catalog-component-adapter.js";
3
+ import { tripComponents, tripEnvelopes } from "./schema.js";
4
+ import { aggregateComponentPricing, assertTripComponentCanBeUpdated, pricingSnapshotFromBreakdown, taxLinesFromBreakdown, } from "./service-helpers.js";
5
+ import { createComponentEvent, minComponentPriceExpiry } from "./service-internals.js";
6
+ import { getTrip } from "./service-trips.js";
7
+ import { TripsInvariantError } from "./service-types.js";
8
+ export async function priceTrip(db, input, deps) {
9
+ const trip = await getTrip(db, input.envelopeId);
10
+ if (!trip) {
11
+ throw new TripsInvariantError(`Trip envelope ${input.envelopeId} was not found`);
12
+ }
13
+ const warnings = new Set();
14
+ const failures = [];
15
+ const pricedComponents = [];
16
+ for (const component of trip.components) {
17
+ if (component.status === "removed" || component.status === "cancelled") {
18
+ pricedComponents.push(component);
19
+ continue;
20
+ }
21
+ if (isCatalogBackedTripComponent(component)) {
22
+ const quote = await deps.quoteCatalogComponent({
23
+ component,
24
+ bookingDraft: toBookingDraftV1(component, deps.componentBookingDraftOverrides?.[component.id]),
25
+ scope: input.scope,
26
+ ttlMs: input.ttlMs,
27
+ });
28
+ const updated = await applyQuoteToComponent(db, component, quote);
29
+ pricedComponents.push(updated);
30
+ if (!quote.available || !quote.pricing) {
31
+ const reason = quote.invalidReason ?? "quote_unavailable";
32
+ warnings.add(reason);
33
+ failures.push({ componentId: component.id, reason });
34
+ }
35
+ continue;
36
+ }
37
+ const updated = await applyPlaceholderPricing(db, component);
38
+ pricedComponents.push(updated);
39
+ for (const warning of updated.warningCodes)
40
+ warnings.add(warning);
41
+ if (!updated.pricingSnapshot) {
42
+ failures.push({ componentId: component.id, reason: "manual_price_missing" });
43
+ }
44
+ }
45
+ const aggregate = aggregateComponentPricing(pricedComponents, input.scope.currency);
46
+ for (const warning of aggregate.warnings ?? [])
47
+ warnings.add(warning);
48
+ const pricing = { ...aggregate, warnings: [...warnings] };
49
+ const [envelope] = (await db
50
+ .update(tripEnvelopes)
51
+ .set({
52
+ status: "priced",
53
+ aggregateCurrency: pricing.currency,
54
+ aggregateSubtotalAmountCents: pricing.subtotalAmountCents,
55
+ aggregateTaxAmountCents: pricing.taxAmountCents,
56
+ aggregateTotalAmountCents: pricing.totalAmountCents,
57
+ aggregatePricingSnapshot: pricing,
58
+ currentPriceExpiresAt: minComponentPriceExpiry(pricedComponents),
59
+ updatedAt: new Date(),
60
+ })
61
+ .where(eq(tripEnvelopes.id, input.envelopeId))
62
+ .returning());
63
+ if (!envelope) {
64
+ throw new Error("priceTrip: envelope update returned no rows");
65
+ }
66
+ return {
67
+ envelope,
68
+ components: pricedComponents,
69
+ pricing,
70
+ warnings: [...warnings],
71
+ failures,
72
+ };
73
+ }
74
+ export async function applyQuoteToComponent(db, component, quote) {
75
+ const pricingSnapshot = quote.pricing
76
+ ? pricingSnapshotFromBreakdown(quote.pricing, quote.expiresAt)
77
+ : undefined;
78
+ const warningCodes = quote.invalidReason ? [quote.invalidReason] : [];
79
+ const nextStatus = quote.available && quote.pricing ? "priced" : "unavailable";
80
+ assertTripComponentCanBeUpdated(component, { status: nextStatus });
81
+ const [updated] = (await db
82
+ .update(tripComponents)
83
+ .set({
84
+ status: nextStatus,
85
+ catalogQuoteId: quote.quoteId,
86
+ componentCurrency: pricingSnapshot?.currency ?? null,
87
+ componentSubtotalAmountCents: pricingSnapshot?.subtotalAmountCents ?? null,
88
+ componentTaxAmountCents: pricingSnapshot?.taxAmountCents ?? null,
89
+ componentTotalAmountCents: pricingSnapshot?.totalAmountCents ?? null,
90
+ pricingSnapshot,
91
+ taxLines: quote.pricing ? taxLinesFromBreakdown(quote.pricing) : [],
92
+ priceExpiresAt: quote.expiresAt ? new Date(quote.expiresAt) : null,
93
+ warningCodes,
94
+ updatedAt: new Date(),
95
+ })
96
+ .where(eq(tripComponents.id, component.id))
97
+ .returning());
98
+ if (!updated) {
99
+ throw new Error(`applyQuoteToComponent: update returned no row for ${component.id}`);
100
+ }
101
+ await createComponentEvent(db, {
102
+ envelopeId: updated.envelopeId,
103
+ componentId: updated.id,
104
+ eventType: "priced",
105
+ fromStatus: component.status,
106
+ toStatus: updated.status,
107
+ payload: {
108
+ quoteId: quote.quoteId,
109
+ available: quote.available,
110
+ invalidReason: quote.invalidReason,
111
+ },
112
+ });
113
+ return updated;
114
+ }
115
+ async function applyPlaceholderPricing(db, component) {
116
+ const warningCodes = component.pricingSnapshot
117
+ ? ["manual_placeholder_price"]
118
+ : ["manual_price_missing"];
119
+ const nextStatus = component.pricingSnapshot ? "priced" : "unavailable";
120
+ assertTripComponentCanBeUpdated(component, { status: nextStatus });
121
+ const [updated] = (await db
122
+ .update(tripComponents)
123
+ .set({
124
+ status: nextStatus,
125
+ warningCodes,
126
+ updatedAt: new Date(),
127
+ })
128
+ .where(eq(tripComponents.id, component.id))
129
+ .returning());
130
+ if (!updated) {
131
+ throw new Error(`applyPlaceholderPricing: update returned no row for ${component.id}`);
132
+ }
133
+ await createComponentEvent(db, {
134
+ envelopeId: updated.envelopeId,
135
+ componentId: updated.id,
136
+ eventType: "priced",
137
+ fromStatus: component.status,
138
+ toStatus: updated.status,
139
+ payload: { placeholder: true },
140
+ });
141
+ return updated;
142
+ }
@@ -0,0 +1,5 @@
1
+ import type { AnyDrizzleDb } from "@voyant-travel/db";
2
+ import type { ReserveTripDeps, ReserveTripResult } from "./service-types.js";
3
+ import { type ReserveTripInput } from "./validation.js";
4
+ export declare function reserveTrip(db: AnyDrizzleDb, input: ReserveTripInput, deps: ReserveTripDeps): Promise<ReserveTripResult>;
5
+ //# sourceMappingURL=service-reservation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-reservation.d.ts","sourceRoot":"","sources":["../src/service-reservation.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AA6BrD,OAAO,KAAK,EAEV,eAAe,EACf,iBAAiB,EAGlB,MAAM,oBAAoB,CAAA;AAG3B,OAAO,EAA0C,KAAK,gBAAgB,EAAE,MAAM,iBAAiB,CAAA;AAE/F,wBAAsB,WAAW,CAC/B,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE,gBAAgB,EACvB,IAAI,EAAE,eAAe,GACpB,OAAO,CAAC,iBAAiB,CAAC,CAmK5B"}
@@ -0,0 +1,493 @@
1
+ import { eq } from "drizzle-orm";
2
+ import { isCatalogBackedTripComponent, toBookingDraftV1 } from "./catalog-component-adapter.js";
3
+ import { tripComponents, tripEnvelopes, tripReservationPlans } from "./schema.js";
4
+ import { aggregateComponentPricing, assertTripComponentCanBeReserved, assertTripComponentCanBeUpdated, reserveResultToComponentPatch, shouldReplayReserve, } from "./service-helpers.js";
5
+ import { appendWarningCodes, commonString, createComponentEvent, markComponentForStaffRemediation, minComponentPriceExpiry, statusToEventType, } from "./service-internals.js";
6
+ import { applyQuoteToComponent } from "./service-pricing.js";
7
+ import { getTrip } from "./service-trips.js";
8
+ import { TripsInvariantError } from "./service-types.js";
9
+ import { assertTripTravelerPartyComplete } from "./traveler-party-validation.js";
10
+ import { isAllowedTripComponentStatusTransition } from "./validation.js";
11
+ export async function reserveTrip(db, input, deps) {
12
+ let trip = await getTrip(db, input.envelopeId);
13
+ if (!trip) {
14
+ throw new TripsInvariantError(`Trip envelope ${input.envelopeId} was not found`);
15
+ }
16
+ if (shouldReplayReserve(trip.envelope, input.idempotencyKey)) {
17
+ return {
18
+ envelope: trip.envelope,
19
+ components: trip.components,
20
+ reservationPlanId: null,
21
+ reserved: trip.components
22
+ .filter(isReservedTripComponent)
23
+ .map((component) => ({ componentId: component.id, status: component.status })),
24
+ failures: [],
25
+ compensations: [],
26
+ warnings: ["idempotent_replay"],
27
+ };
28
+ }
29
+ assertTripTravelerPartyComplete(trip.envelope.travelerParty, "Trip reserve");
30
+ const preflight = await refreshComponentsBeforeReserve(db, trip, input, deps);
31
+ if (preflight.failures.length > 0) {
32
+ return {
33
+ envelope: preflight.trip.envelope,
34
+ components: preflight.trip.components,
35
+ reservationPlanId: null,
36
+ reserved: [],
37
+ failures: preflight.failures,
38
+ compensations: [],
39
+ warnings: preflight.warnings,
40
+ };
41
+ }
42
+ trip = preflight.trip;
43
+ const planComponents = prepareReservationPlanComponents(trip.components);
44
+ const reservationPlan = await createReservationPlan(db, trip, input);
45
+ await db
46
+ .update(tripEnvelopes)
47
+ .set({
48
+ status: "reserve_in_progress",
49
+ reserveIdempotencyKey: input.idempotencyKey ?? null,
50
+ reserveStartedAt: new Date(),
51
+ updatedAt: new Date(),
52
+ })
53
+ .where(eq(tripEnvelopes.id, input.envelopeId));
54
+ const warnings = new Set(preflight.warnings);
55
+ const componentsById = new Map(trip.components.map((component) => [component.id, component]));
56
+ if (planComponents.failures.length > 0) {
57
+ const failedResult = await failReservationPlanBeforeSubmission(db, input.envelopeId, reservationPlan.id, planComponents.failures, warnings, componentsById);
58
+ return { ...failedResult, reservationPlanId: reservationPlan.id };
59
+ }
60
+ const submitted = await deps.submitReservationPlan({
61
+ reservationPlan,
62
+ envelope: trip.envelope,
63
+ components: planComponents.components,
64
+ idempotencyKey: input.idempotencyKey ?? null,
65
+ });
66
+ for (const warning of submitted.warnings)
67
+ warnings.add(warning);
68
+ for (const item of submitted.reserved) {
69
+ const component = componentsById.get(item.componentId);
70
+ if (!component)
71
+ continue;
72
+ const updated = await applyReserveResultToComponent(db, component, item.result);
73
+ componentsById.set(updated.id, updated);
74
+ }
75
+ for (const failure of submitted.failures) {
76
+ const component = componentsById.get(failure.componentId);
77
+ if (!component)
78
+ continue;
79
+ const failed = await applyReservationFailureToComponent(db, component, failure.reason);
80
+ componentsById.set(failed.id, failed);
81
+ }
82
+ await applyReservationPlanCompensations(db, componentsById, submitted.compensations);
83
+ if (submitted.status === "failed" || submitted.failures.length > 0) {
84
+ await updateReservationPlanResult(db, reservationPlan.id, {
85
+ status: "failed",
86
+ failures: submitted.failures,
87
+ compensations: submitted.compensations,
88
+ warnings: [...warnings],
89
+ });
90
+ const [failedEnvelope] = (await db
91
+ .update(tripEnvelopes)
92
+ .set({
93
+ status: "failed",
94
+ updatedAt: new Date(),
95
+ })
96
+ .where(eq(tripEnvelopes.id, input.envelopeId))
97
+ .returning());
98
+ if (!failedEnvelope) {
99
+ throw new Error("reserveTrip: failed envelope update returned no rows");
100
+ }
101
+ const refreshed = await getTrip(db, input.envelopeId);
102
+ return {
103
+ envelope: failedEnvelope,
104
+ components: refreshed?.components ?? [...componentsById.values()],
105
+ reservationPlanId: reservationPlan.id,
106
+ reserved: submitted.reserved.map((item) => ({
107
+ componentId: item.componentId,
108
+ status: item.status,
109
+ })),
110
+ failures: submitted.failures,
111
+ compensations: submitted.compensations,
112
+ warnings: [...warnings],
113
+ };
114
+ }
115
+ await updateReservationPlanResult(db, reservationPlan.id, {
116
+ status: "reserved",
117
+ failures: [],
118
+ compensations: [],
119
+ warnings: [...warnings],
120
+ });
121
+ const refs = commonEnvelopeRefs(submitted.reserved.map(({ result }) => result));
122
+ const [envelope] = (await db
123
+ .update(tripEnvelopes)
124
+ .set({
125
+ status: "reserved",
126
+ ...refs,
127
+ reservedAt: new Date(),
128
+ updatedAt: new Date(),
129
+ })
130
+ .where(eq(tripEnvelopes.id, input.envelopeId))
131
+ .returning());
132
+ if (!envelope) {
133
+ throw new Error("reserveTrip: envelope update returned no rows");
134
+ }
135
+ const refreshed = await getTrip(db, input.envelopeId);
136
+ return {
137
+ envelope,
138
+ components: refreshed?.components ?? [...componentsById.values()],
139
+ reservationPlanId: reservationPlan.id,
140
+ reserved: submitted.reserved.map((item) => ({
141
+ componentId: item.componentId,
142
+ status: item.status,
143
+ })),
144
+ failures: [],
145
+ compensations: [],
146
+ warnings: [...warnings],
147
+ };
148
+ }
149
+ function prepareReservationPlanComponents(components) {
150
+ const prepared = [];
151
+ const failures = [];
152
+ for (const component of activeReservationComponents(components)) {
153
+ if (isCatalogBackedTripComponent(component)) {
154
+ try {
155
+ assertTripComponentCanBeReserved(component);
156
+ }
157
+ catch (error) {
158
+ failures.push({
159
+ componentId: component.id,
160
+ reason: error instanceof Error ? error.message : "reservation_not_allowed",
161
+ });
162
+ continue;
163
+ }
164
+ }
165
+ prepared.push({
166
+ componentId: component.id,
167
+ reservationKind: isCatalogBackedTripComponent(component) ? "catalog_backed" : "non_catalog",
168
+ component,
169
+ });
170
+ }
171
+ return { components: prepared, failures };
172
+ }
173
+ async function createReservationPlan(db, trip, input) {
174
+ const componentSnapshots = activeReservationComponents(trip.components).map(reservationPlanComponentSnapshot);
175
+ const now = new Date();
176
+ const [plan] = (await db
177
+ .insert(tripReservationPlans)
178
+ .values({
179
+ envelopeId: trip.envelope.id,
180
+ status: "submitted",
181
+ idempotencyKey: input.idempotencyKey ?? null,
182
+ refreshScope: input.refreshScope ?? null,
183
+ componentCount: componentSnapshots.length,
184
+ components: componentSnapshots,
185
+ failures: [],
186
+ compensations: [],
187
+ warnings: [],
188
+ submittedAt: now,
189
+ updatedAt: now,
190
+ })
191
+ .returning());
192
+ if (!plan) {
193
+ throw new Error("createReservationPlan: insert returned no row");
194
+ }
195
+ return plan;
196
+ }
197
+ function activeReservationComponents(components) {
198
+ return components.filter((component) => component.status !== "removed" && component.status !== "cancelled");
199
+ }
200
+ function reservationPlanComponentSnapshot(component) {
201
+ return {
202
+ componentId: component.id,
203
+ sequence: component.sequence,
204
+ kind: component.kind,
205
+ status: component.status,
206
+ catalogBacked: isCatalogBackedTripComponent(component),
207
+ entityModule: component.entityModule,
208
+ entityId: component.entityId,
209
+ sourceKind: component.sourceKind,
210
+ sourceConnectionId: component.sourceConnectionId,
211
+ sourceRef: component.sourceRef,
212
+ bookingDraftId: component.bookingDraftId,
213
+ catalogQuoteId: component.catalogQuoteId,
214
+ currency: component.componentCurrency,
215
+ totalAmountCents: component.componentTotalAmountCents,
216
+ priceExpiresAt: component.priceExpiresAt?.toISOString() ?? null,
217
+ warningCodes: component.warningCodes,
218
+ };
219
+ }
220
+ async function failReservationPlanBeforeSubmission(db, envelopeId, reservationPlanId, failures, warnings, componentsById) {
221
+ for (const failure of failures) {
222
+ warnings.add(failure.reason);
223
+ const component = componentsById.get(failure.componentId);
224
+ if (!component)
225
+ continue;
226
+ const failed = await applyReservationFailureToComponent(db, component, failure.reason);
227
+ componentsById.set(failed.id, failed);
228
+ }
229
+ await updateReservationPlanResult(db, reservationPlanId, {
230
+ status: "failed",
231
+ failures,
232
+ compensations: [],
233
+ warnings: [...warnings],
234
+ });
235
+ const [failedEnvelope] = (await db
236
+ .update(tripEnvelopes)
237
+ .set({ status: "failed", updatedAt: new Date() })
238
+ .where(eq(tripEnvelopes.id, envelopeId))
239
+ .returning());
240
+ if (!failedEnvelope) {
241
+ throw new Error("reserveTrip: failed envelope update returned no rows");
242
+ }
243
+ const refreshed = await getTrip(db, envelopeId);
244
+ return {
245
+ envelope: failedEnvelope,
246
+ components: refreshed?.components ?? [...componentsById.values()],
247
+ reserved: [],
248
+ failures,
249
+ compensations: [],
250
+ warnings: [...warnings],
251
+ };
252
+ }
253
+ async function updateReservationPlanResult(db, reservationPlanId, result) {
254
+ await db
255
+ .update(tripReservationPlans)
256
+ .set({
257
+ status: result.status,
258
+ failures: result.failures,
259
+ compensations: result.compensations,
260
+ warnings: result.warnings,
261
+ completedAt: new Date(),
262
+ updatedAt: new Date(),
263
+ })
264
+ .where(eq(tripReservationPlans.id, reservationPlanId));
265
+ }
266
+ async function applyReservationPlanCompensations(db, componentsById, compensations) {
267
+ for (const compensation of compensations) {
268
+ const component = componentsById.get(compensation.componentId);
269
+ if (!component)
270
+ continue;
271
+ if (compensation.status === "released") {
272
+ if (!isReservedTripComponent(component))
273
+ continue;
274
+ const released = await markReservedComponentReleased(db, component);
275
+ componentsById.set(released.id, released);
276
+ continue;
277
+ }
278
+ const updated = await markComponentForStaffRemediation(db, component, compensation.reason ?? compensation.status);
279
+ componentsById.set(updated.id, updated);
280
+ }
281
+ }
282
+ async function refreshComponentsBeforeReserve(db, trip, input, deps) {
283
+ const warnings = new Set();
284
+ const failures = [];
285
+ let changed = false;
286
+ const componentsById = new Map(trip.components.map((component) => [component.id, component]));
287
+ for (const component of trip.components) {
288
+ if (component.status === "removed" || component.status === "cancelled")
289
+ continue;
290
+ if (isCatalogBackedTripComponent(component)) {
291
+ if (!deps.quoteCatalogComponentBeforeReserve)
292
+ continue;
293
+ const quote = await deps.quoteCatalogComponentBeforeReserve({
294
+ component,
295
+ bookingDraft: toBookingDraftV1(component),
296
+ scope: input.refreshScope ?? defaultReserveRefreshScope(trip.envelope),
297
+ });
298
+ const updated = await applyQuoteToComponent(db, component, quote);
299
+ componentsById.set(updated.id, updated);
300
+ changed = true;
301
+ if (!quote.available || !quote.pricing) {
302
+ const reason = quote.invalidReason ?? "quote_unavailable";
303
+ warnings.add(reason);
304
+ failures.push({
305
+ componentId: component.id,
306
+ reason,
307
+ code: "unavailable",
308
+ details: { quoteId: quote.quoteId },
309
+ });
310
+ continue;
311
+ }
312
+ if (componentPricingChanged(component, updated)) {
313
+ warnings.add("price_changed");
314
+ failures.push({
315
+ componentId: component.id,
316
+ reason: "price_changed",
317
+ code: "price_changed",
318
+ details: pricingChangeDetails(component, updated),
319
+ });
320
+ }
321
+ continue;
322
+ }
323
+ const result = await deps.validateNonCatalogComponentBeforeReserve?.({
324
+ envelope: trip.envelope,
325
+ component,
326
+ });
327
+ if (!result || result.status === "ok") {
328
+ for (const warning of result?.warnings ?? [])
329
+ warnings.add(warning);
330
+ continue;
331
+ }
332
+ const reason = result.reason ?? result.status;
333
+ warnings.add(reason);
334
+ failures.push({
335
+ componentId: component.id,
336
+ reason,
337
+ code: result.status,
338
+ details: result.details,
339
+ });
340
+ }
341
+ if (changed || failures.length > 0) {
342
+ const components = [...componentsById.values()];
343
+ const aggregate = aggregateComponentPricing(components, input.refreshScope?.currency ?? trip.envelope.aggregateCurrency ?? undefined);
344
+ const [envelope] = (await db
345
+ .update(tripEnvelopes)
346
+ .set({
347
+ status: "priced",
348
+ aggregateCurrency: aggregate.currency,
349
+ aggregateSubtotalAmountCents: aggregate.subtotalAmountCents,
350
+ aggregateTaxAmountCents: aggregate.taxAmountCents,
351
+ aggregateTotalAmountCents: aggregate.totalAmountCents,
352
+ aggregatePricingSnapshot: aggregate,
353
+ currentPriceExpiresAt: minComponentPriceExpiry(components),
354
+ updatedAt: new Date(),
355
+ })
356
+ .where(eq(tripEnvelopes.id, trip.envelope.id))
357
+ .returning());
358
+ const refreshed = await getTrip(db, trip.envelope.id);
359
+ return {
360
+ trip: refreshed ?? { envelope: envelope ?? trip.envelope, components },
361
+ failures,
362
+ warnings: [...warnings, ...(aggregate.warnings ?? [])],
363
+ };
364
+ }
365
+ return { trip, failures, warnings: [...warnings] };
366
+ }
367
+ function defaultReserveRefreshScope(envelope) {
368
+ return {
369
+ locale: "en-US",
370
+ audience: "staff",
371
+ market: "default",
372
+ currency: envelope.aggregateCurrency ?? undefined,
373
+ };
374
+ }
375
+ function componentPricingChanged(before, after) {
376
+ return (before.componentCurrency !== after.componentCurrency ||
377
+ before.componentSubtotalAmountCents !== after.componentSubtotalAmountCents ||
378
+ before.componentTaxAmountCents !== after.componentTaxAmountCents ||
379
+ before.componentTotalAmountCents !== after.componentTotalAmountCents);
380
+ }
381
+ function pricingChangeDetails(before, after) {
382
+ return {
383
+ previous: {
384
+ currency: before.componentCurrency,
385
+ subtotalAmountCents: before.componentSubtotalAmountCents,
386
+ taxAmountCents: before.componentTaxAmountCents,
387
+ totalAmountCents: before.componentTotalAmountCents,
388
+ },
389
+ current: {
390
+ currency: after.componentCurrency,
391
+ subtotalAmountCents: after.componentSubtotalAmountCents,
392
+ taxAmountCents: after.componentTaxAmountCents,
393
+ totalAmountCents: after.componentTotalAmountCents,
394
+ },
395
+ };
396
+ }
397
+ async function applyReserveResultToComponent(db, component, result) {
398
+ assertTripComponentCanBeUpdated(component, { status: result.status });
399
+ const [updated] = (await db
400
+ .update(tripComponents)
401
+ .set({
402
+ ...reserveResultToComponentPatch(result),
403
+ warningCodes: appendWarningCodes(component.warningCodes, result.warnings ?? []),
404
+ updatedAt: new Date(),
405
+ })
406
+ .where(eq(tripComponents.id, component.id))
407
+ .returning());
408
+ if (!updated) {
409
+ throw new Error(`applyReserveResultToComponent: update returned no row for ${component.id}`);
410
+ }
411
+ await createComponentEvent(db, {
412
+ envelopeId: updated.envelopeId,
413
+ componentId: updated.id,
414
+ eventType: statusToEventType(updated.status),
415
+ fromStatus: component.status,
416
+ toStatus: updated.status,
417
+ payload: {
418
+ bookingId: updated.bookingId,
419
+ bookingGroupId: updated.bookingGroupId,
420
+ orderId: updated.orderId,
421
+ paymentSessionId: updated.paymentSessionId,
422
+ providerRef: updated.providerRef,
423
+ supplierRef: updated.supplierRef,
424
+ holdExpiresAt: updated.holdExpiresAt?.toISOString(),
425
+ },
426
+ });
427
+ return updated;
428
+ }
429
+ async function applyReservationFailureToComponent(db, component, reason) {
430
+ if (!isAllowedTripComponentStatusTransition(component.status, "failed")) {
431
+ return markComponentForStaffRemediation(db, component, reason);
432
+ }
433
+ const [updated] = (await db
434
+ .update(tripComponents)
435
+ .set({
436
+ status: "failed",
437
+ warningCodes: appendWarningCodes(component.warningCodes, [reason]),
438
+ updatedAt: new Date(),
439
+ })
440
+ .where(eq(tripComponents.id, component.id))
441
+ .returning());
442
+ if (!updated) {
443
+ throw new Error(`applyReservationFailureToComponent: update returned no row for ${component.id}`);
444
+ }
445
+ await createComponentEvent(db, {
446
+ envelopeId: updated.envelopeId,
447
+ componentId: updated.id,
448
+ eventType: "failed",
449
+ fromStatus: component.status,
450
+ toStatus: updated.status,
451
+ payload: { reason },
452
+ });
453
+ return updated;
454
+ }
455
+ async function markReservedComponentReleased(db, component) {
456
+ const [updated] = (await db
457
+ .update(tripComponents)
458
+ .set({
459
+ status: "cancelled",
460
+ warningCodes: appendWarningCodes(component.warningCodes, ["reservation_released"]),
461
+ updatedAt: new Date(),
462
+ })
463
+ .where(eq(tripComponents.id, component.id))
464
+ .returning());
465
+ if (!updated) {
466
+ throw new Error(`markReservedComponentReleased: update returned no row for ${component.id}`);
467
+ }
468
+ await createComponentEvent(db, {
469
+ envelopeId: updated.envelopeId,
470
+ componentId: updated.id,
471
+ eventType: "cancelled",
472
+ fromStatus: component.status,
473
+ toStatus: updated.status,
474
+ payload: { reason: "reservation_released" },
475
+ });
476
+ return updated;
477
+ }
478
+ function isReservedTripComponent(component) {
479
+ return component.status === "held" || component.status === "booked";
480
+ }
481
+ function commonEnvelopeRefs(results) {
482
+ const refs = {};
483
+ const bookingGroupId = commonString(results.map((result) => result.bookingGroupId));
484
+ const orderId = commonString(results.map((result) => result.orderId));
485
+ const paymentSessionId = commonString(results.map((result) => result.paymentSessionId));
486
+ if (bookingGroupId)
487
+ refs.bookingGroupId = bookingGroupId;
488
+ if (orderId)
489
+ refs.orderId = orderId;
490
+ if (paymentSessionId)
491
+ refs.paymentSessionId = paymentSessionId;
492
+ return refs;
493
+ }
@@ -0,0 +1,8 @@
1
+ import type { AnyDrizzleDb } from "@voyant-travel/db";
2
+ import type { TripComponent, TripEnvelope, TripSnapshot, TripSnapshotProposal } from "./schema.js";
3
+ import type { CreateTripSnapshotInput } from "./validation.js";
4
+ export declare function freezeTripSnapshot(db: AnyDrizzleDb, input: CreateTripSnapshotInput): Promise<TripSnapshot>;
5
+ export declare function getTripSnapshotById(db: AnyDrizzleDb, snapshotId: string): Promise<TripSnapshot | null>;
6
+ export declare function listTripSnapshots(db: AnyDrizzleDb, envelopeId: string): Promise<TripSnapshot[]>;
7
+ export declare function buildTripSnapshotProposal(envelope: TripEnvelope, components: TripComponent[], frozenAt: Date): TripSnapshotProposal;
8
+ //# sourceMappingURL=service-snapshots.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-snapshots.d.ts","sourceRoot":"","sources":["../src/service-snapshots.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAGrD,OAAO,KAAK,EAEV,aAAa,EACb,YAAY,EACZ,YAAY,EACZ,oBAAoB,EAErB,MAAM,aAAa,CAAA;AAKpB,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,iBAAiB,CAAA;AAE9D,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE,uBAAuB,GAC7B,OAAO,CAAC,YAAY,CAAC,CAwCvB;AAED,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,YAAY,EAChB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAO9B;AAED,wBAAsB,iBAAiB,CACrC,EAAE,EAAE,YAAY,EAChB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,YAAY,EAAE,CAAC,CAMzB;AAED,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,YAAY,EACtB,UAAU,EAAE,aAAa,EAAE,EAC3B,QAAQ,EAAE,IAAI,GACb,oBAAoB,CAgBtB"}