@voyant-travel/finance 0.164.0 → 0.165.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.
@@ -12,9 +12,16 @@ export type ResolvedBookingSellTaxRate = {
12
12
  rate: number;
13
13
  priceMode: "inclusive" | "exclusive";
14
14
  };
15
+ export type InvoicingMode = "direct" | "proforma-first";
15
16
  export type BookingTaxSettings = {
16
17
  taxPriceMode?: "inclusive" | "exclusive" | null;
17
18
  taxPolicyProfileId?: string | null;
19
+ /**
20
+ * Operator invoicing mode. `direct` bills the fiscal invoice
21
+ * straight away; `proforma-first` issues a proforma and converts it
22
+ * to a fiscal invoice on full settlement. Absent/null → `direct`.
23
+ */
24
+ invoicingMode?: InvoicingMode | null;
18
25
  };
19
26
  export type ResolveBookingTaxSettings = (db: PostgresJsDatabase) => BookingTaxSettings | null | undefined | Promise<BookingTaxSettings | null | undefined>;
20
27
  export type UpdateBookingTaxSettings = (db: PostgresJsDatabase, settings: BookingTaxSettings) => BookingTaxSettings | null | undefined | Promise<BookingTaxSettings | null | undefined>;
@@ -40,6 +47,12 @@ export declare function resolveBookingSellTaxRate(db: PostgresJsDatabase, args:
40
47
  productId?: string | null;
41
48
  facts?: Partial<ProductTaxFacts>;
42
49
  }, options?: ResolveBookingSellTaxRateOptions): Promise<ResolvedBookingSellTaxRate | null>;
50
+ /**
51
+ * Resolve just the operator invoicing mode, defaulting to `direct`
52
+ * when no settings row exists. Shared by the proforma-conversion
53
+ * subscriber so it never converts unless the operator opted in.
54
+ */
55
+ export declare function resolveInvoicingModeOrDefault(db: PostgresJsDatabase, options?: ResolveBookingSellTaxRateOptions): Promise<InvoicingMode>;
43
56
  export declare function computeBookingItemTaxLine(taxRate: ResolvedBookingSellTaxRate | null, amountCents: number, currency: string, sortOrder?: number): {
44
57
  code: string;
45
58
  name: string;
@@ -64,6 +77,7 @@ export declare function createBookingTaxRoutes(options?: BookingTaxRouteOptions)
64
77
  data: {
65
78
  taxPriceMode: "inclusive" | "exclusive";
66
79
  taxPolicyProfileId: string | null;
80
+ invoicingMode: "direct" | "proforma-first";
67
81
  };
68
82
  };
69
83
  outputFormat: "json";
@@ -77,6 +91,7 @@ export declare function createBookingTaxRoutes(options?: BookingTaxRouteOptions)
77
91
  json: {
78
92
  taxPriceMode?: "inclusive" | "exclusive" | undefined;
79
93
  taxPolicyProfileId?: string | null | undefined;
94
+ invoicingMode?: "direct" | "proforma-first" | undefined;
80
95
  };
81
96
  };
82
97
  output: {};
@@ -87,12 +102,14 @@ export declare function createBookingTaxRoutes(options?: BookingTaxRouteOptions)
87
102
  json: {
88
103
  taxPriceMode?: "inclusive" | "exclusive" | undefined;
89
104
  taxPolicyProfileId?: string | null | undefined;
105
+ invoicingMode?: "direct" | "proforma-first" | undefined;
90
106
  };
91
107
  };
92
108
  output: {
93
109
  data: {
94
110
  taxPriceMode: "inclusive" | "exclusive";
95
111
  taxPolicyProfileId: string | null;
112
+ invoicingMode: "direct" | "proforma-first";
96
113
  };
97
114
  };
98
115
  outputFormat: "json";
