@voyant-travel/quotes 0.119.3 → 0.120.1

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/index.d.ts CHANGED
@@ -8,6 +8,8 @@ export declare function createQuotesHonoModule(): HonoModule;
8
8
  export declare const quotesHonoModule: HonoModule;
9
9
  export type { BookingQuoteDetail, NewBookingQuoteDetail } from "./booking-extension.js";
10
10
  export { bookingQuoteDetails, bookingQuoteExtensionService, quotesBookingExtension, } from "./booking-extension.js";
11
+ export type { AcceptPublicProposalResult, ApplyTripSnapshotToQuoteVersionResult, DeclinePublicProposalResult, PublicQuoteVersionProposal, PublicQuoteVersionProposalLine, QuoteProposalRoutesOptions, SendQuoteVersionResult, } from "./proposal-routes.js";
12
+ export { buildQuoteVersionProposalUrl, createQuoteProposalAdminRoutes, createQuoteProposalPublicRoutes, createQuoteVersionSnapshotRoutes, tripSnapshotToQuoteVersionApply, } from "./proposal-routes.js";
11
13
  export type { NewPipeline, NewQuote, NewQuoteParticipant, NewQuoteProduct, NewQuoteVersion, NewQuoteVersionLine, NewStage, Pipeline, Quote, QuoteParticipant, QuoteProduct, QuoteVersion, QuoteVersionLine, Stage, } from "./schema.js";
12
14
  export { pipelines, quoteParticipants, quoteProducts, quoteStatusEnum, quotes, quoteVersionLines, quoteVersionStatusEnum, quoteVersions, stages, } from "./schema.js";
13
15
  export type { AcceptQuoteVersionResult } from "./service/index.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AACrE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAA;AAI5D,YAAY,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAErD,eAAO,MAAM,aAAa,EAAE,kBAK3B,CAAA;AAED,eAAO,MAAM,oBAAoB,EAAE,kBAKlC,CAAA;AAED,eAAO,MAAM,YAAY,EAAE,MAO1B,CAAA;AAED,wBAAgB,sBAAsB,IAAI,UAAU,CAKnD;AAED,eAAO,MAAM,gBAAgB,EAAE,UAAqC,CAAA;AAEpE,YAAY,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAA;AACvF,OAAO,EACL,mBAAmB,EACnB,4BAA4B,EAC5B,sBAAsB,GACvB,MAAM,wBAAwB,CAAA;AAC/B,YAAY,EACV,WAAW,EACX,QAAQ,EACR,mBAAmB,EACnB,eAAe,EACf,eAAe,EACf,mBAAmB,EACnB,QAAQ,EACR,QAAQ,EACR,KAAK,EACL,gBAAgB,EAChB,YAAY,EACZ,YAAY,EACZ,gBAAgB,EAChB,KAAK,GACN,MAAM,aAAa,CAAA;AACpB,OAAO,EACL,SAAS,EACT,iBAAiB,EACjB,aAAa,EACb,eAAe,EACf,MAAM,EACN,iBAAiB,EACjB,sBAAsB,EACtB,aAAa,EACb,MAAM,GACP,MAAM,aAAa,CAAA;AACpB,YAAY,EAAE,wBAAwB,EAAE,MAAM,oBAAoB,CAAA;AAClE,OAAO,EACL,gBAAgB,EAChB,yBAAyB,EACzB,mBAAmB,EACnB,aAAa,EACb,oBAAoB,GACrB,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EACL,wBAAwB,EACxB,uCAAuC,EACvC,qCAAqC,EACrC,yBAAyB,EACzB,gBAAgB,EAChB,yBAAyB,EACzB,oBAAoB,EACpB,4BAA4B,EAC5B,wBAAwB,EACxB,iBAAiB,EACjB,4BAA4B,EAC5B,wBAAwB,EACxB,iBAAiB,EACjB,qBAAqB,EACrB,uBAAuB,EACvB,oBAAoB,EACpB,iBAAiB,EACjB,2BAA2B,EAC3B,wBAAwB,EACxB,sBAAsB,EACtB,oBAAoB,EACpB,oBAAoB,EACpB,wBAAwB,EACxB,iBAAiB,EACjB,4BAA4B,EAC5B,wBAAwB,EACxB,iBAAiB,GAClB,MAAM,iBAAiB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AACrE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAA;AAI5D,YAAY,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAErD,eAAO,MAAM,aAAa,EAAE,kBAK3B,CAAA;AAED,eAAO,MAAM,oBAAoB,EAAE,kBAKlC,CAAA;AAED,eAAO,MAAM,YAAY,EAAE,MAO1B,CAAA;AAED,wBAAgB,sBAAsB,IAAI,UAAU,CAKnD;AAED,eAAO,MAAM,gBAAgB,EAAE,UAAqC,CAAA;AAEpE,YAAY,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAA;AACvF,OAAO,EACL,mBAAmB,EACnB,4BAA4B,EAC5B,sBAAsB,GACvB,MAAM,wBAAwB,CAAA;AAC/B,YAAY,EACV,0BAA0B,EAC1B,qCAAqC,EACrC,2BAA2B,EAC3B,0BAA0B,EAC1B,8BAA8B,EAC9B,0BAA0B,EAC1B,sBAAsB,GACvB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EACL,4BAA4B,EAC5B,8BAA8B,EAC9B,+BAA+B,EAC/B,gCAAgC,EAChC,+BAA+B,GAChC,MAAM,sBAAsB,CAAA;AAC7B,YAAY,EACV,WAAW,EACX,QAAQ,EACR,mBAAmB,EACnB,eAAe,EACf,eAAe,EACf,mBAAmB,EACnB,QAAQ,EACR,QAAQ,EACR,KAAK,EACL,gBAAgB,EAChB,YAAY,EACZ,YAAY,EACZ,gBAAgB,EAChB,KAAK,GACN,MAAM,aAAa,CAAA;AACpB,OAAO,EACL,SAAS,EACT,iBAAiB,EACjB,aAAa,EACb,eAAe,EACf,MAAM,EACN,iBAAiB,EACjB,sBAAsB,EACtB,aAAa,EACb,MAAM,GACP,MAAM,aAAa,CAAA;AACpB,YAAY,EAAE,wBAAwB,EAAE,MAAM,oBAAoB,CAAA;AAClE,OAAO,EACL,gBAAgB,EAChB,yBAAyB,EACzB,mBAAmB,EACnB,aAAa,EACb,oBAAoB,GACrB,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EACL,wBAAwB,EACxB,uCAAuC,EACvC,qCAAqC,EACrC,yBAAyB,EACzB,gBAAgB,EAChB,yBAAyB,EACzB,oBAAoB,EACpB,4BAA4B,EAC5B,wBAAwB,EACxB,iBAAiB,EACjB,4BAA4B,EAC5B,wBAAwB,EACxB,iBAAiB,EACjB,qBAAqB,EACrB,uBAAuB,EACvB,oBAAoB,EACpB,iBAAiB,EACjB,2BAA2B,EAC3B,wBAAwB,EACxB,sBAAsB,EACtB,oBAAoB,EACpB,oBAAoB,EACpB,wBAAwB,EACxB,iBAAiB,EACjB,4BAA4B,EAC5B,wBAAwB,EACxB,iBAAiB,GAClB,MAAM,iBAAiB,CAAA"}
