@voyant-travel/finance 0.180.0 → 0.181.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.
@@ -1,5 +1,6 @@
1
1
  import { defineGraphRuntimeFactory } from "@voyant-travel/core/project";
2
2
  import { createBookingTaxPreviewApiExtension, createBookingTaxSettingsApiExtension, } from "./booking-tax.js";
3
+ import { INVOICE_FX_SETTINGS_RUNTIME_KEY } from "./invoice-fx.js";
3
4
  import { createFinanceBookingTaxRuntime } from "./runtime.js";
4
5
  import { financeOperatorSettingsRuntimePort } from "./runtime-port.js";
5
6
  // ─────────────────────────────────────────────────────────────────
@@ -35,7 +36,8 @@ export function resolveBookingTaxRouteOptions(c, runtimeKey, fallback) {
35
36
  }
36
37
  }
37
38
  export const createBookingTaxSettingsVoyantRuntime = defineGraphRuntimeFactory(async ({ getPort }) => {
38
- const options = createFinanceBookingTaxRuntime(await getPort(financeOperatorSettingsRuntimePort));
39
+ const settings = await getPort(financeOperatorSettingsRuntimePort);
40
+ const options = createFinanceBookingTaxRuntime(settings);
39
41
  const configured = createBookingTaxSettingsApiExtension(options);
40
42
  const selected = {
41
43
  ...configured,
@@ -45,6 +47,13 @@ export const createBookingTaxSettingsVoyantRuntime = defineGraphRuntimeFactory(a
45
47
  container.register(BOOKING_TAX_SETTINGS_RUNTIME_KEY, {
46
48
  resolveRoutesOptions: () => options,
47
49
  });
50
+ // The same operator-settings port also backs invoice-FX settings, so the
51
+ // invoice-fx routes (in the core finance module) can persist the base
52
+ // currency without a separate extension/graph plugin.
53
+ container.register(INVOICE_FX_SETTINGS_RUNTIME_KEY, {
54
+ resolveInvoiceFxSettings: settings.resolveInvoiceFxSettings,
55
+ updateInvoiceFxSettings: settings.updateInvoiceFxSettings,
56
+ });
48
57
  },
49
58
  },
50
59
  };
@@ -71,6 +71,16 @@ type InvoiceFxRouteEnv = Env & {
71
71
  container?: ModuleContainer;
72
72
  };
73
73
  };
74
+ /**
75
+ * Container key under which the operator-settings bridge registers persistable
76
+ * invoice-FX settings (base currency + commission). When present, it overlays
77
+ * the base route runtime so `PATCH /invoice-fx-settings` can actually write.
78
+ */
79
+ export declare const INVOICE_FX_SETTINGS_RUNTIME_KEY = "finance.invoiceFxSettingsRuntime";
80
+ export interface InvoiceFxSettingsRuntime {
81
+ resolveInvoiceFxSettings: ResolveInvoiceFxSettings;
82
+ updateInvoiceFxSettings: UpdateInvoiceFxSettings;
83
+ }
74
84
  export declare function resolveInvoiceFxSettingsOrDefault(db: PostgresJsDatabase, options?: InvoiceFxOptions): Promise<ResolvedInvoiceFxSettings>;
75
85
  export declare function resolveInvoiceFxContext(db: PostgresJsDatabase, invoice: InvoiceFxInvoice, options?: InvoiceFxOptions): Promise<InvoiceFxContext | null>;
76
86
  export declare function createVoyantDataFxExchangeRateResolver(options: VoyantDataFxResolverOptions): ResolveInvoiceExchangeRate;
@@ -13,10 +13,27 @@ const invoiceExchangeRateQuerySchema = z.object({
13
13
  quoteCurrency: z.string().min(3).max(8),
14
14
  date: z.string().optional(),
15
15
  });
