@voyantjs/travel-composer 0.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -0
- package/dist/catalog-component-adapter.d.ts +16 -0
- package/dist/catalog-component-adapter.d.ts.map +1 -0
- package/dist/catalog-component-adapter.js +34 -0
- package/dist/cruise-extension.d.ts +48 -0
- package/dist/cruise-extension.d.ts.map +1 -0
- package/dist/cruise-extension.js +66 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/mcp-tools.d.ts +157 -0
- package/dist/mcp-tools.d.ts.map +1 -0
- package/dist/mcp-tools.js +109 -0
- package/dist/routes.d.ts +1988 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +239 -0
- package/dist/schema.d.ts +1228 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +163 -0
- package/dist/service-cancellation.d.ts +6 -0
- package/dist/service-cancellation.d.ts.map +1 -0
- package/dist/service-cancellation.js +251 -0
- package/dist/service-checkout.d.ts +6 -0
- package/dist/service-checkout.d.ts.map +1 -0
- package/dist/service-checkout.js +328 -0
- package/dist/service-drafts.d.ts +13 -0
- package/dist/service-drafts.d.ts.map +1 -0
- package/dist/service-drafts.js +223 -0
- package/dist/service-helpers.d.ts +17 -0
- package/dist/service-helpers.d.ts.map +1 -0
- package/dist/service-helpers.js +161 -0
- package/dist/service-internals.d.ts +11 -0
- package/dist/service-internals.d.ts.map +1 -0
- package/dist/service-internals.js +72 -0
- package/dist/service-pricing.d.ts +8 -0
- package/dist/service-pricing.d.ts.map +1 -0
- package/dist/service-pricing.js +142 -0
- package/dist/service-reservation.d.ts +5 -0
- package/dist/service-reservation.d.ts.map +1 -0
- package/dist/service-reservation.js +447 -0
- package/dist/service-trips.d.ts +14 -0
- package/dist/service-trips.d.ts.map +1 -0
- package/dist/service-trips.js +377 -0
- package/dist/service-types.d.ts +252 -0
- package/dist/service-types.d.ts.map +1 -0
- package/dist/service-types.js +6 -0
- package/dist/service.d.ts +33 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +35 -0
- package/dist/traveler-party-validation.d.ts +3 -0
- package/dist/traveler-party-validation.d.ts.map +1 -0
- package/dist/traveler-party-validation.js +68 -0
- package/dist/validation.d.ts +363 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +226 -0
- package/package.json +88 -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 { TravelComposerInvariantError } 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 TravelComposerInvariantError(`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 TravelComposerInvariantError(`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 TravelComposerInvariantError("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 TravelComposerInvariantError(`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 TravelComposerInvariantError(`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 TravelComposerInvariantError(`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,13 @@
|
|
|
1
|
+
import type { AnyDrizzleDb } from "@voyantjs/db";
|
|
2
|
+
import type { TripComponent, TripEnvelope } from "./schema.js";
|
|
3
|
+
import { type TripDraft } from "./service-types.js";
|
|
4
|
+
import type { CreateTripComponentInput, CreateTripEnvelopeInput, ReorderTripComponentsInput, UpdateTripComponentInput, UpdateTripComponentRefsInput, UpdateTripEnvelopeInput } from "./validation.js";
|
|
5
|
+
export declare function createDraft(db: AnyDrizzleDb, input: CreateTripEnvelopeInput): Promise<TripDraft>;
|
|
6
|
+
export declare function getDraft(db: AnyDrizzleDb, envelopeId: string): Promise<TripDraft | null>;
|
|
7
|
+
export declare function updateDraft(db: AnyDrizzleDb, envelopeId: string, input: UpdateTripEnvelopeInput): Promise<TripEnvelope | null>;
|
|
8
|
+
export declare function addComponent(db: AnyDrizzleDb, input: CreateTripComponentInput): Promise<TripComponent>;
|
|
9
|
+
export declare function updateComponent(db: AnyDrizzleDb, componentId: string, input: UpdateTripComponentInput): Promise<TripComponent | null>;
|
|
10
|
+
export declare function updateComponentRefs(db: AnyDrizzleDb, componentId: string, input: UpdateTripComponentRefsInput): Promise<TripComponent | null>;
|
|
11
|
+
export declare function removeComponent(db: AnyDrizzleDb, componentId: string): Promise<TripComponent | null>;
|
|
12
|
+
export declare function reorderComponents(db: AnyDrizzleDb, input: ReorderTripComponentsInput): Promise<TripComponent[]>;
|
|
13
|
+
//# sourceMappingURL=service-drafts.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service-drafts.d.ts","sourceRoot":"","sources":["../src/service-drafts.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAGhD,OAAO,KAAK,EAAqC,aAAa,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAOjG,OAAO,EAAgC,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAA;AACjF,OAAO,KAAK,EACV,wBAAwB,EACxB,uBAAuB,EACvB,0BAA0B,EAC1B,wBAAwB,EACxB,4BAA4B,EAC5B,uBAAuB,EACxB,MAAM,iBAAiB,CAAA;AAExB,wBAAsB,WAAW,CAC/B,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE,uBAAuB,GAC7B,OAAO,CAAC,SAAS,CAAC,CAcpB;AAED,wBAAsB,QAAQ,CAAC,EAAE,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAe9F;AAED,wBAAsB,WAAW,CAC/B,EAAE,EAAE,YAAY,EAChB,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,uBAAuB,GAC7B,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAkB9B;AAED,wBAAsB,YAAY,CAChC,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE,wBAAwB,GAC9B,OAAO,CAAC,aAAa,CAAC,CAmCxB;AAED,wBAAsB,eAAe,CACnC,EAAE,EAAE,YAAY,EAChB,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,wBAAwB,GAC9B,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAgC/B;AAED,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,YAAY,EAChB,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,4BAA4B,GAClC,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CA8B/B;AAED,wBAAsB,eAAe,CACnC,EAAE,EAAE,YAAY,EAChB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAE/B;AAED,wBAAsB,iBAAiB,CACrC,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE,0BAA0B,GAChC,OAAO,CAAC,aAAa,EAAE,CAAC,CAoC1B"}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { asc, eq, inArray } from "drizzle-orm";
|
|
2
|
+
import { tripComponents, tripEnvelopes } from "./schema.js";
|
|
3
|
+
import { assertTripComponentCanBeUpdated, assertTripComponentCanReceiveRefs, } from "./service-helpers.js";
|
|
4
|
+
import { createComponentEvent, statusToEventType } from "./service-internals.js";
|
|
5
|
+
import { TravelComposerInvariantError } from "./service-types.js";
|
|
6
|
+
export async function createDraft(db, input) {
|
|
7
|
+
const values = {
|
|
8
|
+
title: input.title,
|
|
9
|
+
description: input.description,
|
|
10
|
+
travelerParty: input.travelerParty,
|
|
11
|
+
constraints: input.constraints,
|
|
12
|
+
createdBy: input.createdBy,
|
|
13
|
+
updatedBy: input.createdBy,
|
|
14
|
+
};
|
|
15
|
+
const [envelope] = (await db.insert(tripEnvelopes).values(values).returning());
|
|
16
|
+
if (!envelope)
|
|
17
|
+
throw new Error("createDraft: insert returned no envelope");
|
|
18
|
+
return { envelope, components: [] };
|
|
19
|
+
}
|
|
20
|
+
export async function getDraft(db, envelopeId) {
|
|
21
|
+
const [envelope] = (await db
|
|
22
|
+
.select()
|
|
23
|
+
.from(tripEnvelopes)
|
|
24
|
+
.where(eq(tripEnvelopes.id, envelopeId))
|
|
25
|
+
.limit(1));
|
|
26
|
+
if (!envelope)
|
|
27
|
+
return null;
|
|
28
|
+
const components = (await db
|
|
29
|
+
.select()
|
|
30
|
+
.from(tripComponents)
|
|
31
|
+
.where(eq(tripComponents.envelopeId, envelopeId))
|
|
32
|
+
.orderBy(asc(tripComponents.sequence), asc(tripComponents.createdAt)));
|
|
33
|
+
return { envelope, components };
|
|
34
|
+
}
|
|
35
|
+
export async function updateDraft(db, envelopeId, input) {
|
|
36
|
+
const updates = {
|
|
37
|
+
updatedAt: new Date(),
|
|
38
|
+
};
|
|
39
|
+
if (input.title !== undefined)
|
|
40
|
+
updates.title = input.title;
|
|
41
|
+
if (input.description !== undefined)
|
|
42
|
+
updates.description = input.description;
|
|
43
|
+
if (input.travelerParty !== undefined)
|
|
44
|
+
updates.travelerParty = input.travelerParty;
|
|
45
|
+
if (input.constraints !== undefined)
|
|
46
|
+
updates.constraints = input.constraints;
|
|
47
|
+
if (input.status !== undefined)
|
|
48
|
+
updates.status = input.status;
|
|
49
|
+
if (input.updatedBy !== undefined)
|
|
50
|
+
updates.updatedBy = input.updatedBy;
|
|
51
|
+
const [row] = (await db
|
|
52
|
+
.update(tripEnvelopes)
|
|
53
|
+
.set(updates)
|
|
54
|
+
.where(eq(tripEnvelopes.id, envelopeId))
|
|
55
|
+
.returning());
|
|
56
|
+
return row ?? null;
|
|
57
|
+
}
|
|
58
|
+
export async function addComponent(db, input) {
|
|
59
|
+
const values = {
|
|
60
|
+
envelopeId: input.envelopeId,
|
|
61
|
+
sequence: input.sequence,
|
|
62
|
+
kind: input.kind,
|
|
63
|
+
title: input.title,
|
|
64
|
+
description: input.description,
|
|
65
|
+
entityModule: input.catalogRef?.entityModule,
|
|
66
|
+
entityId: input.catalogRef?.entityId,
|
|
67
|
+
sourceKind: input.catalogRef?.sourceKind,
|
|
68
|
+
sourceConnectionId: input.catalogRef?.sourceConnectionId,
|
|
69
|
+
sourceRef: input.catalogRef?.sourceRef,
|
|
70
|
+
componentCurrency: input.estimatedPricing?.currency,
|
|
71
|
+
componentSubtotalAmountCents: input.estimatedPricing?.subtotalAmountCents,
|
|
72
|
+
componentTaxAmountCents: input.estimatedPricing?.taxAmountCents,
|
|
73
|
+
componentTotalAmountCents: input.estimatedPricing?.totalAmountCents,
|
|
74
|
+
pricingSnapshot: input.estimatedPricing,
|
|
75
|
+
metadata: input.metadata,
|
|
76
|
+
};
|
|
77
|
+
const [component] = (await db
|
|
78
|
+
.insert(tripComponents)
|
|
79
|
+
.values(values)
|
|
80
|
+
.returning());
|
|
81
|
+
if (!component)
|
|
82
|
+
throw new Error("addComponent: insert returned no component");
|
|
83
|
+
await createComponentEvent(db, {
|
|
84
|
+
envelopeId: component.envelopeId,
|
|
85
|
+
componentId: component.id,
|
|
86
|
+
eventType: "created",
|
|
87
|
+
toStatus: component.status,
|
|
88
|
+
payload: {},
|
|
89
|
+
});
|
|
90
|
+
return component;
|
|
91
|
+
}
|
|
92
|
+
export async function updateComponent(db, componentId, input) {
|
|
93
|
+
const existing = await getTripComponentOrThrow(db, componentId);
|
|
94
|
+
assertTripComponentCanBeUpdated(existing, input);
|
|
95
|
+
const updates = {
|
|
96
|
+
updatedAt: new Date(),
|
|
97
|
+
...toCatalogRefPatch(input.catalogRef),
|
|
98
|
+
};
|
|
99
|
+
if (input.sequence !== undefined)
|
|
100
|
+
updates.sequence = input.sequence;
|
|
101
|
+
if (input.status !== undefined)
|
|
102
|
+
updates.status = input.status;
|
|
103
|
+
if (input.title !== undefined)
|
|
104
|
+
updates.title = input.title;
|
|
105
|
+
if (input.description !== undefined)
|
|
106
|
+
updates.description = input.description;
|
|
107
|
+
if (input.metadata !== undefined)
|
|
108
|
+
updates.metadata = input.metadata;
|
|
109
|
+
if (input.warningCodes !== undefined)
|
|
110
|
+
updates.warningCodes = input.warningCodes;
|
|
111
|
+
const [component] = (await db
|
|
112
|
+
.update(tripComponents)
|
|
113
|
+
.set(updates)
|
|
114
|
+
.where(eq(tripComponents.id, componentId))
|
|
115
|
+
.returning());
|
|
116
|
+
if (!component)
|
|
117
|
+
return null;
|
|
118
|
+
await createComponentEvent(db, {
|
|
119
|
+
envelopeId: component.envelopeId,
|
|
120
|
+
componentId: component.id,
|
|
121
|
+
eventType: input.status ? statusToEventType(input.status) : "updated",
|
|
122
|
+
fromStatus: existing.status,
|
|
123
|
+
toStatus: component.status,
|
|
124
|
+
payload: {},
|
|
125
|
+
});
|
|
126
|
+
return component;
|
|
127
|
+
}
|
|
128
|
+
export async function updateComponentRefs(db, componentId, input) {
|
|
129
|
+
const existing = await getTripComponentOrThrow(db, componentId);
|
|
130
|
+
assertTripComponentCanReceiveRefs(existing, input);
|
|
131
|
+
const updates = {
|
|
132
|
+
updatedAt: new Date(),
|
|
133
|
+
};
|
|
134
|
+
if (input.bookingDraftId !== undefined)
|
|
135
|
+
updates.bookingDraftId = input.bookingDraftId;
|
|
136
|
+
if (input.catalogQuoteId !== undefined)
|
|
137
|
+
updates.catalogQuoteId = input.catalogQuoteId;
|
|
138
|
+
if (input.committedRef?.bookingId !== undefined)
|
|
139
|
+
updates.bookingId = input.committedRef.bookingId;
|
|
140
|
+
if (input.committedRef?.bookingGroupId !== undefined) {
|
|
141
|
+
updates.bookingGroupId = input.committedRef.bookingGroupId;
|
|
142
|
+
}
|
|
143
|
+
if (input.committedRef?.orderId !== undefined)
|
|
144
|
+
updates.orderId = input.committedRef.orderId;
|
|
145
|
+
if (input.committedRef?.paymentSessionId !== undefined) {
|
|
146
|
+
updates.paymentSessionId = input.committedRef.paymentSessionId;
|
|
147
|
+
}
|
|
148
|
+
if (input.committedRef?.providerRef !== undefined) {
|
|
149
|
+
updates.providerRef = input.committedRef.providerRef;
|
|
150
|
+
}
|
|
151
|
+
if (input.committedRef?.supplierRef !== undefined) {
|
|
152
|
+
updates.supplierRef = input.committedRef.supplierRef;
|
|
153
|
+
}
|
|
154
|
+
const [component] = (await db
|
|
155
|
+
.update(tripComponents)
|
|
156
|
+
.set(updates)
|
|
157
|
+
.where(eq(tripComponents.id, componentId))
|
|
158
|
+
.returning());
|
|
159
|
+
return component ?? null;
|
|
160
|
+
}
|
|
161
|
+
export async function removeComponent(db, componentId) {
|
|
162
|
+
return updateComponent(db, componentId, { status: "removed" });
|
|
163
|
+
}
|
|
164
|
+
export async function reorderComponents(db, input) {
|
|
165
|
+
const rows = (await db
|
|
166
|
+
.select()
|
|
167
|
+
.from(tripComponents)
|
|
168
|
+
.where(inArray(tripComponents.id, input.componentIds)));
|
|
169
|
+
const found = new Set(rows.map((row) => row.id));
|
|
170
|
+
const missing = input.componentIds.filter((id) => !found.has(id));
|
|
171
|
+
if (missing.length > 0) {
|
|
172
|
+
throw new TravelComposerInvariantError(`Cannot reorder missing trip components: ${missing.join(", ")}`);
|
|
173
|
+
}
|
|
174
|
+
for (const row of rows) {
|
|
175
|
+
if (row.envelopeId !== input.envelopeId) {
|
|
176
|
+
throw new TravelComposerInvariantError(`Trip component ${row.id} does not belong to envelope ${input.envelopeId}`);
|
|
177
|
+
}
|
|
178
|
+
if (row.status === "removed") {
|
|
179
|
+
throw new TravelComposerInvariantError(`Trip component ${row.id} is removed`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const updated = [];
|
|
183
|
+
for (const [sequence, componentId] of input.componentIds.entries()) {
|
|
184
|
+
const [row] = (await db
|
|
185
|
+
.update(tripComponents)
|
|
186
|
+
.set({ sequence, updatedAt: new Date() })
|
|
187
|
+
.where(eq(tripComponents.id, componentId))
|
|
188
|
+
.returning());
|
|
189
|
+
if (row)
|
|
190
|
+
updated.push(row);
|
|
191
|
+
}
|
|
192
|
+
return updated.sort((a, b) => a.sequence - b.sequence);
|
|
193
|
+
}
|
|
194
|
+
function toCatalogRefPatch(input) {
|
|
195
|
+
if (input === undefined)
|
|
196
|
+
return {};
|
|
197
|
+
if (input === null) {
|
|
198
|
+
return {
|
|
199
|
+
entityModule: null,
|
|
200
|
+
entityId: null,
|
|
201
|
+
sourceKind: null,
|
|
202
|
+
sourceConnectionId: null,
|
|
203
|
+
sourceRef: null,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
entityModule: input.entityModule,
|
|
208
|
+
entityId: input.entityId,
|
|
209
|
+
sourceKind: input.sourceKind,
|
|
210
|
+
sourceConnectionId: input.sourceConnectionId ?? null,
|
|
211
|
+
sourceRef: input.sourceRef ?? null,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
async function getTripComponentOrThrow(db, id) {
|
|
215
|
+
const [component] = (await db
|
|
216
|
+
.select()
|
|
217
|
+
.from(tripComponents)
|
|
218
|
+
.where(eq(tripComponents.id, id))
|
|
219
|
+
.limit(1));
|
|
220
|
+
if (!component)
|
|
221
|
+
throw new TravelComposerInvariantError(`Trip component ${id} was not found`);
|
|
222
|
+
return component;
|
|
223
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PricingBreakdownV1 } from "@voyantjs/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,kCAAkC,CAAA;AAG1E,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,CAgBN;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"}
|