@voyant-travel/finance 0.119.5
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 +192 -0
- package/dist/action-ledger-drift.d.ts +29 -0
- package/dist/action-ledger-drift.d.ts.map +1 -0
- package/dist/action-ledger-drift.js +166 -0
- package/dist/booking-tax.d.ts +124 -0
- package/dist/booking-tax.d.ts.map +1 -0
- package/dist/booking-tax.js +264 -0
- package/dist/checkout-routes.d.ts +1154 -0
- package/dist/checkout-routes.d.ts.map +1 -0
- package/dist/checkout-routes.js +116 -0
- package/dist/checkout-service-plan.d.ts +137 -0
- package/dist/checkout-service-plan.d.ts.map +1 -0
- package/dist/checkout-service-plan.js +119 -0
- package/dist/checkout-service.d.ts +9 -0
- package/dist/checkout-service.d.ts.map +1 -0
- package/dist/checkout-service.js +324 -0
- package/dist/checkout-validation.d.ts +1682 -0
- package/dist/checkout-validation.d.ts.map +1 -0
- package/dist/checkout-validation.js +228 -0
- package/dist/document-download.d.ts +3 -0
- package/dist/document-download.d.ts.map +1 -0
- package/dist/document-download.js +1 -0
- package/dist/fx-money.d.ts +17 -0
- package/dist/fx-money.d.ts.map +1 -0
- package/dist/fx-money.js +194 -0
- package/dist/index.d.ts +65 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +108 -0
- package/dist/invoice-fx.d.ts +134 -0
- package/dist/invoice-fx.d.ts.map +1 -0
- package/dist/invoice-fx.js +240 -0
- package/dist/invoice-number-errors.d.ts +2 -0
- package/dist/invoice-number-errors.d.ts.map +1 -0
- package/dist/invoice-number-errors.js +58 -0
- package/dist/markets-ref.d.ts +149 -0
- package/dist/markets-ref.d.ts.map +1 -0
- package/dist/markets-ref.js +17 -0
- package/dist/payment-link.d.ts +23 -0
- package/dist/payment-link.d.ts.map +1 -0
- package/dist/payment-link.js +30 -0
- package/dist/payment-policy.d.ts +113 -0
- package/dist/payment-policy.d.ts.map +1 -0
- package/dist/payment-policy.js +193 -0
- package/dist/route-runtime.d.ts +22 -0
- package/dist/route-runtime.d.ts.map +1 -0
- package/dist/route-runtime.js +18 -0
- package/dist/routes-action-ledger.d.ts +181 -0
- package/dist/routes-action-ledger.d.ts.map +1 -0
- package/dist/routes-action-ledger.js +142 -0
- package/dist/routes-booking-billing.d.ts +852 -0
- package/dist/routes-booking-billing.d.ts.map +1 -0
- package/dist/routes-booking-billing.js +223 -0
- package/dist/routes-booking-create.d.ts +3 -0
- package/dist/routes-booking-create.d.ts.map +1 -0
- package/dist/routes-booking-create.js +194 -0
- package/dist/routes-booking-reads.d.ts +46 -0
- package/dist/routes-booking-reads.d.ts.map +1 -0
- package/dist/routes-booking-reads.js +20 -0
- package/dist/routes-documents.d.ts +195 -0
- package/dist/routes-documents.d.ts.map +1 -0
- package/dist/routes-documents.js +93 -0
- package/dist/routes-invoice-core.d.ts +794 -0
- package/dist/routes-invoice-core.d.ts.map +1 -0
- package/dist/routes-invoice-core.js +238 -0
- package/dist/routes-invoice-documents.d.ts +401 -0
- package/dist/routes-invoice-documents.d.ts.map +1 -0
- package/dist/routes-invoice-documents.js +91 -0
- package/dist/routes-invoice-issue.d.ts +384 -0
- package/dist/routes-invoice-issue.d.ts.map +1 -0
- package/dist/routes-invoice-issue.js +208 -0
- package/dist/routes-payment-processing.d.ts +1193 -0
- package/dist/routes-payment-processing.d.ts.map +1 -0
- package/dist/routes-payment-processing.js +238 -0
- package/dist/routes-payments.d.ts +309 -0
- package/dist/routes-payments.d.ts.map +1 -0
- package/dist/routes-payments.js +94 -0
- package/dist/routes-public.d.ts +1948 -0
- package/dist/routes-public.d.ts.map +1 -0
- package/dist/routes-public.js +275 -0
- package/dist/routes-reference-data.d.ts +977 -0
- package/dist/routes-reference-data.d.ts.map +1 -0
- package/dist/routes-reference-data.js +191 -0
- package/dist/routes-reports.d.ts +344 -0
- package/dist/routes-reports.d.ts.map +1 -0
- package/dist/routes-reports.js +93 -0
- package/dist/routes-runtime.d.ts +71 -0
- package/dist/routes-runtime.d.ts.map +1 -0
- package/dist/routes-runtime.js +59 -0
- package/dist/routes-settlement.d.ts +67 -0
- package/dist/routes-settlement.d.ts.map +1 -0
- package/dist/routes-settlement.js +23 -0
- package/dist/routes-shared.d.ts +35 -0
- package/dist/routes-shared.d.ts.map +1 -0
- package/dist/routes-shared.js +10 -0
- package/dist/routes-supplier-invoices.d.ts +778 -0
- package/dist/routes-supplier-invoices.d.ts.map +1 -0
- package/dist/routes-supplier-invoices.js +159 -0
- package/dist/routes-vouchers.d.ts +228 -0
- package/dist/routes-vouchers.d.ts.map +1 -0
- package/dist/routes-vouchers.js +54 -0
- package/dist/routes.d.ts +5577 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +44 -0
- package/dist/schema/booking-billing.d.ts +1006 -0
- package/dist/schema/booking-billing.d.ts.map +1 -0
- package/dist/schema/booking-billing.js +106 -0
- package/dist/schema/enums.d.ts +48 -0
- package/dist/schema/enums.d.ts.map +1 -0
- package/dist/schema/enums.js +237 -0
- package/dist/schema/invoice-documents.d.ts +1245 -0
- package/dist/schema/invoice-documents.d.ts.map +1 -0
- package/dist/schema/invoice-documents.js +140 -0
- package/dist/schema/payment-instruments.d.ts +418 -0
- package/dist/schema/payment-instruments.d.ts.map +1 -0
- package/dist/schema/payment-instruments.js +45 -0
- package/dist/schema/payment-processing.d.ts +563 -0
- package/dist/schema/payment-processing.d.ts.map +1 -0
- package/dist/schema/payment-processing.js +65 -0
- package/dist/schema/payment-sessions.d.ts +728 -0
- package/dist/schema/payment-sessions.d.ts.map +1 -0
- package/dist/schema/payment-sessions.js +79 -0
- package/dist/schema/receivables.d.ts +1474 -0
- package/dist/schema/receivables.d.ts.map +1 -0
- package/dist/schema/receivables.js +179 -0
- package/dist/schema/relations.d.ts +82 -0
- package/dist/schema/relations.d.ts.map +1 -0
- package/dist/schema/relations.js +144 -0
- package/dist/schema/supplier-invoices.d.ts +1619 -0
- package/dist/schema/supplier-invoices.d.ts.map +1 -0
- package/dist/schema/supplier-invoices.js +228 -0
- package/dist/schema/tax.d.ts +712 -0
- package/dist/schema/tax.d.ts.map +1 -0
- package/dist/schema/tax.js +98 -0
- package/dist/schema/vouchers.d.ts +444 -0
- package/dist/schema/vouchers.d.ts.map +1 -0
- package/dist/schema/vouchers.js +64 -0
- package/dist/schema.d.ts +12 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +11 -0
- package/dist/service-accountant-shares.d.ts +106 -0
- package/dist/service-accountant-shares.d.ts.map +1 -0
- package/dist/service-accountant-shares.js +331 -0
- package/dist/service-action-ledger-accounting.d.ts +104 -0
- package/dist/service-action-ledger-accounting.d.ts.map +1 -0
- package/dist/service-action-ledger-accounting.js +386 -0
- package/dist/service-action-ledger-booking-payments.d.ts +48 -0
- package/dist/service-action-ledger-booking-payments.d.ts.map +1 -0
- package/dist/service-action-ledger-booking-payments.js +178 -0
- package/dist/service-action-ledger-bookings.d.ts +44 -0
- package/dist/service-action-ledger-bookings.d.ts.map +1 -0
- package/dist/service-action-ledger-bookings.js +81 -0
- package/dist/service-action-ledger-payment-authorizations.d.ts +48 -0
- package/dist/service-action-ledger-payment-authorizations.d.ts.map +1 -0
- package/dist/service-action-ledger-payment-authorizations.js +209 -0
- package/dist/service-action-ledger-payment-sessions.d.ts +83 -0
- package/dist/service-action-ledger-payment-sessions.d.ts.map +1 -0
- package/dist/service-action-ledger-payment-sessions.js +294 -0
- package/dist/service-action-ledger-supplier-invoices.d.ts +27 -0
- package/dist/service-action-ledger-supplier-invoices.d.ts.map +1 -0
- package/dist/service-action-ledger-supplier-invoices.js +111 -0
- package/dist/service-action-ledger-supplier-payments.d.ts +21 -0
- package/dist/service-action-ledger-supplier-payments.d.ts.map +1 -0
- package/dist/service-action-ledger-supplier-payments.js +97 -0
- package/dist/service-action-ledger.d.ts +7 -0
- package/dist/service-action-ledger.d.ts.map +1 -0
- package/dist/service-action-ledger.js +6 -0
- package/dist/service-aggregates.d.ts +96 -0
- package/dist/service-aggregates.d.ts.map +1 -0
- package/dist/service-aggregates.js +294 -0
- package/dist/service-booking-billing.d.ts +2322 -0
- package/dist/service-booking-billing.d.ts.map +1 -0
- package/dist/service-booking-billing.js +8 -0
- package/dist/service-booking-create.d.ts +410 -0
- package/dist/service-booking-create.d.ts.map +1 -0
- package/dist/service-booking-create.js +1256 -0
- package/dist/service-booking-guarantees.d.ts +725 -0
- package/dist/service-booking-guarantees.d.ts.map +1 -0
- package/dist/service-booking-guarantees.js +153 -0
- package/dist/service-booking-item-billing.d.ts +1062 -0
- package/dist/service-booking-item-billing.d.ts.map +1 -0
- package/dist/service-booking-item-billing.js +77 -0
- package/dist/service-booking-payment-schedules.d.ts +557 -0
- package/dist/service-booking-payment-schedules.d.ts.map +1 -0
- package/dist/service-booking-payment-schedules.js +372 -0
- package/dist/service-bookings-dual-create.d.ts +308 -0
- package/dist/service-bookings-dual-create.d.ts.map +1 -0
- package/dist/service-bookings-dual-create.js +131 -0
- package/dist/service-boundary-sql.d.ts +6 -0
- package/dist/service-boundary-sql.d.ts.map +1 -0
- package/dist/service-boundary-sql.js +15 -0
- package/dist/service-cost-categories.d.ts +26 -0
- package/dist/service-cost-categories.d.ts.map +1 -0
- package/dist/service-cost-categories.js +76 -0
- package/dist/service-documents.d.ts +80 -0
- package/dist/service-documents.d.ts.map +1 -0
- package/dist/service-documents.js +228 -0
- package/dist/service-invoice-artifacts.d.ts +246 -0
- package/dist/service-invoice-artifacts.d.ts.map +1 -0
- package/dist/service-invoice-artifacts.js +277 -0
- package/dist/service-invoice-core.d.ts +405 -0
- package/dist/service-invoice-core.d.ts.map +1 -0
- package/dist/service-invoice-core.js +290 -0
- package/dist/service-invoice-credit-notes.d.ts +973 -0
- package/dist/service-invoice-credit-notes.d.ts.map +1 -0
- package/dist/service-invoice-credit-notes.js +142 -0
- package/dist/service-invoice-from-booking.d.ts +41 -0
- package/dist/service-invoice-from-booking.d.ts.map +1 -0
- package/dist/service-invoice-from-booking.js +267 -0
- package/dist/service-invoice-line-items.d.ts +432 -0
- package/dist/service-invoice-line-items.d.ts.map +1 -0
- package/dist/service-invoice-line-items.js +102 -0
- package/dist/service-invoice-numbering.d.ts +227 -0
- package/dist/service-invoice-numbering.d.ts.map +1 -0
- package/dist/service-invoice-numbering.js +260 -0
- package/dist/service-invoice-payments.d.ts +673 -0
- package/dist/service-invoice-payments.d.ts.map +1 -0
- package/dist/service-invoice-payments.js +398 -0
- package/dist/service-invoices.d.ts +2501 -0
- package/dist/service-invoices.d.ts.map +1 -0
- package/dist/service-invoices.js +12 -0
- package/dist/service-issue.d.ts +207 -0
- package/dist/service-issue.d.ts.map +1 -0
- package/dist/service-issue.js +431 -0
- package/dist/service-payment-authorizations.d.ts +164 -0
- package/dist/service-payment-authorizations.d.ts.map +1 -0
- package/dist/service-payment-authorizations.js +227 -0
- package/dist/service-payment-instruments.d.ts +116 -0
- package/dist/service-payment-instruments.d.ts.map +1 -0
- package/dist/service-payment-instruments.js +99 -0
- package/dist/service-payment-processing.d.ts +676 -0
- package/dist/service-payment-processing.d.ts.map +1 -0
- package/dist/service-payment-processing.js +10 -0
- package/dist/service-payment-session-completion.d.ts +48 -0
- package/dist/service-payment-session-completion.d.ts.map +1 -0
- package/dist/service-payment-session-completion.js +238 -0
- package/dist/service-payment-sessions.d.ts +361 -0
- package/dist/service-payment-sessions.d.ts.map +1 -0
- package/dist/service-payment-sessions.js +280 -0
- package/dist/service-profitability.d.ts +114 -0
- package/dist/service-profitability.d.ts.map +1 -0
- package/dist/service-profitability.js +794 -0
- package/dist/service-public.d.ts +553 -0
- package/dist/service-public.d.ts.map +1 -0
- package/dist/service-public.js +583 -0
- package/dist/service-reference-data.d.ts +272 -0
- package/dist/service-reference-data.d.ts.map +1 -0
- package/dist/service-reference-data.js +280 -0
- package/dist/service-rendition-wait.d.ts +38 -0
- package/dist/service-rendition-wait.d.ts.map +1 -0
- package/dist/service-rendition-wait.js +67 -0
- package/dist/service-reports.d.ts +37 -0
- package/dist/service-reports.d.ts.map +1 -0
- package/dist/service-reports.js +62 -0
- package/dist/service-settlement.d.ts +46 -0
- package/dist/service-settlement.d.ts.map +1 -0
- package/dist/service-settlement.js +185 -0
- package/dist/service-shared.d.ts +541 -0
- package/dist/service-shared.d.ts.map +1 -0
- package/dist/service-shared.js +764 -0
- package/dist/service-supplier-invoices.d.ts +871 -0
- package/dist/service-supplier-invoices.d.ts.map +1 -0
- package/dist/service-supplier-invoices.js +744 -0
- package/dist/service-supplier-payments.d.ts +69 -0
- package/dist/service-supplier-payments.d.ts.map +1 -0
- package/dist/service-supplier-payments.js +136 -0
- package/dist/service-vouchers-migration.d.ts +44 -0
- package/dist/service-vouchers-migration.d.ts.map +1 -0
- package/dist/service-vouchers-migration.js +148 -0
- package/dist/service-vouchers.d.ts +157 -0
- package/dist/service-vouchers.d.ts.map +1 -0
- package/dist/service-vouchers.js +191 -0
- package/dist/service.d.ts +6490 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +29 -0
- package/dist/validation-billing.d.ts +2 -0
- package/dist/validation-billing.d.ts.map +1 -0
- package/dist/validation-billing.js +1 -0
- package/dist/validation-payments.d.ts +2 -0
- package/dist/validation-payments.d.ts.map +1 -0
- package/dist/validation-payments.js +1 -0
- package/dist/validation-public.d.ts +2 -0
- package/dist/validation-public.d.ts.map +1 -0
- package/dist/validation-public.js +1 -0
- package/dist/validation-shared.d.ts +2 -0
- package/dist/validation-shared.d.ts.map +1 -0
- package/dist/validation-shared.js +1 -0
- package/dist/validation-vouchers.d.ts +2 -0
- package/dist/validation-vouchers.d.ts.map +1 -0
- package/dist/validation-vouchers.js +1 -0
- package/dist/validation.d.ts +2 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +1 -0
- package/package.json +121 -0
|
@@ -0,0 +1,1256 @@
|
|
|
1
|
+
// agent-quality: file-size exception -- owner: finance; existing service module stays co-located until a dedicated split preserves behavior and tests.
|
|
2
|
+
import { appendActionLedgerMutation, } from "@voyant-travel/action-ledger";
|
|
3
|
+
import { bookingGroupsService, bookingsService, } from "@voyant-travel/bookings";
|
|
4
|
+
import { verifyBookingDraft, } from "@voyant-travel/bookings/pricing-assignment";
|
|
5
|
+
import { bookingItems, bookingItemTravelers, bookingTravelers, } from "@voyant-travel/bookings/schema";
|
|
6
|
+
import { bookingStatusSchema } from "@voyant-travel/bookings/validation";
|
|
7
|
+
import { eq, sql } from "drizzle-orm";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { bookingPaymentSchedules, vouchers } from "./schema.js";
|
|
10
|
+
import { financeService, toRows } from "./service.js";
|
|
11
|
+
import { buildBookingCreateRejectedActionLedgerInput, buildBookingCreateSucceededActionLedgerInput, } from "./service-action-ledger.js";
|
|
12
|
+
import { financeDocumentsService } from "./service-documents.js";
|
|
13
|
+
import { VoucherServiceError, vouchersService } from "./service-vouchers.js";
|
|
14
|
+
import { paymentMethodSchema, paymentScheduleStatusSchema, paymentScheduleTypeSchema, } from "./validation-shared.js";
|
|
15
|
+
// ---------- validation ----------
|
|
16
|
+
const travelerInputSchema = z.object({
|
|
17
|
+
clientTravelerKey: z.string().min(1).max(255).optional().nullable(),
|
|
18
|
+
firstName: z.string().min(1).max(255),
|
|
19
|
+
lastName: z.string().min(1).max(255),
|
|
20
|
+
email: z.string().email().optional().nullable(),
|
|
21
|
+
phone: z.string().max(50).optional().nullable(),
|
|
22
|
+
personId: z.string().optional().nullable(),
|
|
23
|
+
participantType: z.enum(["traveler", "occupant", "other"]).default("traveler"),
|
|
24
|
+
travelerCategory: z.enum(["adult", "child", "infant", "senior", "other"]).optional().nullable(),
|
|
25
|
+
preferredLanguage: z.string().max(35).optional().nullable(),
|
|
26
|
+
specialRequests: z.string().optional().nullable(),
|
|
27
|
+
/**
|
|
28
|
+
* Deprecated compatibility alias for the traveler's pricing-tier option
|
|
29
|
+
* unit. Accepted by the input schema for wire compatibility but not
|
|
30
|
+
* persisted; item-line travelerKeys are the supported traveler-to-item
|
|
31
|
+
* linkage.
|
|
32
|
+
*/
|
|
33
|
+
roomUnitId: z.string().optional().nullable(),
|
|
34
|
+
isPrimary: z.boolean().optional().nullable(),
|
|
35
|
+
notes: z.string().optional().nullable(),
|
|
36
|
+
});
|
|
37
|
+
const paymentScheduleInputSchema = z.object({
|
|
38
|
+
scheduleType: paymentScheduleTypeSchema.default("balance"),
|
|
39
|
+
status: paymentScheduleStatusSchema.default("pending"),
|
|
40
|
+
dueDate: z.string().min(1),
|
|
41
|
+
currency: z.string().min(3).max(3),
|
|
42
|
+
amountCents: z.number().int().min(0),
|
|
43
|
+
notes: z.string().optional().nullable(),
|
|
44
|
+
});
|
|
45
|
+
const documentGenerationInputSchema = z
|
|
46
|
+
.object({
|
|
47
|
+
contractDocument: z.boolean().default(false),
|
|
48
|
+
invoiceDocument: z.boolean().default(false),
|
|
49
|
+
/**
|
|
50
|
+
* Kind of invoice to issue when `invoiceDocument` is true. Defaults
|
|
51
|
+
* to a final `invoice`; pass `proforma` for the placeholder used in
|
|
52
|
+
* pre-payment flows (operator dashboard's "Generate proforma"
|
|
53
|
+
* shortcut on the new-booking dialog).
|
|
54
|
+
*/
|
|
55
|
+
invoiceType: z.enum(["invoice", "proforma"]).default("invoice"),
|
|
56
|
+
})
|
|
57
|
+
.default({ contractDocument: false, invoiceDocument: false, invoiceType: "invoice" });
|
|
58
|
+
const itemLineInputSchema = z.object({
|
|
59
|
+
/**
|
|
60
|
+
* Stable client-side key (e.g. `unit:optu_adult`). Server stamps
|
|
61
|
+
* this into `booking_items.metadata.bookingCreateLineKey` so the
|
|
62
|
+
* post-insert pass can look up the row and link it to travelers
|
|
63
|
+
* via `booking_item_travelers`. See voyant-travel/voyant#1267.
|
|
64
|
+
*/
|
|
65
|
+
clientLineKey: z.string().min(1).max(255).optional().nullable(),
|
|
66
|
+
optionUnitId: z.string().min(1),
|
|
67
|
+
quantity: z.number().int().min(1),
|
|
68
|
+
title: z.string().min(1).max(255).optional().nullable(),
|
|
69
|
+
description: z.string().max(5000).optional().nullable(),
|
|
70
|
+
unitSellAmountCents: z.number().int().min(0).optional().nullable(),
|
|
71
|
+
totalSellAmountCents: z.number().int().min(0).optional().nullable(),
|
|
72
|
+
/**
|
|
73
|
+
* Stable traveler keys this item applies to. Server inserts one
|
|
74
|
+
* `booking_item_travelers` row per traveler.
|
|
75
|
+
*/
|
|
76
|
+
travelerKeys: z.array(z.string().min(1).max(255)).optional().nullable(),
|
|
77
|
+
/**
|
|
78
|
+
* Deprecated position-based traveler links. Removal target: next
|
|
79
|
+
* booking-create wire-format major.
|
|
80
|
+
*/
|
|
81
|
+
travelerIndexes: z.array(z.number().int().min(0)).optional().nullable(),
|
|
82
|
+
});
|
|
83
|
+
const extraLineInputSchema = z.object({
|
|
84
|
+
clientLineKey: z.string().min(1).max(255).optional().nullable(),
|
|
85
|
+
productExtraId: z.string().min(1),
|
|
86
|
+
optionExtraConfigId: z.string().min(1).optional().nullable(),
|
|
87
|
+
name: z.string().min(1).max(255),
|
|
88
|
+
description: z.string().max(5000).optional().nullable(),
|
|
89
|
+
pricingMode: z.string().max(50).optional().nullable(),
|
|
90
|
+
pricedPerPerson: z.boolean().optional().nullable(),
|
|
91
|
+
quantity: z.number().int().min(1),
|
|
92
|
+
sellCurrency: z.string().length(3),
|
|
93
|
+
unitSellAmountCents: z.number().int().min(0).optional().nullable(),
|
|
94
|
+
totalSellAmountCents: z.number().int().min(0).optional().nullable(),
|
|
95
|
+
travelerKeys: z.array(z.string().min(1).max(255)).optional().nullable(),
|
|
96
|
+
travelerIndexes: z.array(z.number().int().min(0)).optional().nullable(),
|
|
97
|
+
});
|
|
98
|
+
const voucherRedemptionInputSchema = z.object({
|
|
99
|
+
voucherId: z.string().min(1),
|
|
100
|
+
amountCents: z.number().int().min(1),
|
|
101
|
+
});
|
|
102
|
+
const groupJoinSchema = z.object({
|
|
103
|
+
action: z.literal("join"),
|
|
104
|
+
groupId: z.string().min(1),
|
|
105
|
+
role: z.enum(["primary", "shared"]).default("shared"),
|
|
106
|
+
});
|
|
107
|
+
const groupCreateSchema = z.object({
|
|
108
|
+
action: z.literal("create"),
|
|
109
|
+
kind: z.enum(["shared_room", "other"]).default("shared_room"),
|
|
110
|
+
label: z.string().max(255).optional().nullable(),
|
|
111
|
+
optionUnitId: z.string().optional().nullable(),
|
|
112
|
+
/**
|
|
113
|
+
* When true (the default), the freshly-created booking becomes the group's
|
|
114
|
+
* primary booking. Operators creating a dual-booking can set this false and
|
|
115
|
+
* supply a different primaryBookingId — not wired in this slice, but the
|
|
116
|
+
* field is reserved.
|
|
117
|
+
*/
|
|
118
|
+
makeBookingPrimary: z.boolean().default(true),
|
|
119
|
+
});
|
|
120
|
+
const groupMembershipInputSchema = z.discriminatedUnion("action", [
|
|
121
|
+
groupJoinSchema,
|
|
122
|
+
groupCreateSchema,
|
|
123
|
+
]);
|
|
124
|
+
const placeholderEmails = new Set([
|
|
125
|
+
"noreply@example.com",
|
|
126
|
+
"tbd@example.com",
|
|
127
|
+
"traveler@example.com",
|
|
128
|
+
]);
|
|
129
|
+
function requirePriceOverrideReason(value, ctx) {
|
|
130
|
+
if (value.confirmedSellAmountCents == null)
|
|
131
|
+
return;
|
|
132
|
+
if (value.catalogSellAmountCents === value.confirmedSellAmountCents)
|
|
133
|
+
return;
|
|
134
|
+
if (value.priceOverrideReason)
|
|
135
|
+
return;
|
|
136
|
+
ctx.addIssue({
|
|
137
|
+
code: z.ZodIssueCode.custom,
|
|
138
|
+
path: ["priceOverrideReason"],
|
|
139
|
+
message: "A price override reason is required when the confirmed total differs from catalog pricing",
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
function requireCompleteBookingParty(value, ctx) {
|
|
143
|
+
if (!value.personId && !value.organizationId) {
|
|
144
|
+
ctx.addIssue({
|
|
145
|
+
code: z.ZodIssueCode.custom,
|
|
146
|
+
path: ["personId"],
|
|
147
|
+
message: "Select a billing person or organization",
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
if (value.personId) {
|
|
151
|
+
if (!value.contactFirstName?.trim() || !value.contactLastName?.trim()) {
|
|
152
|
+
ctx.addIssue({
|
|
153
|
+
code: z.ZodIssueCode.custom,
|
|
154
|
+
path: ["contactFirstName"],
|
|
155
|
+
message: "Billing person requires first and last name",
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
const hasRealEmail = isRealEmail(value.contactEmail);
|
|
159
|
+
const hasPhone = Boolean(value.contactPhone?.trim());
|
|
160
|
+
if (value.contactEmail && !hasRealEmail) {
|
|
161
|
+
ctx.addIssue({
|
|
162
|
+
code: z.ZodIssueCode.custom,
|
|
163
|
+
path: ["contactEmail"],
|
|
164
|
+
message: "Billing email cannot be a placeholder address",
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
if (!hasRealEmail && !hasPhone) {
|
|
168
|
+
ctx.addIssue({
|
|
169
|
+
code: z.ZodIssueCode.custom,
|
|
170
|
+
path: ["contactEmail"],
|
|
171
|
+
message: "Billing person requires an email or phone number",
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
else if (value.contactEmail && !isRealEmail(value.contactEmail)) {
|
|
176
|
+
ctx.addIssue({
|
|
177
|
+
code: z.ZodIssueCode.custom,
|
|
178
|
+
path: ["contactEmail"],
|
|
179
|
+
message: "Billing email cannot be a placeholder address",
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
if (!value.travelers || value.travelers.length === 0) {
|
|
183
|
+
ctx.addIssue({
|
|
184
|
+
code: z.ZodIssueCode.custom,
|
|
185
|
+
path: ["travelers"],
|
|
186
|
+
message: "Add at least one traveler",
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
value.travelers?.forEach((traveler, index) => {
|
|
190
|
+
if (!traveler.personId && (!traveler.firstName.trim() || !traveler.lastName.trim())) {
|
|
191
|
+
ctx.addIssue({
|
|
192
|
+
code: z.ZodIssueCode.custom,
|
|
193
|
+
path: ["travelers", index],
|
|
194
|
+
message: "Traveler requires a name or person record",
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
if (traveler.email && !isRealEmail(traveler.email)) {
|
|
198
|
+
ctx.addIssue({
|
|
199
|
+
code: z.ZodIssueCode.custom,
|
|
200
|
+
path: ["travelers", index, "email"],
|
|
201
|
+
message: "Traveler email cannot be a placeholder address",
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
function findDuplicateClientTravelerKeys(travelers) {
|
|
207
|
+
const seen = new Set();
|
|
208
|
+
const duplicates = new Set();
|
|
209
|
+
for (const traveler of travelers ?? []) {
|
|
210
|
+
const key = traveler.clientTravelerKey?.trim();
|
|
211
|
+
if (!key)
|
|
212
|
+
continue;
|
|
213
|
+
if (seen.has(key))
|
|
214
|
+
duplicates.add(key);
|
|
215
|
+
else
|
|
216
|
+
seen.add(key);
|
|
217
|
+
}
|
|
218
|
+
return [...duplicates];
|
|
219
|
+
}
|
|
220
|
+
function requireUniqueClientTravelerKeys(value, ctx) {
|
|
221
|
+
for (const duplicateKey of findDuplicateClientTravelerKeys(value.travelers)) {
|
|
222
|
+
ctx.addIssue({
|
|
223
|
+
code: z.ZodIssueCode.custom,
|
|
224
|
+
path: ["travelers"],
|
|
225
|
+
message: `Duplicate clientTravelerKey: ${duplicateKey}`,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function requireKnownTravelerKeys(value, ctx) {
|
|
230
|
+
const knownKeys = new Set((value.travelers ?? [])
|
|
231
|
+
.map((traveler) => traveler.clientTravelerKey?.trim())
|
|
232
|
+
.filter((key) => Boolean(key)));
|
|
233
|
+
const checkLines = (field, lines) => {
|
|
234
|
+
lines?.forEach((line, lineIndex) => {
|
|
235
|
+
line.travelerKeys?.forEach((travelerKey, keyIndex) => {
|
|
236
|
+
const key = travelerKey.trim();
|
|
237
|
+
if (!key || knownKeys.has(key))
|
|
238
|
+
return;
|
|
239
|
+
ctx.addIssue({
|
|
240
|
+
code: z.ZodIssueCode.custom,
|
|
241
|
+
path: [field, lineIndex, "travelerKeys", keyIndex],
|
|
242
|
+
message: `Unknown travelerKey: ${key}`,
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
};
|
|
247
|
+
checkLines("itemLines", value.itemLines);
|
|
248
|
+
checkLines("extraLines", value.extraLines);
|
|
249
|
+
}
|
|
250
|
+
function isRealEmail(value) {
|
|
251
|
+
const normalized = value?.trim().toLowerCase() ?? "";
|
|
252
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized) && !placeholderEmails.has(normalized);
|
|
253
|
+
}
|
|
254
|
+
const bookingCreateBaseSchema = z.object({
|
|
255
|
+
// Convert-product fields (mirrors convertProductSchema in bookings)
|
|
256
|
+
productId: z.string().min(1),
|
|
257
|
+
optionId: z.string().optional().nullable(),
|
|
258
|
+
slotId: z.string().optional().nullable(),
|
|
259
|
+
bookingNumber: z.string().min(1),
|
|
260
|
+
personId: z.string().optional().nullable(),
|
|
261
|
+
organizationId: z.string().optional().nullable(),
|
|
262
|
+
pax: z.number().int().positive().optional().nullable(),
|
|
263
|
+
internalNotes: z.string().optional().nullable(),
|
|
264
|
+
/**
|
|
265
|
+
* Override the seed `sellAmountCents` on the new booking + line item.
|
|
266
|
+
* Threads through to `convertProductToBooking` so promotion-discounted
|
|
267
|
+
* quotes land at the discounted amount instead of the product's list
|
|
268
|
+
* price. Per docs/architecture/promotions-architecture.md §7.1.
|
|
269
|
+
*/
|
|
270
|
+
sellAmountCentsOverride: z.number().int().min(0).optional().nullable(),
|
|
271
|
+
catalogSellAmountCents: z.number().int().min(0).optional().nullable(),
|
|
272
|
+
confirmedSellAmountCents: z.number().int().min(0).optional().nullable(),
|
|
273
|
+
priceOverrideReason: z.string().trim().min(1).max(1000).optional().nullable(),
|
|
274
|
+
/**
|
|
275
|
+
* Initial lifecycle status to seat the booking in — defaults to `draft`.
|
|
276
|
+
* Lets the dialog commit straight to `confirmed` or `awaiting_payment`
|
|
277
|
+
* in the same transaction, avoiding the post-create `/override-status`
|
|
278
|
+
* roundtrip that previously occasionally raced the create's COMMIT.
|
|
279
|
+
*
|
|
280
|
+
* When set to `confirmed`, the orchestrator emits `booking.confirmed`
|
|
281
|
+
* post-commit so notification + document-bundle subscribers fire just
|
|
282
|
+
* like they would for an after-the-fact transition.
|
|
283
|
+
*/
|
|
284
|
+
initialStatus: bookingStatusSchema.optional(),
|
|
285
|
+
/**
|
|
286
|
+
* When true and `initialStatus === "confirmed"`, the post-commit
|
|
287
|
+
* `booking.confirmed` event carries `suppressNotifications: true` so
|
|
288
|
+
* downstream subscribers skip customer-facing email + document
|
|
289
|
+
* bundles. Operators can confirm a booking silently this way.
|
|
290
|
+
*/
|
|
291
|
+
suppressNotifications: z.boolean().optional(),
|
|
292
|
+
/**
|
|
293
|
+
* Explicit operator override for same billing party + departure creates.
|
|
294
|
+
* Defaults to guarded behavior so retries and concurrent double-submit
|
|
295
|
+
* attempts return a structured duplicate signal instead of minting another
|
|
296
|
+
* active booking.
|
|
297
|
+
*/
|
|
298
|
+
allowDuplicate: z.boolean().optional(),
|
|
299
|
+
// Billing-contact snapshot — captured at create time. Caller (the
|
|
300
|
+
// dialog) reads the linked CRM person/org and supplies what it
|
|
301
|
+
// knows; the convertProductToBooking helper writes everything
|
|
302
|
+
// through to the booking row's contact_* columns.
|
|
303
|
+
contactFirstName: z.string().max(255).optional().nullable(),
|
|
304
|
+
contactLastName: z.string().max(255).optional().nullable(),
|
|
305
|
+
contactEmail: z.string().max(255).optional().nullable(),
|
|
306
|
+
contactPhone: z.string().max(50).optional().nullable(),
|
|
307
|
+
contactPreferredLanguage: z.string().max(35).optional().nullable(),
|
|
308
|
+
contactCountry: z.string().max(2).optional().nullable(),
|
|
309
|
+
contactRegion: z.string().max(100).optional().nullable(),
|
|
310
|
+
contactCity: z.string().max(100).optional().nullable(),
|
|
311
|
+
contactAddressLine1: z.string().max(500).optional().nullable(),
|
|
312
|
+
contactAddressLine2: z.string().max(500).optional().nullable(),
|
|
313
|
+
contactPostalCode: z.string().max(20).optional().nullable(),
|
|
314
|
+
// Orchestration fields
|
|
315
|
+
travelers: z.array(travelerInputSchema).optional(),
|
|
316
|
+
itemLines: z.array(itemLineInputSchema).optional(),
|
|
317
|
+
extraLines: z.array(extraLineInputSchema).optional(),
|
|
318
|
+
paymentSchedules: z.array(paymentScheduleInputSchema).optional(),
|
|
319
|
+
voucherRedemption: voucherRedemptionInputSchema.optional(),
|
|
320
|
+
groupMembership: groupMembershipInputSchema.optional(),
|
|
321
|
+
documentGeneration: documentGenerationInputSchema.optional(),
|
|
322
|
+
});
|
|
323
|
+
export const bookingCreateSchema = bookingCreateBaseSchema
|
|
324
|
+
.superRefine(requirePriceOverrideReason)
|
|
325
|
+
.superRefine(requireCompleteBookingParty)
|
|
326
|
+
.superRefine(requireUniqueClientTravelerKeys)
|
|
327
|
+
.superRefine(requireKnownTravelerKeys);
|
|
328
|
+
export const bookingCreateSubSchema = bookingCreateBaseSchema
|
|
329
|
+
.omit({ groupMembership: true })
|
|
330
|
+
.superRefine(requirePriceOverrideReason)
|
|
331
|
+
.superRefine(requireCompleteBookingParty)
|
|
332
|
+
.superRefine(requireUniqueClientTravelerKeys)
|
|
333
|
+
.superRefine(requireKnownTravelerKeys);
|
|
334
|
+
// ---------- service ----------
|
|
335
|
+
/**
|
|
336
|
+
* Atomic booking-create orchestrator. Runs product conversion + travelers +
|
|
337
|
+
* payment schedules + voucher redemption + group membership inside a single
|
|
338
|
+
* transaction so partial failures (e.g. voucher insufficient-balance after
|
|
339
|
+
* schedules have been written) roll the whole thing back.
|
|
340
|
+
*
|
|
341
|
+
* Event emission is post-commit — if the tx rolls back, subscribers never
|
|
342
|
+
* hear about it.
|
|
343
|
+
*
|
|
344
|
+
* Why the orchestrator lives in `@voyant-travel/finance`: finance already imports
|
|
345
|
+
* from `@voyant-travel/bookings` (invoices-from-bookings, voucher service, payment
|
|
346
|
+
* schedules all sit here), so this is the one place that can compose the
|
|
347
|
+
* three packages without creating a new workspace dep cycle. The route wires
|
|
348
|
+
* it under `/v1/admin/bookings/create` via a HonoExtension whose
|
|
349
|
+
* `module` targets `"bookings"`.
|
|
350
|
+
*/
|
|
351
|
+
/**
|
|
352
|
+
* Sentinel thrown inside the tx to force drizzle to roll back. Returning a
|
|
353
|
+
* non-ok result from the tx callback doesn't abort the tx — only a thrown
|
|
354
|
+
* error does — so the orchestrator uses this to unwind cleanly when a
|
|
355
|
+
* downstream step discovers a precondition failure.
|
|
356
|
+
*/
|
|
357
|
+
class BookingCreateAbort extends Error {
|
|
358
|
+
outcome;
|
|
359
|
+
constructor(outcome) {
|
|
360
|
+
super(`create aborted: ${outcome.status}`);
|
|
361
|
+
this.outcome = outcome;
|
|
362
|
+
this.name = "BookingCreateAbort";
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
class BookingCreateValidationError extends Error {
|
|
366
|
+
code;
|
|
367
|
+
mismatches;
|
|
368
|
+
constructor(code, mismatches) {
|
|
369
|
+
super(code);
|
|
370
|
+
this.code = code;
|
|
371
|
+
this.mismatches = mismatches;
|
|
372
|
+
this.name = "BookingCreateValidationError";
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function parseAlreadyPaidScheduleMetadata(notes) {
|
|
376
|
+
if (!notes)
|
|
377
|
+
return null;
|
|
378
|
+
try {
|
|
379
|
+
const parsed = JSON.parse(notes);
|
|
380
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
function isAlreadyPaidSchedule(schedule) {
|
|
387
|
+
const metadata = parseAlreadyPaidScheduleMetadata(schedule.notes);
|
|
388
|
+
return schedule.status === "paid" || metadata?.alreadyPaid === true;
|
|
389
|
+
}
|
|
390
|
+
function duplicateBookingGuardKey(input) {
|
|
391
|
+
if (!input.slotId)
|
|
392
|
+
return null;
|
|
393
|
+
if (input.personId)
|
|
394
|
+
return `booking-create:person:${input.personId}:slot:${input.slotId}`;
|
|
395
|
+
if (input.organizationId) {
|
|
396
|
+
return `booking-create:organization:${input.organizationId}:slot:${input.slotId}`;
|
|
397
|
+
}
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
async function findDuplicateBookingForCreate(tx, input) {
|
|
401
|
+
const guardKey = duplicateBookingGuardKey(input);
|
|
402
|
+
if (!guardKey || input.allowDuplicate)
|
|
403
|
+
return null;
|
|
404
|
+
// agent-quality: raw-sql reviewed -- owner: finance; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
405
|
+
await tx.execute(sql `SELECT pg_advisory_xact_lock(hashtextextended(${guardKey}, 0))`);
|
|
406
|
+
const partyCondition = input.personId
|
|
407
|
+
? // agent-quality: raw-sql reviewed -- owner: finance; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
408
|
+
sql `b.person_id = ${input.personId}`
|
|
409
|
+
: // agent-quality: raw-sql reviewed -- owner: finance; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
410
|
+
sql `b.organization_id = ${input.organizationId}`;
|
|
411
|
+
const rows = await tx.execute(sql `
|
|
412
|
+
SELECT
|
|
413
|
+
b.id AS "id",
|
|
414
|
+
b.booking_number AS "bookingNumber",
|
|
415
|
+
b.status AS "status"
|
|
416
|
+
FROM bookings b
|
|
417
|
+
WHERE b.status NOT IN ('cancelled', 'expired')
|
|
418
|
+
AND ${partyCondition}
|
|
419
|
+
AND EXISTS (
|
|
420
|
+
SELECT 1
|
|
421
|
+
FROM booking_items bi
|
|
422
|
+
WHERE bi.booking_id = b.id
|
|
423
|
+
AND bi.availability_slot_id = ${input.slotId}
|
|
424
|
+
)
|
|
425
|
+
ORDER BY b.created_at ASC
|
|
426
|
+
LIMIT 1
|
|
427
|
+
`);
|
|
428
|
+
return toRows(rows)[0] ?? null;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Load the option_unit catalog for a product so the resolver can
|
|
432
|
+
* verify the submitted itemLines server-side. Raw SQL because
|
|
433
|
+
* `option_units` lives in `@voyant-travel/inventory` and finance doesn't
|
|
434
|
+
* depend on it directly — adding a runtime dependency for a log-only
|
|
435
|
+
* sanity check would be overkill.
|
|
436
|
+
*/
|
|
437
|
+
async function loadProductOptionUnits(tx, productId) {
|
|
438
|
+
const result = await tx.execute(sql `
|
|
439
|
+
SELECT
|
|
440
|
+
ou.id AS "optionUnitId",
|
|
441
|
+
ou.option_id AS "optionId",
|
|
442
|
+
ou.name AS "unitName",
|
|
443
|
+
ou.code AS "unitCode",
|
|
444
|
+
ou.min_age AS "minAge",
|
|
445
|
+
ou.max_age AS "maxAge",
|
|
446
|
+
ou.unit_type AS "unitType",
|
|
447
|
+
ou.occupancy_max AS "occupancyMax",
|
|
448
|
+
ou.is_required AS "isRequired",
|
|
449
|
+
ou.min_quantity AS "minQuantity",
|
|
450
|
+
ou.sort_order AS "sortOrder",
|
|
451
|
+
po.is_default AS "optionIsDefault",
|
|
452
|
+
po.sort_order AS "optionSortOrder",
|
|
453
|
+
po.created_at AS "optionCreatedAt"
|
|
454
|
+
FROM option_units ou
|
|
455
|
+
JOIN product_options po ON po.id = ou.option_id
|
|
456
|
+
WHERE po.product_id = ${productId}
|
|
457
|
+
`);
|
|
458
|
+
return toRows(result).map((row) => ({
|
|
459
|
+
optionId: row.optionId ?? null,
|
|
460
|
+
optionUnitId: row.optionUnitId,
|
|
461
|
+
unitName: row.unitName,
|
|
462
|
+
unitCode: row.unitCode ?? null,
|
|
463
|
+
minAge: row.minAge ?? null,
|
|
464
|
+
maxAge: row.maxAge ?? null,
|
|
465
|
+
unitType: row.unitType ?? null,
|
|
466
|
+
occupancyMax: row.occupancyMax ?? null,
|
|
467
|
+
isRequired: row.isRequired ?? null,
|
|
468
|
+
minQuantity: row.minQuantity ?? null,
|
|
469
|
+
sortOrder: row.sortOrder ?? null,
|
|
470
|
+
optionIsDefault: row.optionIsDefault ?? null,
|
|
471
|
+
optionSortOrder: row.optionSortOrder ?? null,
|
|
472
|
+
optionCreatedAt: row.optionCreatedAt ?? null,
|
|
473
|
+
}));
|
|
474
|
+
}
|
|
475
|
+
function isInventoryOptionUnit(unit) {
|
|
476
|
+
return unit.unitType === "room" || unit.unitType === "vehicle";
|
|
477
|
+
}
|
|
478
|
+
function isPersonOptionUnit(unit) {
|
|
479
|
+
return unit.unitType == null || unit.unitType === "person";
|
|
480
|
+
}
|
|
481
|
+
function normalizeAccommodationItemLinesToInventoryUnits(options) {
|
|
482
|
+
if (!options.itemLines?.length || options.units.length === 0)
|
|
483
|
+
return options.itemLines;
|
|
484
|
+
const unitsByOption = new Map();
|
|
485
|
+
const unitById = new Map();
|
|
486
|
+
const unitToPrimaryInventory = new Map();
|
|
487
|
+
for (const unit of options.units) {
|
|
488
|
+
const optionKey = unit.optionId ?? unit.optionUnitId;
|
|
489
|
+
unitById.set(unit.optionUnitId, unit);
|
|
490
|
+
const optionUnits = unitsByOption.get(optionKey);
|
|
491
|
+
if (optionUnits)
|
|
492
|
+
optionUnits.push(unit);
|
|
493
|
+
else
|
|
494
|
+
unitsByOption.set(optionKey, [unit]);
|
|
495
|
+
}
|
|
496
|
+
for (const optionUnits of unitsByOption.values()) {
|
|
497
|
+
const primaryInventory = optionUnits.find(isInventoryOptionUnit);
|
|
498
|
+
if (!primaryInventory)
|
|
499
|
+
continue;
|
|
500
|
+
for (const unit of optionUnits) {
|
|
501
|
+
unitToPrimaryInventory.set(unit.optionUnitId, primaryInventory);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return options.itemLines.map((line) => {
|
|
505
|
+
const submittedUnit = unitById.get(line.optionUnitId);
|
|
506
|
+
const targetInventory = unitToPrimaryInventory.get(line.optionUnitId);
|
|
507
|
+
if (!submittedUnit || !targetInventory)
|
|
508
|
+
return line;
|
|
509
|
+
if (isInventoryOptionUnit(submittedUnit) || !isPersonOptionUnit(submittedUnit))
|
|
510
|
+
return line;
|
|
511
|
+
return {
|
|
512
|
+
...line,
|
|
513
|
+
optionUnitId: targetInventory.optionUnitId,
|
|
514
|
+
};
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
function resolveDefaultOptionId(units) {
|
|
518
|
+
const optionIds = [...new Set(units.map((unit) => unit.optionId).filter(Boolean))];
|
|
519
|
+
if (optionIds.length === 0)
|
|
520
|
+
return null;
|
|
521
|
+
const optionRows = optionIds.map((optionId) => {
|
|
522
|
+
const firstUnit = units.find((unit) => unit.optionId === optionId);
|
|
523
|
+
return {
|
|
524
|
+
optionId,
|
|
525
|
+
isDefault: firstUnit?.optionIsDefault === true,
|
|
526
|
+
sortOrder: firstUnit?.optionSortOrder ?? 0,
|
|
527
|
+
createdAt: firstUnit?.optionCreatedAt ? new Date(firstUnit.optionCreatedAt).getTime() : 0,
|
|
528
|
+
};
|
|
529
|
+
});
|
|
530
|
+
optionRows.sort((a, b) => {
|
|
531
|
+
if (a.isDefault !== b.isDefault)
|
|
532
|
+
return a.isDefault ? -1 : 1;
|
|
533
|
+
if (a.sortOrder !== b.sortOrder)
|
|
534
|
+
return a.sortOrder - b.sortOrder;
|
|
535
|
+
return a.createdAt - b.createdAt;
|
|
536
|
+
});
|
|
537
|
+
return optionRows[0]?.optionId ?? null;
|
|
538
|
+
}
|
|
539
|
+
function defaultSeedItemQuantity(unit, pax) {
|
|
540
|
+
if (unit.unitType === "person" && pax)
|
|
541
|
+
return pax;
|
|
542
|
+
return unit.minQuantity && unit.minQuantity > 0 ? unit.minQuantity : 1;
|
|
543
|
+
}
|
|
544
|
+
function roomOccupancyMaxForCreate(unit) {
|
|
545
|
+
return Math.max(1, unit.occupancyMax ?? 1);
|
|
546
|
+
}
|
|
547
|
+
function selectedRoomOccupancyMaxForCreate(options) {
|
|
548
|
+
const roomUnits = options.units.filter((unit) => unit.unitType === "room");
|
|
549
|
+
if (roomUnits.length === 0)
|
|
550
|
+
return null;
|
|
551
|
+
const unitById = new Map(options.units.map((unit) => [unit.optionUnitId, unit]));
|
|
552
|
+
if (options.itemLines?.length) {
|
|
553
|
+
const referencedOptionIds = new Set(options.itemLines
|
|
554
|
+
.map((line) => unitById.get(line.optionUnitId)?.optionId ?? null)
|
|
555
|
+
.filter((optionId) => Boolean(optionId)));
|
|
556
|
+
const relevantRoomUnits = roomUnits.filter((unit) => unit.optionId && referencedOptionIds.has(unit.optionId));
|
|
557
|
+
if (relevantRoomUnits.length === 0)
|
|
558
|
+
return null;
|
|
559
|
+
return options.itemLines.reduce((total, line) => {
|
|
560
|
+
const unit = unitById.get(line.optionUnitId);
|
|
561
|
+
if (unit?.unitType !== "room")
|
|
562
|
+
return total;
|
|
563
|
+
return total + roomOccupancyMaxForCreate(unit) * line.quantity;
|
|
564
|
+
}, 0);
|
|
565
|
+
}
|
|
566
|
+
const selectedOptionId = options.optionId ?? resolveDefaultOptionId(options.units);
|
|
567
|
+
const selectedUnits = selectedOptionId === null
|
|
568
|
+
? []
|
|
569
|
+
: options.units.filter((unit) => unit.optionId === selectedOptionId);
|
|
570
|
+
if (!selectedUnits.some((unit) => unit.unitType === "room"))
|
|
571
|
+
return null;
|
|
572
|
+
const unitsToSeed = selectedUnits.some((unit) => unit.isRequired)
|
|
573
|
+
? selectedUnits.filter((unit) => unit.isRequired)
|
|
574
|
+
: selectedUnits.length === 1
|
|
575
|
+
? selectedUnits
|
|
576
|
+
: [];
|
|
577
|
+
return unitsToSeed.reduce((total, unit) => {
|
|
578
|
+
if (unit.unitType !== "room")
|
|
579
|
+
return total;
|
|
580
|
+
return total + roomOccupancyMaxForCreate(unit) * defaultSeedItemQuantity(unit, options.pax);
|
|
581
|
+
}, 0);
|
|
582
|
+
}
|
|
583
|
+
function validateRoomOccupancyForCreate(options) {
|
|
584
|
+
if (!options.pax || options.pax <= 0)
|
|
585
|
+
return null;
|
|
586
|
+
const occupancyMax = selectedRoomOccupancyMaxForCreate(options);
|
|
587
|
+
if (occupancyMax === null || occupancyMax >= options.pax)
|
|
588
|
+
return null;
|
|
589
|
+
return {
|
|
590
|
+
status: "room_occupancy_insufficient",
|
|
591
|
+
pax: options.pax,
|
|
592
|
+
occupancyMax,
|
|
593
|
+
shortfall: options.pax - occupancyMax,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
function hasResolverRejectionSignals(input) {
|
|
597
|
+
const hasTravelerLinks = (line) => (Array.isArray(line.travelerKeys) && line.travelerKeys.length > 0) ||
|
|
598
|
+
(Array.isArray(line.travelerIndexes) && line.travelerIndexes.length > 0);
|
|
599
|
+
return (input.travelers.every((traveler) => traveler.travelerCategory === "adult" ||
|
|
600
|
+
traveler.travelerCategory === "child" ||
|
|
601
|
+
traveler.travelerCategory === "infant") && input.itemLines.every(hasTravelerLinks));
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Re-runs `resolveBookingDraft` against the submitted payload and
|
|
605
|
+
* rejects mismatches between submitted itemLines quantities and
|
|
606
|
+
* what the resolver would derive when the request carries the
|
|
607
|
+
* traveler band + line assignment metadata the verifier needs.
|
|
608
|
+
*/
|
|
609
|
+
async function verifyBookingCreatePayload(tx, input) {
|
|
610
|
+
const itemLines = input.itemLines ?? [];
|
|
611
|
+
const travelers = input.travelers ?? [];
|
|
612
|
+
if (itemLines.length === 0 || travelers.length === 0)
|
|
613
|
+
return;
|
|
614
|
+
const units = await loadProductOptionUnits(tx, input.productId);
|
|
615
|
+
const verification = verifyBookingDraft({
|
|
616
|
+
travelers,
|
|
617
|
+
itemLines,
|
|
618
|
+
units,
|
|
619
|
+
});
|
|
620
|
+
if (!verification.ok) {
|
|
621
|
+
if (!hasResolverRejectionSignals({ travelers, itemLines })) {
|
|
622
|
+
console.warn(`[bookings/create] payload drift skipped hard rejection for product=${input.productId}`, JSON.stringify(verification.mismatches));
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
throw new BookingCreateValidationError("payload_resolver_mismatch", verification.mismatches);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Filter + dedupe deprecated `travelerIndexes` against the inserted traveler
|
|
630
|
+
* array, dropping any indexes outside `[0, travelersLength)`.
|
|
631
|
+
*/
|
|
632
|
+
function uniqueValidTravelerIndexes(indexes, travelersLength) {
|
|
633
|
+
if (!indexes?.length)
|
|
634
|
+
return [];
|
|
635
|
+
const seen = new Set();
|
|
636
|
+
const result = [];
|
|
637
|
+
for (const index of indexes) {
|
|
638
|
+
if (index < 0 || index >= travelersLength)
|
|
639
|
+
continue;
|
|
640
|
+
if (seen.has(index))
|
|
641
|
+
continue;
|
|
642
|
+
seen.add(index);
|
|
643
|
+
result.push(index);
|
|
644
|
+
}
|
|
645
|
+
return result;
|
|
646
|
+
}
|
|
647
|
+
function uniqueTravelerKeys(keys) {
|
|
648
|
+
if (!keys?.length)
|
|
649
|
+
return [];
|
|
650
|
+
const seen = new Set();
|
|
651
|
+
const result = [];
|
|
652
|
+
for (const key of keys) {
|
|
653
|
+
const normalized = key.trim();
|
|
654
|
+
if (!normalized || seen.has(normalized))
|
|
655
|
+
continue;
|
|
656
|
+
seen.add(normalized);
|
|
657
|
+
result.push(normalized);
|
|
658
|
+
}
|
|
659
|
+
return result;
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Look up each `booking_item` the converter inserted by its stamped
|
|
663
|
+
* `metadata.bookingCreateLineKey`, then write one
|
|
664
|
+
* `booking_item_travelers` row per requested traveler. Idempotent —
|
|
665
|
+
* dedupes by `(item_id, traveler_id)` and skips when the lookup
|
|
666
|
+
* fails (e.g. the converter didn't create an item for that key).
|
|
667
|
+
*
|
|
668
|
+
* The metadata-key bridge lets the wire-format `clientLineKey` thread
|
|
669
|
+
* through the create flow without forcing the converter to return a
|
|
670
|
+
* map back to the orchestrator. See voyant-travel/voyant#1267.
|
|
671
|
+
*/
|
|
672
|
+
async function linkBookingCreateItemsToTravelers(tx, bookingId, travelers, travelerInputs, lines) {
|
|
673
|
+
if (travelers.length === 0 || lines.length === 0)
|
|
674
|
+
return;
|
|
675
|
+
const duplicateTravelerKeys = findDuplicateClientTravelerKeys(travelerInputs);
|
|
676
|
+
if (duplicateTravelerKeys.length > 0) {
|
|
677
|
+
throw new Error(`Duplicate clientTravelerKey: ${duplicateTravelerKeys.join(", ")}`);
|
|
678
|
+
}
|
|
679
|
+
const travelerByClientKey = new Map();
|
|
680
|
+
for (const [index, travelerInput] of travelerInputs.entries()) {
|
|
681
|
+
const key = travelerInput.clientTravelerKey?.trim();
|
|
682
|
+
const traveler = travelers[index];
|
|
683
|
+
if (key && traveler && !travelerByClientKey.has(key))
|
|
684
|
+
travelerByClientKey.set(key, traveler);
|
|
685
|
+
}
|
|
686
|
+
const requestedLinks = [];
|
|
687
|
+
for (const line of lines) {
|
|
688
|
+
const travelerKeys = uniqueTravelerKeys(line.travelerKeys);
|
|
689
|
+
if (travelerKeys.length > 0) {
|
|
690
|
+
for (const travelerKey of travelerKeys) {
|
|
691
|
+
requestedLinks.push({
|
|
692
|
+
clientLineKey: line.clientLineKey ?? null,
|
|
693
|
+
travelerKey,
|
|
694
|
+
traveler: travelerByClientKey.get(travelerKey) ?? null,
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
for (const travelerIndex of uniqueValidTravelerIndexes(line.travelerIndexes, travelers.length)) {
|
|
700
|
+
requestedLinks.push({
|
|
701
|
+
clientLineKey: line.clientLineKey ?? null,
|
|
702
|
+
travelerKey: null,
|
|
703
|
+
traveler: travelers[travelerIndex] ?? null,
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
if (requestedLinks.length === 0)
|
|
708
|
+
return;
|
|
709
|
+
const itemRows = await tx.select().from(bookingItems).where(eq(bookingItems.bookingId, bookingId));
|
|
710
|
+
const itemByClientLineKey = new Map();
|
|
711
|
+
for (const item of itemRows) {
|
|
712
|
+
const key = item.metadata
|
|
713
|
+
?.bookingCreateLineKey;
|
|
714
|
+
if (typeof key === "string")
|
|
715
|
+
itemByClientLineKey.set(key, item);
|
|
716
|
+
}
|
|
717
|
+
const seen = new Set();
|
|
718
|
+
const unknownTravelerKeys = requestedLinks
|
|
719
|
+
.filter((link) => link.travelerKey && !link.traveler)
|
|
720
|
+
.map((link) => link.travelerKey)
|
|
721
|
+
.filter((key) => Boolean(key));
|
|
722
|
+
if (unknownTravelerKeys.length > 0) {
|
|
723
|
+
throw new Error(`Unknown travelerKey: ${unknownTravelerKeys.join(", ")}`);
|
|
724
|
+
}
|
|
725
|
+
const linkRows = requestedLinks.flatMap(({ clientLineKey, traveler }) => {
|
|
726
|
+
if (!clientLineKey)
|
|
727
|
+
return [];
|
|
728
|
+
const item = itemByClientLineKey.get(clientLineKey);
|
|
729
|
+
if (!item || !traveler)
|
|
730
|
+
return [];
|
|
731
|
+
const dedupeKey = `${item.id}:${traveler.id}`;
|
|
732
|
+
if (seen.has(dedupeKey))
|
|
733
|
+
return [];
|
|
734
|
+
seen.add(dedupeKey);
|
|
735
|
+
return [
|
|
736
|
+
{
|
|
737
|
+
bookingItemId: item.id,
|
|
738
|
+
travelerId: traveler.id,
|
|
739
|
+
role: "traveler",
|
|
740
|
+
isPrimary: traveler.isPrimary,
|
|
741
|
+
},
|
|
742
|
+
];
|
|
743
|
+
});
|
|
744
|
+
if (linkRows.length > 0) {
|
|
745
|
+
await tx.insert(bookingItemTravelers).values(linkRows);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
function validatePaymentSchedules(input, booking) {
|
|
749
|
+
const schedules = input.paymentSchedules ?? [];
|
|
750
|
+
if (schedules.length === 0)
|
|
751
|
+
return [];
|
|
752
|
+
const issues = [];
|
|
753
|
+
const expectedCurrency = booking.sellCurrency;
|
|
754
|
+
schedules.forEach((schedule, index) => {
|
|
755
|
+
if (schedule.currency !== expectedCurrency) {
|
|
756
|
+
issues.push({
|
|
757
|
+
path: ["paymentSchedules", index, "currency"],
|
|
758
|
+
message: `paymentSchedules[${index}].currency must equal the booking's sellCurrency (${expectedCurrency}); got ${schedule.currency}`,
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
if (typeof input.confirmedSellAmountCents === "number") {
|
|
763
|
+
const sum = schedules.reduce((total, schedule) => total + schedule.amountCents, 0);
|
|
764
|
+
if (sum !== input.confirmedSellAmountCents) {
|
|
765
|
+
issues.push({
|
|
766
|
+
path: ["paymentSchedules"],
|
|
767
|
+
message: `paymentSchedules amountCents sum (${sum}) must equal confirmedSellAmountCents (${input.confirmedSellAmountCents})`,
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return issues;
|
|
772
|
+
}
|
|
773
|
+
function bookingItemStatusForInitialStatus(status) {
|
|
774
|
+
if (status === "on_hold")
|
|
775
|
+
return "on_hold";
|
|
776
|
+
if (status === "cancelled")
|
|
777
|
+
return "cancelled";
|
|
778
|
+
if (status === "expired")
|
|
779
|
+
return "expired";
|
|
780
|
+
if (status === "completed")
|
|
781
|
+
return "fulfilled";
|
|
782
|
+
if (status === "confirmed" || status === "awaiting_payment" || status === "in_progress") {
|
|
783
|
+
return "confirmed";
|
|
784
|
+
}
|
|
785
|
+
return "draft";
|
|
786
|
+
}
|
|
787
|
+
function generateInvoiceNumber(bookingNumber) {
|
|
788
|
+
return `INV-${bookingNumber}`.slice(0, 50);
|
|
789
|
+
}
|
|
790
|
+
function todayIsoDate() {
|
|
791
|
+
return new Date().toISOString().slice(0, 10);
|
|
792
|
+
}
|
|
793
|
+
export function deriveBookingCreatePax(input) {
|
|
794
|
+
if (Object.hasOwn(input, "pax")) {
|
|
795
|
+
return input.pax ?? null;
|
|
796
|
+
}
|
|
797
|
+
const pax = input.travelers?.filter((traveler) => [undefined, null, "traveler", "occupant"].includes(traveler.participantType)).length ?? 0;
|
|
798
|
+
return pax > 0 ? pax : null;
|
|
799
|
+
}
|
|
800
|
+
function buildBookingCreateLedgerCommand(input, options) {
|
|
801
|
+
return {
|
|
802
|
+
productId: input.productId,
|
|
803
|
+
optionId: input.optionId ?? null,
|
|
804
|
+
slotId: input.slotId ?? null,
|
|
805
|
+
bookingNumber: input.bookingNumber,
|
|
806
|
+
personId: input.personId ?? null,
|
|
807
|
+
organizationId: input.organizationId ?? null,
|
|
808
|
+
pax: options.pax,
|
|
809
|
+
itemLineCount: input.itemLines?.length ?? 0,
|
|
810
|
+
extraLineCount: input.extraLines?.length ?? 0,
|
|
811
|
+
travelerCount: input.travelers?.length ?? 0,
|
|
812
|
+
paymentScheduleCount: input.paymentSchedules?.length ?? 0,
|
|
813
|
+
voucherRedemptionRequested: Boolean(input.voucherRedemption),
|
|
814
|
+
groupMembershipAction: input.groupMembership?.action ?? null,
|
|
815
|
+
initialStatus: input.initialStatus ?? null,
|
|
816
|
+
documentGeneration: options.documentGeneration,
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
async function appendBookingCreateRejectedActionLedger(db, context, outcome, input, options) {
|
|
820
|
+
if (!context)
|
|
821
|
+
return;
|
|
822
|
+
await appendActionLedgerMutation(db, await buildBookingCreateRejectedActionLedgerInput(context, {
|
|
823
|
+
existingBooking: outcome.existingBooking,
|
|
824
|
+
command: buildBookingCreateLedgerCommand(input, options),
|
|
825
|
+
reason: "duplicate_booking",
|
|
826
|
+
}, { authorizationSource: options.authorizationSource }));
|
|
827
|
+
}
|
|
828
|
+
export async function createBooking(db, rawInput, options = {}) {
|
|
829
|
+
const { userId, runtime } = options;
|
|
830
|
+
// Parse through the schema so defaults (makeBookingPrimary, role,
|
|
831
|
+
// participantType, etc.) are applied even when callers bypass validation —
|
|
832
|
+
// unit tests and hand-written integrations commonly do.
|
|
833
|
+
const input = bookingCreateSchema.parse(rawInput);
|
|
834
|
+
const documentGeneration = input.documentGeneration ?? {
|
|
835
|
+
contractDocument: false,
|
|
836
|
+
invoiceDocument: false,
|
|
837
|
+
invoiceType: "invoice",
|
|
838
|
+
};
|
|
839
|
+
const pax = deriveBookingCreatePax(input);
|
|
840
|
+
// Validate voucher up-front so we can short-circuit before the tx starts.
|
|
841
|
+
// This is a cheap read — the authoritative balance check still happens
|
|
842
|
+
// inside the redeem savepoint so two concurrent redemptions can't double-
|
|
843
|
+
// spend.
|
|
844
|
+
if (input.voucherRedemption) {
|
|
845
|
+
const [voucher] = await db
|
|
846
|
+
.select()
|
|
847
|
+
.from(vouchers)
|
|
848
|
+
.where(eq(vouchers.id, input.voucherRedemption.voucherId))
|
|
849
|
+
.limit(1);
|
|
850
|
+
if (!voucher)
|
|
851
|
+
return { status: "voucher_not_found" };
|
|
852
|
+
if (voucher.status !== "active")
|
|
853
|
+
return { status: "voucher_inactive" };
|
|
854
|
+
if (voucher.validFrom && voucher.validFrom.getTime() > Date.now()) {
|
|
855
|
+
return { status: "voucher_not_started" };
|
|
856
|
+
}
|
|
857
|
+
if (voucher.expiresAt && voucher.expiresAt.getTime() < Date.now()) {
|
|
858
|
+
return { status: "voucher_expired" };
|
|
859
|
+
}
|
|
860
|
+
if (input.voucherRedemption.amountCents > voucher.remainingAmountCents) {
|
|
861
|
+
return { status: "voucher_insufficient_balance" };
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
let result;
|
|
865
|
+
try {
|
|
866
|
+
result = await db.transaction(async (tx) => {
|
|
867
|
+
const duplicateBooking = await findDuplicateBookingForCreate(tx, input);
|
|
868
|
+
if (duplicateBooking) {
|
|
869
|
+
throw new BookingCreateAbort({
|
|
870
|
+
status: "duplicate_booking",
|
|
871
|
+
existingBooking: duplicateBooking,
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
const productOptionUnits = await loadProductOptionUnits(tx, input.productId);
|
|
875
|
+
const normalizedItemLines = normalizeAccommodationItemLinesToInventoryUnits({
|
|
876
|
+
itemLines: input.itemLines,
|
|
877
|
+
units: productOptionUnits,
|
|
878
|
+
});
|
|
879
|
+
const roomOccupancyIssue = validateRoomOccupancyForCreate({
|
|
880
|
+
itemLines: normalizedItemLines,
|
|
881
|
+
units: productOptionUnits,
|
|
882
|
+
optionId: input.optionId ?? null,
|
|
883
|
+
pax,
|
|
884
|
+
});
|
|
885
|
+
if (roomOccupancyIssue) {
|
|
886
|
+
throw new BookingCreateAbort(roomOccupancyIssue);
|
|
887
|
+
}
|
|
888
|
+
// 1. Booking from product
|
|
889
|
+
const booking = await bookingsService.createBookingFromProduct(tx, {
|
|
890
|
+
productId: input.productId,
|
|
891
|
+
optionId: input.optionId ?? null,
|
|
892
|
+
slotId: input.slotId ?? null,
|
|
893
|
+
bookingNumber: input.bookingNumber,
|
|
894
|
+
personId: input.personId ?? null,
|
|
895
|
+
organizationId: input.organizationId ?? null,
|
|
896
|
+
pax,
|
|
897
|
+
internalNotes: input.internalNotes ?? null,
|
|
898
|
+
sellAmountCentsOverride: input.sellAmountCentsOverride ?? null,
|
|
899
|
+
catalogSellAmountCents: input.catalogSellAmountCents ?? null,
|
|
900
|
+
confirmedSellAmountCents: input.confirmedSellAmountCents ?? null,
|
|
901
|
+
priceOverrideReason: input.priceOverrideReason ?? null,
|
|
902
|
+
initialStatus: input.initialStatus,
|
|
903
|
+
contactFirstName: input.contactFirstName ?? null,
|
|
904
|
+
contactLastName: input.contactLastName ?? null,
|
|
905
|
+
contactEmail: input.contactEmail ?? null,
|
|
906
|
+
contactPhone: input.contactPhone ?? null,
|
|
907
|
+
contactPreferredLanguage: input.contactPreferredLanguage ?? null,
|
|
908
|
+
contactCountry: input.contactCountry ?? null,
|
|
909
|
+
contactRegion: input.contactRegion ?? null,
|
|
910
|
+
contactCity: input.contactCity ?? null,
|
|
911
|
+
contactAddressLine1: input.contactAddressLine1 ?? null,
|
|
912
|
+
contactAddressLine2: input.contactAddressLine2 ?? null,
|
|
913
|
+
contactPostalCode: input.contactPostalCode ?? null,
|
|
914
|
+
itemLines: normalizedItemLines,
|
|
915
|
+
});
|
|
916
|
+
if (!booking) {
|
|
917
|
+
// Caller gave us a product that doesn't resolve. Throw so drizzle
|
|
918
|
+
// rolls back any writes the convert helper may have made.
|
|
919
|
+
throw new BookingCreateAbort({ status: "product_not_found" });
|
|
920
|
+
}
|
|
921
|
+
const paymentScheduleIssues = validatePaymentSchedules(input, booking);
|
|
922
|
+
if (paymentScheduleIssues.length > 0) {
|
|
923
|
+
throw new BookingCreateAbort({
|
|
924
|
+
status: "invalid_payment_schedules",
|
|
925
|
+
issues: paymentScheduleIssues,
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
if (input.extraLines?.length) {
|
|
929
|
+
await tx.insert(bookingItems).values(input.extraLines.map((line) => {
|
|
930
|
+
const unitSellAmountCents = line.unitSellAmountCents ?? null;
|
|
931
|
+
const totalSellAmountCents = line.totalSellAmountCents ??
|
|
932
|
+
(unitSellAmountCents == null ? null : unitSellAmountCents * line.quantity);
|
|
933
|
+
return {
|
|
934
|
+
bookingId: booking.id,
|
|
935
|
+
title: line.name,
|
|
936
|
+
description: line.description ?? null,
|
|
937
|
+
itemType: "extra",
|
|
938
|
+
status: bookingItemStatusForInitialStatus(input.initialStatus),
|
|
939
|
+
quantity: line.quantity,
|
|
940
|
+
sellCurrency: line.sellCurrency,
|
|
941
|
+
unitSellAmountCents,
|
|
942
|
+
totalSellAmountCents,
|
|
943
|
+
costCurrency: null,
|
|
944
|
+
unitCostAmountCents: null,
|
|
945
|
+
totalCostAmountCents: null,
|
|
946
|
+
productId: input.productId,
|
|
947
|
+
optionId: input.optionId ?? null,
|
|
948
|
+
optionUnitId: null,
|
|
949
|
+
metadata: {
|
|
950
|
+
productExtraId: line.productExtraId,
|
|
951
|
+
optionExtraConfigId: line.optionExtraConfigId ?? null,
|
|
952
|
+
pricingMode: line.pricingMode ?? null,
|
|
953
|
+
pricedPerPerson: line.pricedPerPerson ?? null,
|
|
954
|
+
// Mirror what the item-line converter does so
|
|
955
|
+
// `linkBookingCreateItemsToTravelers` can look up
|
|
956
|
+
// extra rows by clientLineKey and write
|
|
957
|
+
// booking_item_travelers links for per-person
|
|
958
|
+
// extras. See voyant-travel/voyant#1267.
|
|
959
|
+
...(line.clientLineKey ? { bookingCreateLineKey: line.clientLineKey } : {}),
|
|
960
|
+
},
|
|
961
|
+
};
|
|
962
|
+
}));
|
|
963
|
+
}
|
|
964
|
+
// 2. Travelers. The wire-format `roomUnitId` on a traveler is a
|
|
965
|
+
// deprecated pricing-tier alias accepted for compatibility but
|
|
966
|
+
// not stored on the traveler row itself. Per-traveler item linkage
|
|
967
|
+
// is expressed through `booking_item_travelers` rows linked from
|
|
968
|
+
// each `booking_item`. See voyant-travel/voyant#1267.
|
|
969
|
+
const travelers = [];
|
|
970
|
+
for (const traveler of input.travelers ?? []) {
|
|
971
|
+
const [row] = await tx
|
|
972
|
+
.insert(bookingTravelers)
|
|
973
|
+
.values({
|
|
974
|
+
bookingId: booking.id,
|
|
975
|
+
personId: traveler.personId ?? null,
|
|
976
|
+
participantType: traveler.participantType,
|
|
977
|
+
travelerCategory: traveler.travelerCategory ?? null,
|
|
978
|
+
firstName: traveler.firstName,
|
|
979
|
+
lastName: traveler.lastName,
|
|
980
|
+
email: traveler.email ?? null,
|
|
981
|
+
phone: traveler.phone ?? null,
|
|
982
|
+
preferredLanguage: traveler.preferredLanguage ?? null,
|
|
983
|
+
specialRequests: traveler.specialRequests ?? null,
|
|
984
|
+
isPrimary: traveler.isPrimary ?? false,
|
|
985
|
+
notes: traveler.notes ?? null,
|
|
986
|
+
})
|
|
987
|
+
.returning();
|
|
988
|
+
if (row)
|
|
989
|
+
travelers.push(row);
|
|
990
|
+
}
|
|
991
|
+
// 2b. Link booking_items + extras to specific travelers when
|
|
992
|
+
// the caller supplied `clientLineKey` + `travelerKeys` on any
|
|
993
|
+
// line. Deprecated `travelerIndexes` remain a fallback. Item
|
|
994
|
+
// rows were inserted earlier by
|
|
995
|
+
// `convertProductToBooking` (this slice's product converter
|
|
996
|
+
// doesn't run them in the orchestrator); we look them up by
|
|
997
|
+
// the `metadata.bookingCreateLineKey` the converter stamped.
|
|
998
|
+
await linkBookingCreateItemsToTravelers(tx, booking.id, travelers, input.travelers ?? [], [
|
|
999
|
+
...(normalizedItemLines ?? []),
|
|
1000
|
+
...(input.extraLines ?? []),
|
|
1001
|
+
]);
|
|
1002
|
+
// 2c. Re-run the resolver server-side against the submitted
|
|
1003
|
+
// itemLines + travelers and reject any client/server drift on
|
|
1004
|
+
// per-band quantities. See voyant-travel/voyant#1272.
|
|
1005
|
+
await verifyBookingCreatePayload(tx, { ...input, itemLines: normalizedItemLines });
|
|
1006
|
+
// 3. Payment schedules
|
|
1007
|
+
const paymentSchedules = [];
|
|
1008
|
+
for (const schedule of input.paymentSchedules ?? []) {
|
|
1009
|
+
const [row] = await tx
|
|
1010
|
+
.insert(bookingPaymentSchedules)
|
|
1011
|
+
.values({
|
|
1012
|
+
bookingId: booking.id,
|
|
1013
|
+
scheduleType: schedule.scheduleType,
|
|
1014
|
+
status: schedule.status,
|
|
1015
|
+
dueDate: schedule.dueDate,
|
|
1016
|
+
currency: schedule.currency,
|
|
1017
|
+
amountCents: schedule.amountCents,
|
|
1018
|
+
notes: schedule.notes ?? null,
|
|
1019
|
+
})
|
|
1020
|
+
.returning();
|
|
1021
|
+
if (row)
|
|
1022
|
+
paymentSchedules.push(row);
|
|
1023
|
+
}
|
|
1024
|
+
// 4. Voucher redemption. Delegates to vouchersService so the balance
|
|
1025
|
+
// decrement + redemption-log insert share the savepoint. If anything
|
|
1026
|
+
// goes wrong (race with a concurrent redemption, mostly), the thrown
|
|
1027
|
+
// VoucherServiceError surfaces as the outcome below.
|
|
1028
|
+
let voucherRedemption = null;
|
|
1029
|
+
if (input.voucherRedemption) {
|
|
1030
|
+
const { voucher, redemption } = await vouchersService.redeem(tx, input.voucherRedemption.voucherId, {
|
|
1031
|
+
bookingId: booking.id,
|
|
1032
|
+
amountCents: input.voucherRedemption.amountCents,
|
|
1033
|
+
}, userId);
|
|
1034
|
+
if (redemption) {
|
|
1035
|
+
voucherRedemption = { voucher, redemption };
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
// 5. Group membership (partaj). Either attach to an existing group or
|
|
1039
|
+
// spin up a new one with this booking as the primary.
|
|
1040
|
+
let groupMembership = null;
|
|
1041
|
+
if (input.groupMembership) {
|
|
1042
|
+
if (input.groupMembership.action === "create") {
|
|
1043
|
+
const group = await bookingGroupsService.createBookingGroup(tx, {
|
|
1044
|
+
kind: input.groupMembership.kind,
|
|
1045
|
+
label: input.groupMembership.label ?? `Shared — ${booking.bookingNumber}`,
|
|
1046
|
+
productId: input.productId,
|
|
1047
|
+
optionUnitId: input.groupMembership.optionUnitId ?? null,
|
|
1048
|
+
primaryBookingId: input.groupMembership.makeBookingPrimary ? booking.id : null,
|
|
1049
|
+
});
|
|
1050
|
+
const memberResult = await bookingGroupsService.addGroupMember(tx, group.id, {
|
|
1051
|
+
bookingId: booking.id,
|
|
1052
|
+
role: input.groupMembership.makeBookingPrimary ? "primary" : "shared",
|
|
1053
|
+
});
|
|
1054
|
+
if (memberResult.status !== "ok") {
|
|
1055
|
+
// Shouldn't happen — we just created both rows — but throw so
|
|
1056
|
+
// the tx rolls back instead of leaving a half-created group.
|
|
1057
|
+
throw new BookingCreateAbort({ status: "group_not_found" });
|
|
1058
|
+
}
|
|
1059
|
+
groupMembership = { groupId: group.id, member: memberResult.member };
|
|
1060
|
+
}
|
|
1061
|
+
else {
|
|
1062
|
+
const memberResult = await bookingGroupsService.addGroupMember(tx, input.groupMembership.groupId, {
|
|
1063
|
+
bookingId: booking.id,
|
|
1064
|
+
role: input.groupMembership.role,
|
|
1065
|
+
});
|
|
1066
|
+
if (memberResult.status === "group_not_found") {
|
|
1067
|
+
throw new BookingCreateAbort({ status: "group_not_found" });
|
|
1068
|
+
}
|
|
1069
|
+
if (memberResult.status === "booking_not_found") {
|
|
1070
|
+
// Same booking we just inserted. Pg transaction visibility should
|
|
1071
|
+
// prevent this; surface as group_not_found for the caller — we
|
|
1072
|
+
// can't tell them the booking we created doesn't exist.
|
|
1073
|
+
throw new BookingCreateAbort({ status: "group_not_found" });
|
|
1074
|
+
}
|
|
1075
|
+
if (memberResult.status === "already_in_group") {
|
|
1076
|
+
throw new BookingCreateAbort({
|
|
1077
|
+
status: "booking_already_in_group",
|
|
1078
|
+
currentGroupId: memberResult.currentGroupId,
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
groupMembership = {
|
|
1082
|
+
groupId: input.groupMembership.groupId,
|
|
1083
|
+
member: memberResult.member,
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
if (runtime?.actionLedgerContext) {
|
|
1088
|
+
await appendActionLedgerMutation(tx, await buildBookingCreateSucceededActionLedgerInput(runtime.actionLedgerContext, {
|
|
1089
|
+
booking,
|
|
1090
|
+
command: buildBookingCreateLedgerCommand(input, { pax, documentGeneration }),
|
|
1091
|
+
}, { authorizationSource: runtime.actionLedgerAuthorizationSource }));
|
|
1092
|
+
}
|
|
1093
|
+
return {
|
|
1094
|
+
booking,
|
|
1095
|
+
travelers,
|
|
1096
|
+
paymentSchedules,
|
|
1097
|
+
voucherRedemption,
|
|
1098
|
+
groupMembership,
|
|
1099
|
+
invoice: null,
|
|
1100
|
+
invoiceDocument: { status: "not_requested" },
|
|
1101
|
+
payments: [],
|
|
1102
|
+
};
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
catch (error) {
|
|
1106
|
+
if (error instanceof BookingCreateAbort) {
|
|
1107
|
+
if (error.outcome.status === "duplicate_booking") {
|
|
1108
|
+
await appendBookingCreateRejectedActionLedger(db, runtime?.actionLedgerContext, error.outcome, input, {
|
|
1109
|
+
pax,
|
|
1110
|
+
documentGeneration,
|
|
1111
|
+
authorizationSource: runtime?.actionLedgerAuthorizationSource,
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
return error.outcome;
|
|
1115
|
+
}
|
|
1116
|
+
if (error instanceof BookingCreateValidationError) {
|
|
1117
|
+
await runtime?.eventBus?.emit("booking_create.rejected", {
|
|
1118
|
+
reason: error.code,
|
|
1119
|
+
productId: input.productId,
|
|
1120
|
+
optionId: input.optionId ?? null,
|
|
1121
|
+
slotId: input.slotId ?? null,
|
|
1122
|
+
bookingNumber: input.bookingNumber,
|
|
1123
|
+
mismatchCount: error.mismatches.length,
|
|
1124
|
+
mismatches: error.mismatches,
|
|
1125
|
+
createdByUserId: userId ?? null,
|
|
1126
|
+
occurredAt: new Date(),
|
|
1127
|
+
}, { category: "internal", source: "service" });
|
|
1128
|
+
return { status: error.code, mismatches: error.mismatches };
|
|
1129
|
+
}
|
|
1130
|
+
if (error instanceof VoucherServiceError) {
|
|
1131
|
+
if (error.code === "voucher_not_found")
|
|
1132
|
+
return { status: "voucher_not_found" };
|
|
1133
|
+
if (error.code === "voucher_inactive")
|
|
1134
|
+
return { status: "voucher_inactive" };
|
|
1135
|
+
if (error.code === "voucher_not_started")
|
|
1136
|
+
return { status: "voucher_not_started" };
|
|
1137
|
+
if (error.code === "voucher_expired")
|
|
1138
|
+
return { status: "voucher_expired" };
|
|
1139
|
+
if (error.code === "insufficient_balance")
|
|
1140
|
+
return { status: "voucher_insufficient_balance" };
|
|
1141
|
+
}
|
|
1142
|
+
throw error;
|
|
1143
|
+
}
|
|
1144
|
+
const paidSchedules = (input.paymentSchedules ?? []).filter(isAlreadyPaidSchedule);
|
|
1145
|
+
const shouldCreateInvoice = documentGeneration.invoiceDocument || paidSchedules.length > 0;
|
|
1146
|
+
if (shouldCreateInvoice) {
|
|
1147
|
+
const items = await db
|
|
1148
|
+
.select()
|
|
1149
|
+
.from(bookingItems)
|
|
1150
|
+
.where(eq(bookingItems.bookingId, result.booking.id));
|
|
1151
|
+
const issueDate = todayIsoDate();
|
|
1152
|
+
const dueDate = input.paymentSchedules?.find((schedule) => schedule.dueDate)?.dueDate ??
|
|
1153
|
+
result.booking.endDate ??
|
|
1154
|
+
issueDate;
|
|
1155
|
+
const dueDatePaymentSchedule = result.paymentSchedules.find((schedule) => schedule.dueDate === dueDate) ?? null;
|
|
1156
|
+
const invoice = await financeService.createInvoiceFromBooking(db, {
|
|
1157
|
+
bookingId: result.booking.id,
|
|
1158
|
+
invoiceNumber: generateInvoiceNumber(result.booking.bookingNumber),
|
|
1159
|
+
issueDate,
|
|
1160
|
+
dueDate,
|
|
1161
|
+
invoiceType: documentGeneration.invoiceType,
|
|
1162
|
+
notes: "Generated from booking create.",
|
|
1163
|
+
}, { booking: result.booking, dueDatePaymentSchedule, items }, runtime);
|
|
1164
|
+
result = {
|
|
1165
|
+
...result,
|
|
1166
|
+
invoice,
|
|
1167
|
+
};
|
|
1168
|
+
if (invoice) {
|
|
1169
|
+
const payments = [];
|
|
1170
|
+
for (const schedule of paidSchedules) {
|
|
1171
|
+
const metadata = parseAlreadyPaidScheduleMetadata(schedule.notes);
|
|
1172
|
+
const methodResult = paymentMethodSchema.safeParse(metadata?.paymentMethod ?? "bank_transfer");
|
|
1173
|
+
const payment = await financeService.createPayment(db, invoice.id, {
|
|
1174
|
+
amountCents: schedule.amountCents,
|
|
1175
|
+
currency: schedule.currency,
|
|
1176
|
+
paymentMethod: methodResult.success ? methodResult.data : "bank_transfer",
|
|
1177
|
+
status: "completed",
|
|
1178
|
+
referenceNumber: metadata?.paymentReference?.trim() || null,
|
|
1179
|
+
paymentDate: metadata?.paymentDate || schedule.dueDate || issueDate,
|
|
1180
|
+
notes: schedule.notes ?? null,
|
|
1181
|
+
});
|
|
1182
|
+
if (payment)
|
|
1183
|
+
payments.push(payment);
|
|
1184
|
+
}
|
|
1185
|
+
let invoiceDocument = { status: "not_requested" };
|
|
1186
|
+
if (documentGeneration.invoiceDocument) {
|
|
1187
|
+
if (runtime?.invoiceDocumentGenerator) {
|
|
1188
|
+
const generated = await financeDocumentsService.generateInvoiceDocument(db, invoice.id, { format: "pdf", replaceExisting: true, publicDelivery: false }, {
|
|
1189
|
+
generator: runtime.invoiceDocumentGenerator,
|
|
1190
|
+
eventBus: runtime.eventBus,
|
|
1191
|
+
bindings: runtime.bindings,
|
|
1192
|
+
});
|
|
1193
|
+
invoiceDocument =
|
|
1194
|
+
generated.status === "generated"
|
|
1195
|
+
? { status: "generated", renditionId: generated.rendition.id }
|
|
1196
|
+
: { status: "failed" };
|
|
1197
|
+
}
|
|
1198
|
+
else {
|
|
1199
|
+
const requested = await financeService.renderInvoice(db, invoice.id, { format: "pdf" });
|
|
1200
|
+
invoiceDocument =
|
|
1201
|
+
requested.status === "requested"
|
|
1202
|
+
? { status: "requested", renditionId: requested.rendition?.id ?? null }
|
|
1203
|
+
: { status: "failed" };
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
result = {
|
|
1207
|
+
...result,
|
|
1208
|
+
invoice: await financeService.getInvoiceById(db, invoice.id),
|
|
1209
|
+
invoiceDocument,
|
|
1210
|
+
payments,
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
// Post-commit event emission. Fire-and-forget (the eventBus contract
|
|
1215
|
+
// handles subscriber errors); callers that need strict delivery can
|
|
1216
|
+
// re-emit from their own subscriber chain.
|
|
1217
|
+
if (runtime?.eventBus) {
|
|
1218
|
+
const event = {
|
|
1219
|
+
bookingId: result.booking.id,
|
|
1220
|
+
bookingNumber: result.booking.bookingNumber,
|
|
1221
|
+
productId: input.productId,
|
|
1222
|
+
travelerCount: result.travelers.length,
|
|
1223
|
+
paymentScheduleCount: result.paymentSchedules.length,
|
|
1224
|
+
voucherRedeemedCents: result.voucherRedemption
|
|
1225
|
+
? result.voucherRedemption.redemption.amountCents
|
|
1226
|
+
: null,
|
|
1227
|
+
groupId: result.groupMembership?.groupId ?? null,
|
|
1228
|
+
documentGeneration,
|
|
1229
|
+
createdByUserId: userId ?? null,
|
|
1230
|
+
occurredAt: new Date(),
|
|
1231
|
+
};
|
|
1232
|
+
await runtime.eventBus.emit("booking.created", event);
|
|
1233
|
+
// When the caller asked us to land the booking already in
|
|
1234
|
+
// `confirmed`, fan out the `booking.confirmed` event the same way
|
|
1235
|
+
// the verb endpoint would so notification / document-bundle
|
|
1236
|
+
// subscribers fire just once at create-time.
|
|
1237
|
+
if (input.initialStatus === "confirmed") {
|
|
1238
|
+
const confirmedEvent = {
|
|
1239
|
+
bookingId: result.booking.id,
|
|
1240
|
+
bookingNumber: result.booking.bookingNumber,
|
|
1241
|
+
actorId: userId ?? null,
|
|
1242
|
+
suppressNotifications: input.suppressNotifications === true ? true : undefined,
|
|
1243
|
+
};
|
|
1244
|
+
await runtime.eventBus.emit("booking.confirmed", confirmedEvent);
|
|
1245
|
+
}
|
|
1246
|
+
if (documentGeneration.contractDocument) {
|
|
1247
|
+
await runtime.eventBus.emit("booking.contract_document.requested", {
|
|
1248
|
+
bookingId: result.booking.id,
|
|
1249
|
+
bookingNumber: result.booking.bookingNumber,
|
|
1250
|
+
createdByUserId: userId ?? null,
|
|
1251
|
+
occurredAt: new Date(),
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
return { status: "ok", result };
|
|
1256
|
+
}
|