16
+ /**
17
+ * Container key under which the operator-settings bridge registers persistable
18
+ * invoice-FX settings (base currency + commission). When present, it overlays
19
+ * the base route runtime so `PATCH /invoice-fx-settings` can actually write.
20
+ */
21
+ export const INVOICE_FX_SETTINGS_RUNTIME_KEY = "finance.invoiceFxSettingsRuntime";
16
22
  function getInvoiceFxRuntime(options, bindings, container) {
17
- return ((container?.has(FINANCE_ROUTE_RUNTIME_CONTAINER_KEY)
23
+ const base = (container?.has(FINANCE_ROUTE_RUNTIME_CONTAINER_KEY)
18
24
  ? container.resolve(FINANCE_ROUTE_RUNTIME_CONTAINER_KEY)
19
- : undefined) ?? buildFinanceRouteRuntime(bindings, options));
25
+ : undefined) ?? buildFinanceRouteRuntime(bindings, options);
26
+ // Overlay the operator-settings-backed resolver/updater at request time so the
27
+ // wiring is independent of module-bootstrap ordering.
28
+ if (container?.has(INVOICE_FX_SETTINGS_RUNTIME_KEY)) {
29
+ const settings = container.resolve(INVOICE_FX_SETTINGS_RUNTIME_KEY);
30
+ return {
31
+ ...base,
32
+ resolveInvoiceFxSettings: settings.resolveInvoiceFxSettings,
33
+ updateInvoiceFxSettings: settings.updateInvoiceFxSettings,
34
+ };
35
+ }
36
+ return base;
20
37
  }