@@ -14,11 +14,13 @@ const taxPreviewBodySchema = z.object({
14
14
  const bookingTaxSettingsPatchSchema = z.object({
15
15
  taxPriceMode: z.enum(["inclusive", "exclusive"]).optional(),
16
16
  taxPolicyProfileId: z.string().min(1).nullable().optional(),
17
+ invoicingMode: z.enum(["direct", "proforma-first"]).optional(),
17
18
  });
18
19
  const bookingTaxSettingsResponseSchema = z.object({
19
20
  data: z.object({
20
21
  taxPriceMode: z.enum(["inclusive", "exclusive"]),
21
22
  taxPolicyProfileId: z.string().nullable(),
23
+ invoicingMode: z.enum(["direct", "proforma-first"]),
22
24
  }),
23
25
  });
24
26
  const bookingTaxPreviewResponseSchema = z.object({
@@ -113,8 +115,18 @@ async function resolveBookingTaxSettingsOrDefault(db, options = {}) {
113
115
  return {
114
116
  taxPriceMode: settings?.taxPriceMode === "exclusive" ? "exclusive" : "inclusive",
115
117
  taxPolicyProfileId: settings?.taxPolicyProfileId ?? null,
118
+ invoicingMode: settings?.invoicingMode === "proforma-first" ? "proforma-first" : "direct",
116
119
  };
117
120
  }
121
+ /**
122
+ * Resolve just the operator invoicing mode, defaulting to `direct`
123
+ * when no settings row exists. Shared by the proforma-conversion
124
+ * subscriber so it never converts unless the operator opted in.
125
+ */
126
+ export async function resolveInvoicingModeOrDefault(db, options = {}) {
127
+ const settings = await resolveBookingTaxSettingsOrDefault(db, options);
128
+ return settings.invoicingMode;
129
+ }
118
130
  export function computeBookingItemTaxLine(taxRate, amountCents, currency, sortOrder = 0) {
119
131
  if (!taxRate || taxRate.rate <= 0 || amountCents <= 0)
120
132
  return null;
@@ -275,6 +287,7 @@ export function createBookingTaxRoutes(options = {}) {
275
287
  taxPolicyProfileId: patch.taxPolicyProfileId === undefined
276
288
  ? current.taxPolicyProfileId
277
289
  : patch.taxPolicyProfileId,
290
+ invoicingMode: patch.invoicingMode ?? current.invoicingMode,
278
291
  });
279
292
  return c.json({
280
293
  data: await resolveBookingTaxSettingsOrDefault(c.get("db"), { settings: next }),
@@ -30,6 +30,7 @@ import { openApiValidationHook, parseJsonBody } from "@voyant-travel/hono";
30
30
  import { asc, eq } from "drizzle-orm";
31
31
  import { BOOKING_SCHEDULE_SUBSCRIBER_RUNTIME_KEY } from "../booking-schedule/subscriber-runtime.js";
32
32
  import { computePaymentSchedule, noDepositPolicy, resolveEffectivePaymentPolicy, } from "../payment-policy.js";
33
+ import { PROFORMA_CONVERSION_SUBSCRIBER_RUNTIME_KEY } from "../proforma-conversion-runtime.js";
33
34
  import { createFinanceBookingScheduleRuntime } from "../runtime.js";
34
35
  import { financeAccommodationsPaymentPolicyRuntimePort, financeCruisesPaymentPolicyRuntimePort, financeDistributionPaymentPolicyRuntimePort, financeHostRuntimePort, financeInventoryPaymentPolicyRuntimePort, financeOperatorSettingsRuntimePort, } from "../runtime-port.js";
35
36
  import { bookingPaymentSchedules } from "../schema/booking-billing.js";
@@ -373,16 +374,25 @@ export function createBookingScheduleApiExtension(options) {
373
374
  };
374
375
  }
375
376
  export const createBookingScheduleVoyantRuntime = defineGraphRuntimeFactory(async ({ api, getPort }) => {
376
- const provider = createFinanceBookingScheduleRuntime(await getPort(financeHostRuntimePort), await getPort(financeOperatorSettingsRuntimePort), await getPort(financeDistributionPaymentPolicyRuntimePort), await getPort(financeAccommodationsPaymentPolicyRuntimePort), await getPort(financeCruisesPaymentPolicyRuntimePort), await getPort(financeInventoryPaymentPolicyRuntimePort));
377
+ const operatorSettings = await getPort(financeOperatorSettingsRuntimePort);
378
+ const provider = createFinanceBookingScheduleRuntime(await getPort(financeHostRuntimePort), operatorSettings, await getPort(financeDistributionPaymentPolicyRuntimePort), await getPort(financeAccommodationsPaymentPolicyRuntimePort), await getPort(financeCruisesPaymentPolicyRuntimePort), await getPort(financeInventoryPaymentPolicyRuntimePort));
377
379
  const configured = createBookingScheduleApiExtension(provider.options);
378
380
  const selected = {
379
381
  extension: {
380
382
  ...configured.extension,
381
- bootstrap: async ({ container }) => {
383
+ bootstrap: async ({ container, eventBus }) => {
382
384
  container.register(BOOKING_SCHEDULE_SUBSCRIBER_RUNTIME_KEY, {
383
385
  resolveRoutesOptions: () => provider.options,
384
386
  withDb: provider.withDb,
385
387
  });
388
+ // Same host/settings wiring powers the proforma-conversion
389
+ // subscriber: it reads the operator invoicing mode and, in
390
+ // proforma-first mode, mints the fiscal invoice on settlement.
391
+ container.register(PROFORMA_CONVERSION_SUBSCRIBER_RUNTIME_KEY, {
392
+ resolveInvoicingMode: operatorSettings.resolveInvoicingMode,
393
+ withDb: provider.withDb,
394
+ eventBus,
395
+ });
386
396
  },
387
397
  },
388
398
  };
@@ -0,0 +1,34 @@
1
+ import type { EventBus, SubscriberRuntimeDescriptor } from "@voyant-travel/core";
2
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
3
+ import type { InvoicingMode } from "./booking-tax.js";
4
+ /**
5
+ * Standard proforma → fiscal invoice conversion.
6
+ *
7
+ * When the operator runs `invoicing.mode: "proforma-first"`, a proforma
8
+ * is issued at checkout and the fiscal invoice is minted once the
9
+ * proforma is fully settled. This subscriber watches settlement signals
10
+ * (`invoice.settled` from the settlement poller and
11
+ * `invoice.payment.recorded` from manual/processed payments) and, in
12
+ * proforma-first mode only, converts a fully-paid proforma to its fiscal
13
+ * invoice via {@link convertProformaToInvoice} (which copies lines,
14
+ * assigns the fiscal number, links both documents, and voids the
15
+ * proforma). `direct` mode is a no-op — zero behaviour change for
16
+ * existing deployments — and the conversion is idempotent (the service
17
+ * guards against double-conversion under an advisory lock).
18
+ */
19
+ export declare const FINANCE_PROFORMA_CONVERSION_SUBSCRIBER_ID = "@voyant-travel/finance#subscriber.proforma-conversion";
20
+ export declare const PROFORMA_CONVERSION_SUBSCRIBER_RUNTIME_KEY = "finance.proformaConversionSubscriberRuntime";
21
+ export interface ProformaConversionSubscriberRuntime {
22
+ /** Operator invoicing mode; defaults to `direct` when unconfigured. */
23
+ resolveInvoicingMode(db: PostgresJsDatabase): Promise<InvoicingMode>;
24
+ /** Resolve the deployment database and retain ownership of its lifecycle. */
25
+ withDb<T>(bindings: unknown, operation: (db: PostgresJsDatabase) => Promise<T>): Promise<T>;
26
+ /** Event bus used to re-emit conversion events for downstream plugins. */
27
+ eventBus?: EventBus;
28
+ }
29
+ export interface ProformaConversionSubscriberDependencies {
30
+ logger?: Pick<Console, "error" | "info">;
31
+ }
32
+ /** Build the package-owned descriptor resolved by selected-graph lowering. */
33
+ export declare function createProformaConversionSubscriberRuntime(dependencies?: ProformaConversionSubscriberDependencies): SubscriberRuntimeDescriptor;
34
+ export declare const proformaConversionSubscriber: SubscriberRuntimeDescriptor;
@@ -0,0 +1,73 @@
1
+ import { financeService } from "./service.js";
2
+ import { convertProformaToInvoice } from "./service-issue.js";
3
+ /**
4
+ * Standard proforma → fiscal invoice conversion.
5
+ *
6
+ * When the operator runs `invoicing.mode: "proforma-first"`, a proforma
7
+ * is issued at checkout and the fiscal invoice is minted once the
8
+ * proforma is fully settled. This subscriber watches settlement signals
9
+ * (`invoice.settled` from the settlement poller and
10
+ * `invoice.payment.recorded` from manual/processed payments) and, in
11
+ * proforma-first mode only, converts a fully-paid proforma to its fiscal
12
+ * invoice via {@link convertProformaToInvoice} (which copies lines,
13
+ * assigns the fiscal number, links both documents, and voids the
14
+ * proforma). `direct` mode is a no-op — zero behaviour change for
15
+ * existing deployments — and the conversion is idempotent (the service
16
+ * guards against double-conversion under an advisory lock).
17
+ */
18
+ export const FINANCE_PROFORMA_CONVERSION_SUBSCRIBER_ID = "@voyant-travel/finance#subscriber.proforma-conversion";
19
+ export const PROFORMA_CONVERSION_SUBSCRIBER_RUNTIME_KEY = "finance.proformaConversionSubscriberRuntime";
20
+ const SETTLEMENT_EVENTS = ["invoice.settled", "invoice.payment.recorded"];
21
+ /** Build the package-owned descriptor resolved by selected-graph lowering. */
22
+ export function createProformaConversionSubscriberRuntime(dependencies = {}) {
23
+ const logger = dependencies.logger ?? console;
24
+ return {
25
+ id: FINANCE_PROFORMA_CONVERSION_SUBSCRIBER_ID,
26
+ eventType: "invoice.settled",
27
+ register: ({ bindings, container, eventBus }) => {
28
+ const handler = async ({ data }) => {
29
+ // Runtime is only registered when the operator-settings port is
30
+ // available. Absent → treat as `direct` mode → no-op.
31
+ if (!container.has(PROFORMA_CONVERSION_SUBSCRIBER_RUNTIME_KEY))
32
+ return;
33
+ const runtime = resolveProformaConversionSubscriberRuntime(container);
34
+ try {
35
+ await runtime.withDb(bindings, async (db) => {
36
+ if ((await runtime.resolveInvoicingMode(db)) !== "proforma-first")
37
+ return;
38
+ const invoice = await financeService.getInvoiceById(db, data.invoiceId);
39
+ if (!invoice)
40
+ return;
41
+ // Only fully-settled proformas convert. Partial payments leave
42
+ // the proforma outstanding until the balance is cleared.
43
+ if (invoice.invoiceType !== "proforma" || invoice.balanceDueCents !== 0)
44
+ return;
45
+ const result = await convertProformaToInvoice(db, invoice.id, {}, { eventBus: runtime.eventBus ?? eventBus });
46
+ if (result.status !== "ok" && result.status !== "already_converted") {
47
+ logger.error("[proforma-conversion] conversion did not complete", {
48
+ invoiceId: invoice.id,
49
+ status: result.status,
50
+ });
51
+ }
52
+ });
53
+ }
54
+ catch (error) {
55
+ logger.error("[proforma-conversion] failed to convert settled proforma", {
56
+ invoiceId: data.invoiceId,
57
+ error: error instanceof Error ? error.message : String(error),
58
+ });
59
+ }
60
+ };
61
+ for (const eventType of SETTLEMENT_EVENTS) {
62
+ eventBus.subscribe(eventType, handler);
63
+ }
64
+ },
65
+ };
66
+ }
67
+ function resolveProformaConversionSubscriberRuntime(container) {
68
+ if (!container.has(PROFORMA_CONVERSION_SUBSCRIBER_RUNTIME_KEY)) {
69
+ throw new Error(`Proforma-conversion subscriber runtime is not registered at "${PROFORMA_CONVERSION_SUBSCRIBER_RUNTIME_KEY}".`);
70
+ }
71
+ return container.resolve(PROFORMA_CONVERSION_SUBSCRIBER_RUNTIME_KEY);
72
+ }
73
+ export const proformaConversionSubscriber = createProformaConversionSubscriberRuntime();
@@ -17,6 +17,13 @@ export interface FinanceOperatorSettingsRuntime {
17
17
  resolveOperatorDefaultPaymentPolicy: BookingScheduleRoutesOptions["resolveOperatorDefaultPaymentPolicy"];
18
18
  resolveBookingTaxSettings: NonNullable<BookingTaxRouteOptions["resolveBookingTaxSettings"]>;
19
19
  updateBookingTaxSettings: NonNullable<BookingTaxRouteOptions["updateBookingTaxSettings"]>;
20
+ /**
21
+ * Resolve the operator's invoicing mode (`direct` | `proforma-first`).
22
+ * Defaults to `direct` when unconfigured. The finance
23
+ * proforma-conversion subscriber reads this to decide whether a
24
+ * settled proforma should be auto-converted to a fiscal invoice.
25
+ */
26
+ resolveInvoicingMode: (db: PostgresJsDatabase) => Promise<"direct" | "proforma-first">;
20
27
  }
21
28
  export interface FinanceNotificationsRuntime {
22
29
  resolveNotificationDispatcher: NonNullable<CheckoutRoutesOptions["resolveNotificationDispatcher"]>;
@@ -15,7 +15,12 @@ function objectPort(id, methods = []) {
15
15
  });
16
16
  }
17
17
  export const financeHostRuntimePort = objectPort("finance.host.runtime");
18
- export const financeOperatorSettingsRuntimePort = objectPort("finance.operator-settings.runtime", ["resolveOperatorDefaultPaymentPolicy", "resolveBookingTaxSettings", "updateBookingTaxSettings"]);
18
+ export const financeOperatorSettingsRuntimePort = objectPort("finance.operator-settings.runtime", [
19
+ "resolveOperatorDefaultPaymentPolicy",
20
+ "resolveBookingTaxSettings",
21
+ "updateBookingTaxSettings",
22
+ "resolveInvoicingMode",
23
+ ]);
19
24
  export const financeNotificationsRuntimePort = objectPort("finance.notifications.runtime", ["resolveNotificationDispatcher", "listBookingReminderRuns"]);
20
25
  export const financeDistributionPaymentPolicyRuntimePort = objectPort("finance.distribution-payment-policy.runtime", ["resolveSupplierPolicy", "resolveSupplierPolicyById"]);
21
26
  export const financeAccommodationsPaymentPolicyRuntimePort = objectPort("finance.accommodations-payment-policy.runtime", ["resolveBookingPolicy", "resolveEntityPolicy"]);
package/dist/voyant.js CHANGED
@@ -487,6 +487,15 @@ export const financeBookingScheduleVoyantPlugin = defineExtension({
487
487
  export: "bookingScheduleConfirmedSubscriber",
488
488
  },
489
489
  },
490
+ {
491
+ id: "@voyant-travel/finance#subscriber.proforma-conversion",
492
+ eventType: "invoice.settled",
493
+ source: "@voyant-travel/finance/proforma-conversion-subscriber",
494
+ runtime: {
495
+ entry: "@voyant-travel/finance/proforma-conversion-subscriber",
496
+ export: "proformaConversionSubscriber",
497
+ },
498
+ },
490
499
  ],
491
500
  meta: {
492
501
  ownership: "package",
@@ -35,7 +35,8 @@
35
35
  "type": "object",
36
36
  "properties": {
37
37
  "taxPriceMode": { "type": "string", "enum": ["inclusive", "exclusive"] },
38
- "taxPolicyProfileId": { "type": ["string", "null"] }
38
+ "taxPolicyProfileId": { "type": ["string", "null"] },
39
+ "invoicingMode": { "type": "string", "enum": ["direct", "proforma-first"] }
39
40
  }
40
41
  }
41
42
  }
@@ -75,10 +76,11 @@
75
76
  "schemas": {
76
77
  "BookingTaxSettings": {
77
78
  "type": "object",
78
- "required": ["taxPriceMode", "taxPolicyProfileId"],
79
+ "required": ["taxPriceMode", "taxPolicyProfileId", "invoicingMode"],
79
80
  "properties": {
80
81
  "taxPriceMode": { "type": "string", "enum": ["inclusive", "exclusive"] },
81
- "taxPolicyProfileId": { "type": ["string", "null"] }
82
+ "taxPolicyProfileId": { "type": ["string", "null"] },
83
+ "invoicingMode": { "type": "string", "enum": ["direct", "proforma-first"] }
82
84
  }
83
85
  }
84
86
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voyant-travel/finance",
3
- "version": "0.164.0",
3
+ "version": "0.165.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -104,6 +104,11 @@
104
104
  "import": "./dist/booking-schedule/subscriber-runtime.js",
105
105
  "default": "./dist/booking-schedule/subscriber-runtime.js"
106
106
  },
107
+ "./proforma-conversion-subscriber": {
108
+ "types": "./dist/proforma-conversion-runtime.d.ts",
109
+ "import": "./dist/proforma-conversion-runtime.js",
110
+ "default": "./dist/proforma-conversion-runtime.js"
111
+ },
107
112
  "./routes": {
108
113
  "types": "./dist/routes.d.ts",
109
114
  "import": "./dist/routes.js",
@@ -146,16 +151,16 @@
146
151
  "hono": "^4.12.27",
147
152
  "zod": "^4.4.3",
148
153
  "@voyant-travel/action-ledger": "^0.111.1",
149
- "@voyant-travel/bookings": "^0.164.0",
154
+ "@voyant-travel/bookings": "^0.165.0",
150
155
  "@voyant-travel/core": "^0.125.0",
151
- "@voyant-travel/db": "^0.114.9",
152
- "@voyant-travel/types": "^0.109.2",
156
+ "@voyant-travel/db": "^0.114.10",
157
+ "@voyant-travel/types": "^0.109.3",
153
158
  "@voyant-travel/finance-contracts": "^0.106.2",
154
159
  "@voyant-travel/hono": "^0.128.1",
155
- "@voyant-travel/utils": "^0.107.1",
156
- "@voyant-travel/tools": "^0.3.0",
157
160
  "@voyant-travel/public-document-delivery": "^0.4.1",
158
- "@voyant-travel/storage": "^0.111.1"
161
+ "@voyant-travel/storage": "^0.111.1",
162
+ "@voyant-travel/utils": "^0.107.1",
163
+ "@voyant-travel/tools": "^0.3.0"
159
164
  },
160
165
  "devDependencies": {
161
166
  "drizzle-kit": "^0.31.10",