package/dist/index.js CHANGED
@@ -27,6 +27,7 @@ export function createQuotesHonoModule() {
27
27
  }
28
28
  export const quotesHonoModule = createQuotesHonoModule();
29
29
  export { bookingQuoteDetails, bookingQuoteExtensionService, quotesBookingExtension, } from "./booking-extension.js";
30
+ export { buildQuoteVersionProposalUrl, createQuoteProposalAdminRoutes, createQuoteProposalPublicRoutes, createQuoteVersionSnapshotRoutes, tripSnapshotToQuoteVersionApply, } from "./proposal-routes.js";
30
31
  export { pipelines, quoteParticipants, quoteProducts, quoteStatusEnum, quotes, quoteVersionLines, quoteVersionStatusEnum, quoteVersions, stages, } from "./schema.js";
31
32
  export { pipelinesService, QuoteVersionConflictError, quoteRecordsService, quotesService, quoteVersionsService, } from "./service/index.js";
32
33
  export { acceptQuoteVersionSchema, applyTripSnapshotQuoteVersionLineSchema, applyTripSnapshotToQuoteVersionSchema, declineQuoteVersionSchema, entityTypeSchema, expireQuoteVersionsSchema, insertPipelineSchema, insertQuoteParticipantSchema, insertQuoteProductSchema, insertQuoteSchema, insertQuoteVersionLineSchema, insertQuoteVersionSchema, insertStageSchema, participantRoleSchema, pipelineListQuerySchema, quoteListQuerySchema, quoteStatusSchema, quoteVersionListQuerySchema, quoteVersionStatusSchema, sendQuoteVersionSchema, stageListQuerySchema, updatePipelineSchema, updateQuoteProductSchema, updateQuoteSchema, updateQuoteVersionLineSchema, updateQuoteVersionSchema, updateStageSchema, } from "./validation.js";
