@voyant-travel/commerce 0.2.3 → 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 (43) 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/sellability/routes.d.ts +10 -10
  40. package/dist/sellability/service-records.d.ts +4 -4
  41. package/dist/sellability/service-snapshots.d.ts +2 -2
  42. package/dist/sellability/service.d.ts +10 -10
  43. package/package.json +27 -5
@@ -0,0 +1,21 @@
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
+ export {};
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Storefront checkout route module, owned by `@voyant-travel/commerce`.
3
+ *
4
+ * POST /checkout/start parses the BookingJourney checkout request and
5
+ * delegates to the checkout-start service. A deployment composes this and
6
+ * supplies the `CheckoutStartOptions` (injected tax-settings + owned-product
7
+ * name + bank-transfer instruction readers) the service needs.
8
+ *
9
+ * Mount the returned Hono at `/v1/public/catalog` (relative paths).
10
+ */
11
+ import type { Context } from "hono";
12
+ import { Hono } from "hono";
13
+ import type { CheckoutStartOptions } from "./options.js";
14
+ /**
15
+ * Build the storefront checkout routes. `options` may be a value or a
16
+ * per-request factory — the deployment passes a factory when an injected
17
+ * option needs to capture the request `Context` (e.g. resolving a payment
18
+ * provider runtime from the per-request container).
19
+ */
20
+ export declare function createCatalogCheckoutRoutes(options: CheckoutStartOptions | ((c: Context) => CheckoutStartOptions)): Hono;
21
+ //# sourceMappingURL=routes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/checkout/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AACnC,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAC3B,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAA;AASxD;;;;;GAKG;AACH,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,oBAAoB,GAAG,CAAC,CAAC,CAAC,EAAE,OAAO,KAAK,oBAAoB,CAAC,GACrE,IAAI,CAMN"}
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Storefront checkout route module, owned by `@voyant-travel/commerce`.
3
+ *
4
+ * POST /checkout/start parses the BookingJourney checkout request and
5
+ * delegates to the checkout-start service. A deployment composes this and
6
+ * supplies the `CheckoutStartOptions` (injected tax-settings + owned-product
7
+ * name + bank-transfer instruction readers) the service needs.
8
+ *
9
+ * Mount the returned Hono at `/v1/public/catalog` (relative paths).
10
+ */
11
+ import { parseJsonBody } from "@voyant-travel/hono";
12
+ import { Hono } from "hono";
13
+ import { CatalogCheckoutStartError, checkoutStartSchema, startCatalogCheckout, } from "./start-service.js";
14
+ /**
15
+ * Build the storefront checkout routes. `options` may be a value or a
16
+ * per-request factory — the deployment passes a factory when an injected
17
+ * option needs to capture the request `Context` (e.g. resolving a payment
18
+ * provider runtime from the per-request container).
19
+ */
20
+ export function createCatalogCheckoutRoutes(options) {
21
+ const routes = new Hono();
22
+ routes.post("/checkout/start", (c) => handleCheckoutStart(c, typeof options === "function" ? options(c) : options));
23
+ return routes;
24
+ }
25
+ async function handleCheckoutStart(c, options) {
26
+ let body;
27
+ try {
28
+ body = await parseJsonBody(c, checkoutStartSchema);
29
+ }
30
+ catch (err) {
31
+ return c.json({ error: err instanceof Error ? err.message : "invalid body" }, 400);
32
+ }
33
+ try {
34
+ const result = await startCatalogCheckout({
35
+ db: c.get("db"),
36
+ env: c.env,
37
+ eventBus: c.var.eventBus,
38
+ resolveRuntime: (key) => c.var.container?.resolve(key),
39
+ requestMeta: checkoutRequestMeta(c),
40
+ options,
41
+ }, body);
42
+ return c.json(result);
43
+ }
44
+ catch (err) {
45
+ if (err instanceof CatalogCheckoutStartError) {
46
+ return c.json({ error: err.code }, err.status);
47
+ }
48
+ throw err;
49
+ }
50
+ }
51
+ function checkoutRequestMeta(c) {
52
+ return {
53
+ clientIp: c.req.header("cf-connecting-ip") ??
54
+ c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
55
+ c.req.header("x-real-ip") ??
56
+ "",
57
+ userAgent: c.req.header("user-agent") ?? "",
58
+ };
59
+ }
@@ -0,0 +1,75 @@
1
+ import type { EventBus } from "@voyant-travel/core";
2
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
3
+ import { z } from "zod";
4
+ import type { CheckoutStartOptions } from "./options.js";
5
+ export declare const checkoutStartSchema: z.ZodObject<{
6
+ bookingId: z.ZodString;
7
+ paymentIntent: z.ZodEnum<{
8
+ hold: "hold";
9
+ card: "card";
10
+ bank_transfer: "bank_transfer";
11
+ inquiry: "inquiry";
12
+ }>;
13
+ contractAcceptance: z.ZodOptional<z.ZodObject<{
14
+ templateId: z.ZodString;
15
+ templateSlug: z.ZodString;
16
+ acceptedTerms: z.ZodLiteral<true>;
17
+ acceptedMarketing: z.ZodBoolean;
18
+ acceptedAt: z.ZodString;
19
+ renderedHtml: z.ZodString;
20
+ }, z.core.$strip>>;
21
+ payerEmail: z.ZodOptional<z.ZodString>;
22
+ payerName: z.ZodOptional<z.ZodString>;
23
+ returnOrigin: z.ZodOptional<z.ZodString>;
24
+ }, z.core.$strip>;
25
+ export type CheckoutStartInput = z.infer<typeof checkoutStartSchema>;
26
+ export interface CheckoutStartRequestMeta {
27
+ clientIp?: string;
28
+ userAgent?: string;
29
+ }
30
+ export interface CatalogCheckoutStartContext {
31
+ db: PostgresJsDatabase;
32
+ env: Record<string, string | undefined>;
33
+ eventBus?: EventBus;
34
+ resolveRuntime?: (key: string) => unknown;
35
+ requestMeta?: CheckoutStartRequestMeta;
36
+ /** Deployment-supplied injected readers (tax settings, owned product name, bank transfer). */
37
+ options: CheckoutStartOptions;
38
+ }
39
+ export type CatalogCheckoutStartResult = {
40
+ kind: "card_redirect";
41
+ bookingId: string;
42
+ paymentSessionId: string;
43
+ redirectUrl: string | null;
44
+ note?: string;
45
+ } | {
46
+ kind: "bank_transfer_instructions";
47
+ bookingId: string;
48
+ proformaId: string | null;
49
+ proformaNumber: string | null;
50
+ paymentSessionId: string | null;
51
+ instructions: {
52
+ beneficiary: string;
53
+ iban: string;
54
+ bankName: string;
55
+ reference: string;
56
+ amountCents: number;
57
+ currency: string;
58
+ dueAt: string;
59
+ };
60
+ } | {
61
+ kind: "inquiry_received";
62
+ bookingId: string;
63
+ inquiryId: string;
64
+ note?: string;
65
+ } | {
66
+ kind: "hold_placed";
67
+ bookingId: string;
68
+ };
69
+ export declare class CatalogCheckoutStartError extends Error {
70
+ readonly code: string;
71
+ readonly status: 404 | 409 | 500 | 502;
72
+ constructor(code: string, status: 404 | 409 | 500 | 502);
73
+ }
74
+ export declare function startCatalogCheckout(context: CatalogCheckoutStartContext, body: CheckoutStartInput): Promise<CatalogCheckoutStartResult>;
75
+ //# sourceMappingURL=start-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"start-service.d.ts","sourceRoot":"","sources":["../../src/checkout/start-service.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAOnD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AACjE,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAA;AAExD,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;iBAgB9B,CAAA;AAEF,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAA;AAEpE,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,2BAA2B;IAC1C,EAAE,EAAE,kBAAkB,CAAA;IACtB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;IACvC,QAAQ,CAAC,EAAE,QAAQ,CAAA;IACnB,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAA;IACzC,WAAW,CAAC,EAAE,wBAAwB,CAAA;IACtC,8FAA8F;IAC9F,OAAO,EAAE,oBAAoB,CAAA;CAC9B;AAED,MAAM,MAAM,0BAA0B,GAClC;IACE,IAAI,EAAE,eAAe,CAAA;IACrB,SAAS,EAAE,MAAM,CAAA;IACjB,gBAAgB,EAAE,MAAM,CAAA;IACxB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,IAAI,CAAC,EAAE,MAAM,CAAA;CACd,GACD;IACE,IAAI,EAAE,4BAA4B,CAAA;IAClC,SAAS,EAAE,MAAM,CAAA;IACjB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAA;IAC/B,YAAY,EAAE;QACZ,WAAW,EAAE,MAAM,CAAA;QACnB,IAAI,EAAE,MAAM,CAAA;QACZ,QAAQ,EAAE,MAAM,CAAA;QAChB,SAAS,EAAE,MAAM,CAAA;QACjB,WAAW,EAAE,MAAM,CAAA;QACnB,QAAQ,EAAE,MAAM,CAAA;QAChB,KAAK,EAAE,MAAM,CAAA;KACd,CAAA;CACF,GACD;IACE,IAAI,EAAE,kBAAkB,CAAA;IACxB,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,IAAI,CAAC,EAAE,MAAM,CAAA;CACd,GACD;IACE,IAAI,EAAE,aAAa,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;CAClB,CAAA;AAEL,qBAAa,yBAA0B,SAAQ,KAAK;aAEhC,IAAI,EAAE,MAAM;aACZ,MAAM,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG;gBAD7B,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG;CAKhD;AAYD,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,2BAA2B,EACpC,IAAI,EAAE,kBAAkB,GACvB,OAAO,CAAC,0BAA0B,CAAC,CAmErC"}
@@ -0,0 +1,415 @@
1
+ // agent-quality: file-size exception -- owner: commerce; the checkout-start
2
+ // service (card / bank-transfer / inquiry / hold intents) is one cohesive
3
+ // entry point; splitting it would scatter a single request lifecycle.
4
+ import { bookingsService, canTransitionBooking, transitionBooking } from "@voyant-travel/bookings";
5
+ import { bookings } from "@voyant-travel/bookings/schema";
6
+ import { financeService, issueProformaFromBooking, } from "@voyant-travel/finance";
7
+ import { eq } from "drizzle-orm";
8
+ import { z } from "zod";
9
+ import { materializeBookingFromSnapshot } from "./materialization.js";
10
+ export const checkoutStartSchema = z.object({
11
+ bookingId: z.string().min(1),
12
+ paymentIntent: z.enum(["card", "bank_transfer", "hold", "inquiry"]),
13
+ contractAcceptance: z
14
+ .object({
15
+ templateId: z.string().min(1),
16
+ templateSlug: z.string().min(1),
17
+ acceptedTerms: z.literal(true),
18
+ acceptedMarketing: z.boolean(),
19
+ acceptedAt: z.string().datetime(),
20
+ renderedHtml: z.string().min(1),
21
+ })
22
+ .optional(),
23
+ payerEmail: z.string().email().optional(),
24
+ payerName: z.string().optional(),
25
+ returnOrigin: z.string().url().optional(),
26
+ });
27
+ export class CatalogCheckoutStartError extends Error {
28
+ code;
29
+ status;
30
+ constructor(code, status) {
31
+ super(code);
32
+ this.code = code;
33
+ this.status = status;
34
+ this.name = "CatalogCheckoutStartError";
35
+ }
36
+ }
37
+ export async function startCatalogCheckout(context, body) {
38
+ const db = context.db;
39
+ let booking = (await db.select().from(bookings).where(eq(bookings.id, body.bookingId)).limit(1))[0] ?? null;
40
+ // Sourced products go through the catalog-snapshot path on
41
+ // /book — they never write to the `bookings` table directly.
42
+ // Materialize a minimal row from the snapshot so the rest of the
43
+ // checkout-start flow (state transitions, payment session, etc)
44
+ // can operate on a normal booking. Owned products already have
45
+ // the row written by their OwnedBookingHandler.commit.
46
+ if (!booking) {
47
+ booking = await materializeBookingFromSnapshot(db, body.bookingId, context.env, context.options);
48
+ }
49
+ if (!booking)
50
+ throw new CatalogCheckoutStartError("booking_not_found", 404);
51
+ if ((body.paymentIntent === "card" || body.paymentIntent === "bank_transfer") &&
52
+ booking.holdExpiresAt &&
53
+ booking.holdExpiresAt <= new Date()) {
54
+ throw new CatalogCheckoutStartError("hold_expired", 409);
55
+ }
56
+ // Pre-create a draft contract carrying the acceptance fingerprint
57
+ // in `metadata.acceptance`. The auto-generate-contract subscriber
58
+ // (fired by `booking.confirmed` after payment) detects this draft
59
+ // by booking_id, populates the rendered body + variables from the
60
+ // confirmed booking state, and issues + generates the PDF —
61
+ // allocating the contract number at issue time. The signature
62
+ // promotion path then reads `metadata.acceptance` straight off
63
+ // the contract row instead of relaying through internal_notes.
64
+ //
65
+ // Idempotency: re-entering /checkout/start (e.g. customer hits
66
+ // Back then resubmits) finds the existing draft and updates its
67
+ // metadata in place — no duplicate contract rows, no duplicate
68
+ // acceptance fingerprints.
69
+ if (body.contractAcceptance) {
70
+ try {
71
+ await persistAcceptanceDraftContract(db, context.requestMeta ?? {}, booking, body.contractAcceptance);
72
+ }
73
+ catch (err) {
74
+ // Acceptance recording is best-effort during checkout-start —
75
+ // the customer still needs to reach payment even if our
76
+ // legal-side pre-create stumbles. Surfacing as a 5xx here
77
+ // would block real bookings on a contract-template mis-config;
78
+ // we log and proceed so payment can land.
79
+ console.error("[catalog-checkout] persistAcceptanceDraftContract failed", err);
80
+ }
81
+ }
82
+ switch (body.paymentIntent) {
83
+ case "card":
84
+ return startCardCheckout(context, booking, body);
85
+ case "bank_transfer":
86
+ return startBankTransferCheckout(context, booking);
87
+ case "inquiry":
88
+ return startInquiryCheckout(context, booking);
89
+ case "hold":
90
+ return {
91
+ kind: "hold_placed",
92
+ bookingId: booking.id,
93
+ };
94
+ }
95
+ }
96
+ /**
97
+ * Inquiry intent — write a quote for the operator to follow
98
+ * up on, then cancel the booking so inventory isn't blocked.
99
+ *
100
+ * The pipeline + stage used can be pinned via env vars
101
+ * (`INQUIRY_PIPELINE_ID` / `INQUIRY_STAGE_ID`); otherwise we pick the
102
+ * first sales pipeline + its first stage. Without any configured
103
+ * pipeline the endpoint falls back to a stub response so the journey
104
+ * keeps working through demos.
105
+ */
106
+ async function startInquiryCheckout(context, booking) {
107
+ const db = context.db;
108
+ const env = context.env;
109
+ const eventBus = context.eventBus;
110
+ let pipelineId = env.INQUIRY_PIPELINE_ID ?? null;
111
+ let stageId = env.INQUIRY_STAGE_ID ?? null;
112
+ if (!pipelineId || !stageId) {
113
+ const { quotesService } = await import("@voyant-travel/quotes");
114
+ const pipelines = await quotesService
115
+ .listPipelines(db, { entityType: "quote", limit: 1, offset: 0 })
116
+ .catch(() => null);
117
+ const firstPipeline = pipelines?.data?.[0] ?? null;
118
+ if (firstPipeline) {
119
+ pipelineId = pipelineId ?? firstPipeline.id;
120
+ const stages = await quotesService
121
+ .listStages(db, { pipelineId: firstPipeline.id, limit: 1, offset: 0 })
122
+ .catch(() => null);
123
+ stageId = stageId ?? stages?.data?.[0]?.id ?? null;
124
+ }
125
+ }
126
+ if (!pipelineId || !stageId) {
127
+ // No quote pipeline configured. Still cancel the booking so the
128
+ // hold doesn't linger, and return a stub inquiry reference.
129
+ await releaseInquiryBooking(db, booking, eventBus);
130
+ return {
131
+ kind: "inquiry_received",
132
+ bookingId: booking.id,
133
+ inquiryId: `inq-${booking.id}`,
134
+ note: "No quote pipeline configured — set INQUIRY_PIPELINE_ID + INQUIRY_STAGE_ID to record a real quote.",
135
+ };
136
+ }
137
+ const { quotesService } = await import("@voyant-travel/quotes");
138
+ const quote = await quotesService.createQuote(db, {
139
+ title: `Inquiry — booking ${booking.bookingNumber}`,
140
+ pipelineId,
141
+ stageId,
142
+ personId: booking.personId,
143
+ organizationId: booking.organizationId,
144
+ status: "open",
145
+ valueAmountCents: booking.sellAmountCents ?? null,
146
+ valueCurrency: booking.sellCurrency ?? null,
147
+ source: "storefront-inquiry",
148
+ sourceRef: booking.id,
149
+ tags: [],
150
+ });
151
+ await releaseInquiryBooking(db, booking, eventBus);
152
+ await eventBus?.emit("inquiry.created", {
153
+ quoteId: quote?.id ?? null,
154
+ bookingId: booking.id,
155
+ bookingNumber: booking.bookingNumber,
156
+ pipelineId,
157
+ stageId,
158
+ });
159
+ return {
160
+ kind: "inquiry_received",
161
+ bookingId: booking.id,
162
+ inquiryId: quote?.id ?? `inq-${booking.id}`,
163
+ };
164
+ }
165
+ async function releaseInquiryBooking(db, booking, eventBus) {
166
+ // Inquiry mode: don't keep capacity locked. Cancel the booking so
167
+ // the hold drops; the row stays for the audit trail.
168
+ if (!canTransitionBooking(booking.status, "cancelled"))
169
+ return;
170
+ try {
171
+ await bookingsService.cancelBooking(db, booking.id, { reason: "Released — converted to inquiry" }, undefined, { eventBus });
172
+ }
173
+ catch (err) {
174
+ console.warn("[catalog-checkout] could not release booking on inquiry path", err);
175
+ }
176
+ }
177
+ /**
178
+ * Move the booking from `on_hold` (or `draft`) into `awaiting_payment`
179
+ * so ops can see in the bookings list which rows are pending money
180
+ * vs. just brokered. The state machine accepts the transition;
181
+ * already-`awaiting_payment` / already-`confirmed` rows are
182
+ * silently no-op'd so re-entries (e.g. user reloads the dialog
183
+ * twice) stay idempotent.
184
+ */
185
+ async function markAwaitingPayment(db, booking) {
186
+ if (!canTransitionBooking(booking.status, "awaiting_payment"))
187
+ return;
188
+ const patch = transitionBooking(booking.status, "awaiting_payment");
189
+ await db
190
+ .update(bookings)
191
+ .set({ ...patch, updatedAt: new Date() })
192
+ .where(eq(bookings.id, booking.id));
193
+ }
194
+ async function startCardCheckout(context, booking, body) {
195
+ const db = context.db;
196
+ // Without a card provider configured, fall back to a placeholder
197
+ // redirect — the storefront's confirmation page polls booking status
198
+ // and surfaces "we're still processing" until ops marks payment
199
+ // received manually. Useful for demos without sandbox creds.
200
+ const amountCents = booking.sellAmountCents ?? 0;
201
+ const currency = booking.sellCurrency ?? "EUR";
202
+ await markAwaitingPayment(db, booking);
203
+ const session = await financeService.createPaymentSession(db, {
204
+ bookingId: booking.id,
205
+ amountCents,
206
+ currency,
207
+ status: "pending",
208
+ expiresAt: booking.holdExpiresAt?.toISOString() ?? null,
209
+ payerName: body.payerName ?? null,
210
+ payerEmail: body.payerEmail ?? null,
211
+ notes: `Storefront card payment for booking ${booking.bookingNumber}`,
212
+ targetType: "booking",
213
+ });
214
+ if (!session) {
215
+ throw new CatalogCheckoutStartError("could_not_create_payment_session", 500);
216
+ }
217
+ // Derive billing name from the payer name; the deployment-supplied
218
+ // `startCardPayment` fills in any provider-specific placeholder billing
219
+ // (city, country code, postal code, etc).
220
+ const [firstName, ...rest] = (body.payerName ?? "").trim().split(/\s+/);
221
+ const lastName = rest.length > 0 ? rest.join(" ") : "Customer";
222
+ let started = null;
223
+ try {
224
+ started =
225
+ (await context.options.startCardPayment?.({
226
+ db,
227
+ sessionId: session.id,
228
+ billing: {
229
+ email: body.payerEmail ?? "tbd@example.com",
230
+ firstName: firstName || "Customer",
231
+ lastName,
232
+ },
233
+ description: `Booking ${booking.bookingNumber}`,
234
+ // The provider redirects the customer back to this URL after 3DS.
235
+ // Land them on the confirmation page in card_pending mode — the
236
+ // provider webhook does the actual booking confirmation in the
237
+ // background; this page just polls until the booking flips to
238
+ // `confirmed`.
239
+ returnUrl: body.returnOrigin
240
+ ? `${body.returnOrigin}/shop/confirmation/${encodeURIComponent(booking.id)}?kind=card_pending`
241
+ : undefined,
242
+ })) ?? null;
243
+ }
244
+ catch (err) {
245
+ console.error("[catalog-checkout] startCardPayment failed", err);
246
+ throw new CatalogCheckoutStartError("payment_provider_failed", 502);
247
+ }
248
+ if (!started) {
249
+ // No card provider configured — surface the booking on the standard
250
+ // confirmation page in `card_pending` mode. The page polls booking
251
+ // status and unlocks contract/invoice download links once the
252
+ // operator marks payment received via the booking detail's
253
+ // pending-payment-sessions panel.
254
+ return {
255
+ kind: "card_redirect",
256
+ bookingId: booking.id,
257
+ paymentSessionId: session.id,
258
+ redirectUrl: `/shop/confirmation/${encodeURIComponent(booking.id)}?kind=card_pending&session=${encodeURIComponent(session.id)}`,
259
+ note: "Netopia not configured — falling back to a confirmation-page poll.",
260
+ };
261
+ }
262
+ return {
263
+ kind: "card_redirect",
264
+ bookingId: booking.id,
265
+ paymentSessionId: session.id,
266
+ redirectUrl: started.redirectUrl,
267
+ };
268
+ }
269
+ async function startBankTransferCheckout(context, booking) {
270
+ const db = context.db;
271
+ await markAwaitingPayment(db, booking);
272
+ // Issue a proforma synchronously so the customer leaves with a
273
+ // document reference. SmartBill (subscribing to
274
+ // invoice.proforma.issued) will sync to its proforma endpoint.
275
+ const issueDate = new Date().toISOString().slice(0, 10);
276
+ const dueDate = new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
277
+ const eventBus = context.eventBus;
278
+ const proformaInput = {
279
+ bookingId: booking.id,
280
+ issueDate,
281
+ dueDate,
282
+ invoiceType: "proforma",
283
+ notes: null,
284
+ };
285
+ // Pull the booking's items via the shared schema; financeService
286
+ // wants the InvoiceFromBookingData shape (booking + items).
287
+ const { bookingItems } = await import("@voyant-travel/bookings/schema");
288
+ const bookingItemRows = await db
289
+ .select()
290
+ .from(bookingItems)
291
+ .where(eq(bookingItems.bookingId, booking.id));
292
+ const proforma = await issueProformaFromBooking(db, proformaInput, {
293
+ booking: {
294
+ id: booking.id,
295
+ bookingNumber: booking.bookingNumber,
296
+ personId: booking.personId,
297
+ organizationId: booking.organizationId,
298
+ sellCurrency: booking.sellCurrency,
299
+ baseCurrency: booking.baseCurrency,
300
+ fxRateSetId: null,
301
+ sellAmountCents: booking.sellAmountCents,
302
+ baseSellAmountCents: booking.baseSellAmountCents,
303
+ },
304
+ items: bookingItemRows.map((item) => ({
305
+ id: item.id,
306
+ title: item.title,
307
+ quantity: item.quantity,
308
+ unitSellAmountCents: item.unitSellAmountCents,
309
+ totalSellAmountCents: item.totalSellAmountCents,
310
+ })),
311
+ }, { eventBus });
312
+ // Create a payment session targeting the booking + proforma so the
313
+ // operator can mark it received via the existing
314
+ // POST /v1/admin/finance/payment-sessions/:id/complete endpoint.
315
+ // That endpoint emits payment.completed which fires the
316
+ // checkout-finalize workflow (final invoice, contract auto-gen).
317
+ const paymentSession = await financeService.createPaymentSession(db, {
318
+ bookingId: booking.id,
319
+ invoiceId: proforma?.id ?? null,
320
+ amountCents: booking.sellAmountCents ?? 0,
321
+ currency: booking.sellCurrency ?? "EUR",
322
+ status: "pending",
323
+ paymentMethod: "bank_transfer",
324
+ expiresAt: booking.holdExpiresAt?.toISOString() ?? null,
325
+ notes: `Bank transfer for booking ${booking.bookingNumber} (proforma ${proforma?.invoiceNumber ?? "—"})`,
326
+ targetType: "booking",
327
+ });
328
+ const bankTransfer = await context.options.resolveBankTransferInstructions(db, context.env);
329
+ return {
330
+ kind: "bank_transfer_instructions",
331
+ bookingId: booking.id,
332
+ proformaId: proforma?.id ?? null,
333
+ proformaNumber: proforma?.invoiceNumber ?? null,
334
+ paymentSessionId: paymentSession?.id ?? null,
335
+ instructions: {
336
+ beneficiary: bankTransfer.beneficiary,
337
+ iban: bankTransfer.iban,
338
+ bankName: bankTransfer.bankName,
339
+ reference: `BOOK-${booking.bookingNumber}`,
340
+ amountCents: booking.sellAmountCents ?? 0,
341
+ currency: booking.sellCurrency ?? "EUR",
342
+ dueAt: dueDate,
343
+ },
344
+ };
345
+ }
346
+ /**
347
+ * Pre-create (or update) a draft contract carrying the acceptance
348
+ * fingerprint in `metadata.acceptance`. Called from /checkout/start
349
+ * when the customer accepts the contract preview, BEFORE payment
350
+ * lands.
351
+ *
352
+ * The draft has:
353
+ * - status="draft" (no number yet — issued post-payment)
354
+ * - templateVersionId pointing at the slug's current version
355
+ * - bookingId / personId / organizationId from the booking
356
+ * - metadata.acceptance with templateId/Slug, acceptedAt,
357
+ * acceptedMarketing, ipAddress, userAgent, renderedHtmlLength
358
+ *
359
+ * The body is left empty; `autoGenerateContractForBooking` (fired by
360
+ * `booking.confirmed`) detects the existing draft, fills in the
361
+ * fully-resolved variables, then issues + generates the PDF.
362
+ *
363
+ * Idempotency: a re-entry of /checkout/start finds the existing draft
364
+ * and updates its `metadata.acceptance` in place (last acceptance
365
+ * wins — typical when customer hits Back, edits acceptance, resubmits).
366
+ */
367
+ async function persistAcceptanceDraftContract(db, requestMeta, booking, acceptance) {
368
+ const { contractsService } = await import("@voyant-travel/legal/contracts");
369
+ const template = await contractsService.findTemplateBySlug(db, acceptance.templateSlug);
370
+ if (!template?.currentVersionId) {
371
+ console.warn(`[catalog-checkout] persistAcceptanceDraftContract: template "${acceptance.templateSlug}" not found or has no current version; skipping.`);
372
+ return;
373
+ }
374
+ const acceptanceMetadata = {
375
+ templateId: acceptance.templateId,
376
+ templateSlug: acceptance.templateSlug,
377
+ acceptedAt: acceptance.acceptedAt,
378
+ acceptedMarketing: acceptance.acceptedMarketing,
379
+ clientIp: requestMeta.clientIp ?? "",
380
+ userAgent: requestMeta.userAgent ?? "",
381
+ renderedHtmlLength: acceptance.renderedHtml.length,
382
+ };
383
+ // Look for an existing draft contract on this booking. Storefront
384
+ // re-submissions hit this branch.
385
+ const existingList = await contractsService.listContracts(db, {
386
+ bookingId: booking.id,
387
+ limit: 1,
388
+ offset: 0,
389
+ });
390
+ const existing = existingList.data[0];
391
+ if (existing) {
392
+ const prior = existing.metadata ?? {};
393
+ await contractsService.updateContract(db, existing.id, {
394
+ metadata: { ...prior, acceptance: acceptanceMetadata },
395
+ });
396
+ return;
397
+ }
398
+ await contractsService.createContract(db, {
399
+ scope: "customer",
400
+ status: "draft",
401
+ title: `${template.name} — ${booking.bookingNumber}`,
402
+ templateVersionId: template.currentVersionId,
403
+ seriesId: null,
404
+ bookingId: booking.id,
405
+ personId: booking.personId ?? null,
406
+ organizationId: booking.organizationId ?? null,
407
+ language: template.language,
408
+ variables: {},
409
+ metadata: {
410
+ autoGenerated: true,
411
+ trigger: "storefront.checkout-acceptance",
412
+ acceptance: acceptanceMetadata,
413
+ },
414
+ });
415
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=start-service.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"start-service.test.d.ts","sourceRoot":"","sources":["../../src/checkout/start-service.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,57 @@
1
+ import { Hono } from "hono";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { createCatalogCheckoutRoutes } from "./routes.js";
4
+ import { CatalogCheckoutStartError, startCatalogCheckout } from "./start-service.js";
5
+ function stubOptions(overrides = {}) {
6
+ return {
7
+ resolveBookingTaxSettings: vi.fn(),
8
+ getOwnedProductName: vi.fn().mockResolvedValue(null),
9
+ resolveBankTransferInstructions: vi
10
+ .fn()
11
+ .mockResolvedValue({ beneficiary: "Acme", iban: "RO00", bankName: "Bank" }),
12
+ ...overrides,
13
+ };
14
+ }
15
+ /** Stub db whose first `select().from().where().limit()` returns `bookingRows`. */
16
+ function stubDb(bookingRows) {
17
+ return {
18
+ select: () => ({
19
+ from: () => ({
20
+ where: () => ({ limit: async () => bookingRows }),
21
+ }),
22
+ }),
23
+ };
24
+ }
25
+ describe("startCatalogCheckout", () => {
26
+ it("places a hold for an existing booking", async () => {
27
+ const booking = { id: "bk_1", status: "on_hold", holdExpiresAt: null };
28
+ const result = await startCatalogCheckout({
29
+ db: stubDb([booking]),
30
+ env: {},
31
+ options: stubOptions(),
32
+ }, { bookingId: "bk_1", paymentIntent: "hold" });
33
+ expect(result).toEqual({ kind: "hold_placed", bookingId: "bk_1" });
34
+ });
35
+ it("throws booking_not_found when no booking + no snapshot materializes", async () => {
36
+ // No booking row, and the snapshot lookup (dynamic import of catalog)
37
+ // returns nothing → materializeBookingFromSnapshot yields null.
38
+ const db = stubDb([]);
39
+ const err = await startCatalogCheckout({ db, env: {}, options: stubOptions() }, { bookingId: "missing", paymentIntent: "hold" }).catch((e) => e);
40
+ expect(err).toBeInstanceOf(CatalogCheckoutStartError);
41
+ expect(err).toMatchObject({ code: "booking_not_found", status: 404 });
42
+ });
43
+ });
44
+ describe("createCatalogCheckoutRoutes", () => {
45
+ it("returns 400 on an invalid checkout body", async () => {
46
+ // Invalid body fails schema validation before the handler reads `db`,
47
+ // so no db wiring is needed for the 400 path.
48
+ const app = new Hono();
49
+ app.route("/v1/public/catalog", createCatalogCheckoutRoutes(stubOptions()));
50
+ const res = await app.request("/v1/public/catalog/checkout/start", {
51
+ method: "POST",
52
+ headers: { "content-type": "application/json" },
53
+ body: JSON.stringify({ bookingId: "", paymentIntent: "nope" }),
54
+ });
55
+ expect(res.status).toBe(400);
56
+ });
57
+ });