@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.
- package/LICENSE +201 -0
- 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 +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/mcp-contract.d.ts +79 -0
- package/dist/mcp-contract.d.ts.map +1 -0
- package/dist/mcp-contract.js +21 -0
- package/dist/mcp-registry.d.ts +48 -0
- package/dist/mcp-registry.d.ts.map +1 -0
- package/dist/mcp-registry.js +88 -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 +2068 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +280 -0
- package/dist/schema.d.ts +1897 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +255 -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-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 +493 -0
- package/dist/service-snapshots.d.ts +8 -0
- package/dist/service-snapshots.d.ts.map +1 -0
- package/dist/service-snapshots.js +115 -0
- package/dist/service-trips.d.ts +14 -0
- package/dist/service-trips.d.ts.map +1 -0
- package/dist/service-trips.js +378 -0
- package/dist/service-types.d.ts +285 -0
- package/dist/service-types.d.ts.map +1 -0
- package/dist/service-types.js +6 -0
- package/dist/service.d.ts +38 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +40 -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 +449 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +261 -0
- 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"}
|