21
38
  export async function resolveInvoiceFxSettingsOrDefault(db, options = {}) {
22
39
  const settings = await resolveConfiguredInvoiceFxSettings(db, options);
@@ -14,6 +14,7 @@ export declare const financeReceivablesDatasetDefinition: {
14
14
  requiredScopes: string[];
15
15
  defaultLimit: number;
16
16
  maximumLimit: number;
17
+ defaultDateField: string;
17
18
  fields: ({
18
19
  id: string;
19
20
  label: string;
@@ -14,6 +14,7 @@ export const financeReceivablesDatasetDefinition = {
14
14
  requiredScopes: ["finance:read"],
15
15
  defaultLimit: 100,
16
16
  maximumLimit: 1_000,
17
+ defaultDateField: "issueDate",
17
18
  fields: [
18
19
  {
19
20
  id: "issueDate",
@@ -1,7 +1,8 @@
1
1
  import type { ReportDatasetContribution, ReportDatasetExecutionInput, ReportResult } from "@voyant-travel/reporting-contracts";
2
+ import { ReportDatasetQueryError } from "@voyant-travel/reporting-contracts";
2
3
  import { type SQL } from "drizzle-orm";
3
4
  /** A query shape outside Finance's deliberately small reporting surface. */
4
- export declare class FinanceReportingQueryError extends Error {
5
+ export declare class FinanceReportingQueryError extends ReportDatasetQueryError {
5
6
  constructor(message: string);
6
7
  }
7
8
  /**
package/dist/reporting.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { ReportDatasetQueryError } from "@voyant-travel/reporting-contracts";
1
2
  import { hasApiKeyPermission, permissionStringsToPermissions } from "@voyant-travel/types/api-keys";
2
3
  import { sql } from "drizzle-orm";
3
4
  import { FINANCE_RECEIVABLES_DATASET_ID, financeReceivablesDatasetDefinition, } from "./reporting-definitions.js";
@@ -74,8 +75,67 @@ const semanticReceivables = sql `
74
75
  WHERE invoice.invoice_type = 'invoice'
75
76
  AND invoice.status IN ('issued', 'partially_paid', 'paid', 'overdue')
76
77
  `;
78
+ /** Reserved report parameter that switches money measures to the operator base currency. */
79
+ const REPORT_CURRENCY_PARAM = "reportCurrency";
80
+ const BASE_CURRENCY_MODE = "base";
81
+ /**
82
+ * Base-currency variant of {@link semanticReceivables}. Every monetary figure is
83
+ * read from the record's persisted base-currency snapshot (`base_*_cents`), which
84
+ * was converted at the moment the record was created using that day's FX rate set
85
+ * — so aggregating across documents is exact and recording-time accurate. Records
86
+ * without an FX snapshot (base currency/amount unset) are excluded rather than
87
+ * silently counted at parity.
88
+ */
89
+ const baseReceivables = sql `
90
+ SELECT
91
+ invoice.issue_date AS "issueDate",
92
+ invoice.due_date AS "dueDate",
93
+ invoice.status::text AS status,
94
+ invoice.base_currency AS currency,
95
+ invoice.base_total_cents::bigint AS "grossIssuedCents",
96
+ credit_totals.credited_cents::bigint AS "creditedCents",
97
+ greatest(invoice.base_total_cents::bigint - credit_totals.credited_cents, 0) AS "netIssuedCents",
98
+ payment_totals.settled_cents::bigint AS "settledCents",
99
+ payment_totals.refunded_cents::bigint AS "refundedCents",
100
+ greatest(
101
+ invoice.base_total_cents::bigint
102
+ - credit_totals.credited_cents
103
+ - payment_totals.settled_cents,
104
+ 0
105
+ ) AS "outstandingBalanceCents"
106
+ FROM invoices invoice
107
+ LEFT JOIN LATERAL (
108
+ SELECT coalesce(sum(credit.base_amount_cents), 0)::bigint AS credited_cents
109
+ FROM credit_notes credit
110
+ WHERE credit.invoice_id = invoice.id
111
+ AND credit.status IN ('issued', 'applied')
112
+ AND credit.base_amount_cents IS NOT NULL
113
+ ) credit_totals ON true
114
+ LEFT JOIN LATERAL (
115
+ SELECT
116
+ coalesce(
117
+ sum(
118
+ CASE WHEN payment.status = 'completed' THEN payment.base_amount_cents ELSE 0 END
119
+ ),
120
+ 0
121
+ )::bigint AS settled_cents,
122
+ coalesce(
123
+ sum(
124
+ CASE WHEN payment.status = 'refunded' THEN payment.base_amount_cents ELSE 0 END
125
+ ),
126
+ 0
127
+ )::bigint AS refunded_cents
128
+ FROM payments payment
129
+ WHERE payment.invoice_id = invoice.id
130
+ AND payment.base_amount_cents IS NOT NULL
131
+ ) payment_totals ON true
132
+ WHERE invoice.invoice_type = 'invoice'
133
+ AND invoice.status IN ('issued', 'partially_paid', 'paid', 'overdue')
134
+ AND invoice.base_currency IS NOT NULL
135
+ AND invoice.base_total_cents IS NOT NULL
136
+ `;
77
137
  /** A query shape outside Finance's deliberately small reporting surface. */
78
- export class FinanceReportingQueryError extends Error {
138
+ export class FinanceReportingQueryError extends ReportDatasetQueryError {
79
139
  constructor(message) {
80
140
  super(message);
81
141
  this.name = "FinanceReportingQueryError";
@@ -88,6 +148,9 @@ export class FinanceReportingQueryError extends Error {
88
148
  */
89
149
  export function compileFinanceReceivablesQuery(input) {
90
150
  const { query, parameters } = input;
151
+ // Page-level "show in base currency": read money from recording-time base
152
+ // snapshots so every amount is already in one currency (the operator base).
153
+ const baseMode = parameters[REPORT_CURRENCY_PARAM] === BASE_CURRENCY_MODE;
91
154
  if (!Number.isInteger(input.maximumRows) || input.maximumRows < 1) {
92
155
  throw new FinanceReportingQueryError("maximumRows must be a positive integer.");
93
156
  }
@@ -173,7 +236,8 @@ export function compileFinanceReceivablesQuery(input) {
173
236
  const currencyIsExplicit = aggregateQuery
174
237
  ? groups.has("currency")
175
238
  : query.select.some((selection) => selection.kind === "field" && selection.field === "currency");
176
- if (moneySelections.length > 0 &&
239
+ if (!baseMode &&
240
+ moneySelections.length > 0 &&
177
241
  !currencyIsExplicit &&
178
242
  !hasSingleCurrencyFilter(query, parameters)) {
179
243
  throw new FinanceReportingQueryError("Currency measures must include or group by currency, or be filtered to exactly one currency.");
@@ -190,7 +254,7 @@ export function compileFinanceReceivablesQuery(input) {
190
254
  const rowLimit = Math.min(query.limit ?? input.maximumRows, input.maximumRows);
191
255
  return {
192
256
  statement: sql `
193
- WITH receivable AS (${semanticReceivables})
257
+ WITH receivable AS (${baseMode ? baseReceivables : semanticReceivables})
194
258
  SELECT ${selectSql}
195
259
  FROM receivable
196
260
  ${filters.length ? sql `WHERE ${sql.join(filters, sql ` AND `)}` : sql ``}
@@ -4,6 +4,7 @@ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
4
4
  import type { BookingTaxRouteOptions } from "./booking-tax.js";
5
5
  import type { CheckoutRoutesOptions } from "./checkout-routes.js";
6
6
  import type { CheckoutPaymentStarter } from "./checkout-service.js";
7
+ import type { ResolveInvoiceFxSettings, UpdateInvoiceFxSettings } from "./invoice-fx.js";
7
8
  import type { PaymentPolicy } from "./payment-policy.js";
8
9
  import type { PaymentPolicyEntityContext } from "./payment-policy-cascade.js";
9
10
  import type { BookingScheduleRoutesOptions } from "./payment-schedule/routes.js";
@@ -24,6 +25,10 @@ export interface FinanceOperatorSettingsRuntime {
24
25
  * settled proforma should be auto-converted to a fiscal invoice.
25
26
  */
26
27
  resolveInvoicingMode: (db: PostgresJsDatabase) => Promise<"direct" | "proforma-first">;
28
+ /** Resolve the operator's invoice-FX settings (base currency, commission). */
29
+ resolveInvoiceFxSettings: ResolveInvoiceFxSettings;
30
+ /** Persist the operator's invoice-FX settings. */
31
+ updateInvoiceFxSettings: UpdateInvoiceFxSettings;
27
32
  }
28
33
  export interface FinanceNotificationsRuntime {
29
34
  resolveNotificationDispatcher: NonNullable<CheckoutRoutesOptions["resolveNotificationDispatcher"]>;
@@ -20,6 +20,8 @@ export const financeOperatorSettingsRuntimePort = objectPort("finance.operator-s
20
20
  "resolveBookingTaxSettings",
21
21
  "updateBookingTaxSettings",
22
22
  "resolveInvoicingMode",
23
+ "resolveInvoiceFxSettings",
24
+ "updateInvoiceFxSettings",
23
25
  ]);
24
26
  export const financeNotificationsRuntimePort = objectPort("finance.notifications.runtime", ["resolveNotificationDispatcher", "listBookingReminderRuns"]);
25
27
  export const financeFxReferenceRuntimePort = objectPort("finance.fx-reference.runtime", ["resolveReferenceRate"]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voyant-travel/finance",
3
- "version": "0.180.0",
3
+ "version": "0.181.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -157,15 +157,15 @@
157
157
  "hono": "^4.12.27",
158
158
  "zod": "^4.4.3",
159
159
  "@voyant-travel/action-ledger": "^0.111.9",
160
- "@voyant-travel/bookings": "^0.180.0",
160
+ "@voyant-travel/bookings": "^0.181.0",
161
161
  "@voyant-travel/core": "^0.130.0",
162
- "@voyant-travel/db": "^0.117.0",
162
+ "@voyant-travel/db": "^0.117.1",
163
163
  "@voyant-travel/types": "^0.109.8",
164
- "@voyant-travel/finance-contracts": "^0.107.0",
164
+ "@voyant-travel/finance-contracts": "^0.107.1",
165
165
  "@voyant-travel/hono": "^0.131.2",
166
- "@voyant-travel/payments": "^0.2.6",
166
+ "@voyant-travel/payments": "^0.3.0",
167
167
  "@voyant-travel/public-document-delivery": "^0.4.8",
168
- "@voyant-travel/reporting-contracts": "^0.2.1",
168
+ "@voyant-travel/reporting-contracts": "^0.3.0",
169
169
  "@voyant-travel/storage": "^0.111.6",
170
170
  "@voyant-travel/utils": "^0.108.0",
171
171
  "@voyant-travel/tools": "^0.3.0"