@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,328 @@
1
+ import { eq } from "drizzle-orm";
2
+ import { tripComponents, tripEnvelopes } from "./schema.js";
3
+ import { aggregateComponentPricing, assertTripComponentCanBeUpdated, assertTripComponentCanStartCheckout, checkoutResultToComponentPatch, shouldReplayCheckout, } from "./service-helpers.js";
4
+ import { appendWarningCodes, commonString, createComponentEvent, markComponentForStaffRemediation, minComponentHoldExpiry, statusToEventType, } from "./service-internals.js";
5
+ import { getTrip } from "./service-trips.js";
6
+ import { TripsInvariantError } from "./service-types.js";
7
+ import { assertTripTravelerPartyComplete } from "./traveler-party-validation.js";
8
+ const payableEnvelopeStatuses = new Set(["reserved", "checkout_started", "booked"]);
9
+ const payableComponentStatuses = new Set(["held", "checkout_started", "booked"]);
10
+ export async function startCheckout(db, input, deps) {
11
+ const trip = await getTrip(db, input.envelopeId);
12
+ if (!trip) {
13
+ throw new TripsInvariantError(`Trip envelope ${input.envelopeId} was not found`);
14
+ }
15
+ if (shouldReplayCheckout(trip.envelope, input.idempotencyKey)) {
16
+ return replayCheckoutResult(trip);
17
+ }
18
+ if (trip.envelope.status !== "reserved") {
19
+ throw new TripsInvariantError(`Trip envelope ${input.envelopeId} is ${trip.envelope.status} and must be reserved before checkout`);
20
+ }
21
+ assertTripTravelerPartyComplete(trip.envelope.travelerParty, "Trip checkout");
22
+ const warnings = new Set();
23
+ const failures = [];
24
+ const componentCheckouts = [];
25
+ const componentsById = new Map(trip.components.map((component) => [component.id, component]));
26
+ const checkoutable = trip.components.filter((component) => component.status !== "removed" && component.status !== "cancelled");
27
+ if (deps.startTripCheckout) {
28
+ const result = await deps.startTripCheckout({
29
+ trip,
30
+ intent: input.intent,
31
+ request: input.request,
32
+ });
33
+ for (const warning of result.warnings ?? [])
34
+ warnings.add(warning);
35
+ const [envelope] = (await db
36
+ .update(tripEnvelopes)
37
+ .set({
38
+ status: result.status ?? "checkout_started",
39
+ checkoutIdempotencyKey: input.idempotencyKey ?? null,
40
+ checkoutStartedAt: new Date(),
41
+ paymentSessionId: result.paymentSessionId ?? null,
42
+ updatedAt: new Date(),
43
+ })
44
+ .where(eq(tripEnvelopes.id, input.envelopeId))
45
+ .returning());
46
+ if (!envelope) {
47
+ throw new Error("startCheckout: aggregate envelope update returned no rows");
48
+ }
49
+ const refreshed = await getTrip(db, input.envelopeId);
50
+ const components = refreshed?.components ?? trip.components;
51
+ const componentCheckouts = aggregateComponentCheckouts(components, result);
52
+ return {
53
+ envelope,
54
+ components,
55
+ target: checkoutTargetFromTrip(envelope, components, componentCheckouts),
56
+ componentCheckouts,
57
+ failures: [],
58
+ warnings: [...warnings],
59
+ };
60
+ }
61
+ for (const component of checkoutable) {
62
+ try {
63
+ assertTripComponentCanStartCheckout(component);
64
+ const result = await deps.startComponentCheckout({
65
+ envelope: trip.envelope,
66
+ component,
67
+ intent: input.intent,
68
+ request: input.request,
69
+ });
70
+ const updated = await applyCheckoutResultToComponent(db, component, result);
71
+ componentsById.set(updated.id, updated);
72
+ componentCheckouts.push(toStartedComponentCheckout(updated, result));
73
+ for (const warning of result.warnings ?? [])
74
+ warnings.add(warning);
75
+ }
76
+ catch (error) {
77
+ const reason = error instanceof Error ? error.message : "checkout_start_failed";
78
+ warnings.add(reason);
79
+ failures.push({ componentId: component.id, reason });
80
+ const updated = await markComponentForStaffRemediation(db, component, reason);
81
+ componentsById.set(updated.id, updated);
82
+ }
83
+ }
84
+ if (failures.length > 0) {
85
+ const [failedEnvelope] = (await db
86
+ .update(tripEnvelopes)
87
+ .set({
88
+ status: "failed",
89
+ updatedAt: new Date(),
90
+ })
91
+ .where(eq(tripEnvelopes.id, input.envelopeId))
92
+ .returning());
93
+ if (!failedEnvelope) {
94
+ throw new Error("startCheckout: failed envelope update returned no rows");
95
+ }
96
+ const refreshed = await getTrip(db, input.envelopeId);
97
+ const components = refreshed?.components ?? [...componentsById.values()];
98
+ return {
99
+ envelope: failedEnvelope,
100
+ components,
101
+ target: checkoutTargetFromTrip(failedEnvelope, components, componentCheckouts),
102
+ componentCheckouts,
103
+ failures,
104
+ warnings: [...warnings],
105
+ };
106
+ }
107
+ const refs = commonEnvelopeCheckoutRefs(componentCheckouts);
108
+ const [envelope] = (await db
109
+ .update(tripEnvelopes)
110
+ .set({
111
+ status: "checkout_started",
112
+ checkoutIdempotencyKey: input.idempotencyKey ?? null,
113
+ checkoutStartedAt: new Date(),
114
+ ...refs,
115
+ updatedAt: new Date(),
116
+ })
117
+ .where(eq(tripEnvelopes.id, input.envelopeId))
118
+ .returning());
119
+ if (!envelope) {
120
+ throw new Error("startCheckout: envelope update returned no rows");
121
+ }
122
+ const refreshed = await getTrip(db, input.envelopeId);
123
+ const components = refreshed?.components ?? [...componentsById.values()];
124
+ return {
125
+ envelope,
126
+ components,
127
+ target: checkoutTargetFromTrip(envelope, components, componentCheckouts),
128
+ componentCheckouts,
129
+ failures,
130
+ warnings: [...warnings],
131
+ };
132
+ }
133
+ export async function completeTripCheckout(db, input) {
134
+ if (!input.envelopeId && !input.paymentSessionId) {
135
+ throw new TripsInvariantError("completeTripCheckout requires an envelopeId or paymentSessionId");
136
+ }
137
+ const trip = await findTripForCheckoutCompletion(db, input);
138
+ if (!trip)
139
+ return null;
140
+ if (!payableEnvelopeStatuses.has(trip.envelope.status)) {
141
+ throw new TripsInvariantError(`Trip envelope ${trip.envelope.id} is ${trip.envelope.status} and cannot be completed from payment`);
142
+ }
143
+ const paymentSessionId = input.paymentSessionId ?? trip.envelope.paymentSessionId ?? null;
144
+ if (input.paymentSessionId &&
145
+ trip.envelope.paymentSessionId &&
146
+ trip.envelope.paymentSessionId !== input.paymentSessionId) {
147
+ throw new TripsInvariantError(`Payment session ${input.paymentSessionId} does not belong to trip envelope ${trip.envelope.id}`);
148
+ }
149
+ const alreadyCompleted = trip.envelope.status === "booked" &&
150
+ trip.components
151
+ .filter((component) => component.status !== "removed" && component.status !== "cancelled")
152
+ .every((component) => component.status === "booked");
153
+ if (alreadyCompleted) {
154
+ return {
155
+ envelope: trip.envelope,
156
+ components: trip.components,
157
+ updatedComponentIds: [],
158
+ alreadyCompleted: true,
159
+ };
160
+ }
161
+ const paidAt = input.paidAt ? new Date(input.paidAt) : new Date();
162
+ const updatedComponentIds = [];
163
+ for (const component of trip.components) {
164
+ if (component.status === "removed" || component.status === "cancelled")
165
+ continue;
166
+ if (!payableComponentStatuses.has(component.status)) {
167
+ throw new TripsInvariantError(`Trip component ${component.id} is ${component.status} and cannot be completed from payment`);
168
+ }
169
+ if (component.status === "booked")
170
+ continue;
171
+ const [updated] = (await db
172
+ .update(tripComponents)
173
+ .set({ status: "booked", updatedAt: paidAt })
174
+ .where(eq(tripComponents.id, component.id))
175
+ .returning());
176
+ if (!updated) {
177
+ throw new Error(`completeTripCheckout: update returned no row for ${component.id}`);
178
+ }
179
+ updatedComponentIds.push(updated.id);
180
+ await createComponentEvent(db, {
181
+ envelopeId: updated.envelopeId,
182
+ componentId: updated.id,
183
+ eventType: "booked",
184
+ fromStatus: component.status,
185
+ toStatus: "booked",
186
+ actorId: input.actorId ?? null,
187
+ payload: {
188
+ paymentSessionId,
189
+ paidAt: paidAt.toISOString(),
190
+ ...(input.payload ?? {}),
191
+ },
192
+ });
193
+ }
194
+ const [envelope] = (await db
195
+ .update(tripEnvelopes)
196
+ .set({
197
+ status: "booked",
198
+ paymentSessionId: paymentSessionId ?? trip.envelope.paymentSessionId ?? null,
199
+ updatedAt: paidAt,
200
+ })
201
+ .where(eq(tripEnvelopes.id, trip.envelope.id))
202
+ .returning());
203
+ if (!envelope) {
204
+ throw new Error(`completeTripCheckout: envelope update returned no row for ${trip.envelope.id}`);
205
+ }
206
+ const refreshed = await getTrip(db, envelope.id);
207
+ return {
208
+ envelope,
209
+ components: refreshed?.components ?? trip.components,
210
+ updatedComponentIds,
211
+ alreadyCompleted: false,
212
+ };
213
+ }
214
+ async function findTripForCheckoutCompletion(db, input) {
215
+ if (input.envelopeId) {
216
+ return getTrip(db, input.envelopeId);
217
+ }
218
+ const [envelope] = (await db
219
+ .select()
220
+ .from(tripEnvelopes)
221
+ .where(eq(tripEnvelopes.paymentSessionId, input.paymentSessionId))
222
+ .limit(1));
223
+ if (!envelope)
224
+ return null;
225
+ return getTrip(db, envelope.id);
226
+ }
227
+ function aggregateComponentCheckouts(components, result) {
228
+ return components
229
+ .filter((component) => component.status !== "removed" && component.status !== "cancelled")
230
+ .map((component) => ({
231
+ componentId: component.id,
232
+ kind: result.kind,
233
+ bookingId: component.bookingId,
234
+ orderId: component.orderId,
235
+ paymentSessionId: result.paymentSessionId ?? null,
236
+ checkoutUrl: result.checkoutUrl ?? null,
237
+ bankTransferInstructions: result.bankTransferInstructions ?? null,
238
+ expiresAt: result.expiresAt ?? component.holdExpiresAt?.toISOString() ?? null,
239
+ }));
240
+ }
241
+ async function applyCheckoutResultToComponent(db, component, result) {
242
+ const nextStatus = result.status ?? "checkout_started";
243
+ assertTripComponentCanBeUpdated(component, { status: nextStatus });
244
+ const [updated] = (await db
245
+ .update(tripComponents)
246
+ .set({
247
+ ...checkoutResultToComponentPatch(result),
248
+ warningCodes: appendWarningCodes(component.warningCodes, result.warnings ?? []),
249
+ updatedAt: new Date(),
250
+ })
251
+ .where(eq(tripComponents.id, component.id))
252
+ .returning());
253
+ if (!updated) {
254
+ throw new Error(`applyCheckoutResultToComponent: update returned no row for ${component.id}`);
255
+ }
256
+ await createComponentEvent(db, {
257
+ envelopeId: updated.envelopeId,
258
+ componentId: updated.id,
259
+ eventType: statusToEventType(updated.status),
260
+ fromStatus: component.status,
261
+ toStatus: updated.status,
262
+ payload: {
263
+ kind: result.kind,
264
+ bookingId: updated.bookingId,
265
+ bookingGroupId: updated.bookingGroupId,
266
+ orderId: updated.orderId,
267
+ paymentSessionId: updated.paymentSessionId,
268
+ providerRef: updated.providerRef,
269
+ supplierRef: updated.supplierRef,
270
+ checkoutUrl: result.checkoutUrl,
271
+ externalReference: result.externalReference,
272
+ },
273
+ });
274
+ return updated;
275
+ }
276
+ function replayCheckoutResult(trip) {
277
+ const components = trip.components;
278
+ const componentCheckouts = components
279
+ .filter((component) => component.status === "checkout_started" || component.status === "booked")
280
+ .map((component) => toStartedComponentCheckout(component, {
281
+ kind: component.paymentSessionId ? "payment_session" : "hold_placed",
282
+ status: component.status === "booked" ? "booked" : "checkout_started",
283
+ paymentSessionId: component.paymentSessionId ?? undefined,
284
+ }));
285
+ return {
286
+ envelope: trip.envelope,
287
+ components,
288
+ target: checkoutTargetFromTrip(trip.envelope, components, componentCheckouts),
289
+ componentCheckouts,
290
+ failures: [],
291
+ warnings: ["idempotent_replay"],
292
+ };
293
+ }
294
+ function toStartedComponentCheckout(component, result) {
295
+ return {
296
+ componentId: component.id,
297
+ kind: result.kind,
298
+ bookingId: component.bookingId,
299
+ orderId: component.orderId,
300
+ paymentSessionId: component.paymentSessionId,
301
+ checkoutUrl: result.checkoutUrl ?? null,
302
+ bankTransferInstructions: result.bankTransferInstructions ?? null,
303
+ expiresAt: result.expiresAt ?? component.holdExpiresAt?.toISOString() ?? null,
304
+ };
305
+ }
306
+ function checkoutTargetFromTrip(envelope, components, componentCheckouts) {
307
+ return {
308
+ envelopeId: envelope.id,
309
+ status: "checkout_started",
310
+ currency: envelope.aggregateCurrency ?? aggregateComponentPricing(components).currency,
311
+ totalAmountCents: envelope.aggregateTotalAmountCents ?? aggregateComponentPricing(components).totalAmountCents,
312
+ paymentSessionId: envelope.paymentSessionId ??
313
+ commonString(componentCheckouts.map((checkout) => checkout.paymentSessionId ?? undefined)) ??
314
+ null,
315
+ checkoutUrl: commonString(componentCheckouts.map((checkout) => checkout.checkoutUrl ?? undefined)) ?? null,
316
+ holdExpiresAt: minComponentHoldExpiry(components)?.toISOString() ?? null,
317
+ };
318
+ }
319
+ function commonEnvelopeCheckoutRefs(componentCheckouts) {
320
+ const refs = {};
321
+ const paymentSessionId = commonString(componentCheckouts.map((checkout) => checkout.paymentSessionId ?? undefined));
322
+ const orderId = commonString(componentCheckouts.map((checkout) => checkout.orderId ?? undefined));
323
+ if (paymentSessionId)
324
+ refs.paymentSessionId = paymentSessionId;
325
+ if (orderId)
326
+ refs.orderId = orderId;
327
+ return refs;
328
+ }
@@ -0,0 +1,17 @@
1
+ import type { PricingBreakdownV1 } from "@voyant-travel/catalog/booking-engine";
2
+ import type { NewTripComponent, TripComponent, TripEnvelope } from "./schema.js";
3
+ import type { ComponentCheckoutResult, ReserveComponentResult } from "./service-types.js";
4
+ import { type TripComponentPricingSnapshot, type TripComponentTaxLine, type TripEnvelopePricingSnapshot, type UpdateTripComponentInput, type UpdateTripComponentRefsInput } from "./validation.js";
5
+ export declare function hasCommittedComponentReference(component: Pick<TripComponent, "bookingId" | "bookingGroupId" | "orderId" | "paymentSessionId" | "providerRef" | "supplierRef">): boolean;
6
+ export declare function assertTripComponentCanBeUpdated(component: TripComponent, patch: UpdateTripComponentInput): void;
7
+ export declare function assertTripComponentCanReceiveRefs(component: TripComponent, refs: UpdateTripComponentRefsInput): void;
8
+ export declare function assertTripComponentCanBeReserved(component: TripComponent, now?: Date): void;
9
+ export declare function reserveResultToComponentPatch(result: ReserveComponentResult): Partial<NewTripComponent>;
10
+ export declare function shouldReplayReserve(envelope: Pick<TripEnvelope, "reserveIdempotencyKey" | "status">, idempotencyKey?: string): boolean;
11
+ export declare function shouldReplayCheckout(envelope: Pick<TripEnvelope, "checkoutIdempotencyKey" | "status">, idempotencyKey?: string): boolean;
12
+ export declare function assertTripComponentCanStartCheckout(component: TripComponent, now?: Date): void;
13
+ export declare function checkoutResultToComponentPatch(result: ComponentCheckoutResult): Partial<NewTripComponent>;
14
+ export declare function pricingSnapshotFromBreakdown(pricing: PricingBreakdownV1, priceExpiresAt?: string, warnings?: string[]): TripComponentPricingSnapshot;
15
+ export declare function taxLinesFromBreakdown(pricing: PricingBreakdownV1): TripComponentTaxLine[];
16
+ export declare function aggregateComponentPricing(components: Array<Pick<TripComponent, "pricingSnapshot" | "warningCodes">>, preferredCurrency?: string): TripEnvelopePricingSnapshot;
17
+ //# sourceMappingURL=service-helpers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-helpers.d.ts","sourceRoot":"","sources":["../src/service-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,uCAAuC,CAAA;AAG/E,OAAO,KAAK,EAAE,gBAAgB,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAChF,OAAO,KAAK,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,MAAM,oBAAoB,CAAA;AAEzF,OAAO,EAGL,KAAK,4BAA4B,EACjC,KAAK,oBAAoB,EACzB,KAAK,2BAA2B,EAChC,KAAK,wBAAwB,EAC7B,KAAK,4BAA4B,EAClC,MAAM,iBAAiB,CAAA;AAExB,wBAAgB,8BAA8B,CAC5C,SAAS,EAAE,IAAI,CACb,aAAa,EACb,WAAW,GAAG,gBAAgB,GAAG,SAAS,GAAG,kBAAkB,GAAG,aAAa,GAAG,aAAa,CAChG,GACA,OAAO,CAST;AAED,wBAAgB,+BAA+B,CAC7C,SAAS,EAAE,aAAa,EACxB,KAAK,EAAE,wBAAwB,GAC9B,IAAI,CAkBN;AAED,wBAAgB,iCAAiC,CAC/C,SAAS,EAAE,aAAa,EACxB,IAAI,EAAE,4BAA4B,GACjC,IAAI,CAcN;AAED,wBAAgB,gCAAgC,CAC9C,SAAS,EAAE,aAAa,EACxB,GAAG,GAAE,IAAiB,GACrB,IAAI,CAsBN;AAED,wBAAgB,6BAA6B,CAC3C,MAAM,EAAE,sBAAsB,GAC7B,OAAO,CAAC,gBAAgB,CAAC,CAe3B;AAED,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,IAAI,CAAC,YAAY,EAAE,uBAAuB,GAAG,QAAQ,CAAC,EAChE,cAAc,CAAC,EAAE,MAAM,GACtB,OAAO,CAMT;AAED,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,IAAI,CAAC,YAAY,EAAE,wBAAwB,GAAG,QAAQ,CAAC,EACjE,cAAc,CAAC,EAAE,MAAM,GACtB,OAAO,CAMT;AAED,wBAAgB,mCAAmC,CACjD,SAAS,EAAE,aAAa,EACxB,GAAG,GAAE,IAAiB,GACrB,IAAI,CAgBN;AAED,wBAAgB,8BAA8B,CAC5C,MAAM,EAAE,uBAAuB,GAC9B,OAAO,CAAC,gBAAgB,CAAC,CAa3B;AAED,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE,kBAAkB,EAC3B,cAAc,CAAC,EAAE,MAAM,EACvB,QAAQ,CAAC,EAAE,MAAM,EAAE,GAClB,4BAA4B,CAS9B;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,kBAAkB,GAAG,oBAAoB,EAAE,CAUzF;AAED,wBAAgB,yBAAyB,CACvC,UAAU,EAAE,KAAK,CAAC,IAAI,CAAC,aAAa,EAAE,iBAAiB,GAAG,cAAc,CAAC,CAAC,EAC1E,iBAAiB,CAAC,EAAE,MAAM,GACzB,2BAA2B,CAiC7B"}
@@ -0,0 +1,161 @@
1
+ import { isCatalogBackedTripComponent } from "./catalog-component-adapter.js";
2
+ import { TripsInvariantError } from "./service-types.js";
3
+ import { isAllowedTripComponentStatusTransition, isTerminalTripComponentStatus, } from "./validation.js";
4
+ export function hasCommittedComponentReference(component) {
5
+ return Boolean(component.bookingId ||
6
+ component.bookingGroupId ||
7
+ component.orderId ||
8
+ component.paymentSessionId ||
9
+ component.providerRef ||
10
+ component.supplierRef);
11
+ }
12
+ export function assertTripComponentCanBeUpdated(component, patch) {
13
+ if (isTerminalTripComponentStatus(component.status)) {
14
+ throw new TripsInvariantError(`Trip component ${component.id} is ${component.status} and cannot be updated`);
15
+ }
16
+ if (patch.status && !isAllowedTripComponentStatusTransition(component.status, patch.status)) {
17
+ throw new TripsInvariantError(`Invalid trip component status transition: ${component.status} -> ${patch.status}`);
18
+ }
19
+ if (hasCommittedComponentReference(component) && patch.catalogRef !== undefined) {
20
+ throw new TripsInvariantError(`Trip component ${component.id} has committed references and cannot change catalog identity`);
21
+ }
22
+ }
23
+ export function assertTripComponentCanReceiveRefs(component, refs) {
24
+ if (isTerminalTripComponentStatus(component.status)) {
25
+ throw new TripsInvariantError(`Trip component ${component.id} is ${component.status} and cannot receive references`);
26
+ }
27
+ if (hasCommittedComponentReference(component) &&
28
+ refs.committedRef &&
29
+ Object.keys(refs.committedRef).length > 0) {
30
+ throw new TripsInvariantError(`Trip component ${component.id} already has committed references`);
31
+ }
32
+ }
33
+ export function assertTripComponentCanBeReserved(component, now = new Date()) {
34
+ if (component.status !== "priced") {
35
+ throw new TripsInvariantError(`Trip component ${component.id} is ${component.status} and must be priced before reserve`);
36
+ }
37
+ if (!isCatalogBackedTripComponent(component)) {
38
+ throw new TripsInvariantError(`Trip component ${component.id} is not a catalog-backed booking component`);
39
+ }
40
+ if (!component.pricingSnapshot) {
41
+ throw new TripsInvariantError(`Trip component ${component.id} has no pricing snapshot to reserve`);
42
+ }
43
+ if (component.priceExpiresAt && component.priceExpiresAt.getTime() <= now.getTime()) {
44
+ throw new TripsInvariantError(`Trip component ${component.id} price has expired`);
45
+ }
46
+ }
47
+ export function reserveResultToComponentPatch(result) {
48
+ const patch = {
49
+ status: result.status,
50
+ };
51
+ if (result.bookingId !== undefined)
52
+ patch.bookingId = result.bookingId;
53
+ if (result.bookingGroupId !== undefined)
54
+ patch.bookingGroupId = result.bookingGroupId;
55
+ if (result.orderId !== undefined)
56
+ patch.orderId = result.orderId;
57
+ if (result.paymentSessionId !== undefined)
58
+ patch.paymentSessionId = result.paymentSessionId;
59
+ if (result.providerRef !== undefined)
60
+ patch.providerRef = result.providerRef;
61
+ if (result.supplierRef !== undefined)
62
+ patch.supplierRef = result.supplierRef;
63
+ if (result.holdToken !== undefined)
64
+ patch.holdToken = result.holdToken;
65
+ if (result.holdExpiresAt !== undefined)
66
+ patch.holdExpiresAt = new Date(result.holdExpiresAt);
67
+ return patch;
68
+ }
69
+ export function shouldReplayReserve(envelope, idempotencyKey) {
70
+ return Boolean(idempotencyKey &&
71
+ envelope.reserveIdempotencyKey === idempotencyKey &&
72
+ ["reserved", "checkout_started", "booked"].includes(envelope.status));
73
+ }
74
+ export function shouldReplayCheckout(envelope, idempotencyKey) {
75
+ return Boolean(idempotencyKey &&
76
+ envelope.checkoutIdempotencyKey === idempotencyKey &&
77
+ ["checkout_started", "booked"].includes(envelope.status));
78
+ }
79
+ export function assertTripComponentCanStartCheckout(component, now = new Date()) {
80
+ if (component.status !== "held" && component.status !== "booked") {
81
+ throw new TripsInvariantError(`Trip component ${component.id} is ${component.status} and must be held or booked before checkout`);
82
+ }
83
+ if (!component.bookingId && !component.orderId) {
84
+ throw new TripsInvariantError(`Trip component ${component.id} has no booking/order reference for checkout`);
85
+ }
86
+ if (component.holdExpiresAt && component.holdExpiresAt.getTime() <= now.getTime()) {
87
+ throw new TripsInvariantError(`Trip component ${component.id} hold has expired`);
88
+ }
89
+ }
90
+ export function checkoutResultToComponentPatch(result) {
91
+ const patch = {
92
+ status: result.status ?? "checkout_started",
93
+ };
94
+ if (result.bookingId !== undefined)
95
+ patch.bookingId = result.bookingId;
96
+ if (result.bookingGroupId !== undefined)
97
+ patch.bookingGroupId = result.bookingGroupId;
98
+ if (result.orderId !== undefined)
99
+ patch.orderId = result.orderId;
100
+ if (result.paymentSessionId !== undefined)
101
+ patch.paymentSessionId = result.paymentSessionId;
102
+ if (result.providerRef !== undefined)
103
+ patch.providerRef = result.providerRef;
104
+ if (result.supplierRef !== undefined)
105
+ patch.supplierRef = result.supplierRef;
106
+ return patch;
107
+ }
108
+ export function pricingSnapshotFromBreakdown(pricing, priceExpiresAt, warnings) {
109
+ return {
110
+ currency: pricing.currency,
111
+ subtotalAmountCents: pricing.subtotal,
112
+ taxAmountCents: pricing.taxTotal,
113
+ totalAmountCents: pricing.total,
114
+ priceExpiresAt,
115
+ warnings,
116
+ };
117
+ }
118
+ export function taxLinesFromBreakdown(pricing) {
119
+ return pricing.taxes.map((tax) => ({
120
+ code: tax.code,
121
+ label: tax.label,
122
+ amountCents: tax.amount,
123
+ baseAmountCents: tax.base,
124
+ rate: tax.rate,
125
+ includedInPrice: tax.includedInPrice,
126
+ source: tax.scope,
127
+ }));
128
+ }
129
+ export function aggregateComponentPricing(components, preferredCurrency) {
130
+ const priced = components.filter((component) => component.pricingSnapshot);
131
+ const currency = preferredCurrency ?? priced[0]?.pricingSnapshot?.currency ?? "EUR";
132
+ const warnings = new Set();
133
+ let subtotalAmountCents = 0;
134
+ let taxAmountCents = 0;
135
+ let totalAmountCents = 0;
136
+ for (const component of priced) {
137
+ const snapshot = component.pricingSnapshot;
138
+ if (!snapshot)
139
+ continue;
140
+ if (snapshot.currency !== currency) {
141
+ warnings.add(`currency_mismatch:${snapshot.currency}`);
142
+ continue;
143
+ }
144
+ subtotalAmountCents += snapshot.subtotalAmountCents;
145
+ taxAmountCents += snapshot.taxAmountCents;
146
+ totalAmountCents += snapshot.totalAmountCents;
147
+ for (const warning of snapshot.warnings ?? [])
148
+ warnings.add(warning);
149
+ for (const warning of component.warningCodes ?? [])
150
+ warnings.add(warning);
151
+ }
152
+ return {
153
+ currency,
154
+ subtotalAmountCents,
155
+ taxAmountCents,
156
+ totalAmountCents,
157
+ componentCount: components.length,
158
+ pricedComponentCount: priced.length,
159
+ warnings: [...warnings],
160
+ };
161
+ }
@@ -0,0 +1,11 @@
1
+ import type { AnyDrizzleDb } from "@voyant-travel/db";
2
+ import type { NewTripComponentEvent, TripComponent } from "./schema.js";
3
+ import type { TripComponentStatus } from "./validation.js";
4
+ export declare function createComponentEvent(db: AnyDrizzleDb, data: Omit<NewTripComponentEvent, "id" | "occurredAt">): Promise<void>;
5
+ export declare function statusToEventType(to: TripComponentStatus): NewTripComponentEvent["eventType"];
6
+ export declare function appendWarningCodes(existing: string[], incoming: string[]): string[];
7
+ export declare function markComponentForStaffRemediation(db: AnyDrizzleDb, component: TripComponent, reason: string): Promise<TripComponent>;
8
+ export declare function commonString(values: Array<string | undefined>): string | undefined;
9
+ export declare function minComponentPriceExpiry(components: TripComponent[]): Date | null;
10
+ export declare function minComponentHoldExpiry(components: TripComponent[]): Date | null;
11
+ //# sourceMappingURL=service-internals.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-internals.d.ts","sourceRoot":"","sources":["../src/service-internals.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAGrD,OAAO,KAAK,EAAE,qBAAqB,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAEvE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAA;AAE1D,wBAAsB,oBAAoB,CACxC,EAAE,EAAE,YAAY,EAChB,IAAI,EAAE,IAAI,CAAC,qBAAqB,EAAE,IAAI,GAAG,YAAY,CAAC,iBAGvD;AAED,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,mBAAmB,GAAG,qBAAqB,CAAC,WAAW,CAAC,CAoB7F;AAED,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAEnF;AAED,wBAAsB,gCAAgC,CACpD,EAAE,EAAE,YAAY,EAChB,SAAS,EAAE,aAAa,EACxB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,aAAa,CAAC,CA2BxB;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,KAAK,CAAC,MAAM,GAAG,SAAS,CAAC,GAAG,MAAM,GAAG,SAAS,CAGlF;AAED,wBAAgB,uBAAuB,CAAC,UAAU,EAAE,aAAa,EAAE,GAAG,IAAI,GAAG,IAAI,CAMhF;AAED,wBAAgB,sBAAsB,CAAC,UAAU,EAAE,aAAa,EAAE,GAAG,IAAI,GAAG,IAAI,CAM/E"}
@@ -0,0 +1,72 @@
1
+ import { eq } from "drizzle-orm";
2
+ import { tripComponentEvents, tripComponents } from "./schema.js";
3
+ export async function createComponentEvent(db, data) {
4
+ await db.insert(tripComponentEvents).values(data);
5
+ }
6
+ export function statusToEventType(to) {
7
+ switch (to) {
8
+ case "priced":
9
+ case "unavailable":
10
+ return "priced";
11
+ case "held":
12
+ return "hold_placed";
13
+ case "booked":
14
+ return "booked";
15
+ case "checkout_started":
16
+ return "checkout_started";
17
+ case "failed":
18
+ return "failed";
19
+ case "cancelled":
20
+ return "cancelled";
21
+ case "removed":
22
+ return "removed";
23
+ case "draft":
24
+ return "updated";
25
+ }
26
+ }
27
+ export function appendWarningCodes(existing, incoming) {
28
+ return [...new Set([...existing, ...incoming])];
29
+ }
30
+ export async function markComponentForStaffRemediation(db, component, reason) {
31
+ const [updated] = (await db
32
+ .update(tripComponents)
33
+ .set({
34
+ warningCodes: appendWarningCodes(component.warningCodes, [
35
+ "staff_remediation_required",
36
+ reason,
37
+ ]),
38
+ updatedAt: new Date(),
39
+ })
40
+ .where(eq(tripComponents.id, component.id))
41
+ .returning());
42
+ if (!updated) {
43
+ throw new Error(`markComponentForStaffRemediation: update returned no row for ${component.id}`);
44
+ }
45
+ await createComponentEvent(db, {
46
+ envelopeId: updated.envelopeId,
47
+ componentId: updated.id,
48
+ eventType: "staff_remediation_required",
49
+ fromStatus: component.status,
50
+ toStatus: updated.status,
51
+ payload: { reason },
52
+ });
53
+ return updated;
54
+ }
55
+ export function commonString(values) {
56
+ const unique = [...new Set(values.filter((value) => Boolean(value)))];
57
+ return unique.length === 1 ? unique[0] : undefined;
58
+ }
59
+ export function minComponentPriceExpiry(components) {
60
+ const expiries = components
61
+ .map((component) => component.priceExpiresAt)
62
+ .filter((value) => value instanceof Date)
63
+ .sort((a, b) => a.getTime() - b.getTime());
64
+ return expiries[0] ?? null;
65
+ }
66
+ export function minComponentHoldExpiry(components) {
67
+ const expiries = components
68
+ .map((component) => component.holdExpiresAt)
69
+ .filter((value) => value instanceof Date)
70
+ .sort((a, b) => a.getTime() - b.getTime());
71
+ return expiries[0] ?? null;
72
+ }
@@ -0,0 +1,8 @@
1
+ import type { QuoteResponseV1 } from "@voyant-travel/catalog/booking-engine";
2
+ import type { AnyDrizzleDb } from "@voyant-travel/db";
3
+ import type { TripComponent } from "./schema.js";
4
+ import type { PriceTripDeps, PriceTripResult } from "./service-types.js";
5
+ import type { PriceTripInput } from "./validation.js";
6
+ export declare function priceTrip(db: AnyDrizzleDb, input: PriceTripInput, deps: PriceTripDeps): Promise<PriceTripResult>;
7
+ export declare function applyQuoteToComponent(db: AnyDrizzleDb, component: TripComponent, quote: QuoteResponseV1): Promise<TripComponent>;
8
+ //# sourceMappingURL=service-pricing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-pricing.d.ts","sourceRoot":"","sources":["../src/service-pricing.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uCAAuC,CAAA;AAC5E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAIrD,OAAO,KAAK,EAAE,aAAa,EAAgB,MAAM,aAAa,CAAA;AAU9D,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAA;AAExE,OAAO,KAAK,EAAE,cAAc,EAAuB,MAAM,iBAAiB,CAAA;AAE1E,wBAAsB,SAAS,CAC7B,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE,cAAc,EACrB,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,eAAe,CAAC,CA0E1B;AAED,wBAAsB,qBAAqB,CACzC,EAAE,EAAE,YAAY,EAChB,SAAS,EAAE,aAAa,EACxB,KAAK,EAAE,eAAe,GACrB,OAAO,CAAC,aAAa,CAAC,CA8CxB"}