@voyant-travel/flights 0.120.1 → 0.122.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/dist/hono.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ import type { HonoModule } from "@voyant-travel/hono";
2
+ import { type Context, Hono } from "hono";
3
+ import type { FlightConnectorAdapter } from "./contract/adapter.js";
4
+ import type { FlightOrder } from "./contract/types.js";
5
+ /** A resolved payment session for a flight hold order. */
6
+ export interface FlightOrderPaymentSummary {
7
+ sessionId: string;
8
+ status: string;
9
+ }
10
+ /**
11
+ * Deployment-supplied payment integration for flight hold orders. The flights
12
+ * module stays payment-provider agnostic; the deployment wires its finance /
13
+ * payment provider here.
14
+ */
15
+ export interface FlightPaymentIntegration {
16
+ /** Ensure (idempotently) a payment session exists for a hold order. */
17
+ ensureOrderSession(c: Context, order: FlightOrder, contact?: {
18
+ email?: string;
19
+ phone?: string;
20
+ }): Promise<FlightOrderPaymentSummary | null>;
21
+ /** Bulk-resolve the most relevant payment session per order id (no N+1). */
22
+ fetchOrderSessions(c: Context, orderIds: string[]): Promise<Map<string, FlightOrderPaymentSummary>>;
23
+ }
24
+ export interface FlightsRouteOptions {
25
+ /**
26
+ * Resolve the flight connector adapter for a request. The deployment picks
27
+ * the demo connector or a real GDS (Sabre / Amadeus / Duffel).
28
+ */
29
+ resolveAdapter(c: Context): FlightConnectorAdapter;
30
+ /** Optional payment-link integration for hold orders. */
31
+ payment?: FlightPaymentIntegration;
32
+ }
33
+ export type FlightsHonoModuleOptions = FlightsRouteOptions;
34
+ /** Build the flight admin routes (relative paths; mount at `/v1/admin/flights`). */
35
+ export declare function createFlightAdminRoutes(options: FlightsRouteOptions): Hono;
36
+ /**
37
+ * The flights route module — mounts the admin routes at `/v1/admin/flights`.
38
+ * A deployment composes this and supplies the connector + payment options.
39
+ */
40
+ export declare function createFlightsHonoModule(options: FlightsHonoModuleOptions): HonoModule;
41
+ //# sourceMappingURL=hono.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hono.d.ts","sourceRoot":"","sources":["../src/hono.ts"],"names":[],"mappings":"AAwBA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AAErD,OAAO,EAAE,KAAK,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAEzC,OAAO,KAAK,EAEV,sBAAsB,EAEvB,MAAM,uBAAuB,CAAA;AAC9B,OAAO,KAAK,EAGV,WAAW,EAIZ,MAAM,qBAAqB,CAAA;AAO5B,0DAA0D;AAC1D,MAAM,WAAW,yBAAyB;IACxC,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,MAAM,CAAA;CACf;AAED;;;;GAIG;AACH,MAAM,WAAW,wBAAwB;IACvC,uEAAuE;IACvE,kBAAkB,CAChB,CAAC,EAAE,OAAO,EACV,KAAK,EAAE,WAAW,EAClB,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAC3C,OAAO,CAAC,yBAAyB,GAAG,IAAI,CAAC,CAAA;IAC5C,4EAA4E;IAC5E,kBAAkB,CAChB,CAAC,EAAE,OAAO,EACV,QAAQ,EAAE,MAAM,EAAE,GACjB,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,yBAAyB,CAAC,CAAC,CAAA;CACnD;AAED,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,cAAc,CAAC,CAAC,EAAE,OAAO,GAAG,sBAAsB,CAAA;IAClD,yDAAyD;IACzD,OAAO,CAAC,EAAE,wBAAwB,CAAA;CACnC;AAED,MAAM,MAAM,wBAAwB,GAAG,mBAAmB,CAAA;AA8B1D,oFAAoF;AACpF,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,mBAAmB,GAAG,IAAI,CAsO1E;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,wBAAwB,GAAG,UAAU,CAKrF"}
package/dist/hono.js ADDED
@@ -0,0 +1,265 @@
1
+ import { ilike, or } from "drizzle-orm";
2
+ import { Hono } from "hono";
3
+ import { referenceAircraft, referenceAirlines, referenceAirports, } from "./reference/local-postgres.js";
4
+ function buildContext(c) {
5
+ return {
6
+ connectionId: "demo",
7
+ correlationId: c.req.header("x-request-id") ?? undefined,
8
+ };
9
+ }
10
+ // `c.var.db` is set by the createApp DB middleware; the global ContextVariableMap
11
+ // doesn't declare it, so cast at the call site to keep type-safety local.
12
+ function getDb(c) {
13
+ return c.var.db;
14
+ }
15
+ function attachPaymentSession(order, summary) {
16
+ if (!summary)
17
+ return order;
18
+ return {
19
+ ...order,
20
+ providerData: {
21
+ ...(order.providerData ?? {}),
22
+ paymentSessionId: summary.sessionId,
23
+ paymentStatus: summary.status,
24
+ },
25
+ };
26
+ }
27
+ /** Build the flight admin routes (relative paths; mount at `/v1/admin/flights`). */
28
+ export function createFlightAdminRoutes(options) {
29
+ const { resolveAdapter, payment } = options;
30
+ const hono = new Hono();
31
+ // ── Search ──────────────────────────────────────────────────────────────
32
+ hono.post("/search", async (c) => {
33
+ let body;
34
+ try {
35
+ body = await c.req.json();
36
+ }
37
+ catch {
38
+ return c.json({ error: "Invalid JSON body" }, 400);
39
+ }
40
+ if (!body.slices?.length)
41
+ return c.json({ error: "slices is required" }, 400);
42
+ if (!body.passengers?.adults || body.passengers.adults < 1) {
43
+ return c.json({ error: "passengers.adults must be at least 1" }, 400);
44
+ }
45
+ try {
46
+ const response = await resolveAdapter(c).searchFlights(buildContext(c), body);
47
+ return c.json(response);
48
+ }
49
+ catch (err) {
50
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
51
+ }
52
+ });
53
+ // ── Ancillaries ─────────────────────────────────────────────────────────
54
+ hono.post("/ancillaries", async (c) => {
55
+ let body;
56
+ try {
57
+ body = await c.req.json();
58
+ }
59
+ catch {
60
+ return c.json({ error: "Invalid JSON body" }, 400);
61
+ }
62
+ if (!body.offerId)
63
+ return c.json({ error: "offerId is required" }, 400);
64
+ const adapter = resolveAdapter(c);
65
+ if (!adapter.getAncillaries) {
66
+ return c.json({ error: "Connector does not declare flight/ancillaries capability" }, 501);
67
+ }
68
+ try {
69
+ const response = await adapter.getAncillaries(buildContext(c), body);
70
+ return c.json(response);
71
+ }
72
+ catch (err) {
73
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
74
+ }
75
+ });
76
+ // ── Seat map ────────────────────────────────────────────────────────────
77
+ hono.post("/seatmap", async (c) => {
78
+ let body;
79
+ try {
80
+ body = await c.req.json();
81
+ }
82
+ catch {
83
+ return c.json({ error: "Invalid JSON body" }, 400);
84
+ }
85
+ if (!body.offerId)
86
+ return c.json({ error: "offerId is required" }, 400);
87
+ if (!body.segmentId)
88
+ return c.json({ error: "segmentId is required" }, 400);
89
+ const adapter = resolveAdapter(c);
90
+ if (!adapter.getSeatMap) {
91
+ return c.json({ error: "Connector does not declare flight/seatmap capability" }, 501);
92
+ }
93
+ try {
94
+ const response = await adapter.getSeatMap(buildContext(c), body);
95
+ return c.json(response);
96
+ }
97
+ catch (err) {
98
+ const message = err instanceof Error ? err.message : String(err);
99
+ return c.json({ error: message }, /not found/i.test(message) ? 404 : 500);
100
+ }
101
+ });
102
+ // ── Re-price ────────────────────────────────────────────────────────────
103
+ hono.post("/price", async (c) => {
104
+ let body;
105
+ try {
106
+ body = await c.req.json();
107
+ }
108
+ catch {
109
+ return c.json({ error: "Invalid JSON body" }, 400);
110
+ }
111
+ if (!body.offerId)
112
+ return c.json({ error: "offerId is required" }, 400);
113
+ try {
114
+ const response = await resolveAdapter(c).priceOffer(buildContext(c), body);
115
+ return c.json(response);
116
+ }
117
+ catch (err) {
118
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
119
+ }
120
+ });
121
+ // ── Book ────────────────────────────────────────────────────────────────
122
+ hono.post("/book", async (c) => {
123
+ let body;
124
+ try {
125
+ body = await c.req.json();
126
+ }
127
+ catch {
128
+ return c.json({ error: "Invalid JSON body" }, 400);
129
+ }
130
+ if (!body.offerId)
131
+ return c.json({ error: "offerId is required" }, 400);
132
+ if (!body.passengers?.length)
133
+ return c.json({ error: "passengers is required" }, 400);
134
+ try {
135
+ const response = await resolveAdapter(c).bookFlight(buildContext(c), body);
136
+ // Hold is the default intent; eagerly create the payment session so the
137
+ // order page can show the shareable link immediately.
138
+ const isHold = !body.paymentIntent || body.paymentIntent.type === "hold";
139
+ if (isHold && response.order && payment) {
140
+ const summary = await payment.ensureOrderSession(c, response.order, body.contact);
141
+ response.order = attachPaymentSession(response.order, summary);
142
+ }
143
+ return c.json(response);
144
+ }
145
+ catch (err) {
146
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
147
+ }
148
+ });
149
+ // ── List orders ─────────────────────────────────────────────────────────
150
+ hono.get("/orders", async (c) => {
151
+ const adapter = resolveAdapter(c);
152
+ if (!adapter.listOrders) {
153
+ return c.json({ error: "Adapter does not support listing orders" }, 501);
154
+ }
155
+ const url = new URL(c.req.url);
156
+ const limitParam = url.searchParams.get("limit");
157
+ const cursor = url.searchParams.get("cursor") ?? undefined;
158
+ const search = url.searchParams.get("q") ?? url.searchParams.get("search") ?? undefined;
159
+ const statusParam = url.searchParams.getAll("status");
160
+ const status = statusParam.length > 0 ? statusParam : undefined;
161
+ const limit = limitParam
162
+ ? Math.max(1, Math.min(100, Number.parseInt(limitParam, 10)))
163
+ : undefined;
164
+ const paymentStatusParam = url.searchParams.getAll("paymentStatus");
165
+ const paymentStatusFilter = paymentStatusParam.length > 0 ? new Set(paymentStatusParam) : null;
166
+ try {
167
+ const response = await adapter.listOrders(buildContext(c), {
168
+ ...(limit !== undefined ? { limit } : {}),
169
+ ...(cursor !== undefined ? { cursor } : {}),
170
+ ...(search !== undefined ? { search } : {}),
171
+ ...(status !== undefined ? { status } : {}),
172
+ });
173
+ if (payment) {
174
+ const sessionByOrderId = await payment.fetchOrderSessions(c, response.orders.map((o) => o.orderId));
175
+ response.orders = response.orders.map((order) => attachPaymentSession(order, sessionByOrderId.get(order.orderId) ?? null));
176
+ }
177
+ if (paymentStatusFilter) {
178
+ response.orders = response.orders.filter((o) => paymentStatusFilter.has(o.providerData?.paymentStatus ?? "none"));
179
+ }
180
+ return c.json(response);
181
+ }
182
+ catch (err) {
183
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
184
+ }
185
+ });
186
+ // ── Get order ───────────────────────────────────────────────────────────
187
+ hono.get("/orders/:orderId", async (c) => {
188
+ const orderId = c.req.param("orderId");
189
+ if (!orderId)
190
+ return c.json({ error: "orderId is required" }, 400);
191
+ try {
192
+ const response = await resolveAdapter(c).getOrder(buildContext(c), orderId);
193
+ if (response.order && payment) {
194
+ const summary = await payment.ensureOrderSession(c, response.order, response.order.contact);
195
+ response.order = attachPaymentSession(response.order, summary);
196
+ }
197
+ return c.json(response);
198
+ }
199
+ catch (err) {
200
+ const message = err instanceof Error ? err.message : String(err);
201
+ return c.json({ error: message }, /not found/i.test(message) ? 404 : 500);
202
+ }
203
+ });
204
+ // ── Cancel order ────────────────────────────────────────────────────────
205
+ hono.post("/orders/:orderId/cancel", async (c) => {
206
+ const orderId = c.req.param("orderId");
207
+ if (!orderId)
208
+ return c.json({ error: "orderId is required" }, 400);
209
+ let body = {};
210
+ try {
211
+ body = await c.req.json();
212
+ }
213
+ catch {
214
+ // Body is optional for cancel.
215
+ }
216
+ try {
217
+ const response = await resolveAdapter(c).cancelOrder(buildContext(c), orderId, body.reason);
218
+ return c.json(response);
219
+ }
220
+ catch (err) {
221
+ const message = err instanceof Error ? err.message : String(err);
222
+ return c.json({ error: message }, /not found/i.test(message) ? 404 : 500);
223
+ }
224
+ });
225
+ // ── Reference: airports (with substring search) ───────────────────────────
226
+ hono.get("/reference/airports", async (c) => {
227
+ const db = getDb(c);
228
+ const q = c.req.query("q")?.trim();
229
+ const limit = Math.min(Number(c.req.query("limit") ?? 50), 200);
230
+ let rows;
231
+ if (q) {
232
+ const pattern = `%${q}%`;
233
+ rows = await db
234
+ .select()
235
+ .from(referenceAirports)
236
+ .where(or(ilike(referenceAirports.iataCode, pattern), ilike(referenceAirports.city, pattern), ilike(referenceAirports.name, pattern)))
237
+ .limit(limit);
238
+ }
239
+ else {
240
+ rows = await db.select().from(referenceAirports).limit(limit);
241
+ }
242
+ return c.json({ data: rows });
243
+ });
244
+ // ── Reference: airlines (full list) ───────────────────────────────────────
245
+ hono.get("/reference/airlines", async (c) => {
246
+ const rows = await getDb(c).select().from(referenceAirlines);
247
+ return c.json({ data: rows });
248
+ });
249
+ // ── Reference: aircraft (full list) ────────────────────────────────────────
250
+ hono.get("/reference/aircraft", async (c) => {
251
+ const rows = await getDb(c).select().from(referenceAircraft);
252
+ return c.json({ data: rows });
253
+ });
254
+ return hono;
255
+ }
256
+ /**
257
+ * The flights route module — mounts the admin routes at `/v1/admin/flights`.
258
+ * A deployment composes this and supplies the connector + payment options.
259
+ */
260
+ export function createFlightsHonoModule(options) {
261
+ return {
262
+ module: { name: "flights" },
263
+ adminRoutes: createFlightAdminRoutes(options),
264
+ };
265
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=hono.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hono.test.d.ts","sourceRoot":"","sources":["../src/hono.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,53 @@
1
+ import { Hono } from "hono";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { createFlightAdminRoutes, createFlightsHonoModule } from "./hono.js";
4
+ function stubAdapter(over = {}) {
5
+ return {
6
+ capabilities: { provider: "stub", declared: [] },
7
+ searchFlights: vi.fn(async () => ({ offers: [] })),
8
+ priceOffer: vi.fn(async () => ({ offer: {}, valid: true })),
9
+ bookFlight: vi.fn(async () => ({ order: { orderId: "ord_1", passengers: [] } })),
10
+ getOrder: vi.fn(async () => ({ order: { orderId: "ord_1", passengers: [] } })),
11
+ cancelOrder: vi.fn(async () => ({ order: { orderId: "ord_1" } })),
12
+ ...over,
13
+ };
14
+ }
15
+ function mount(adapter) {
16
+ return new Hono().route("/v1/admin/flights", createFlightAdminRoutes({ resolveAdapter: () => adapter }));
17
+ }
18
+ describe("flights hono module", () => {
19
+ it("createFlightsHonoModule exposes adminRoutes on module 'flights'", () => {
20
+ const mod = createFlightsHonoModule({ resolveAdapter: () => stubAdapter() });
21
+ expect(mod.module.name).toBe("flights");
22
+ expect(mod.adminRoutes).toBeDefined();
23
+ });
24
+ it("POST /search delegates to the resolved adapter", async () => {
25
+ const adapter = stubAdapter();
26
+ const app = mount(adapter);
27
+ const res = await app.request("/v1/admin/flights/search", {
28
+ method: "POST",
29
+ headers: { "Content-Type": "application/json" },
30
+ body: JSON.stringify({ slices: [{}], passengers: { adults: 1 } }),
31
+ });
32
+ expect(res.status).toBe(200);
33
+ expect(adapter.searchFlights).toHaveBeenCalledTimes(1);
34
+ });
35
+ it("POST /search validates required fields", async () => {
36
+ const app = mount(stubAdapter());
37
+ const res = await app.request("/v1/admin/flights/search", {
38
+ method: "POST",
39
+ headers: { "Content-Type": "application/json" },
40
+ body: JSON.stringify({ passengers: { adults: 1 } }),
41
+ });
42
+ expect(res.status).toBe(400);
43
+ });
44
+ it("returns 501 when the connector lacks an optional capability", async () => {
45
+ const app = mount(stubAdapter());
46
+ const res = await app.request("/v1/admin/flights/ancillaries", {
47
+ method: "POST",
48
+ headers: { "Content-Type": "application/json" },
49
+ body: JSON.stringify({ offerId: "off_1" }),
50
+ });
51
+ expect(res.status).toBe(501);
52
+ });
53
+ });
package/dist/index.d.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  export { type AdapterLogger, CAPABILITY_NOT_SUPPORTED, type FlightAdapterCapabilities, type FlightAdapterContext, type FlightAdapterEnvironment, type FlightBookResponse, type FlightCancelReason, type FlightCancelResponse, FlightCapabilityNotSupportedError, type FlightConnectorAdapter, type FlightGetOrderResponse, type FlightPriceRequest, type FlightPriceResponse, type FlightSearchResponse, requireCapability, } from "./contract/adapter.js";
2
2
  export * from "./contract/schemas.js";
3
3
  export * from "./contract/types.js";
4
+ export { createFlightAdminRoutes, createFlightsHonoModule, type FlightOrderPaymentSummary, type FlightPaymentIntegration, type FlightsHonoModuleOptions, type FlightsRouteOptions, } from "./hono.js";
4
5
  export { type ConnectionResult, type ConnectionSearchStatus, type FanOutFlightSearchOptions, type FanOutFlightSearchResult, fanOutFlightSearch, type MergedFlightOffer, } from "./orchestration/fan-out.js";
5
6
  export { itineraryFingerprint } from "./orchestration/fingerprint.js";
7
+ export { buildFlightSummary, createFlightOrderPaymentIntegration, type FlightCardBilling, type FlightOrderPaymentIntegrationDeps, formatDay, type OrderPaymentSessionsLike, parseAmountToCents, synthesizeBilling, } from "./payment-integration.js";
6
8
  export { type Aircraft, type Airline, type Airport, dedupeCodes, type ReferenceDataCapabilities, type ReferenceDataProvider, } from "./reference/contract.js";
7
9
  export { createLocalPostgresReferenceProvider, type LocalPostgresReferenceProviderOptions, referenceAircraft, referenceAirlines, referenceAirports, } from "./reference/local-postgres.js";
8
10
  export { createStaticBundleReferenceProvider, type StaticBundleProviderOptions, type StaticBundleReferenceData, } from "./reference/static-bundle.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,KAAK,aAAa,EAClB,wBAAwB,EACxB,KAAK,yBAAyB,EAC9B,KAAK,oBAAoB,EACzB,KAAK,wBAAwB,EAC7B,KAAK,kBAAkB,EACvB,KAAK,kBAAkB,EACvB,KAAK,oBAAoB,EACzB,iCAAiC,EACjC,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,EAC3B,KAAK,kBAAkB,EACvB,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,iBAAiB,GAClB,MAAM,uBAAuB,CAAA;AAC9B,cAAc,uBAAuB,CAAA;AACrC,cAAc,qBAAqB,CAAA;AACnC,OAAO,EACL,KAAK,gBAAgB,EACrB,KAAK,sBAAsB,EAC3B,KAAK,yBAAyB,EAC9B,KAAK,wBAAwB,EAC7B,kBAAkB,EAClB,KAAK,iBAAiB,GACvB,MAAM,4BAA4B,CAAA;AAEnC,OAAO,EAAE,oBAAoB,EAAE,MAAM,gCAAgC,CAAA;AAErE,OAAO,EACL,KAAK,QAAQ,EACb,KAAK,OAAO,EACZ,KAAK,OAAO,EACZ,WAAW,EACX,KAAK,yBAAyB,EAC9B,KAAK,qBAAqB,GAC3B,MAAM,yBAAyB,CAAA;AAChC,OAAO,EACL,oCAAoC,EACpC,KAAK,qCAAqC,EAC1C,iBAAiB,EACjB,iBAAiB,EACjB,iBAAiB,GAClB,MAAM,+BAA+B,CAAA;AACtC,OAAO,EACL,mCAAmC,EACnC,KAAK,2BAA2B,EAChC,KAAK,yBAAyB,GAC/B,MAAM,8BAA8B,CAAA;AAErC,OAAO,EACL,KAAK,+BAA+B,EACpC,wBAAwB,GACzB,MAAM,eAAe,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,KAAK,aAAa,EAClB,wBAAwB,EACxB,KAAK,yBAAyB,EAC9B,KAAK,oBAAoB,EACzB,KAAK,wBAAwB,EAC7B,KAAK,kBAAkB,EACvB,KAAK,kBAAkB,EACvB,KAAK,oBAAoB,EACzB,iCAAiC,EACjC,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,EAC3B,KAAK,kBAAkB,EACvB,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,iBAAiB,GAClB,MAAM,uBAAuB,CAAA;AAC9B,cAAc,uBAAuB,CAAA;AACrC,cAAc,qBAAqB,CAAA;AAGnC,OAAO,EACL,uBAAuB,EACvB,uBAAuB,EACvB,KAAK,yBAAyB,EAC9B,KAAK,wBAAwB,EAC7B,KAAK,wBAAwB,EAC7B,KAAK,mBAAmB,GACzB,MAAM,WAAW,CAAA;AAClB,OAAO,EACL,KAAK,gBAAgB,EACrB,KAAK,sBAAsB,EAC3B,KAAK,yBAAyB,EAC9B,KAAK,wBAAwB,EAC7B,kBAAkB,EAClB,KAAK,iBAAiB,GACvB,MAAM,4BAA4B,CAAA;AAEnC,OAAO,EAAE,oBAAoB,EAAE,MAAM,gCAAgC,CAAA;AAGrE,OAAO,EACL,kBAAkB,EAClB,mCAAmC,EACnC,KAAK,iBAAiB,EACtB,KAAK,iCAAiC,EACtC,SAAS,EACT,KAAK,wBAAwB,EAC7B,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,0BAA0B,CAAA;AAEjC,OAAO,EACL,KAAK,QAAQ,EACb,KAAK,OAAO,EACZ,KAAK,OAAO,EACZ,WAAW,EACX,KAAK,yBAAyB,EAC9B,KAAK,qBAAqB,GAC3B,MAAM,yBAAyB,CAAA;AAChC,OAAO,EACL,oCAAoC,EACpC,KAAK,qCAAqC,EAC1C,iBAAiB,EACjB,iBAAiB,EACjB,iBAAiB,GAClB,MAAM,+BAA+B,CAAA;AACtC,OAAO,EACL,mCAAmC,EACnC,KAAK,2BAA2B,EAChC,KAAK,yBAAyB,GAC/B,MAAM,8BAA8B,CAAA;AAErC,OAAO,EACL,KAAK,+BAA+B,EACpC,wBAAwB,GACzB,MAAM,eAAe,CAAA"}
