@voyant-travel/quotes 0.122.5 → 0.122.7

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.
@@ -1,4 +1,4 @@
1
- import { type ReserveTripDeps, type StartCheckoutDeps, type TripSnapshot } from "@voyant-travel/trips";
1
+ import { type CancelTripComponentsDeps, type ReserveTripDeps, type StartCheckoutDeps, type TripSnapshot } from "@voyant-travel/trips";
2
2
  import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
3
3
  import { type Context, Hono } from "hono";
4
4
  import type { QuoteVersion, QuoteVersionLine } from "./schema.js";
@@ -23,6 +23,11 @@ export interface QuoteProposalRoutesOptions {
23
23
  reserveTripDeps(c: Context): ReserveTripDeps;
24
24
  /** Build the trips checkout deps for a request (payment-session wiring). */
25
25
  startCheckoutDeps(c: Context): StartCheckoutDeps;
26
+ /**
27
+ * Build the trips cancel deps for a request (provider hold-release wiring).
28
+ * Used to release a reserved Trip when final CRM acceptance loses a race.
29
+ */
30
+ cancelTripComponentsDeps(c: Context): CancelTripComponentsDeps;
26
31
  /**
27
32
  * Resolve the deployment's public operator profile, surfaced on the public
28
33
  * proposal payload. Returns `null` when no profile is configured.
@@ -1 +1 @@
1
- {"version":3,"file":"proposal-routes.d.ts","sourceRoot":"","sources":["../src/proposal-routes.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"proposal-routes.d.ts","sourceRoot":"","sources":["../src/proposal-routes.ts"],"names":[],"mappings":"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"}
@@ -202,30 +202,78 @@ async function handleAcceptPublicProposal(c, options) {
202
202
  const proposalForLock = await quotesService.getQuoteVersionProposal(db, quoteVersionId);
203
203
  if (!proposalForLock)
204
204
  return c.json({ error: "Proposal not found" }, 404);
205
+ const quoteId = proposalForLock.quote.id;
205
206
  try {
206
- const lockedResult = await db.transaction((tx) => acceptPublicProposalWithQuoteLock({
207
+ // Phase 1 prepare under the quote-accept lock (txn 1). Validates the
208
+ // proposal/snapshot and either fast-paths an accepted replay or returns a
209
+ // prepared snapshot ready to reserve.
210
+ const prepared = await db.transaction((tx) => preparePublicProposalAcceptWithQuoteLock({
207
211
  c,
208
- options,
209
212
  db: tx,
210
- quoteId: proposalForLock.quote.id,
213
+ quoteId,
211
214
  quoteVersionId,
212
215
  body,
213
216
  }));
214
- if (lockedResult.kind === "response")
215
- return lockedResult.response;
216
- const checkout = await startAcceptedProposalCheckout(c, options, lockedResult.snapshot, body, quoteVersionId);
217
- const checkoutWarnings = checkout
218
- ? checkout.failures.map((failure) => failure.reason)
219
- : ["checkout_start_failed"];
220
- return c.json({
221
- data: {
222
- status: "accepted",
223
- checkoutUrl: checkout?.target.checkoutUrl ?? null,
224
- paymentSessionId: checkout?.target.paymentSessionId ?? null,
225
- currency: checkout?.target.currency ?? lockedResult.accepted.quoteVersion.currency,
226
- totalAmountCents: checkout?.target.totalAmountCents ?? lockedResult.accepted.quoteVersion.totalAmountCents,
227
- warnings: [...lockedResult.warnings, ...(checkout?.warnings ?? []), ...checkoutWarnings],
228
- },
217
+ if (prepared.kind === "response")
218
+ return prepared.response;
219
+ if (prepared.kind === "accepted") {
220
+ return respondWithAcceptedProposal({
221
+ c,
222
+ options,
223
+ snapshot: prepared.snapshot,
224
+ body,
225
+ quoteVersionId,
226
+ accepted: prepared.accepted,
227
+ reserveWarnings: prepared.warnings,
228
+ });
229
+ }
230
+ // Phase 2 reserve OUTSIDE any transaction, on the durable request db.
231
+ // Sourced catalog adapters may create upstream supplier holds; running this
232
+ // outside the CRM accept transaction keeps those holds durably recorded.
233
+ // reserveTrip's own atomic claim serializes concurrent accepts so only one
234
+ // request can create holds for the same envelope.
235
+ const reserved = await reservePreparedPublicProposal(c, options, db, prepared.snapshot, body, quoteVersionId);
236
+ if (reserved.failures.length > 0) {
237
+ return c.json({
238
+ error: "Proposal could not be reserved",
239
+ failures: reserved.failures.map(({ code, reason }) => ({ code, reason })),
240
+ }, 409);
241
+ }
242
+ // Phase 3 — finalize CRM acceptance under the quote-accept lock (txn 2).
243
+ // If the final accept loses a race (declined/superseded/conflict), release
244
+ // the reservation so the supplier hold isn't orphaned.
245
+ let finalized;
246
+ try {
247
+ finalized = await db.transaction((tx) => finalizePublicProposalAcceptWithQuoteLock({
248
+ c,
249
+ db: tx,
250
+ quoteId,
251
+ quoteVersionId,
252
+ snapshot: prepared.snapshot,
253
+ }));
254
+ }
255
+ catch (error) {
256
+ await releaseAcceptedProposalReservation(c, options, db, prepared.snapshot, reserved, {
257
+ quoteVersionId,
258
+ reason: "quote_accept_failed",
259
+ });
260
+ throw error;
261
+ }
262
+ if (finalized.kind === "response") {
263
+ await releaseAcceptedProposalReservation(c, options, db, prepared.snapshot, reserved, {
264
+ quoteVersionId,
265
+ reason: "quote_accept_failed",
266
+ });
267
+ return finalized.response;
268
+ }
269
+ return respondWithAcceptedProposal({
270
+ c,
271
+ options,
272
+ snapshot: prepared.snapshot,
273
+ body,
274
+ quoteVersionId,
275
+ accepted: finalized.accepted,
276
+ reserveWarnings: reserved.warnings,
229
277
  });
230
278
  }
231
279
  catch (error) {
@@ -238,7 +286,7 @@ async function handleAcceptPublicProposal(c, options) {
238
286
  throw error;
239
287
  }
240
288
  }
241
- async function acceptPublicProposalWithQuoteLock({ c, options, db, quoteId, quoteVersionId, body, }) {
289
+ async function preparePublicProposalAcceptWithQuoteLock({ c, db, quoteId, quoteVersionId, body, }) {
242
290
  await lockQuoteAccept(db, quoteId);
243
291
  await quotesService.expireQuoteVersionIfPastValidUntil(db, quoteVersionId);
244
292
  const proposal = await quotesService.getQuoteVersionProposal(db, quoteVersionId);
@@ -282,7 +330,6 @@ async function acceptPublicProposalWithQuoteLock({ c, options, db, quoteId, quot
282
330
  }
283
331
  return { kind: "accepted", accepted, snapshot, warnings: [] };
284
332
  }
285
- assertSnapshotCanUsePublicAcceptReserve(snapshot);
286
333
  const liveTrip = await tripsService.getTrip(db, snapshot.envelopeId);
287
334
  if (!liveTrip) {
288
335
  return {
@@ -290,11 +337,61 @@ async function acceptPublicProposalWithQuoteLock({ c, options, db, quoteId, quot
290
337
  response: c.json({ error: "Proposal Trip envelope not found" }, 409),
291
338
  };
292
339
  }
293
- assertLiveTripMatchesSnapshot(liveTrip, snapshot);
294
- const reserveIdempotencyKey = `proposal-accept-reserve:${quoteVersionId}:${body.idempotencyKey ?? "default"}`;
295
- const reserved = await tripsService.reserveTrip(db, {
340
+ // Resume a crashed acceptance: if the Trip was already reserved under this
341
+ // proposal's reserve key (reserve succeeded, finalize never ran), the live
342
+ // Trip is no longer `priced`, so skip the frozen-snapshot comparison and let
343
+ // phase 2 replay the reservation idempotently before finalize accepts.
344
+ if (!isResumableProposalReservation(liveTrip.envelope, proposalReserveIdempotencyKey(quoteVersionId, body))) {
345
+ assertLiveTripMatchesSnapshot(liveTrip, snapshot);
346
+ }
347
+ return { kind: "prepared", snapshot };
348
+ }
349
+ async function finalizePublicProposalAcceptWithQuoteLock({ c, db, quoteId, quoteVersionId, snapshot, }) {
350
+ await lockQuoteAccept(db, quoteId);
351
+ await quotesService.expireQuoteVersionIfPastValidUntil(db, quoteVersionId);
352
+ const proposal = await quotesService.getQuoteVersionProposal(db, quoteVersionId);
353
+ if (!proposal)
354
+ return { kind: "response", response: c.json({ error: "Proposal not found" }, 404) };
355
+ if (proposal.quoteVersion.status === "draft") {
356
+ return { kind: "response", response: c.json({ error: "Proposal not found" }, 404) };
357
+ }
358
+ if (proposal.quoteVersion.status === "superseded") {
359
+ return {
360
+ kind: "response",
361
+ response: c.json({ error: "Proposal has been superseded" }, 410),
362
+ };
363
+ }
364
+ if (proposal.quoteVersion.status === "accepted" &&
365
+ proposal.quote.acceptedVersionId === proposal.quoteVersion.id) {
366
+ const accepted = await quotesService.acceptQuoteVersion(db, quoteVersionId, {});
367
+ if (!accepted) {
368
+ return { kind: "response", response: c.json({ error: "Proposal not found" }, 404) };
369
+ }
370
+ return { kind: "accepted", accepted };
371
+ }
372
+ if (proposal.quoteVersion.status !== "sent") {
373
+ return {
374
+ kind: "response",
375
+ response: c.json({ error: "Proposal can no longer be accepted" }, 409),
376
+ };
377
+ }
378
+ // The frozen snapshot must not have changed between prepare and finalize.
379
+ if (proposal.quoteVersion.tripSnapshotId !== snapshot.id) {
380
+ return {
381
+ kind: "response",
382
+ response: c.json({ error: "Proposal Trip snapshot changed before acceptance" }, 409),
383
+ };
384
+ }
385
+ assertProposalMatchesTripSnapshot(proposal, snapshot);
386
+ const accepted = await quotesService.acceptQuoteVersion(db, quoteVersionId, {});
387
+ if (!accepted)
388
+ return { kind: "response", response: c.json({ error: "Proposal not found" }, 404) };
389
+ return { kind: "accepted", accepted };
390
+ }
391
+ function reservePreparedPublicProposal(c, options, db, snapshot, body, quoteVersionId) {
392
+ return tripsService.reserveTrip(db, {
296
393
  envelopeId: snapshot.envelopeId,
297
- idempotencyKey: reserveIdempotencyKey,
394
+ idempotencyKey: proposalReserveIdempotencyKey(quoteVersionId, body),
298
395
  refreshScope: {
299
396
  locale: "en-US",
300
397
  audience: "customer",
@@ -302,19 +399,66 @@ async function acceptPublicProposalWithQuoteLock({ c, options, db, quoteId, quot
302
399
  currency: snapshot.currency,
303
400
  },
304
401
  }, options.reserveTripDeps(c));
305
- if (reserved.failures.length > 0) {
306
- return {
307
- kind: "response",
308
- response: c.json({
309
- error: "Proposal could not be reserved",
310
- failures: reserved.failures.map(({ code, reason }) => ({ code, reason })),
311
- }, 409),
312
- };
402
+ }
403
+ /**
404
+ * Deterministic reserve idempotency key for a proposal acceptance. Stable
405
+ * across retries with the same request body, so a crashed accept can replay the
406
+ * same reservation instead of creating a second supplier hold.
407
+ */
408
+ function proposalReserveIdempotencyKey(quoteVersionId, body) {
409
+ return `proposal-accept-reserve:${quoteVersionId}:${body.idempotencyKey ?? "default"}`;
410
+ }
411
+ /**
412
+ * A live Trip that has already been claimed/reserved under THIS proposal's
413
+ * reserve idempotency key is a resumable in-flight acceptance — not a "Trip
414
+ * changed since sent" conflict. Recognising it lets a retry replay the
415
+ * reservation and finalize, instead of wedging on the frozen `priced` snapshot
416
+ * comparison and stranding the supplier hold.
417
+ */
418
+ function isResumableProposalReservation(envelope, reserveKey) {
419
+ return (envelope.reserveIdempotencyKey === reserveKey &&
420
+ ["reserve_in_progress", "reserved", "checkout_started", "booked"].includes(envelope.status));
421
+ }
422
+ async function releaseAcceptedProposalReservation(c, options, db, snapshot, reserved, release) {
423
+ // An idempotent replay returns the existing holds without creating new ones,
424
+ // so it must never trigger a cancellation of components owned by the request
425
+ // that actually reserved them.
426
+ if (reserved.warnings.includes("idempotent_replay"))
427
+ return;
428
+ const reservedComponentIds = reserved.reserved.map((component) => component.componentId);
429
+ if (reservedComponentIds.length === 0)
430
+ return;
431
+ try {
432
+ await tripsService.cancelComponents(db, {
433
+ envelopeId: snapshot.envelopeId,
434
+ componentIds: reservedComponentIds,
435
+ reason: release.reason,
436
+ idempotencyKey: `proposal-accept-release:${release.quoteVersionId}:${release.reason}`,
437
+ request: {
438
+ initiatedBy: "public-proposal-accept",
439
+ quoteVersionId: release.quoteVersionId,
440
+ },
441
+ }, options.cancelTripComponentsDeps(c));
313
442
  }
314
- const accepted = await quotesService.acceptQuoteVersion(db, quoteVersionId, {});
315
- if (!accepted)
316
- return { kind: "response", response: c.json({ error: "Proposal not found" }, 404) };
317
- return { kind: "accepted", accepted, snapshot, warnings: reserved.warnings };
443
+ catch (error) {
444
+ console.warn("[proposal] failed to release reservation after proposal accept conflict:", error);
445
+ }
446
+ }
447
+ async function respondWithAcceptedProposal({ c, options, snapshot, body, quoteVersionId, accepted, reserveWarnings, }) {
448
+ const checkout = await startAcceptedProposalCheckout(c, options, snapshot, body, quoteVersionId);
449
+ const checkoutWarnings = checkout
450
+ ? checkout.failures.map((failure) => failure.reason)
451
+ : ["checkout_start_failed"];
452
+ return c.json({
453
+ data: {
454
+ status: "accepted",
455
+ checkoutUrl: checkout?.target.checkoutUrl ?? null,
456
+ paymentSessionId: checkout?.target.paymentSessionId ?? null,
457
+ currency: checkout?.target.currency ?? accepted.quoteVersion.currency,
458
+ totalAmountCents: checkout?.target.totalAmountCents ?? accepted.quoteVersion.totalAmountCents,
459
+ warnings: [...reserveWarnings, ...(checkout?.warnings ?? []), ...checkoutWarnings],
460
+ },
461
+ });
318
462
  }
319
463
  function lockQuoteAccept(db, quoteId) {
320
464
  return db.execute(
@@ -324,22 +468,6 @@ function lockQuoteAccept(db, quoteId) {
324
468
  function quoteAcceptLockKey(quoteId) {
325
469
  return `quote-accept:${quoteId}`;
326
470
  }
327
- function assertSnapshotCanUsePublicAcceptReserve(snapshot) {
328
- const sourcedCatalogComponent = snapshot.frozenComponents.find(isSourcedCatalogSnapshotComponent);
329
- if (!sourcedCatalogComponent)
330
- return;
331
- // reserveTrip runs under the Quote accept transaction. Owned catalog holds
332
- // and manual placeholders are DB-local, but sourced catalog adapters can
333
- // create upstream holds before local release records commit.
334
- throw new QuoteVersionConflictError("Sourced catalog components cannot be accepted from public proposals yet");
335
- }
336
- function isSourcedCatalogSnapshotComponent(component) {
337
- return Boolean(component.kind === "catalog_booking" &&
338
- component.entityModule &&
339
- component.entityId &&
340
- component.sourceKind &&
341
- component.sourceKind !== "owned");
342
- }
343
471
  async function startAcceptedProposalCheckout(c, options, snapshot, body, quoteVersionId) {
344
472
  try {
345
473
  return await tripsService.startCheckout(options.resolveDb(c), {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voyant-travel/quotes",
3
- "version": "0.122.5",
3
+ "version": "0.122.7",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -43,7 +43,7 @@
43
43
  "@voyant-travel/quotes-contracts": "^0.108.0",
44
44
  "@voyant-travel/db": "^0.108.4",
45
45
  "@voyant-travel/hono": "^0.112.2",
46
- "@voyant-travel/trips": "^0.120.0"
46
+ "@voyant-travel/trips": "^0.121.0"
47
47
  },
48
48
  "devDependencies": {
49
49
  "drizzle-kit": "^0.31.10",