@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.
Files changed (294) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +192 -0
  3. package/dist/action-ledger-drift.d.ts +29 -0
  4. package/dist/action-ledger-drift.d.ts.map +1 -0
  5. package/dist/action-ledger-drift.js +166 -0
  6. package/dist/booking-tax.d.ts +124 -0
  7. package/dist/booking-tax.d.ts.map +1 -0
  8. package/dist/booking-tax.js +264 -0
  9. package/dist/checkout-routes.d.ts +1154 -0
  10. package/dist/checkout-routes.d.ts.map +1 -0
  11. package/dist/checkout-routes.js +116 -0
  12. package/dist/checkout-service-plan.d.ts +137 -0
  13. package/dist/checkout-service-plan.d.ts.map +1 -0
  14. package/dist/checkout-service-plan.js +119 -0
  15. package/dist/checkout-service.d.ts +9 -0
  16. package/dist/checkout-service.d.ts.map +1 -0
  17. package/dist/checkout-service.js +324 -0
  18. package/dist/checkout-validation.d.ts +1682 -0
  19. package/dist/checkout-validation.d.ts.map +1 -0
  20. package/dist/checkout-validation.js +228 -0
  21. package/dist/document-download.d.ts +3 -0
  22. package/dist/document-download.d.ts.map +1 -0
  23. package/dist/document-download.js +1 -0
  24. package/dist/fx-money.d.ts +17 -0
  25. package/dist/fx-money.d.ts.map +1 -0
  26. package/dist/fx-money.js +194 -0
  27. package/dist/index.d.ts +65 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +108 -0
  30. package/dist/invoice-fx.d.ts +134 -0
  31. package/dist/invoice-fx.d.ts.map +1 -0
  32. package/dist/invoice-fx.js +240 -0
  33. package/dist/invoice-number-errors.d.ts +2 -0
  34. package/dist/invoice-number-errors.d.ts.map +1 -0
  35. package/dist/invoice-number-errors.js +58 -0
  36. package/dist/markets-ref.d.ts +149 -0
  37. package/dist/markets-ref.d.ts.map +1 -0
  38. package/dist/markets-ref.js +17 -0
  39. package/dist/payment-link.d.ts +23 -0
  40. package/dist/payment-link.d.ts.map +1 -0
  41. package/dist/payment-link.js +30 -0
  42. package/dist/payment-policy.d.ts +113 -0
  43. package/dist/payment-policy.d.ts.map +1 -0
  44. package/dist/payment-policy.js +193 -0
  45. package/dist/route-runtime.d.ts +22 -0
  46. package/dist/route-runtime.d.ts.map +1 -0
  47. package/dist/route-runtime.js +18 -0
  48. package/dist/routes-action-ledger.d.ts +181 -0
  49. package/dist/routes-action-ledger.d.ts.map +1 -0
  50. package/dist/routes-action-ledger.js +142 -0
  51. package/dist/routes-booking-billing.d.ts +852 -0
  52. package/dist/routes-booking-billing.d.ts.map +1 -0
  53. package/dist/routes-booking-billing.js +223 -0
  54. package/dist/routes-booking-create.d.ts +3 -0
  55. package/dist/routes-booking-create.d.ts.map +1 -0
  56. package/dist/routes-booking-create.js +194 -0
  57. package/dist/routes-booking-reads.d.ts +46 -0
  58. package/dist/routes-booking-reads.d.ts.map +1 -0
  59. package/dist/routes-booking-reads.js +20 -0
  60. package/dist/routes-documents.d.ts +195 -0
  61. package/dist/routes-documents.d.ts.map +1 -0
  62. package/dist/routes-documents.js +93 -0
  63. package/dist/routes-invoice-core.d.ts +794 -0
  64. package/dist/routes-invoice-core.d.ts.map +1 -0
  65. package/dist/routes-invoice-core.js +238 -0
  66. package/dist/routes-invoice-documents.d.ts +401 -0
  67. package/dist/routes-invoice-documents.d.ts.map +1 -0
  68. package/dist/routes-invoice-documents.js +91 -0
  69. package/dist/routes-invoice-issue.d.ts +384 -0
  70. package/dist/routes-invoice-issue.d.ts.map +1 -0
  71. package/dist/routes-invoice-issue.js +208 -0
  72. package/dist/routes-payment-processing.d.ts +1193 -0
  73. package/dist/routes-payment-processing.d.ts.map +1 -0
  74. package/dist/routes-payment-processing.js +238 -0
  75. package/dist/routes-payments.d.ts +309 -0
  76. package/dist/routes-payments.d.ts.map +1 -0
  77. package/dist/routes-payments.js +94 -0
  78. package/dist/routes-public.d.ts +1948 -0
  79. package/dist/routes-public.d.ts.map +1 -0
  80. package/dist/routes-public.js +275 -0
  81. package/dist/routes-reference-data.d.ts +977 -0
  82. package/dist/routes-reference-data.d.ts.map +1 -0
  83. package/dist/routes-reference-data.js +191 -0
  84. package/dist/routes-reports.d.ts +344 -0
  85. package/dist/routes-reports.d.ts.map +1 -0
  86. package/dist/routes-reports.js +93 -0
  87. package/dist/routes-runtime.d.ts +71 -0
  88. package/dist/routes-runtime.d.ts.map +1 -0
  89. package/dist/routes-runtime.js +59 -0
  90. package/dist/routes-settlement.d.ts +67 -0
  91. package/dist/routes-settlement.d.ts.map +1 -0
  92. package/dist/routes-settlement.js +23 -0
  93. package/dist/routes-shared.d.ts +35 -0
  94. package/dist/routes-shared.d.ts.map +1 -0
  95. package/dist/routes-shared.js +10 -0
  96. package/dist/routes-supplier-invoices.d.ts +778 -0
  97. package/dist/routes-supplier-invoices.d.ts.map +1 -0
  98. package/dist/routes-supplier-invoices.js +159 -0
  99. package/dist/routes-vouchers.d.ts +228 -0
  100. package/dist/routes-vouchers.d.ts.map +1 -0
  101. package/dist/routes-vouchers.js +54 -0
  102. package/dist/routes.d.ts +5577 -0
  103. package/dist/routes.d.ts.map +1 -0
  104. package/dist/routes.js +44 -0
  105. package/dist/schema/booking-billing.d.ts +1006 -0
  106. package/dist/schema/booking-billing.d.ts.map +1 -0
  107. package/dist/schema/booking-billing.js +106 -0
  108. package/dist/schema/enums.d.ts +48 -0
  109. package/dist/schema/enums.d.ts.map +1 -0
  110. package/dist/schema/enums.js +237 -0
  111. package/dist/schema/invoice-documents.d.ts +1245 -0
  112. package/dist/schema/invoice-documents.d.ts.map +1 -0
  113. package/dist/schema/invoice-documents.js +140 -0
  114. package/dist/schema/payment-instruments.d.ts +418 -0
  115. package/dist/schema/payment-instruments.d.ts.map +1 -0
  116. package/dist/schema/payment-instruments.js +45 -0
  117. package/dist/schema/payment-processing.d.ts +563 -0
  118. package/dist/schema/payment-processing.d.ts.map +1 -0
  119. package/dist/schema/payment-processing.js +65 -0
  120. package/dist/schema/payment-sessions.d.ts +728 -0
  121. package/dist/schema/payment-sessions.d.ts.map +1 -0
  122. package/dist/schema/payment-sessions.js +79 -0
  123. package/dist/schema/receivables.d.ts +1474 -0
  124. package/dist/schema/receivables.d.ts.map +1 -0
  125. package/dist/schema/receivables.js +179 -0
  126. package/dist/schema/relations.d.ts +82 -0
  127. package/dist/schema/relations.d.ts.map +1 -0
  128. package/dist/schema/relations.js +144 -0
  129. package/dist/schema/supplier-invoices.d.ts +1619 -0
  130. package/dist/schema/supplier-invoices.d.ts.map +1 -0
  131. package/dist/schema/supplier-invoices.js +228 -0
  132. package/dist/schema/tax.d.ts +712 -0
  133. package/dist/schema/tax.d.ts.map +1 -0
  134. package/dist/schema/tax.js +98 -0
  135. package/dist/schema/vouchers.d.ts +444 -0
  136. package/dist/schema/vouchers.d.ts.map +1 -0
  137. package/dist/schema/vouchers.js +64 -0
  138. package/dist/schema.d.ts +12 -0
  139. package/dist/schema.d.ts.map +1 -0
  140. package/dist/schema.js +11 -0
  141. package/dist/service-accountant-shares.d.ts +106 -0
  142. package/dist/service-accountant-shares.d.ts.map +1 -0
  143. package/dist/service-accountant-shares.js +331 -0
  144. package/dist/service-action-ledger-accounting.d.ts +104 -0
  145. package/dist/service-action-ledger-accounting.d.ts.map +1 -0
  146. package/dist/service-action-ledger-accounting.js +386 -0
  147. package/dist/service-action-ledger-booking-payments.d.ts +48 -0
  148. package/dist/service-action-ledger-booking-payments.d.ts.map +1 -0
  149. package/dist/service-action-ledger-booking-payments.js +178 -0
  150. package/dist/service-action-ledger-bookings.d.ts +44 -0
  151. package/dist/service-action-ledger-bookings.d.ts.map +1 -0
  152. package/dist/service-action-ledger-bookings.js +81 -0
  153. package/dist/service-action-ledger-payment-authorizations.d.ts +48 -0
  154. package/dist/service-action-ledger-payment-authorizations.d.ts.map +1 -0
  155. package/dist/service-action-ledger-payment-authorizations.js +209 -0
  156. package/dist/service-action-ledger-payment-sessions.d.ts +83 -0
  157. package/dist/service-action-ledger-payment-sessions.d.ts.map +1 -0
  158. package/dist/service-action-ledger-payment-sessions.js +294 -0
  159. package/dist/service-action-ledger-supplier-invoices.d.ts +27 -0
  160. package/dist/service-action-ledger-supplier-invoices.d.ts.map +1 -0
  161. package/dist/service-action-ledger-supplier-invoices.js +111 -0
  162. package/dist/service-action-ledger-supplier-payments.d.ts +21 -0
  163. package/dist/service-action-ledger-supplier-payments.d.ts.map +1 -0
  164. package/dist/service-action-ledger-supplier-payments.js +97 -0
  165. package/dist/service-action-ledger.d.ts +7 -0
  166. package/dist/service-action-ledger.d.ts.map +1 -0
  167. package/dist/service-action-ledger.js +6 -0
  168. package/dist/service-aggregates.d.ts +96 -0
  169. package/dist/service-aggregates.d.ts.map +1 -0
  170. package/dist/service-aggregates.js +294 -0
  171. package/dist/service-booking-billing.d.ts +2322 -0
  172. package/dist/service-booking-billing.d.ts.map +1 -0
  173. package/dist/service-booking-billing.js +8 -0
  174. package/dist/service-booking-create.d.ts +410 -0
  175. package/dist/service-booking-create.d.ts.map +1 -0
  176. package/dist/service-booking-create.js +1256 -0
  177. package/dist/service-booking-guarantees.d.ts +725 -0
  178. package/dist/service-booking-guarantees.d.ts.map +1 -0
  179. package/dist/service-booking-guarantees.js +153 -0
  180. package/dist/service-booking-item-billing.d.ts +1062 -0
  181. package/dist/service-booking-item-billing.d.ts.map +1 -0
  182. package/dist/service-booking-item-billing.js +77 -0
  183. package/dist/service-booking-payment-schedules.d.ts +557 -0
  184. package/dist/service-booking-payment-schedules.d.ts.map +1 -0
  185. package/dist/service-booking-payment-schedules.js +372 -0
  186. package/dist/service-bookings-dual-create.d.ts +308 -0
  187. package/dist/service-bookings-dual-create.d.ts.map +1 -0
  188. package/dist/service-bookings-dual-create.js +131 -0
  189. package/dist/service-boundary-sql.d.ts +6 -0
  190. package/dist/service-boundary-sql.d.ts.map +1 -0
  191. package/dist/service-boundary-sql.js +15 -0
  192. package/dist/service-cost-categories.d.ts +26 -0
  193. package/dist/service-cost-categories.d.ts.map +1 -0
  194. package/dist/service-cost-categories.js +76 -0
  195. package/dist/service-documents.d.ts +80 -0
  196. package/dist/service-documents.d.ts.map +1 -0
  197. package/dist/service-documents.js +228 -0
  198. package/dist/service-invoice-artifacts.d.ts +246 -0
  199. package/dist/service-invoice-artifacts.d.ts.map +1 -0
  200. package/dist/service-invoice-artifacts.js +277 -0
  201. package/dist/service-invoice-core.d.ts +405 -0
  202. package/dist/service-invoice-core.d.ts.map +1 -0
  203. package/dist/service-invoice-core.js +290 -0
  204. package/dist/service-invoice-credit-notes.d.ts +973 -0
  205. package/dist/service-invoice-credit-notes.d.ts.map +1 -0
  206. package/dist/service-invoice-credit-notes.js +142 -0
  207. package/dist/service-invoice-from-booking.d.ts +41 -0
  208. package/dist/service-invoice-from-booking.d.ts.map +1 -0
  209. package/dist/service-invoice-from-booking.js +267 -0
  210. package/dist/service-invoice-line-items.d.ts +432 -0
  211. package/dist/service-invoice-line-items.d.ts.map +1 -0
  212. package/dist/service-invoice-line-items.js +102 -0
  213. package/dist/service-invoice-numbering.d.ts +227 -0
  214. package/dist/service-invoice-numbering.d.ts.map +1 -0
  215. package/dist/service-invoice-numbering.js +260 -0
  216. package/dist/service-invoice-payments.d.ts +673 -0
  217. package/dist/service-invoice-payments.d.ts.map +1 -0
  218. package/dist/service-invoice-payments.js +398 -0
  219. package/dist/service-invoices.d.ts +2501 -0
  220. package/dist/service-invoices.d.ts.map +1 -0
  221. package/dist/service-invoices.js +12 -0
  222. package/dist/service-issue.d.ts +207 -0
  223. package/dist/service-issue.d.ts.map +1 -0
  224. package/dist/service-issue.js +431 -0
  225. package/dist/service-payment-authorizations.d.ts +164 -0
  226. package/dist/service-payment-authorizations.d.ts.map +1 -0
  227. package/dist/service-payment-authorizations.js +227 -0
  228. package/dist/service-payment-instruments.d.ts +116 -0
  229. package/dist/service-payment-instruments.d.ts.map +1 -0
  230. package/dist/service-payment-instruments.js +99 -0
  231. package/dist/service-payment-processing.d.ts +676 -0
  232. package/dist/service-payment-processing.d.ts.map +1 -0
  233. package/dist/service-payment-processing.js +10 -0
  234. package/dist/service-payment-session-completion.d.ts +48 -0
  235. package/dist/service-payment-session-completion.d.ts.map +1 -0
  236. package/dist/service-payment-session-completion.js +238 -0
  237. package/dist/service-payment-sessions.d.ts +361 -0
  238. package/dist/service-payment-sessions.d.ts.map +1 -0
  239. package/dist/service-payment-sessions.js +280 -0
  240. package/dist/service-profitability.d.ts +114 -0
  241. package/dist/service-profitability.d.ts.map +1 -0
  242. package/dist/service-profitability.js +794 -0
  243. package/dist/service-public.d.ts +553 -0
  244. package/dist/service-public.d.ts.map +1 -0
  245. package/dist/service-public.js +583 -0
  246. package/dist/service-reference-data.d.ts +272 -0
  247. package/dist/service-reference-data.d.ts.map +1 -0
  248. package/dist/service-reference-data.js +280 -0
  249. package/dist/service-rendition-wait.d.ts +38 -0
  250. package/dist/service-rendition-wait.d.ts.map +1 -0
  251. package/dist/service-rendition-wait.js +67 -0
  252. package/dist/service-reports.d.ts +37 -0
  253. package/dist/service-reports.d.ts.map +1 -0
  254. package/dist/service-reports.js +62 -0
  255. package/dist/service-settlement.d.ts +46 -0
  256. package/dist/service-settlement.d.ts.map +1 -0
  257. package/dist/service-settlement.js +185 -0
  258. package/dist/service-shared.d.ts +541 -0
  259. package/dist/service-shared.d.ts.map +1 -0
  260. package/dist/service-shared.js +764 -0
  261. package/dist/service-supplier-invoices.d.ts +871 -0
  262. package/dist/service-supplier-invoices.d.ts.map +1 -0
  263. package/dist/service-supplier-invoices.js +744 -0
  264. package/dist/service-supplier-payments.d.ts +69 -0
  265. package/dist/service-supplier-payments.d.ts.map +1 -0
  266. package/dist/service-supplier-payments.js +136 -0
  267. package/dist/service-vouchers-migration.d.ts +44 -0
  268. package/dist/service-vouchers-migration.d.ts.map +1 -0
  269. package/dist/service-vouchers-migration.js +148 -0
  270. package/dist/service-vouchers.d.ts +157 -0
  271. package/dist/service-vouchers.d.ts.map +1 -0
  272. package/dist/service-vouchers.js +191 -0
  273. package/dist/service.d.ts +6490 -0
  274. package/dist/service.d.ts.map +1 -0
  275. package/dist/service.js +29 -0
  276. package/dist/validation-billing.d.ts +2 -0
  277. package/dist/validation-billing.d.ts.map +1 -0
  278. package/dist/validation-billing.js +1 -0
  279. package/dist/validation-payments.d.ts +2 -0
  280. package/dist/validation-payments.d.ts.map +1 -0
  281. package/dist/validation-payments.js +1 -0
  282. package/dist/validation-public.d.ts +2 -0
  283. package/dist/validation-public.d.ts.map +1 -0
  284. package/dist/validation-public.js +1 -0
  285. package/dist/validation-shared.d.ts +2 -0
  286. package/dist/validation-shared.d.ts.map +1 -0
  287. package/dist/validation-shared.js +1 -0
  288. package/dist/validation-vouchers.d.ts +2 -0
  289. package/dist/validation-vouchers.d.ts.map +1 -0
  290. package/dist/validation-vouchers.js +1 -0
  291. package/dist/validation.d.ts +2 -0
  292. package/dist/validation.d.ts.map +1 -0
  293. package/dist/validation.js +1 -0
  294. 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
+ }