package/dist/index.js CHANGED
@@ -3,9 +3,15 @@
3
3
  export { CAPABILITY_NOT_SUPPORTED, FlightCapabilityNotSupportedError, requireCapability, } from "./contract/adapter.js";
4
4
  export * from "./contract/schemas.js";
5
5
  export * from "./contract/types.js";
6
+ // Flight admin HTTP routes (module-owned; the deployment supplies connector +
7
+ // payment options).
8
+ export { createFlightAdminRoutes, createFlightsHonoModule, } from "./hono.js";
6
9
  export { fanOutFlightSearch, } from "./orchestration/fan-out.js";
7
10
  // Orchestration — fingerprinting + multi-connection fan-out.
8
11
  export { itineraryFingerprint } from "./orchestration/fingerprint.js";
12
+ // Flight-specific payment integration — maps a FlightOrder onto a generic
13
+ // order-payment-session service + an optional card provider (both structural).
14
+ export { buildFlightSummary, createFlightOrderPaymentIntegration, formatDay, parseAmountToCents, synthesizeBilling, } from "./payment-integration.js";
9
15
  // ReferenceDataProvider — swappable provider for global reference data.
10
16
  export { dedupeCodes, } from "./reference/contract.js";
11
17
  export { createLocalPostgresReferenceProvider, referenceAircraft, referenceAirlines, referenceAirports, } from "./reference/local-postgres.js";
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Flight-specific payment integration: maps a `FlightOrder` onto finance's
3
+ * generic order-payment-session service and (optionally) a card provider.
4
+ *
5
+ * The flights module stays payment-provider agnostic AND finance-agnostic — the
6
+ * deps below are typed STRUCTURALLY so this file imports neither
7
+ * `@voyant-travel/finance` nor any payment provider. The deployment wires the
8
+ * concrete services in `createFlightOrderPaymentIntegration({ ... })`.
9
+ *
10
+ * What's flight-specific (and therefore lives here, not in finance):
11
+ * - mapping `order.totalPrice` → amountCents/currency,
12
+ * - picking the payer from `order.passengers[0]`,
13
+ * - the `notes` summary (`buildFlightSummary`),
14
+ * - synthesizing card billing (`synthesizeBilling`).
15
+ */
16
+ import type { AnyDrizzleDb } from "@voyant-travel/db";
17
+ import type { Context } from "hono";
18
+ import type { FlightOrder, FlightPassenger } from "./contract/types.js";
19
+ import type { FlightPaymentIntegration } from "./hono.js";
20
+ export declare function parseAmountToCents(amount: string): number;
21
+ /** Card billing details synthesized from a passenger + (optional) contact. */
22
+ export interface FlightCardBilling {
23
+ email: string;
24
+ phone: string;
25
+ firstName: string;
26
+ lastName: string;
27
+ city: string;
28
+ country: number;
29
+ state: string;
30
+ postalCode: string;
31
+ details: string;
32
+ }
33
+ export declare function synthesizeBilling(pax: FlightPassenger, contact: {
34
+ email?: string;
35
+ phone?: string;
36
+ } | undefined): FlightCardBilling;
37
+ export declare function formatDay(iso: string): string;
38
+ /**
39
+ * Build a single-line human summary of the flight order — surfaced as
40
+ * `payment_session.notes` so the public landing page can show what's being paid.
41
+ */
42
+ export declare function buildFlightSummary(order: FlightOrder): string;
43
+ /**
44
+ * Structural shape of finance's generic order-payment-session service. Matches
45
+ * `@voyant-travel/finance`'s `createOrderPaymentSessions(...)` return value —
46
+ * declared here (not imported) so flights never depends on finance.
47
+ */
48
+ export interface OrderPaymentSessionsLike {
49
+ ensureSession(db: AnyDrizzleDb, params: {
50
+ targetId: string;
51
+ currency: string;
52
+ amountCents: number;
53
+ payerEmail?: string | null;
54
+ payerName?: string | null;
55
+ notes?: string | null;
56
+ }, startProvider?: (db: AnyDrizzleDb, sessionId: string) => Promise<void>): Promise<{
57
+ sessionId: string;
58
+ status: string;
59
+ } | null>;
60
+ fetchSessions(db: AnyDrizzleDb, targetIds: string[]): Promise<Map<string, {
61
+ sessionId: string;
62
+ status: string;
63
+ }>>;
64
+ }
65
+ export interface FlightOrderPaymentIntegrationDeps {
66
+ /** Finance's generic order-payment-session service (structural). */
67
+ orderPaymentSessions: OrderPaymentSessionsLike;
68
+ /**
69
+ * Optional card provider start. Best-effort — called after a session is
70
+ * created so the provider can populate the card redirect link. The deployment
71
+ * supplies its card provider (e.g. Netopia); flights stays provider-agnostic.
72
+ *
73
+ * Receives the request `Context` (so the deployment can resolve its
74
+ * request-scoped provider runtime / container) plus the new session id and
75
+ * synthesized card billing.
76
+ */
77
+ startCardPayment?(c: Context, sessionId: string, billing: FlightCardBilling): Promise<void>;
78
+ }
79
+ /**
80
+ * Build a {@link FlightPaymentIntegration} from the generic order-payment
81
+ * service + an optional card provider. The deployment composes this in its
82
+ * flights runtime wiring.
83
+ */
84
+ export declare function createFlightOrderPaymentIntegration(deps: FlightOrderPaymentIntegrationDeps): FlightPaymentIntegration;
85
+ //# sourceMappingURL=payment-integration.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"payment-integration.d.ts","sourceRoot":"","sources":["../src/payment-integration.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AACrD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AAEnC,OAAO,KAAK,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAA;AACvE,OAAO,KAAK,EAA6B,wBAAwB,EAAE,MAAM,WAAW,CAAA;AAQpF,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAIzD;AAED,8EAA8E;AAC9E,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;IACb,UAAU,EAAE,MAAM,CAAA;IAClB,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,eAAe,EACpB,OAAO,EAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,GACtD,iBAAiB,CAYnB;AAED,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAI7C;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,WAAW,GAAG,MAAM,CAgB7D;AAED;;;;GAIG;AACH,MAAM,WAAW,wBAAwB;IACvC,aAAa,CACX,EAAE,EAAE,YAAY,EAChB,MAAM,EAAE;QACN,QAAQ,EAAE,MAAM,CAAA;QAChB,QAAQ,EAAE,MAAM,CAAA;QAChB,WAAW,EAAE,MAAM,CAAA;QACnB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;QAC1B,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;QACzB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KACtB,EACD,aAAa,CAAC,EAAE,CAAC,EAAE,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GACrE,OAAO,CAAC;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAA;IACxD,aAAa,CACX,EAAE,EAAE,YAAY,EAChB,SAAS,EAAE,MAAM,EAAE,GAClB,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAA;CAC/D;AAED,MAAM,WAAW,iCAAiC;IAChD,oEAAoE;IACpE,oBAAoB,EAAE,wBAAwB,CAAA;IAC9C;;;;;;;;OAQG;IACH,gBAAgB,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CAC5F;AAED;;;;GAIG;AACH,wBAAgB,mCAAmC,CACjD,IAAI,EAAE,iCAAiC,GACtC,wBAAwB,CAoC1B"}
@@ -0,0 +1,86 @@
1
+ // `c.var.db` is set by the createApp DB middleware; the global ContextVariableMap
2
+ // doesn't declare it, so cast at the call site to keep type-safety local.
3
+ function getDb(c) {
4
+ return c.var.db;
5
+ }
6
+ export function parseAmountToCents(amount) {
7
+ const n = Number.parseFloat(amount);
8
+ if (!Number.isFinite(n))
9
+ return 0;
10
+ return Math.round(n * 100);
11
+ }
12
+ export function synthesizeBilling(pax, contact) {
13
+ return {
14
+ email: contact?.email ?? pax.email ?? "tbd@example.com",
15
+ phone: contact?.phone ?? pax.phone ?? "0000000000",
16
+ firstName: pax.firstName,
17
+ lastName: pax.lastName,
18
+ city: "TBD",
19
+ country: 642, // ISO 3166-1 numeric — Romania default; customer overrides on hosted form.
20
+ state: "TBD",
21
+ postalCode: "00000",
22
+ details: "Pending — customer to confirm at payment.",
23
+ };
24
+ }
25
+ export function formatDay(iso) {
26
+ const d = new Date(iso);
27
+ if (Number.isNaN(d.getTime()))
28
+ return iso;
29
+ return d.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" });
30
+ }
31
+ /**
32
+ * Build a single-line human summary of the flight order — surfaced as
33
+ * `payment_session.notes` so the public landing page can show what's being paid.
34
+ */
35
+ export function buildFlightSummary(order) {
36
+ const parts = [];
37
+ for (const itin of order.offer.itineraries) {
38
+ if (itin.segments.length === 0)
39
+ continue;
40
+ const first = itin.segments[0];
41
+ const last = itin.segments[itin.segments.length - 1];
42
+ if (!first || !last)
43
+ continue;
44
+ parts.push(`${first.departure.iataCode} → ${last.arrival.iataCode} · ${formatDay(first.departure.at)}`);
45
+ }
46
+ const paxNames = order.passengers
47
+ .map((p) => `${p.firstName} ${p.lastName}`.trim())
48
+ .filter(Boolean);
49
+ if (paxNames.length > 0)
50
+ parts.push(paxNames.join(", "));
51
+ return parts.join(" · ");
52
+ }
53
+ /**
54
+ * Build a {@link FlightPaymentIntegration} from the generic order-payment
55
+ * service + an optional card provider. The deployment composes this in its
56
+ * flights runtime wiring.
57
+ */
58
+ export function createFlightOrderPaymentIntegration(deps) {
59
+ const { orderPaymentSessions, startCardPayment } = deps;
60
+ return {
61
+ async ensureOrderSession(c, order, contact) {
62
+ const db = getDb(c);
63
+ const passengerForBilling = order.passengers[0];
64
+ if (!passengerForBilling)
65
+ return null;
66
+ const amountCents = parseAmountToCents(order.totalPrice.amount);
67
+ const startProvider = startCardPayment
68
+ ? (_writer, sessionId) => startCardPayment(c, sessionId, synthesizeBilling(passengerForBilling, contact))
69
+ : undefined;
70
+ return orderPaymentSessions.ensureSession(db, {
71
+ targetId: order.orderId,
72
+ currency: order.totalPrice.currency,
73
+ amountCents,
74
+ payerEmail: contact?.email ?? passengerForBilling.email ?? null,
75
+ payerName: `${passengerForBilling.firstName} ${passengerForBilling.lastName}`.trim(),
76
+ notes: buildFlightSummary(order),
77
+ }, startProvider);
78
+ },
79
+ async fetchOrderSessions(c, orderIds) {
80
+ const result = new Map();
81
+ if (orderIds.length === 0)
82
+ return result;
83
+ return orderPaymentSessions.fetchSessions(getDb(c), orderIds);
84
+ },
85
+ };
86
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=payment-integration.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"payment-integration.test.d.ts","sourceRoot":"","sources":["../src/payment-integration.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,124 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { buildFlightSummary, createFlightOrderPaymentIntegration, parseAmountToCents, synthesizeBilling, } from "./payment-integration.js";
3
+ const DB = { __db: true };
4
+ let CTX;
5
+ function stubContext() {
6
+ CTX = { var: { db: DB } };
7
+ return CTX;
8
+ }
9
+ function stubOrder(over = {}) {
10
+ return {
11
+ orderId: "ord_1",
12
+ status: "confirmed",
13
+ offer: {
14
+ itineraries: [
15
+ {
16
+ segments: [
17
+ {
18
+ departure: { iataCode: "LHR", at: "2026-07-01T08:00:00Z" },
19
+ arrival: { iataCode: "CDG", at: "2026-07-01T10:00:00Z" },
20
+ },
21
+ ],
22
+ },
23
+ ],
24
+ },
25
+ passengers: [
26
+ { passengerId: "p1", type: "adult", firstName: "Ada", lastName: "Lovelace" },
27
+ ],
28
+ totalPrice: { amount: "250.50", currency: "EUR" },
29
+ createdAt: "2026-06-01T00:00:00Z",
30
+ ...over,
31
+ };
32
+ }
33
+ describe("helpers", () => {
34
+ it("parseAmountToCents rounds to cents and guards non-finite", () => {
35
+ expect(parseAmountToCents("250.50")).toBe(25050);
36
+ expect(parseAmountToCents("not-a-number")).toBe(0);
37
+ });
38
+ it("synthesizeBilling prefers contact, falls back to passenger then defaults", () => {
39
+ const pax = {
40
+ passengerId: "p1",
41
+ type: "adult",
42
+ firstName: "Ada",
43
+ lastName: "Lovelace",
44
+ email: "pax@x.com",
45
+ };
46
+ expect(synthesizeBilling(pax, { email: "c@x.com" })).toMatchObject({
47
+ email: "c@x.com",
48
+ phone: "0000000000",
49
+ firstName: "Ada",
50
+ lastName: "Lovelace",
51
+ country: 642,
52
+ });
53
+ expect(synthesizeBilling(pax, undefined).email).toBe("pax@x.com");
54
+ });
55
+ it("buildFlightSummary renders route + passenger names", () => {
56
+ const summary = buildFlightSummary(stubOrder());
57
+ expect(summary).toContain("LHR → CDG");
58
+ expect(summary).toContain("Ada Lovelace");
59
+ });
60
+ });
61
+ describe("createFlightOrderPaymentIntegration", () => {
62
+ function stubSessions(over = {}) {
63
+ return {
64
+ ensureSession: vi.fn(async () => ({ sessionId: "ps_1", status: "pending" })),
65
+ fetchSessions: vi.fn(async () => new Map()),
66
+ ...over,
67
+ };
68
+ }
69
+ it("ensureOrderSession maps the order onto generic session params", async () => {
70
+ const orderPaymentSessions = stubSessions();
71
+ const integration = createFlightOrderPaymentIntegration({ orderPaymentSessions });
72
+ const result = await integration.ensureOrderSession(stubContext(), stubOrder(), {
73
+ email: "c@x.com",
74
+ });
75
+ expect(result).toEqual({ sessionId: "ps_1", status: "pending" });
76
+ expect(orderPaymentSessions.ensureSession).toHaveBeenCalledWith(DB, expect.objectContaining({
77
+ targetId: "ord_1",
78
+ currency: "EUR",
79
+ amountCents: 25050,
80
+ payerEmail: "c@x.com",
81
+ payerName: "Ada Lovelace",
82
+ notes: expect.stringContaining("LHR → CDG"),
83
+ }), undefined);
84
+ });
85
+ it("returns null when the order has no passengers", async () => {
86
+ const orderPaymentSessions = stubSessions();
87
+ const integration = createFlightOrderPaymentIntegration({ orderPaymentSessions });
88
+ const result = await integration.ensureOrderSession(stubContext(), stubOrder({ passengers: [] }));
89
+ expect(result).toBeNull();
90
+ expect(orderPaymentSessions.ensureSession).not.toHaveBeenCalled();
91
+ });
92
+ it("wires startCardPayment through the generic startProvider with synthesized billing", async () => {
93
+ const startCardPayment = vi.fn(async () => undefined);
94
+ const ensureSession = vi.fn(async (_db, _params, startProvider) => {
95
+ await startProvider?.(DB, "ps_new");
96
+ return { sessionId: "ps_new", status: "pending" };
97
+ });
98
+ const integration = createFlightOrderPaymentIntegration({
99
+ orderPaymentSessions: stubSessions({ ensureSession: ensureSession }),
100
+ startCardPayment,
101
+ });
102
+ const ctx = stubContext();
103
+ await integration.ensureOrderSession(ctx, stubOrder(), undefined);
104
+ expect(startCardPayment).toHaveBeenCalledWith(ctx, "ps_new", expect.objectContaining({ firstName: "Ada", lastName: "Lovelace", country: 642 }));
105
+ });
106
+ it("fetchOrderSessions delegates to the generic service", async () => {
107
+ const fetchSessions = vi.fn(async () => new Map([["ord_1", { sessionId: "ps_1", status: "paid" }]]));
108
+ const integration = createFlightOrderPaymentIntegration({
109
+ orderPaymentSessions: stubSessions({ fetchSessions: fetchSessions }),
110
+ });
111
+ const result = await integration.fetchOrderSessions(stubContext(), ["ord_1"]);
112
+ expect(result.get("ord_1")).toEqual({ sessionId: "ps_1", status: "paid" });
113
+ expect(fetchSessions).toHaveBeenCalledWith(DB, ["ord_1"]);
114
+ });
115
+ it("fetchOrderSessions short-circuits empty input", async () => {
116
+ const fetchSessions = vi.fn(async () => new Map());
117
+ const integration = createFlightOrderPaymentIntegration({
118
+ orderPaymentSessions: stubSessions({ fetchSessions: fetchSessions }),
119
+ });
120
+ const result = await integration.fetchOrderSessions(stubContext(), []);
121
+ expect(result.size).toBe(0);
122
+ expect(fetchSessions).not.toHaveBeenCalled();
123
+ });
124
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voyant-travel/flights",
3
- "version": "0.120.1",
3
+ "version": "0.122.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -53,6 +53,16 @@
53
53
  "types": "./dist/reference/static-bundle.d.ts",
54
54
  "import": "./dist/reference/static-bundle.js",
55
55
  "default": "./dist/reference/static-bundle.js"
56
+ },
57
+ "./hono": {
58
+ "types": "./dist/hono.d.ts",
59
+ "import": "./dist/hono.js",
60
+ "default": "./dist/hono.js"
61
+ },
62
+ "./payment-integration": {
63
+ "types": "./dist/payment-integration.d.ts",
64
+ "import": "./dist/payment-integration.js",
65
+ "default": "./dist/payment-integration.js"
56
66
  }
57
67
  },
58
68
  "voyant": {
@@ -69,10 +79,12 @@
69
79
  },
70
80
  "dependencies": {
71
81
  "drizzle-orm": "^0.45.2",
82
+ "hono": "^4.12.10",
72
83
  "zod": "^4.3.6",
73
84
  "@voyant-travel/db": "^0.108.1",
74
- "@voyant-travel/catalog": "^0.118.1",
75
- "@voyant-travel/flights-contracts": "^0.104.4"
85
+ "@voyant-travel/catalog": "^0.120.0",
86
+ "@voyant-travel/flights-contracts": "^0.104.4",
87
+ "@voyant-travel/hono": "^0.111.0"
76
88
  },
77
89
  "devDependencies": {
78
90
  "typescript": "^6.0.2",