@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.
- package/dist/checkout/acceptance-signature.d.ts +4 -0
- package/dist/checkout/acceptance-signature.d.ts.map +1 -0
- package/dist/checkout/acceptance-signature.js +95 -0
- package/dist/checkout/finalize.d.ts +42 -0
- package/dist/checkout/finalize.d.ts.map +1 -0
- package/dist/checkout/finalize.js +208 -0
- package/dist/checkout/index.d.ts +26 -0
- package/dist/checkout/index.d.ts.map +1 -0
- package/dist/checkout/index.js +24 -0
- package/dist/checkout/materialization-support.d.ts +105 -0
- package/dist/checkout/materialization-support.d.ts.map +1 -0
- package/dist/checkout/materialization-support.js +451 -0
- package/dist/checkout/materialization-support.test.d.ts +2 -0
- package/dist/checkout/materialization-support.test.d.ts.map +1 -0
- package/dist/checkout/materialization-support.test.js +196 -0
- package/dist/checkout/materialization-tax.d.ts +10 -0
- package/dist/checkout/materialization-tax.d.ts.map +1 -0
- package/dist/checkout/materialization-tax.js +113 -0
- package/dist/checkout/materialization-tax.test.d.ts +2 -0
- package/dist/checkout/materialization-tax.test.d.ts.map +1 -0
- package/dist/checkout/materialization-tax.test.js +69 -0
- package/dist/checkout/materialization.d.ts +99 -0
- package/dist/checkout/materialization.d.ts.map +1 -0
- package/dist/checkout/materialization.js +269 -0
- package/dist/checkout/options.d.ts +89 -0
- package/dist/checkout/options.d.ts.map +1 -0
- package/dist/checkout/options.js +21 -0
- package/dist/checkout/routes.d.ts +21 -0
- package/dist/checkout/routes.d.ts.map +1 -0
- package/dist/checkout/routes.js +59 -0
- package/dist/checkout/start-service.d.ts +75 -0
- package/dist/checkout/start-service.d.ts.map +1 -0
- package/dist/checkout/start-service.js +415 -0
- package/dist/checkout/start-service.test.d.ts +2 -0
- package/dist/checkout/start-service.test.d.ts.map +1 -0
- package/dist/checkout/start-service.test.js +57 -0
- package/dist/markets/routes.d.ts +1 -1
- package/dist/markets/service-core.d.ts +1 -1
- package/dist/pricing/routes-public.d.ts.map +1 -1
- package/dist/pricing/routes-public.js +12 -2
- package/dist/sellability/routes.d.ts +10 -10
- package/dist/sellability/service-records.d.ts +4 -4
- package/dist/sellability/service-snapshots.d.ts +2 -2
- package/dist/sellability/service.d.ts +10 -10
- package/package.json +28 -6
|
@@ -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 @@
|
|
|
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
|
+
});
|