@voyant-travel/commerce 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/checkout/acceptance-signature.d.ts +4 -0
  2. package/dist/checkout/acceptance-signature.d.ts.map +1 -0
  3. package/dist/checkout/acceptance-signature.js +95 -0
  4. package/dist/checkout/finalize.d.ts +42 -0
  5. package/dist/checkout/finalize.d.ts.map +1 -0
  6. package/dist/checkout/finalize.js +208 -0
  7. package/dist/checkout/index.d.ts +26 -0
  8. package/dist/checkout/index.d.ts.map +1 -0
  9. package/dist/checkout/index.js +24 -0
  10. package/dist/checkout/materialization-support.d.ts +105 -0
  11. package/dist/checkout/materialization-support.d.ts.map +1 -0
  12. package/dist/checkout/materialization-support.js +451 -0
  13. package/dist/checkout/materialization-support.test.d.ts +2 -0
  14. package/dist/checkout/materialization-support.test.d.ts.map +1 -0
  15. package/dist/checkout/materialization-support.test.js +196 -0
  16. package/dist/checkout/materialization-tax.d.ts +10 -0
  17. package/dist/checkout/materialization-tax.d.ts.map +1 -0
  18. package/dist/checkout/materialization-tax.js +113 -0
  19. package/dist/checkout/materialization-tax.test.d.ts +2 -0
  20. package/dist/checkout/materialization-tax.test.d.ts.map +1 -0
  21. package/dist/checkout/materialization-tax.test.js +69 -0
  22. package/dist/checkout/materialization.d.ts +99 -0
  23. package/dist/checkout/materialization.d.ts.map +1 -0
  24. package/dist/checkout/materialization.js +269 -0
  25. package/dist/checkout/options.d.ts +89 -0
  26. package/dist/checkout/options.d.ts.map +1 -0
  27. package/dist/checkout/options.js +21 -0
  28. package/dist/checkout/routes.d.ts +21 -0
  29. package/dist/checkout/routes.d.ts.map +1 -0
  30. package/dist/checkout/routes.js +59 -0
  31. package/dist/checkout/start-service.d.ts +75 -0
  32. package/dist/checkout/start-service.d.ts.map +1 -0
  33. package/dist/checkout/start-service.js +415 -0
  34. package/dist/checkout/start-service.test.d.ts +2 -0
  35. package/dist/checkout/start-service.test.d.ts.map +1 -0
  36. package/dist/checkout/start-service.test.js +57 -0
  37. package/dist/markets/routes.d.ts +1 -1
  38. package/dist/markets/service-core.d.ts +1 -1
  39. package/dist/pricing/routes-public.d.ts.map +1 -1
  40. package/dist/pricing/routes-public.js +12 -2
  41. package/dist/sellability/routes.d.ts +10 -10
  42. package/dist/sellability/service-records.d.ts +4 -4
  43. package/dist/sellability/service-snapshots.d.ts +2 -2
  44. package/dist/sellability/service.d.ts +10 -10
  45. package/package.json +28 -6