@@ -0,0 +1,98 @@
1
+ import { type ReserveTripDeps, type StartCheckoutDeps, type TripSnapshot } from "@voyant-travel/trips";
2
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
3
+ import { type Context, Hono } from "hono";
4
+ import type { QuoteVersion, QuoteVersionLine } from "./schema.js";
5
+ import { quotesService } from "./service/index.js";
6
+ /**
7
+ * Deployment-supplied dependencies for the quote proposal + snapshot routes.
8
+ *
9
+ * Generic / structural types keep the quotes package free of operator types and
10
+ * CloudflareBindings — the deployment casts `c.get("db")` to its own concrete
11
+ * type inside `resolveDb` and supplies its trips reserve/checkout deps and
12
+ * public operator profile.
13
+ */
14
+ export interface QuoteProposalRoutesOptions {
15
+ /** Resolve the concrete transactional db for a request. */
16
+ resolveDb(c: Context): PostgresJsDatabase;
17
+ /**
18
+ * Resolve the public base URL for proposal links (e.g. the customer dashboard
19
+ * origin). Returns `null` to emit a root-relative path.
20
+ */
21
+ resolvePublicProposalBaseUrl(c: Context): string | null;
22
+ /** Build the trips reserve deps for a request (catalog/non-catalog wiring). */
23
+ reserveTripDeps(c: Context): ReserveTripDeps;
24
+ /** Build the trips checkout deps for a request (payment-session wiring). */
25
+ startCheckoutDeps(c: Context): StartCheckoutDeps;
26
+ /**
27
+ * Resolve the deployment's public operator profile, surfaced on the public
28
+ * proposal payload. Returns `null` when no profile is configured.
29
+ */
30
+ resolveOperatorProfile(db: PostgresJsDatabase): Promise<unknown | null>;
31
+ }
32
+ type OperatorProposalRouteEnv = {
33
+ Bindings: Record<string, unknown>;
34
+ Variables: {
35
+ db: unknown;
36
+ userId?: string;
37
+ };
38
+ };
39
+ type OperatorQuoteVersionSnapshotRouteEnv = {
40
+ Variables: {
41
+ db: unknown;
42
+ userId?: string;
43
+ };
44
+ };
45
+ export interface PublicQuoteVersionProposal {
46
+ title: string;
47
+ status: QuoteVersion["status"];
48
+ currency: string;
49
+ subtotalAmountCents: number;
50
+ taxAmountCents: number;
51
+ totalAmountCents: number;
52
+ validUntil: string | null;
53
+ lines: PublicQuoteVersionProposalLine[];
54
+ operator: unknown | null;
55
+ proposalUrl: string;
56
+ }
57
+ export interface PublicQuoteVersionProposalLine {
58
+ description: string;
59
+ quantity: number;
60
+ unitPriceAmountCents: number;
61
+ totalAmountCents: number;
62
+ currency: string;
63
+ }
64
+ export interface SendQuoteVersionResult {
65
+ quoteVersion: QuoteVersion;
66
+ proposalUrl: string;
67
+ }
68
+ export interface DeclinePublicProposalResult {
69
+ status: QuoteVersion["status"];
70
+ }
71
+ export interface AcceptPublicProposalResult {
72
+ status: Extract<QuoteVersion["status"], "accepted">;
73
+ checkoutUrl: string | null;
74
+ paymentSessionId: string | null;
75
+ currency: string;
76
+ totalAmountCents: number;
77
+ warnings: string[];
78
+ }
79
+ export type ApplyTripSnapshotToQuoteVersionResult = {
80
+ snapshot: TripSnapshot;
81
+ quoteVersion: QuoteVersion;
82
+ lines: QuoteVersionLine[];
83
+ };
84
+ type ApplyTripSnapshotPayload = Parameters<typeof quotesService.applyTripSnapshotToQuoteVersion>[2];
85
+ /** Build a proposal URL — absolute when a base URL is supplied, else root-relative. */
86
+ export declare function buildQuoteVersionProposalUrl(quoteVersionId: string, options?: {
87
+ baseUrl?: string | null;
88
+ }): string;
89
+ /** Build the admin proposal routes (relative paths; mount at `/v1/admin/quote-versions`). */
90
+ export declare function createQuoteProposalAdminRoutes(options: QuoteProposalRoutesOptions): Hono<OperatorProposalRouteEnv>;
91
+ /** Build the public proposal routes (relative paths; mount at `/v1/public/proposals`). */
92
+ export declare function createQuoteProposalPublicRoutes(options: QuoteProposalRoutesOptions): Hono<OperatorProposalRouteEnv>;
93
+ /** Build the Trip-snapshot freeze route (relative path; mount at `/v1/admin/trips`). */
94
+ export declare function createQuoteVersionSnapshotRoutes(options: QuoteProposalRoutesOptions): Hono<OperatorQuoteVersionSnapshotRouteEnv>;
95
+ /** Map a frozen Trip snapshot's proposal into a quote-version apply payload. */
96
+ export declare function tripSnapshotToQuoteVersionApply(snapshot: TripSnapshot): ApplyTripSnapshotPayload;
97
+ export {};
98
+ //# sourceMappingURL=proposal-routes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proposal-routes.d.ts","sourceRoot":"","sources":["../src/proposal-routes.ts"],"names":[],"mappings":"AA4BA,OAAO,EACL,KAAK,eAAe,EACpB,KAAK,iBAAiB,EAGtB,KAAK,YAAY,EAIlB,MAAM,sBAAsB,CAAA;AAE7B,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AACjE,OAAO,EAAE,KAAK,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAGzC,OAAO,KAAK,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AACjE,OAAO,EAA6B,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAG7E;;;;;;;GAOG;AACH,MAAM,WAAW,0BAA0B;IACzC,2DAA2D;IAC3D,SAAS,CAAC,CAAC,EAAE,OAAO,GAAG,kBAAkB,CAAA;IACzC;;;OAGG;IACH,4BAA4B,CAAC,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAAA;IACvD,+EAA+E;IAC/E,eAAe,CAAC,CAAC,EAAE,OAAO,GAAG,eAAe,CAAA;IAC5C,4EAA4E;IAC5E,iBAAiB,CAAC,CAAC,EAAE,OAAO,GAAG,iBAAiB,CAAA;IAChD;;;OAGG;IACH,sBAAsB,CAAC,EAAE,EAAE,kBAAkB,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAA;CACxE;AAED,KAAK,wBAAwB,GAAG;IAC9B,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACjC,SAAS,EAAE;QACT,EAAE,EAAE,OAAO,CAAA;QACX,MAAM,CAAC,EAAE,MAAM,CAAA;KAChB,CAAA;CACF,CAAA;AAED,KAAK,oCAAoC,GAAG;IAC1C,SAAS,EAAE;QACT,EAAE,EAAE,OAAO,CAAA;QACX,MAAM,CAAC,EAAE,MAAM,CAAA;KAChB,CAAA;CACF,CAAA;AAED,MAAM,WAAW,0BAA0B;IACzC,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,YAAY,CAAC,QAAQ,CAAC,CAAA;IAC9B,QAAQ,EAAE,MAAM,CAAA;IAChB,mBAAmB,EAAE,MAAM,CAAA;IAC3B,cAAc,EAAE,MAAM,CAAA;IACtB,gBAAgB,EAAE,MAAM,CAAA;IACxB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,KAAK,EAAE,8BAA8B,EAAE,CAAA;IACvC,QAAQ,EAAE,OAAO,GAAG,IAAI,CAAA;IACxB,WAAW,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,8BAA8B;IAC7C,WAAW,EAAE,MAAM,CAAA;IACnB,QAAQ,EAAE,MAAM,CAAA;IAChB,oBAAoB,EAAE,MAAM,CAAA;IAC5B,gBAAgB,EAAE,MAAM,CAAA;IACxB,QAAQ,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,WAAW,sBAAsB;IACrC,YAAY,EAAE,YAAY,CAAA;IAC1B,WAAW,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,2BAA2B;IAC1C,MAAM,EAAE,YAAY,CAAC,QAAQ,CAAC,CAAA;CAC/B;AAED,MAAM,WAAW,0BAA0B;IACzC,MAAM,EAAE,OAAO,CAAC,YAAY,CAAC,QAAQ,CAAC,EAAE,UAAU,CAAC,CAAA;IACnD,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAA;IAC/B,QAAQ,EAAE,MAAM,CAAA;IAChB,gBAAgB,EAAE,MAAM,CAAA;IACxB,QAAQ,EAAE,MAAM,EAAE,CAAA;CACnB;AAED,MAAM,MAAM,qCAAqC,GAAG;IAClD,QAAQ,EAAE,YAAY,CAAA;IACtB,YAAY,EAAE,YAAY,CAAA;IAC1B,KAAK,EAAE,gBAAgB,EAAE,CAAA;CAC1B,CAAA;AAED,KAAK,wBAAwB,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,+BAA+B,CAAC,CAAC,CAAC,CAAC,CAAA;AA0BnG,uFAAuF;AACvF,wBAAgB,4BAA4B,CAC1C,cAAc,EAAE,MAAM,EACtB,OAAO,GAAE;IAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAO,UAK1C;AAgCD,6FAA6F;AAC7F,wBAAgB,8BAA8B,CAC5C,OAAO,EAAE,0BAA0B,GAClC,IAAI,CAAC,wBAAwB,CAAC,CAIhC;AAED,0FAA0F;AAC1F,wBAAgB,+BAA+B,CAC7C,OAAO,EAAE,0BAA0B,GAClC,IAAI,CAAC,wBAAwB,CAAC,CAMhC;AAED,wFAAwF;AACxF,wBAAgB,gCAAgC,CAC9C,OAAO,EAAE,0BAA0B,GAClC,IAAI,CAAC,oCAAoC,CAAC,CAM5C;AA6bD,gFAAgF;AAChF,wBAAgB,+BAA+B,CAAC,QAAQ,EAAE,YAAY,GAAG,wBAAwB,CAUhG"}
@@ -0,0 +1,445 @@
1
+ /**
2
+ * Quote-version proposal + Trip-snapshot HTTP routes, owned by the quotes
3
+ * module.
4
+ *
5
+ * agent-quality: file-size exception -- the proposal lifecycle (admin send,
6
+ * public get/accept/decline) plus the Trip-snapshot freeze form one cohesive
7
+ * route family backed by the same quotes/trips services; splitting it would
8
+ * scatter a single accept-under-lock contract.
9
+ *
10
+ * Admin proposal (mount at /v1/admin/quote-versions):
11
+ * POST /:quoteVersionId/send
12
+ * Public proposal (mount at /v1/public/proposals):
13
+ * GET /:quoteVersionId
14
+ * POST /:quoteVersionId/accept
15
+ * POST /:quoteVersionId/decline
16
+ * Admin snapshot (mount at /v1/admin/trips):
17
+ * POST /:envelopeId/quote-versions/:quoteVersionId/snapshot
18
+ *
19
+ * These shapes (validation, status codes, the accept-under-advisory-lock flow,
20
+ * the snapshot↔proposal equivalence checks, and the pure
21
+ * `tripSnapshotToQuoteVersionApply` mapper) are framework logic and live here.
22
+ *
23
+ * The deployment supplies the concrete db resolver, the public proposal base
24
+ * URL resolver, the trips reserve/checkout deps, and the public operator
25
+ * profile via `QuoteProposalRoutesOptions` — all generic / structural so this
26
+ * package stays free of operator types and CloudflareBindings.
27
+ */
28
+ import { parseJsonBody, parseOptionalJsonBody } from "@voyant-travel/hono";
29
+ import { TripsInvariantError, tripsService, } from "@voyant-travel/trips";
30
+ import { sql } from "drizzle-orm";
31
+ import { Hono } from "hono";
32
+ import { z } from "zod";
33
+ import { QuoteVersionConflictError, quotesService } from "./service/index.js";
34
+ import { sendQuoteVersionSchema } from "./validation.js";
35
+ const acceptPublicProposalSchema = z.object({
36
+ intent: z.enum(["card", "bank_transfer"]).default("card"),
37
+ idempotencyKey: z.string().min(1).max(120).optional(),
38
+ });
39
+ const freezeQuoteVersionSnapshotBodySchema = z.object({
40
+ createdBy: z.string().min(1).nullable().optional(),
41
+ });
42
+ /** Build a proposal URL — absolute when a base URL is supplied, else root-relative. */
43
+ export function buildQuoteVersionProposalUrl(quoteVersionId, options = {}) {
44
+ const path = `/proposal/${encodeURIComponent(quoteVersionId)}`;
45
+ const baseUrl = options.baseUrl?.trim().replace(/\/+$/, "");
46
+ return baseUrl ? `${baseUrl}${path}` : path;
47
+ }
48
+ function toPublicQuoteVersionProposal(proposal, options) {
49
+ const quoteVersion = options.quoteVersion ?? proposal.quoteVersion;
50
+ return {
51
+ title: proposal.quote.title,
52
+ status: quoteVersion.status,
53
+ currency: quoteVersion.currency,
54
+ subtotalAmountCents: quoteVersion.subtotalAmountCents,
55
+ taxAmountCents: quoteVersion.taxAmountCents,
56
+ totalAmountCents: quoteVersion.totalAmountCents,
57
+ validUntil: quoteVersion.validUntil,
58
+ lines: proposal.lines.map((line) => ({
59
+ description: line.description,
60
+ quantity: line.quantity,
61
+ unitPriceAmountCents: line.unitPriceAmountCents,
62
+ totalAmountCents: line.totalAmountCents,
63
+ currency: line.currency,
64
+ })),
65
+ operator: options.operator,
66
+ proposalUrl: options.proposalUrl,
67
+ };
68
+ }
69
+ /** Build the admin proposal routes (relative paths; mount at `/v1/admin/quote-versions`). */
70
+ export function createQuoteProposalAdminRoutes(options) {
71
+ const app = new Hono();
72
+ app.post("/:quoteVersionId/send", (c) => handleSendQuoteVersion(c, options));
73
+ return app;
74
+ }
75
+ /** Build the public proposal routes (relative paths; mount at `/v1/public/proposals`). */
76
+ export function createQuoteProposalPublicRoutes(options) {
77
+ const app = new Hono();
78
+ app.get("/:quoteVersionId", (c) => handleGetPublicProposal(c, options));
79
+ app.post("/:quoteVersionId/accept", (c) => handleAcceptPublicProposal(c, options));
80
+ app.post("/:quoteVersionId/decline", (c) => handleDeclinePublicProposal(c, options));
81
+ return app;
82
+ }
83
+ /** Build the Trip-snapshot freeze route (relative path; mount at `/v1/admin/trips`). */
84
+ export function createQuoteVersionSnapshotRoutes(options) {
85
+ const app = new Hono();
86
+ app.post("/:envelopeId/quote-versions/:quoteVersionId/snapshot", (c) => handleFreezeQuoteVersionSnapshot(c, options));
87
+ return app;
88
+ }
89
+ async function handleSendQuoteVersion(c, options) {
90
+ const quoteVersionId = c.req.param("quoteVersionId");
91
+ if (!quoteVersionId)
92
+ return c.json({ error: "Quote Version id is required" }, 400);
93
+ try {
94
+ const quoteVersion = await quotesService.sendQuoteVersion(options.resolveDb(c), quoteVersionId, await parseOptionalJsonBody(c, sendQuoteVersionSchema));
95
+ if (!quoteVersion)
96
+ return c.json({ error: "Quote Version not found" }, 404);
97
+ return c.json({
98
+ data: {
99
+ quoteVersion,
100
+ proposalUrl: buildQuoteVersionProposalUrl(quoteVersion.id, {
101
+ baseUrl: options.resolvePublicProposalBaseUrl(c),
102
+ }),
103
+ },
104
+ });
105
+ }
106
+ catch (error) {
107
+ if (error instanceof QuoteVersionConflictError) {
108
+ return c.json({ error: error.message }, 409);
109
+ }
110
+ throw error;
111
+ }
112
+ }
113
+ async function handleGetPublicProposal(c, options) {
114
+ const quoteVersionId = c.req.param("quoteVersionId");
115
+ if (!quoteVersionId)
116
+ return c.json({ error: "Quote Version id is required" }, 400);
117
+ const db = options.resolveDb(c);
118
+ await quotesService.expireQuoteVersionIfPastValidUntil(db, quoteVersionId);
119
+ const proposal = await quotesService.getQuoteVersionProposal(db, quoteVersionId);
120
+ if (!proposal)
121
+ return c.json({ error: "Proposal not found" }, 404);
122
+ if (proposal.quoteVersion.status === "draft")
123
+ return c.json({ error: "Proposal not found" }, 404);
124
+ if (proposal.quoteVersion.status === "superseded") {
125
+ return c.json({ error: "Proposal has been superseded" }, 410);
126
+ }
127
+ const viewedQuoteVersion = proposal.quoteVersion.status === "sent"
128
+ ? await quotesService.markQuoteVersionViewed(db, quoteVersionId)
129
+ : proposal.quoteVersion;
130
+ const operator = await options.resolveOperatorProfile(db);
131
+ return c.json({
132
+ data: toPublicQuoteVersionProposal(proposal, {
133
+ quoteVersion: viewedQuoteVersion,
134
+ operator: operator ?? null,
135
+ proposalUrl: buildQuoteVersionProposalUrl(quoteVersionId, {
136
+ baseUrl: options.resolvePublicProposalBaseUrl(c),
137
+ }),
138
+ }),
139
+ });
140
+ }
141
+ async function handleDeclinePublicProposal(c, options) {
142
+ const quoteVersionId = c.req.param("quoteVersionId");
143
+ if (!quoteVersionId)
144
+ return c.json({ error: "Quote Version id is required" }, 400);
145
+ const db = options.resolveDb(c);
146
+ await quotesService.expireQuoteVersionIfPastValidUntil(db, quoteVersionId);
147
+ const proposal = await quotesService.getQuoteVersionProposal(db, quoteVersionId);
148
+ if (!proposal)
149
+ return c.json({ error: "Proposal not found" }, 404);
150
+ if (proposal.quoteVersion.status === "draft")
151
+ return c.json({ error: "Proposal not found" }, 404);
152
+ if (proposal.quoteVersion.status === "superseded") {
153
+ return c.json({ error: "Proposal has been superseded" }, 410);
154
+ }
155
+ try {
156
+ const quoteVersion = await quotesService.declineQuoteVersion(db, quoteVersionId);
157
+ if (!quoteVersion)
158
+ return c.json({ error: "Proposal not found" }, 404);
159
+ return c.json({
160
+ data: { status: quoteVersion.status },
161
+ });
162
+ }
163
+ catch (error) {
164
+ if (error instanceof QuoteVersionConflictError) {
165
+ return c.json({ error: error.message }, 409);
166
+ }
167
+ throw error;
168
+ }
169
+ }
170
+ async function handleAcceptPublicProposal(c, options) {
171
+ const quoteVersionId = c.req.param("quoteVersionId");
172
+ if (!quoteVersionId)
173
+ return c.json({ error: "Quote Version id is required" }, 400);
174
+ const body = await parseOptionalJsonBody(c, acceptPublicProposalSchema);
175
+ const db = options.resolveDb(c);
176
+ const proposalForLock = await quotesService.getQuoteVersionProposal(db, quoteVersionId);
177
+ if (!proposalForLock)
178
+ return c.json({ error: "Proposal not found" }, 404);
179
+ try {
180
+ const lockedResult = await db.transaction((tx) => acceptPublicProposalWithQuoteLock({
181
+ c,
182
+ options,
183
+ db: tx,
184
+ quoteId: proposalForLock.quote.id,
185
+ quoteVersionId,
186
+ body,
187
+ }));
188
+ if (lockedResult.kind === "response")
189
+ return lockedResult.response;
190
+ const checkout = await startAcceptedProposalCheckout(c, options, lockedResult.snapshot, body, quoteVersionId);
191
+ const checkoutWarnings = checkout
192
+ ? checkout.failures.map((failure) => failure.reason)
193
+ : ["checkout_start_failed"];
194
+ return c.json({
195
+ data: {
196
+ status: "accepted",
197
+ checkoutUrl: checkout?.target.checkoutUrl ?? null,
198
+ paymentSessionId: checkout?.target.paymentSessionId ?? null,
199
+ currency: checkout?.target.currency ?? lockedResult.accepted.quoteVersion.currency,
200
+ totalAmountCents: checkout?.target.totalAmountCents ?? lockedResult.accepted.quoteVersion.totalAmountCents,
201
+ warnings: [...lockedResult.warnings, ...(checkout?.warnings ?? []), ...checkoutWarnings],
202
+ },
203
+ });
204
+ }
205
+ catch (error) {
206
+ if (error instanceof QuoteVersionConflictError) {
207
+ return c.json({ error: error.message }, 409);
208
+ }
209
+ if (error instanceof TripsInvariantError) {
210
+ return c.json({ error: error.message }, error.message.includes("was not found") ? 404 : 409);
211
+ }
212
+ throw error;
213
+ }
214
+ }
215
+ async function acceptPublicProposalWithQuoteLock({ c, options, db, quoteId, quoteVersionId, body, }) {
216
+ await lockQuoteAccept(db, quoteId);
217
+ await quotesService.expireQuoteVersionIfPastValidUntil(db, quoteVersionId);
218
+ const proposal = await quotesService.getQuoteVersionProposal(db, quoteVersionId);
219
+ if (!proposal)
220
+ return { kind: "response", response: c.json({ error: "Proposal not found" }, 404) };
221
+ if (proposal.quoteVersion.status === "draft") {
222
+ return { kind: "response", response: c.json({ error: "Proposal not found" }, 404) };
223
+ }
224
+ if (proposal.quoteVersion.status === "superseded") {
225
+ return {
226
+ kind: "response",
227
+ response: c.json({ error: "Proposal has been superseded" }, 410),
228
+ };
229
+ }
230
+ const isAcceptedReplay = proposal.quoteVersion.status === "accepted" &&
231
+ proposal.quote.acceptedVersionId === proposal.quoteVersion.id;
232
+ if (proposal.quoteVersion.status !== "sent" && !isAcceptedReplay) {
233
+ return {
234
+ kind: "response",
235
+ response: c.json({ error: "Proposal can no longer be accepted" }, 409),
236
+ };
237
+ }
238
+ if (!proposal.quoteVersion.tripSnapshotId) {
239
+ return {
240
+ kind: "response",
241
+ response: c.json({ error: "Proposal has no frozen Trip snapshot" }, 409),
242
+ };
243
+ }
244
+ const snapshot = await tripsService.getTripSnapshotById(db, proposal.quoteVersion.tripSnapshotId);
245
+ if (!snapshot) {
246
+ return {
247
+ kind: "response",
248
+ response: c.json({ error: "Proposal Trip snapshot not found" }, 409),
249
+ };
250
+ }
251
+ assertProposalMatchesTripSnapshot(proposal, snapshot);
252
+ if (isAcceptedReplay) {
253
+ const accepted = await quotesService.acceptQuoteVersion(db, quoteVersionId, {});
254
+ if (!accepted) {
255
+ return { kind: "response", response: c.json({ error: "Proposal not found" }, 404) };
256
+ }
257
+ return { kind: "accepted", accepted, snapshot, warnings: [] };
258
+ }
259
+ assertSnapshotCanUsePublicAcceptReserve(snapshot);
260
+ const liveTrip = await tripsService.getTrip(db, snapshot.envelopeId);
261
+ if (!liveTrip) {
262
+ return {
263
+ kind: "response",
264
+ response: c.json({ error: "Proposal Trip envelope not found" }, 409),
265
+ };
266
+ }
267
+ assertLiveTripMatchesSnapshot(liveTrip, snapshot);
268
+ const reserveIdempotencyKey = `proposal-accept-reserve:${quoteVersionId}:${body.idempotencyKey ?? "default"}`;
269
+ const reserved = await tripsService.reserveTrip(db, {
270
+ envelopeId: snapshot.envelopeId,
271
+ idempotencyKey: reserveIdempotencyKey,
272
+ refreshScope: {
273
+ locale: "en-US",
274
+ audience: "customer",
275
+ market: "default",
276
+ currency: snapshot.currency,
277
+ },
278
+ }, options.reserveTripDeps(c));
279
+ if (reserved.failures.length > 0) {
280
+ return {
281
+ kind: "response",
282
+ response: c.json({
283
+ error: "Proposal could not be reserved",
284
+ failures: reserved.failures.map(({ code, reason }) => ({ code, reason })),
285
+ }, 409),
286
+ };
287
+ }
288
+ const accepted = await quotesService.acceptQuoteVersion(db, quoteVersionId, {});
289
+ if (!accepted)
290
+ return { kind: "response", response: c.json({ error: "Proposal not found" }, 404) };
291
+ return { kind: "accepted", accepted, snapshot, warnings: reserved.warnings };
292
+ }
293
+ function lockQuoteAccept(db, quoteId) {
294
+ return db.execute(
295
+ // agent-quality: raw-sql reviewed -- owner: quotes; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
296
+ sql `SELECT pg_advisory_xact_lock(hashtextextended(${quoteAcceptLockKey(quoteId)}, 0))`);
297
+ }
298
+ function quoteAcceptLockKey(quoteId) {
299
+ return `quote-accept:${quoteId}`;
300
+ }
301
+ function assertSnapshotCanUsePublicAcceptReserve(snapshot) {
302
+ const sourcedCatalogComponent = snapshot.frozenComponents.find(isSourcedCatalogSnapshotComponent);
303
+ if (!sourcedCatalogComponent)
304
+ return;
305
+ // reserveTrip runs under the Quote accept transaction. Owned catalog holds
306
+ // and manual placeholders are DB-local, but sourced catalog adapters can
307
+ // create upstream holds before local release records commit.
308
+ throw new QuoteVersionConflictError("Sourced catalog components cannot be accepted from public proposals yet");
309
+ }
310
+ function isSourcedCatalogSnapshotComponent(component) {
311
+ return Boolean(component.kind === "catalog_booking" &&
312
+ component.entityModule &&
313
+ component.entityId &&
314
+ component.sourceKind &&
315
+ component.sourceKind !== "owned");
316
+ }
317
+ async function startAcceptedProposalCheckout(c, options, snapshot, body, quoteVersionId) {
318
+ try {
319
+ return await tripsService.startCheckout(options.resolveDb(c), {
320
+ envelopeId: snapshot.envelopeId,
321
+ intent: body.intent,
322
+ idempotencyKey: `proposal-accept-checkout:${quoteVersionId}:${body.intent}:${body.idempotencyKey ?? "default"}`,
323
+ request: {
324
+ initiatedBy: "public-proposal",
325
+ collectionCurrency: snapshot.currency,
326
+ },
327
+ }, options.startCheckoutDeps(c));
328
+ }
329
+ catch (error) {
330
+ console.warn("[proposal] checkout handoff failed after proposal acceptance:", error);
331
+ return null;
332
+ }
333
+ }
334
+ function assertLiveTripMatchesSnapshot(trip, snapshot) {
335
+ const liveComponents = trip.components.filter((component) => component.status !== "removed");
336
+ if (stableSnapshotString(trip.envelope) !== stableSnapshotString(snapshot.frozenEnvelope) ||
337
+ stableSnapshotString(liveComponents) !== stableSnapshotString(snapshot.frozenComponents)) {
338
+ throw new QuoteVersionConflictError("Proposal Trip has changed since this Quote Version was sent");
339
+ }
340
+ }
341
+ function assertProposalMatchesTripSnapshot(proposal, snapshot) {
342
+ const expected = tripSnapshotToQuoteVersionApply(snapshot);
343
+ const actual = proposal.quoteVersion;
344
+ if (actual.tripSnapshotId !== snapshot.id ||
345
+ actual.currency !== expected.currency ||
346
+ actual.subtotalAmountCents !== expected.subtotalAmountCents ||
347
+ actual.taxAmountCents !== expected.taxAmountCents ||
348
+ actual.totalAmountCents !== expected.totalAmountCents ||
349
+ proposal.lines.length !== expected.lines.length) {
350
+ throw new QuoteVersionConflictError("Proposal does not match its frozen Trip snapshot");
351
+ }
352
+ for (const [index, expectedLine] of expected.lines.entries()) {
353
+ const actualLine = proposal.lines[index];
354
+ if (!actualLine ||
355
+ actualLine.description !== expectedLine.description ||
356
+ actualLine.quantity !== expectedLine.quantity ||
357
+ actualLine.unitPriceAmountCents !== expectedLine.unitPriceAmountCents ||
358
+ actualLine.totalAmountCents !== expectedLine.totalAmountCents ||
359
+ actualLine.currency !== expectedLine.currency) {
360
+ throw new QuoteVersionConflictError("Proposal does not match its frozen Trip snapshot");
361
+ }
362
+ }
363
+ }
364
+ function stableSnapshotString(value) {
365
+ return JSON.stringify(canonicalSnapshotValue(value));
366
+ }
367
+ function canonicalSnapshotValue(value) {
368
+ if (value instanceof Date)
369
+ return value.toISOString();
370
+ if (Array.isArray(value))
371
+ return value.map(canonicalSnapshotValue);
372
+ if (value && typeof value === "object") {
373
+ return Object.fromEntries(Object.entries(value)
374
+ .filter(([, entryValue]) => entryValue !== undefined)
375
+ .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
376
+ .map(([key, entryValue]) => [key, canonicalSnapshotValue(entryValue)]));
377
+ }
378
+ return value;
379
+ }
380
+ async function handleFreezeQuoteVersionSnapshot(c, options) {
381
+ const envelopeId = c.req.param("envelopeId");
382
+ const quoteVersionId = c.req.param("quoteVersionId");
383
+ if (!envelopeId)
384
+ return c.json({ error: "Trip envelope id is required" }, 400);
385
+ if (!quoteVersionId)
386
+ return c.json({ error: "Quote version id is required" }, 400);
387
+ const db = options.resolveDb(c);
388
+ const body = await parseJsonBody(c, freezeQuoteVersionSnapshotBodySchema);
389
+ try {
390
+ const quoteVersion = await quotesService.getQuoteVersionById(db, quoteVersionId);
391
+ if (!quoteVersion)
392
+ return c.json({ error: "Quote version not found" }, 404);
393
+ if (quoteVersion.status !== "draft") {
394
+ return c.json({ error: "Trip snapshots can only be applied to draft Quote Versions" }, 409);
395
+ }
396
+ const userId = c.get("userId");
397
+ const snapshot = await tripsService.freezeTripSnapshot(db, {
398
+ envelopeId,
399
+ createdBy: typeof userId === "string" ? userId : (body.createdBy ?? undefined),
400
+ });
401
+ const applied = await quotesService.applyTripSnapshotToQuoteVersion(db, quoteVersionId, tripSnapshotToQuoteVersionApply(snapshot));
402
+ if (!applied)
403
+ return c.json({ error: "Quote version not found" }, 404);
404
+ return c.json({
405
+ data: {
406
+ snapshot,
407
+ quoteVersion: applied.quoteVersion,
408
+ lines: applied.lines,
409
+ },
410
+ }, 201);
411
+ }
412
+ catch (error) {
413
+ if (error instanceof TripsInvariantError) {
414
+ return c.json({ error: error.message }, error.message.includes("was not found") ? 404 : 409);
415
+ }
416
+ if (error instanceof QuoteVersionConflictError) {
417
+ return c.json({ error: error.message }, 409);
418
+ }
419
+ throw error;
420
+ }
421
+ }
422
+ /** Map a frozen Trip snapshot's proposal into a quote-version apply payload. */
423
+ export function tripSnapshotToQuoteVersionApply(snapshot) {
424
+ const proposal = snapshot.proposal;
425
+ return {
426
+ tripSnapshotId: snapshot.id,
427
+ currency: proposal.currency,
428
+ subtotalAmountCents: proposal.subtotalAmountCents,
429
+ taxAmountCents: proposal.taxAmountCents,
430
+ totalAmountCents: proposal.totalAmountCents,
431
+ lines: proposal.lines.map(tripSnapshotLineToQuoteVersionLine),
432
+ };
433
+ }
434
+ function tripSnapshotLineToQuoteVersionLine(line) {
435
+ return {
436
+ componentId: line.componentId,
437
+ productId: line.entityModule === "products" ? line.entityId : null,
438
+ supplierServiceId: line.entityModule === "supplier_services" ? line.entityId : null,
439
+ description: line.description,
440
+ quantity: 1,
441
+ unitPriceAmountCents: line.subtotalAmountCents,
442
+ totalAmountCents: line.totalAmountCents,
443
+ currency: line.currency,
444
+ };
445
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voyant-travel/quotes",
3
- "version": "0.119.3",
3
+ "version": "0.120.1",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -28,6 +28,11 @@
28
28
  "types": "./dist/booking-extension.d.ts",
29
29
  "import": "./dist/booking-extension.js",
30
30
  "default": "./dist/booking-extension.js"
31
+ },
32
+ "./proposal-routes": {
33
+ "types": "./dist/proposal-routes.d.ts",
34
+ "import": "./dist/proposal-routes.js",
35
+ "default": "./dist/proposal-routes.js"
31
36
  }
32
37
  },
33
38
  "dependencies": {
@@ -36,8 +41,9 @@
36
41
  "zod": "^4.3.6",
37
42
  "@voyant-travel/core": "^0.109.0",
38
43
  "@voyant-travel/quotes-contracts": "^0.107.0",
39
- "@voyant-travel/db": "^0.108.0",
40
- "@voyant-travel/hono": "^0.110.0"
44
+ "@voyant-travel/db": "^0.108.1",
45
+ "@voyant-travel/hono": "^0.111.0",
46
+ "@voyant-travel/trips": "^0.113.0"
41
47
  },
42
48
  "devDependencies": {
43
49
  "typescript": "^6.0.2",