@@ -0,0 +1,113 @@
1
+ import { bookingItemTaxLines, computeBookingItemTaxLine, resolveBookingSellTaxRate, } from "@voyant-travel/finance";
2
+ import { eq } from "drizzle-orm";
3
+ import { inferSnapshotTaxFacts } from "./materialization-support.js";
4
+ export async function rebuildBookingItemTaxLines(db, bookingId, options) {
5
+ const { bookingItems: bookingItemsTable, bookings: bookingsTable } = await import("@voyant-travel/bookings/schema");
6
+ const { bookingCatalogSnapshotTable } = await import("@voyant-travel/catalog");
7
+ const [booking] = await db
8
+ .select()
9
+ .from(bookingsTable)
10
+ .where(eq(bookingsTable.id, bookingId))
11
+ .limit(1);
12
+ if (!booking)
13
+ return { rebuilt: 0, itemsWithoutSnapshot: 0 };
14
+ const items = await db
15
+ .select()
16
+ .from(bookingItemsTable)
17
+ .where(eq(bookingItemsTable.bookingId, bookingId));
18
+ let rebuilt = 0;
19
+ let itemsWithoutSnapshot = 0;
20
+ for (const item of items) {
21
+ const snapshot = await loadSnapshotForItem(db, bookingCatalogSnapshotTable, item);
22
+ if (!snapshot) {
23
+ itemsWithoutSnapshot += 1;
24
+ continue;
25
+ }
26
+ await db.delete(bookingItemTaxLines).where(eq(bookingItemTaxLines.bookingItemId, item.id));
27
+ await materializeBookingItemTaxLine(db, booking, item.id, item.totalSellAmountCents ?? 0, snapshot, options);
28
+ rebuilt += 1;
29
+ }
30
+ return { rebuilt, itemsWithoutSnapshot };
31
+ }
32
+ async function loadSnapshotForItem(db, snapshotTable, item) {
33
+ const snapshotId = item.sourceSnapshotId;
34
+ if (!snapshotId) {
35
+ // Item wasn't materialized from a catalog snapshot; fall back to the
36
+ // booking-level snapshot if there is exactly one for this booking.
37
+ const rows = await db
38
+ .select()
39
+ .from(snapshotTable)
40
+ .where(eq(snapshotTable.booking_id, item.bookingId))
41
+ .limit(2);
42
+ return rows.length === 1 && rows[0] ? toMaterializationSnapshot(rows[0]) : null;
43
+ }
44
+ const [row] = await db
45
+ .select()
46
+ .from(snapshotTable)
47
+ .where(eq(snapshotTable.id, snapshotId))
48
+ .limit(1);
49
+ return row ? toMaterializationSnapshot(row) : null;
50
+ }
51
+ function toMaterializationSnapshot(row) {
52
+ return {
53
+ id: row.id,
54
+ entity_module: row.entity_module,
55
+ entity_id: row.entity_id,
56
+ source_kind: row.source_kind,
57
+ source_provider: row.source_provider,
58
+ source_ref: row.source_ref,
59
+ frozen_payload: row.frozen_payload,
60
+ pricing_base_amount: row.pricing_base_amount != null ? String(row.pricing_base_amount) : null,
61
+ pricing_taxes: row.pricing_taxes != null ? String(row.pricing_taxes) : null,
62
+ pricing_fees: row.pricing_fees != null ? String(row.pricing_fees) : null,
63
+ pricing_surcharges: row.pricing_surcharges != null ? String(row.pricing_surcharges) : null,
64
+ pricing_currency: row.pricing_currency,
65
+ };
66
+ }
67
+ export async function materializeBookingItemTaxLine(db, booking, bookingItemId, amountCents, snapshot, options) {
68
+ const currency = booking.sellCurrency ?? snapshot.pricing_currency ?? "EUR";
69
+ const taxRate = await resolveBookingSellTaxRate(db, {
70
+ productId: snapshot.entity_module === "products" ? snapshot.entity_id : null,
71
+ facts: inferSnapshotTaxFacts(snapshot),
72
+ }, {
73
+ resolveBookingTaxSettings: options.resolveBookingTaxSettings,
74
+ });
75
+ const policyLine = computeBookingItemTaxLine(taxRate, amountCents, currency);
76
+ // Fall back to the snapshot's `pricing_taxes` when the operator has no
77
+ // tax policy configured. Without this the booking page (which reads the
78
+ // snapshot directly) shows tax but the invoice (which reads
79
+ // `booking_item_tax_lines`) shows zero — operators see a mismatch.
80
+ // The booking total already includes this tax (sellAmountCents = base +
81
+ // taxes + fees + surcharges), so the row is `includedInPrice: true`.
82
+ const fallbackLine = policyLine ? null : buildSnapshotFallbackTaxLine(snapshot, currency);
83
+ const taxLine = policyLine ?? fallbackLine;
84
+ if (!taxLine)
85
+ return;
86
+ await db
87
+ .insert(bookingItemTaxLines)
88
+ .values({
89
+ bookingItemId,
90
+ ...taxLine,
91
+ })
92
+ .onConflictDoNothing();
93
+ }
94
+ function buildSnapshotFallbackTaxLine(snapshot, currency) {
95
+ if (!snapshot.pricing_taxes)
96
+ return null;
97
+ const taxAmount = Number.parseFloat(snapshot.pricing_taxes);
98
+ if (!Number.isFinite(taxAmount) || taxAmount <= 0)
99
+ return null;
100
+ const taxCents = Math.round(taxAmount);
101
+ if (taxCents <= 0)
102
+ return null;
103
+ return {
104
+ code: "snapshot/tax",
105
+ name: "Tax",
106
+ scope: "included",
107
+ currency,
108
+ amountCents: taxCents,
109
+ rateBasisPoints: null,
110
+ includedInPrice: true,
111
+ sortOrder: 0,
112
+ };
113
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=materialization-tax.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"materialization-tax.test.d.ts","sourceRoot":"","sources":["../../src/checkout/materialization-tax.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { materializeBookingItemTaxLine } from "./materialization-tax.js";
3
+ vi.mock("@voyant-travel/finance", async (importOriginal) => {
4
+ const actual = await importOriginal();
5
+ return {
6
+ ...actual,
7
+ // Force "no tax policy" so the snapshot-fallback path is exercised.
8
+ resolveBookingSellTaxRate: vi.fn().mockResolvedValue(null),
9
+ computeBookingItemTaxLine: vi.fn().mockReturnValue(null),
10
+ };
11
+ });
12
+ function snapshot(overrides = {}) {
13
+ return {
14
+ id: "snap_1",
15
+ entity_module: "products",
16
+ entity_id: "prod_1",
17
+ source_kind: "demo",
18
+ source_provider: null,
19
+ source_ref: null,
20
+ frozen_payload: null,
21
+ pricing_base_amount: null,
22
+ pricing_taxes: null,
23
+ pricing_fees: null,
24
+ pricing_surcharges: null,
25
+ pricing_currency: "EUR",
26
+ ...overrides,
27
+ };
28
+ }
29
+ const booking = { sellCurrency: "EUR" };
30
+ describe("materializeBookingItemTaxLine", () => {
31
+ it("writes a snapshot-fallback tax line when no policy line resolves", async () => {
32
+ const inserted = [];
33
+ const db = {
34
+ insert: () => ({
35
+ values: (v) => ({
36
+ onConflictDoNothing: async () => {
37
+ inserted.push(v);
38
+ },
39
+ }),
40
+ }),
41
+ };
42
+ await materializeBookingItemTaxLine(db, booking, "item_1", 11900, snapshot({ pricing_taxes: "1900" }), {
43
+ resolveBookingTaxSettings: vi.fn(),
44
+ });
45
+ expect(inserted).toHaveLength(1);
46
+ expect(inserted[0]).toMatchObject({
47
+ bookingItemId: "item_1",
48
+ code: "snapshot/tax",
49
+ amountCents: 1900,
50
+ includedInPrice: true,
51
+ });
52
+ });
53
+ it("writes nothing when there is no policy line and no snapshot tax", async () => {
54
+ const inserted = [];
55
+ const db = {
56
+ insert: () => ({
57
+ values: (v) => ({
58
+ onConflictDoNothing: async () => {
59
+ inserted.push(v);
60
+ },
61
+ }),
62
+ }),
63
+ };
64
+ await materializeBookingItemTaxLine(db, booking, "item_1", 10000, snapshot(), {
65
+ resolveBookingTaxSettings: vi.fn(),
66
+ });
67
+ expect(inserted).toHaveLength(0);
68
+ });
69
+ });
@@ -0,0 +1,99 @@
1
+ import { bookings } from "@voyant-travel/bookings/schema";
2
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
3
+ import type { CheckoutModuleOptions } from "./options.js";
4
+ export { rebuildBookingItemTaxLines } from "./materialization-tax.js";
5
+ /**
6
+ * Look up the catalog snapshot for a `bookingId` (the catalog plane
7
+ * always writes one) and materialize a real bookings row plus the
8
+ * traveler / item children. Used when /book went through the sourced
9
+ * arm — sourced adapters don't write to the bookings table directly,
10
+ * so the checkout flow has to bridge the snapshot into normal
11
+ * booking shape before it can place payment sessions, transition
12
+ * status, etc.
13
+ *
14
+ * The booking_drafts table carries the customer-entered detail
15
+ * (passengers, billing contact, configure pax / dates) — we look
16
+ * up the draft via `consumed_booking_id` and pull contact + traveler
17
+ * rows out of it. Snapshot supplies pricing + entity refs.
18
+ */
19
+ export declare function materializeBookingFromSnapshot(db: PostgresJsDatabase, bookingId: string, env: Record<string, unknown>, options: CheckoutModuleOptions): Promise<typeof bookings.$inferSelect | null>;
20
+ export interface DraftPayload {
21
+ billing?: {
22
+ contact?: {
23
+ firstName?: string;
24
+ lastName?: string;
25
+ email?: string;
26
+ phone?: string;
27
+ };
28
+ address?: {
29
+ country?: string;
30
+ city?: string;
31
+ line1?: string;
32
+ line2?: string;
33
+ postal?: string;
34
+ };
35
+ };
36
+ configure?: {
37
+ pax?: {
38
+ adult?: number;
39
+ child?: number;
40
+ infant?: number;
41
+ };
42
+ departureSlotId?: string;
43
+ departureDate?: string;
44
+ dateRange?: {
45
+ checkIn?: string;
46
+ checkOut?: string;
47
+ };
48
+ };
49
+ travelers?: Array<{
50
+ rowId?: string;
51
+ firstName?: string;
52
+ lastName?: string;
53
+ email?: string;
54
+ phone?: string;
55
+ band?: string;
56
+ dateOfBirth?: string;
57
+ nationality?: string;
58
+ documentType?: "passport" | "id_card" | "driver_license" | "visa" | "other";
59
+ documentNumber?: string;
60
+ documentExpiry?: string;
61
+ passportNumber?: string;
62
+ passportExpiry?: string;
63
+ passportExpiresAt?: string;
64
+ dietaryRequirements?: string;
65
+ accessibilityNeeds?: string;
66
+ preferredLanguage?: string;
67
+ specialRequests?: string;
68
+ isPrimary?: boolean;
69
+ isLeadTraveler?: boolean;
70
+ documents?: Record<string, unknown>;
71
+ }>;
72
+ entity?: {
73
+ module?: string;
74
+ id?: string;
75
+ };
76
+ internalNotes?: string;
77
+ }
78
+ /**
79
+ * Snapshot subset `materializeChildren` reads from. The catalog table
80
+ * has more columns (idempotency_key, captured_at, etc.), but children
81
+ * materialization only needs the parts that drive line items + supplier
82
+ * statuses.
83
+ */
84
+ export type MaterializationSnapshot = {
85
+ /** Snapshot id — stamped on each `booking_items.source_snapshot_id`. */
86
+ id?: string;
87
+ entity_module: string;
88
+ entity_id: string;
89
+ source_kind: string;
90
+ source_provider: string | null;
91
+ source_ref: string | null;
92
+ frozen_payload: Record<string, unknown> | null;
93
+ pricing_base_amount: string | null;
94
+ pricing_taxes: string | null;
95
+ pricing_fees: string | null;
96
+ pricing_surcharges: string | null;
97
+ pricing_currency: string | null;
98
+ };
99
+ //# sourceMappingURL=materialization.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"materialization.d.ts","sourceRoot":"","sources":["../../src/checkout/materialization.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,269 @@
1
+ // agent-quality: file-size exception -- owner: commerce; the snapshot→booking
2
+ // materialization (parent row + traveler/item/allocation/supplier children) is
3
+ // one cohesive bridge; splitting it would scatter a single checkout step.
4
+ import { bookings } from "@voyant-travel/bookings/schema";
5
+ import { OWNED_SOURCE_KIND } from "@voyant-travel/catalog/booking-engine";
6
+ import { eq } from "drizzle-orm";
7
+ import { extractBookingDates, extractItemDates, extractItemDescription, materializeBookingAllocations, materializeTravelerTravelDetails, resolveLineItemTitle, resolveSupplierFromSnapshot, resolveUpstreamCostCents, travelerBandToCategory, } from "./materialization-support.js";
8
+ import { materializeBookingItemTaxLine } from "./materialization-tax.js";
9
+ export { rebuildBookingItemTaxLines } from "./materialization-tax.js";
10
+ /**
11
+ * Look up the catalog snapshot for a `bookingId` (the catalog plane
12
+ * always writes one) and materialize a real bookings row plus the
13
+ * traveler / item children. Used when /book went through the sourced
14
+ * arm — sourced adapters don't write to the bookings table directly,
15
+ * so the checkout flow has to bridge the snapshot into normal
16
+ * booking shape before it can place payment sessions, transition
17
+ * status, etc.
18
+ *
19
+ * The booking_drafts table carries the customer-entered detail
20
+ * (passengers, billing contact, configure pax / dates) — we look
21
+ * up the draft via `consumed_booking_id` and pull contact + traveler
22
+ * rows out of it. Snapshot supplies pricing + entity refs.
23
+ */
24
+ export async function materializeBookingFromSnapshot(db, bookingId, env, options) {
25
+ const { bookingCatalogSnapshotTable } = await import("@voyant-travel/catalog");
26
+ const { bookingDraftsTable } = await import("@voyant-travel/catalog/booking-engine");
27
+ const [snapshot] = await db
28
+ .select()
29
+ .from(bookingCatalogSnapshotTable)
30
+ .where(eq(bookingCatalogSnapshotTable.booking_id, bookingId))
31
+ .limit(1);
32
+ if (!snapshot)
33
+ return null;
34
+ const baseAmount = snapshot.pricing_base_amount
35
+ ? Number.parseFloat(String(snapshot.pricing_base_amount))
36
+ : 0;
37
+ const taxes = snapshot.pricing_taxes ? Number.parseFloat(String(snapshot.pricing_taxes)) : 0;
38
+ const fees = snapshot.pricing_fees ? Number.parseFloat(String(snapshot.pricing_fees)) : 0;
39
+ const surcharges = snapshot.pricing_surcharges
40
+ ? Number.parseFloat(String(snapshot.pricing_surcharges))
41
+ : 0;
42
+ const sellAmountCents = Math.round(baseAmount + taxes + fees + surcharges);
43
+ const sellCurrency = snapshot.pricing_currency ?? "EUR";
44
+ // Pull the consuming draft so we can copy the customer-entered
45
+ // billing contact + travelers + dates into the booking row.
46
+ const [draftRow] = await db
47
+ .select()
48
+ .from(bookingDraftsTable)
49
+ .where(eq(bookingDraftsTable.consumed_booking_id, bookingId))
50
+ .limit(1);
51
+ const draftPayload = (draftRow?.draft_payload ?? {});
52
+ const frozenPayload = (snapshot.frozen_payload ?? {});
53
+ const bookingDates = extractBookingDates({
54
+ frozen_payload: frozenPayload,
55
+ }, draftPayload);
56
+ const billingContact = draftPayload.billing?.contact;
57
+ const billingAddress = draftPayload.billing?.address;
58
+ const config = draftPayload.configure;
59
+ const pax = config?.pax;
60
+ const totalPax = pax ? (pax.adult ?? 0) + (pax.child ?? 0) + (pax.infant ?? 0) : null;
61
+ const startDate = bookingDates.startDate;
62
+ const endDate = bookingDates.endDate;
63
+ const bookingNumber = `BK-${bookingId.slice(-12).toUpperCase()}`;
64
+ const [row] = await db
65
+ .insert(bookings)
66
+ .values({
67
+ id: bookingId,
68
+ bookingNumber,
69
+ status: "on_hold",
70
+ sourceType: "direct",
71
+ sellCurrency,
72
+ sellAmountCents,
73
+ contactFirstName: billingContact?.firstName ?? null,
74
+ contactLastName: billingContact?.lastName ?? null,
75
+ contactEmail: billingContact?.email ?? null,
76
+ contactPhone: billingContact?.phone ?? null,
77
+ contactCountry: billingAddress?.country ?? null,
78
+ contactCity: billingAddress?.city ?? null,
79
+ contactAddressLine1: billingAddress?.line1 ?? null,
80
+ contactAddressLine2: billingAddress?.line2 ?? null,
81
+ contactPostalCode: billingAddress?.postal ?? null,
82
+ startDate,
83
+ endDate,
84
+ pax: totalPax && totalPax > 0 ? totalPax : null,
85
+ internalNotes: typeof draftPayload.internalNotes === "string" ? draftPayload.internalNotes : null,
86
+ })
87
+ .onConflictDoNothing({ target: bookings.id })
88
+ .returning();
89
+ const inserted = row ?? null;
90
+ // Materialize travelers + a single line item per booked entity so
91
+ // the operator detail page has something to render. No-ops on
92
+ // re-entry (race or retry) because we only run this when the
93
+ // bookings row was just inserted.
94
+ if (inserted) {
95
+ await materializeChildren(db, inserted, {
96
+ id: snapshot.id,
97
+ entity_module: snapshot.entity_module,
98
+ entity_id: snapshot.entity_id,
99
+ source_kind: snapshot.source_kind,
100
+ source_provider: snapshot.source_provider,
101
+ source_ref: snapshot.source_ref,
102
+ frozen_payload: (snapshot.frozen_payload ?? {}),
103
+ pricing_base_amount: snapshot.pricing_base_amount != null ? String(snapshot.pricing_base_amount) : null,
104
+ pricing_taxes: snapshot.pricing_taxes != null ? String(snapshot.pricing_taxes) : null,
105
+ pricing_fees: snapshot.pricing_fees != null ? String(snapshot.pricing_fees) : null,
106
+ pricing_surcharges: snapshot.pricing_surcharges != null ? String(snapshot.pricing_surcharges) : null,
107
+ pricing_currency: snapshot.pricing_currency,
108
+ }, draftPayload, env, options);
109
+ }
110
+ if (inserted)
111
+ return inserted;
112
+ // Race: another request already inserted; re-fetch.
113
+ const [existing] = await db.select().from(bookings).where(eq(bookings.id, bookingId)).limit(1);
114
+ return existing ?? null;
115
+ }
116
+ async function materializeChildren(db, booking, snapshot, draftPayload, env, options) {
117
+ const { bookingTravelers, bookingItems, bookingSupplierStatuses } = await import("@voyant-travel/bookings/schema");
118
+ const travelers = draftPayload.travelers ?? [];
119
+ if (travelers.length > 0) {
120
+ const travelerRows = travelers
121
+ .map((t, idx) => ({
122
+ draftTraveler: t,
123
+ row: {
124
+ bookingId: booking.id,
125
+ firstName: t.firstName ?? "Traveler",
126
+ lastName: t.lastName ?? `${idx + 1}`,
127
+ email: t.email ?? null,
128
+ phone: t.phone ?? null,
129
+ travelerCategory: travelerBandToCategory(t.band),
130
+ preferredLanguage: t.preferredLanguage ?? null,
131
+ specialRequests: t.specialRequests ?? null,
132
+ isPrimary: t.isPrimary ?? t.isLeadTraveler ?? idx === 0,
133
+ },
134
+ }))
135
+ .filter(({ draftTraveler }) => {
136
+ return ((draftTraveler.firstName?.length ?? 0) > 0 || (draftTraveler.lastName?.length ?? 0) > 0);
137
+ });
138
+ const rows = travelerRows.map(({ row }) => row);
139
+ if (rows.length > 0) {
140
+ const insertedTravelers = await db.insert(bookingTravelers).values(rows).returning();
141
+ try {
142
+ await materializeTravelerTravelDetails(db, insertedTravelers, travelerRows.map(({ draftTraveler }) => draftTraveler), env);
143
+ }
144
+ catch (err) {
145
+ console.warn("[catalog-checkout] traveler travel-details materialization failed", err);
146
+ }
147
+ }
148
+ }
149
+ // One item summarizing the booked entity, so the items tab isn't
150
+ // empty. Real verticals (cruises with cabin lines, accommodations with
151
+ // room lines) fan this out per their own conventions; this is the
152
+ // generic fallback so sourced products show up in the UI.
153
+ //
154
+ // Resolve a real product title rather than the dumb fallback
155
+ // "Tour booking". For sourced products, the projection captured
156
+ // by `catalog_sourced_entries` carries the upstream name; for
157
+ // owned products, the local `products.title` is canonical.
158
+ const resolvedTitle = await resolveLineItemTitle(db, snapshot, options);
159
+ const itemDates = extractItemDates(snapshot, draftPayload, booking);
160
+ const itemDescription = extractItemDescription(snapshot);
161
+ // Item-level cost mirrors the booking-level cost we set later: when
162
+ // the upstream provides a net rate (Bokun-style net/gross split),
163
+ // use it; otherwise fall back to sell. Owned bookings skip cost on
164
+ // the item — the operator IS the supplier, no "cost" makes sense.
165
+ const sellAmountCents = booking.sellAmountCents ?? 0;
166
+ const upstreamCostCents = snapshot.source_kind !== OWNED_SOURCE_KIND ? await resolveUpstreamCostCents(db, snapshot) : null;
167
+ const itemCostAmountCents = snapshot.source_kind !== OWNED_SOURCE_KIND ? (upstreamCostCents ?? sellAmountCents) : null;
168
+ const itemQuantity = booking.pax ?? 1;
169
+ const insertedItems = await db
170
+ .insert(bookingItems)
171
+ .values({
172
+ bookingId: booking.id,
173
+ title: resolvedTitle,
174
+ description: itemDescription,
175
+ productId: snapshot.entity_module === "products" ? snapshot.entity_id : null,
176
+ quantity: itemQuantity,
177
+ itemType: "service",
178
+ status: "on_hold",
179
+ serviceDate: itemDates.serviceDate ?? null,
180
+ startsAt: itemDates.startsAt ?? null,
181
+ endsAt: itemDates.endsAt ?? null,
182
+ unitSellAmountCents: booking.pax && booking.pax > 0 && booking.sellAmountCents
183
+ ? Math.round(booking.sellAmountCents / booking.pax)
184
+ : (booking.sellAmountCents ?? 0),
185
+ totalSellAmountCents: booking.sellAmountCents ?? 0,
186
+ sellCurrency: booking.sellCurrency,
187
+ ...(itemCostAmountCents != null
188
+ ? {
189
+ costCurrency: booking.sellCurrency,
190
+ unitCostAmountCents: itemQuantity > 0
191
+ ? Math.round(itemCostAmountCents / itemQuantity)
192
+ : itemCostAmountCents,
193
+ totalCostAmountCents: itemCostAmountCents,
194
+ }
195
+ : {}),
196
+ sourceSnapshotId: snapshot.id ?? null,
197
+ })
198
+ .onConflictDoNothing()
199
+ .returning();
200
+ for (const item of insertedItems) {
201
+ await materializeBookingItemTaxLine(db, booking, item.id, item.totalSellAmountCents ?? 0, snapshot, options);
202
+ }
203
+ await materializeBookingAllocations(db, booking, insertedItems, draftPayload, snapshot);
204
+ // Sourced bookings: auto-populate the supplier-status row + booking
205
+ // cost columns from the catalog snapshot. Without this the operator
206
+ // sees an empty "Furnizori" tab and "Cost / Marja —" on a deal
207
+ // we already know the supplier for. Owned bookings skip — the
208
+ // operator IS the supplier.
209
+ if (snapshot.source_kind !== OWNED_SOURCE_KIND) {
210
+ const supplierInfo = await resolveSupplierFromSnapshot(db, snapshot);
211
+ if (supplierInfo) {
212
+ // Cost = sell as a working assumption (zero-markup default).
213
+ // Operators with a configured net/gross split should override
214
+ // via the supplier-status edit form. We mark the row's notes so
215
+ // it's clear this came from auto-fill, not from a real
216
+ // supplier confirmation.
217
+ const costAmountCents = supplierInfo.upstreamCostCents ?? sellAmountCents;
218
+ const costCurrency = booking.sellCurrency;
219
+ try {
220
+ await db
221
+ .insert(bookingSupplierStatuses)
222
+ .values({
223
+ bookingId: booking.id,
224
+ supplierServiceId: supplierInfo.supplierServiceId ?? null,
225
+ serviceName: supplierInfo.serviceName,
226
+ supplierReference: supplierInfo.supplierReference ?? null,
227
+ costCurrency,
228
+ costAmountCents,
229
+ status: "pending",
230
+ notes: `Auto-populated from ${snapshot.source_kind} catalog snapshot. ` +
231
+ "Verify against supplier confirmation when it lands.",
232
+ })
233
+ .onConflictDoNothing();
234
+ }
235
+ catch (err) {
236
+ console.warn("[catalog-checkout] auto supplier-status insert failed", err);
237
+ }
238
+ // Stamp booking-level cost so the header's Cost/Marja card
239
+ // shows something meaningful. Margin computed against the
240
+ // current cost+sell — when sell == cost, margin is 0; that's
241
+ // accurate for our zero-markup default until the operator
242
+ // updates the cost.
243
+ try {
244
+ const margin = sellAmountCents > 0
245
+ ? Math.round(((sellAmountCents - costAmountCents) / sellAmountCents) * 100)
246
+ : 0;
247
+ const baseCurrencyMatches = booking.baseCurrency != null && booking.baseCurrency === booking.sellCurrency;
248
+ await db
249
+ .update(bookings)
250
+ .set({
251
+ costAmountCents,
252
+ // Only set base_cost_amount_cents when base_currency is
253
+ // already set on the booking. The check constraint
254
+ // `ck_bookings_base_currency_amounts` rejects setting
255
+ // base_*_amount with no base_currency.
256
+ baseCostAmountCents: baseCurrencyMatches
257
+ ? costAmountCents
258
+ : booking.baseCostAmountCents,
259
+ marginPercent: margin,
260
+ updatedAt: new Date(),
261
+ })
262
+ .where(eq(bookings.id, booking.id));
263
+ }
264
+ catch (err) {
265
+ console.warn("[catalog-checkout] booking cost update failed", err);
266
+ }
267
+ }
268
+ }
269
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Deployment-supplied options for the catalog-checkout cluster.
3
+ *
4
+ * The checkout business logic (materialization, tax, start-service,
5
+ * acceptance signature) lives in `@voyant-travel/commerce`. The two
6
+ * genuinely deployment-specific dependencies are injected here as
7
+ * structural functions so the package never statically imports the
8
+ * deployment, and — crucially — never imports `@voyant-travel/inventory`
9
+ * (which already depends on `@voyant-travel/commerce`; a static import
10
+ * would cycle).
11
+ *
12
+ * - `resolveBookingTaxSettings` — reads the operator's tax-mode /
13
+ * tax-policy-profile row. The deployment owns the settings table.
14
+ * - `getOwnedProductName` — resolves an owned product's title for the
15
+ * line-item fallback. The package can't import inventory's
16
+ * `productsService.getProductById` without cycling, so the
17
+ * deployment hands it in.
18
+ * - `resolveBankTransferInstructions` — reads the operator profile +
19
+ * payment-instruction rows for the bank-transfer checkout path.
20
+ */
21
+ import type { BookingTaxSettings } from "@voyant-travel/finance";
22
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
23
+ /**
24
+ * Bank-transfer instructions surfaced on the bank_transfer_instructions
25
+ * checkout result. The deployment composes these from its operator
26
+ * profile / payment-instruction rows + env fallbacks.
27
+ */
28
+ export interface CheckoutBankTransferInstructions {
29
+ beneficiary: string;
30
+ iban: string;
31
+ bankName: string;
32
+ }
33
+ /**
34
+ * Options shared by the checkout materialization + start-service. All
35
+ * structural — no deployment imports, no platform bindings.
36
+ */
37
+ export interface CheckoutModuleOptions {
38
+ /**
39
+ * Read the operator's booking tax settings (tax-price mode + policy
40
+ * profile). Modelled on the operator's `resolveBookingTaxSettings`;
41
+ * structural so commerce doesn't import the deployment settings table.
42
+ */
43
+ resolveBookingTaxSettings(db: PostgresJsDatabase): Promise<BookingTaxSettings>;
44
+ /**
45
+ * Resolve an owned product's display name for the line-item title
46
+ * fallback. INJECTED because `@voyant-travel/inventory` depends on
47
+ * `@voyant-travel/commerce` — a static import would cycle. Returns the
48
+ * product name, or null when not an owned product / not found.
49
+ */
50
+ getOwnedProductName(db: PostgresJsDatabase, entityModule: string, entityId: string): Promise<string | null>;
51
+ }
52
+ /**
53
+ * Options for the checkout-start service (`startCatalogCheckout`). Extends
54
+ * the shared module options with the bank-transfer instruction reader the
55
+ * bank_transfer path needs.
56
+ */
57
+ export interface CheckoutStartOptions extends CheckoutModuleOptions {
58
+ /**
59
+ * Resolve the bank-transfer instructions for the bank_transfer payment
60
+ * intent. The deployment reads its operator profile / payment
61
+ * instructions and applies env fallbacks.
62
+ */
63
+ resolveBankTransferInstructions(db: PostgresJsDatabase, env: Record<string, string | undefined>): Promise<CheckoutBankTransferInstructions>;
64
+ /**
65
+ * Start the card-payment provider session for the `card` checkout intent.
66
+ * INJECTED so commerce never imports a specific payment provider (which
67
+ * would pull a provider package into the retail-spine closure). The
68
+ * deployment owns the provider choice (e.g. Netopia) and the
69
+ * provider-specific placeholder billing.
70
+ *
71
+ * Returns `{ redirectUrl }` to redirect the customer to the provider, or
72
+ * `null`/`undefined` when no card provider is configured — in which case
73
+ * the checkout falls back to the `card_pending` confirmation-page poll.
74
+ */
75
+ startCardPayment?(params: {
76
+ db: PostgresJsDatabase;
77
+ sessionId: string;
78
+ billing: {
79
+ email: string;
80
+ firstName: string;
81
+ lastName: string;
82
+ };
83
+ description: string;
84
+ returnUrl?: string;
85
+ }): Promise<{
86
+ redirectUrl: string | null;
87
+ } | null>;
88
+ }
89
+ //# sourceMappingURL=options.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"options.d.ts","sourceRoot":"","sources":["../../src/checkout/options.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAA;AAChE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAEjE;;;;GAIG;AACH,MAAM,WAAW,gCAAgC;IAC/C,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,MAAM,CAAA;CACjB;AAED;;;GAGG;AACH,MAAM,WAAW,qBAAqB;IACpC;;;;OAIG;IACH,yBAAyB,CAAC,EAAE,EAAE,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAAA;IAC9E;;;;;OAKG;IACH,mBAAmB,CACjB,EAAE,EAAE,kBAAkB,EACtB,YAAY,EAAE,MAAM,EACpB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;CAC1B;AAED;;;;GAIG;AACH,MAAM,WAAW,oBAAqB,SAAQ,qBAAqB;IACjE;;;;OAIG;IACH,+BAA+B,CAC7B,EAAE,EAAE,kBAAkB,EACtB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GACtC,OAAO,CAAC,gCAAgC,CAAC,CAAA;IAC5C;;;;;;;;;;OAUG;IACH,gBAAgB,CAAC,CAAC,MAAM,EAAE;QACxB,EAAE,EAAE,kBAAkB,CAAA;QACtB,SAAS,EAAE,MAAM,CAAA;QACjB,OAAO,EAAE;YAAE,KAAK,EAAE,MAAM,CAAC;YAAC,SAAS,EAAE,MAAM,CAAC;YAAC,QAAQ,EAAE,MAAM,CAAA;SAAE,CAAA;QAC/D,WAAW,EAAE,MAAM,CAAA;QACnB,SAAS,CAAC,EAAE,MAAM,CAAA;KACnB,GAAG,OAAO,CAAC;QAAE,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI,CAAC,CAAA;CACnD"}