@voyant-travel/finance 0.165.0 → 0.167.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.
@@ -13,15 +13,27 @@ export type ResolvedBookingSellTaxRate = {
13
13
  priceMode: "inclusive" | "exclusive";
14
14
  };
15
15
  export type InvoicingMode = "direct" | "proforma-first";
16
+ /**
17
+ * Official FX reference-rate source. `ecb` (default) covers most EU
18
+ * operators; `bnr` is the National Bank of Romania. A setting, not
19
+ * per-country code — new jurisdictions add values here, not modules.
20
+ */
21
+ export type FxReferenceSource = "ecb" | "bnr";
16
22
  export type BookingTaxSettings = {
17
23
  taxPriceMode?: "inclusive" | "exclusive" | null;
18
24
  taxPolicyProfileId?: string | null;
19
25
  /**
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`.
26
+ * Operator invoicing mode for the deferred bank-transfer path.
27
+ * `proforma-first` issues a proforma at order placement and converts it
28
+ * to a fiscal invoice on full settlement; `direct` issues the fiscal
29
+ * invoice at order placement. Card payments always invoice directly and
30
+ * never consult this. Absent/null → `proforma-first`.
23
31
  */
24
32
  invoicingMode?: InvoicingMode | null;
33
+ /**
34
+ * Operator's official FX reference-rate source. Absent/null → `ecb`.
35
+ */
36
+ fxReferenceSource?: FxReferenceSource | null;
25
37
  };
26
38
  export type ResolveBookingTaxSettings = (db: PostgresJsDatabase) => BookingTaxSettings | null | undefined | Promise<BookingTaxSettings | null | undefined>;
27
39
  export type UpdateBookingTaxSettings = (db: PostgresJsDatabase, settings: BookingTaxSettings) => BookingTaxSettings | null | undefined | Promise<BookingTaxSettings | null | undefined>;
@@ -47,12 +59,6 @@ export declare function resolveBookingSellTaxRate(db: PostgresJsDatabase, args:
47
59
  productId?: string | null;
48
60
  facts?: Partial<ProductTaxFacts>;
49
61
  }, 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>;
56
62
  export declare function computeBookingItemTaxLine(taxRate: ResolvedBookingSellTaxRate | null, amountCents: number, currency: string, sortOrder?: number): {
57
63
  code: string;
58
64
  name: string;
@@ -78,6 +84,7 @@ export declare function createBookingTaxRoutes(options?: BookingTaxRouteOptions)
78
84
  taxPriceMode: "inclusive" | "exclusive";
79
85
  taxPolicyProfileId: string | null;
80
86
  invoicingMode: "direct" | "proforma-first";
87
+ fxReferenceSource: "ecb" | "bnr";
81
88
  };
82
89
  };
83
90
  outputFormat: "json";
@@ -92,6 +99,7 @@ export declare function createBookingTaxRoutes(options?: BookingTaxRouteOptions)
92
99
  taxPriceMode?: "inclusive" | "exclusive" | undefined;
93
100
  taxPolicyProfileId?: string | null | undefined;
94
101
  invoicingMode?: "direct" | "proforma-first" | undefined;
102
+ fxReferenceSource?: "ecb" | "bnr" | undefined;
95
103
  };
96
104
  };
97
105
  output: {};
@@ -103,6 +111,7 @@ export declare function createBookingTaxRoutes(options?: BookingTaxRouteOptions)
103
111
  taxPriceMode?: "inclusive" | "exclusive" | undefined;
104
112
  taxPolicyProfileId?: string | null | undefined;
105
113
  invoicingMode?: "direct" | "proforma-first" | undefined;
114
+ fxReferenceSource?: "ecb" | "bnr" | undefined;
106
115
  };
107
116
  };
108
117
  output: {
@@ -110,6 +119,7 @@ export declare function createBookingTaxRoutes(options?: BookingTaxRouteOptions)
110
119
  taxPriceMode: "inclusive" | "exclusive";
111
120
  taxPolicyProfileId: string | null;
112
121
  invoicingMode: "direct" | "proforma-first";
122
+ fxReferenceSource: "ecb" | "bnr";
113
123
  };
114
124
  };
115
125
  outputFormat: "json";
@@ -15,12 +15,14 @@ const bookingTaxSettingsPatchSchema = z.object({
15
15
  taxPriceMode: z.enum(["inclusive", "exclusive"]).optional(),
16
16
  taxPolicyProfileId: z.string().min(1).nullable().optional(),
17
17
  invoicingMode: z.enum(["direct", "proforma-first"]).optional(),
18
+ fxReferenceSource: z.enum(["ecb", "bnr"]).optional(),
18
19
  });
19
20
  const bookingTaxSettingsResponseSchema = z.object({
20
21
  data: z.object({
21
22
  taxPriceMode: z.enum(["inclusive", "exclusive"]),
22
23
  taxPolicyProfileId: z.string().nullable(),
23
24
  invoicingMode: z.enum(["direct", "proforma-first"]),
25
+ fxReferenceSource: z.enum(["ecb", "bnr"]),
24
26
  }),
25
27
  });
26
28
  const bookingTaxPreviewResponseSchema = z.object({
@@ -115,18 +117,10 @@ async function resolveBookingTaxSettingsOrDefault(db, options = {}) {
115
117
  return {
116
118
  taxPriceMode: settings?.taxPriceMode === "exclusive" ? "exclusive" : "inclusive",
117
119
  taxPolicyProfileId: settings?.taxPolicyProfileId ?? null,
118
- invoicingMode: settings?.invoicingMode === "proforma-first" ? "proforma-first" : "direct",
120
+ invoicingMode: settings?.invoicingMode === "direct" ? "direct" : "proforma-first",
121
+ fxReferenceSource: settings?.fxReferenceSource === "bnr" ? "bnr" : "ecb",
119
122
  };
120
123
  }
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
- }
130
124
  export function computeBookingItemTaxLine(taxRate, amountCents, currency, sortOrder = 0) {
131
125
  if (!taxRate || taxRate.rate <= 0 || amountCents <= 0)
132
126
  return null;
@@ -288,6 +282,7 @@ export function createBookingTaxRoutes(options = {}) {
288
282
  ? current.taxPolicyProfileId
289
283
  : patch.taxPolicyProfileId,
290
284
  invoicingMode: patch.invoicingMode ?? current.invoicingMode,
285
+ fxReferenceSource: patch.fxReferenceSource ?? current.fxReferenceSource,
291
286
  });
292
287
  return c.json({
293
288
  data: await resolveBookingTaxSettingsOrDefault(c.get("db"), { settings: next }),
@@ -11,8 +11,10 @@
11
11
  * Returning `null` from a starter means "this processor isn't configured" so
12
12
  * callers fall back gracefully (bank-transfer paths still work).
13
13
  */
14
+ import type { PaymentAdapter, PaymentAdapterRuntimeContext } from "@voyant-travel/payments";
14
15
  import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
15
16
  import type { Context } from "hono";
17
+ import type { FinanceServiceRuntime } from "./service-shared.js";
16
18
  /**
17
19
  * Billing details a card processor needs to start a hosted payment.
18
20
  *
@@ -54,3 +56,9 @@ export interface CardPaymentStartResult {
54
56
  * configured" — callers fall back (bank transfer still works).
55
57
  */
56
58
  export type CardPaymentStarter = (c: Context, args: CardPaymentStartArgs) => Promise<CardPaymentStartResult | null>;
59
+ export interface PaymentAdapterCardPaymentStarterOptions {
60
+ resolveContext?(c: Context): PaymentAdapterRuntimeContext;
61
+ resolveRuntime?(c: Context): FinanceServiceRuntime;
62
+ idempotencyKey?(sessionId: string): string;
63
+ }
64
+ export declare function createPaymentAdapterCardPaymentStarter(adapter: PaymentAdapter, options?: PaymentAdapterCardPaymentStarterOptions): CardPaymentStarter;
@@ -1 +1,57 @@
1
- export {};
1
+ import { eq } from "drizzle-orm";
2
+ import { paymentSessions } from "./schema/payment-sessions.js";
3
+ import { financePaymentSessionCompletionService } from "./service-payment-session-completion.js";
4
+ import { financePaymentSessionService } from "./service-payment-sessions.js";
5
+ export function createPaymentAdapterCardPaymentStarter(adapter, options = {}) {
6
+ return async (c, args) => {
7
+ const [session] = await args.db
8
+ .select()
9
+ .from(paymentSessions)
10
+ .where(eq(paymentSessions.id, args.sessionId))
11
+ .limit(1);
12
+ if (!session)
13
+ return null;
14
+ const idempotencyKey = session.idempotencyKey ?? options.idempotencyKey?.(session.id) ?? `payment:${session.id}`;
15
+ const result = await adapter.initiate(options.resolveContext?.(c) ?? { env: c.env }, {
16
+ paymentSessionId: session.id,
17
+ money: { amountMinor: session.amountCents, currency: session.currency },
18
+ description: args.description ?? session.notes ?? undefined,
19
+ returnUrl: args.returnUrl ?? session.returnUrl ?? undefined,
20
+ idempotencyKey,
21
+ customer: {
22
+ email: args.billing.email,
23
+ phone: args.billing.phone ?? null,
24
+ firstName: args.billing.firstName,
25
+ lastName: args.billing.lastName ?? null,
26
+ },
27
+ });
28
+ const providerData = {
29
+ providerSessionId: result.processorSessionId ?? undefined,
30
+ providerPaymentId: result.processorPaymentId ?? undefined,
31
+ providerPayload: result.raw === undefined ? undefined : { initiation: result.raw },
32
+ metadata: { paymentAdapterInitiationIdempotencyKey: result.idempotencyKey },
33
+ };
34
+ if (result.nextState === "paid" || result.nextState === "authorized") {
35
+ await financePaymentSessionService.updatePaymentSession(args.db, session.id, {
36
+ provider: adapter.id,
37
+ redirectUrl: result.checkout?.url ?? undefined,
38
+ idempotencyKey,
39
+ });
40
+ await financePaymentSessionCompletionService.completePaymentSession(args.db, session.id, {
41
+ status: result.nextState,
42
+ captureMode: "automatic",
43
+ ...providerData,
44
+ }, options.resolveRuntime?.(c));
45
+ }
46
+ else {
47
+ await financePaymentSessionService.updatePaymentSession(args.db, session.id, {
48
+ provider: adapter.id,
49
+ status: result.nextState,
50
+ redirectUrl: result.checkout?.url ?? undefined,
51
+ idempotencyKey,
52
+ ...providerData,
53
+ });
54
+ }
55
+ return { redirectUrl: result.checkout?.url ?? null };
56
+ };
57
+ }
package/dist/index.d.ts CHANGED
@@ -23,12 +23,16 @@ export interface FinanceApiModuleOptions extends FinanceRuntimeOptions, PublicFi
23
23
  export declare function createFinanceApiModule(options?: FinanceApiModuleOptions): ApiModule;
24
24
  export declare const financeApiModule: ApiModule;
25
25
  export declare const createFinanceVoyantRuntime: import("@voyant-travel/core/project").VoyantGraphRuntimeFactory<ApiModule>;
26
+ export type { PaymentAdapter, PaymentAdapterCapabilities, PaymentAdapterConformanceHarness, PaymentAdapterConformanceResult, PaymentAdapterDiagnostics, PaymentAdapterRuntimeContext, PaymentCallbackEvent, PaymentCallbackRequest, PaymentCallbackVerificationResult, PaymentInitiationInput, PaymentInitiationResult, PaymentMoney, PaymentOperationInput, PaymentOperationResult, PaymentStatusInput, PaymentStatusResult, } from "@voyant-travel/payments";
27
+ export { PAYMENT_ADAPTER_CONTRACT_VERSION, PAYMENT_ADAPTER_RUNTIME_PORT_ID, paymentAdapterRuntimePort, runPaymentAdapterConformance, } from "@voyant-travel/payments";
26
28
  export { type BookingCancellationSettlementInput, buildPaidBookingCancellationSettlementNote, closeTerminalBookingPaymentSchedules, financeBookingLifecycle, recordPaidBookingCancellationSettlement, } from "./booking-lifecycle.js";
27
29
  export { type BookingTaxRouteOptions, type BookingTaxSettings, computeBookingItemTaxLine, createBookingTaxApiExtension, createBookingTaxRoutes, createBookingTaxVoyantRuntime, loadProductTaxFacts, matchesTaxPolicyCondition, mountBookingTaxRoutes, type ProductTaxFacts, type ResolveBookingSellTaxRateOptions, type ResolveBookingTaxSettings, type ResolvedBookingSellTaxRate, resolveBookingSellTaxRate, type TaxPolicyCondition, type UpdateBookingTaxSettings, } from "./booking-tax.js";
28
- export type { CardPaymentBilling, CardPaymentStartArgs, CardPaymentStarter, CardPaymentStartResult, } from "./card-payment.js";
30
+ export type { CardPaymentBilling, CardPaymentStartArgs, CardPaymentStarter, CardPaymentStartResult, PaymentAdapterCardPaymentStarterOptions, } from "./card-payment.js";
31
+ export { createPaymentAdapterCardPaymentStarter } from "./card-payment.js";
29
32
  export { type DocumentDownloadEnvelope, type DocumentDownloadResolution, type DocumentDownloadResolver, resolveStoredDocumentDownload, type StoredDocumentReference, } from "./document-download.js";
30
33
  export { createInvoiceFxApiExtension, createInvoiceFxRoutes, createVoyantDataFxExchangeRateResolver, type InvoiceExchangeRateResolution, type InvoiceFxContext, type InvoiceFxOptions, type InvoiceFxRouteOptions, type InvoiceFxSettings, mountInvoiceFxRoutes, type ResolvedInvoiceFxSettings, type ResolveInvoiceExchangeRate, type ResolveInvoiceExchangeRateInput, type ResolveInvoiceFxSettings, resolveInvoiceFxContext, resolveInvoiceFxSettingsOrDefault, type UpdateInvoiceFxSettings, type VoyantDataFxResolverOptions, } from "./invoice-fx.js";
31
34
  export { type CreateOrderPaymentSessionsOptions, createOrderPaymentSessions, type EnsureOrderSessionParams, type OrderPaymentSessionSummary, type OrderPaymentSessions, type OrderPaymentSessionTargetType, type StartOrderPaymentProvider, } from "./order-payment-sessions.js";
35
+ export { applyPaymentAdapterCallbackEvent } from "./payment-adapter-events.js";
32
36
  export type { ComputedScheduleEntry, ComputeScheduleInput, DepositKind, DepositRule, PaymentPolicy, PaymentPolicyCascadeLayers, PaymentPolicySource, PaymentScheduleEntryType, ResolvedPaymentPolicy, } from "./payment-policy.js";
33
37
  export { computePaymentSchedule, isPaymentPolicyEmpty, noDepositPolicy, normalizePaymentPolicy, policyShouldRequireFullPayment, resolveEffectivePaymentPolicy, } from "./payment-policy.js";
34
38
  export { createPaymentPolicyCascade, type PaymentPolicyCascade, type PaymentPolicyCascadeOptions, type PaymentPolicyCascadeReaders, readPolicySourceFromInternalNotes, stampPolicySourceOnBooking, } from "./payment-policy-cascade.js";
package/dist/index.js CHANGED
@@ -87,11 +87,14 @@ export const createFinanceVoyantRuntime = defineGraphRuntimeFactory(async ({ api
87
87
  }
88
88
  return selected;
89
89
  });
90
+ export { PAYMENT_ADAPTER_CONTRACT_VERSION, PAYMENT_ADAPTER_RUNTIME_PORT_ID, paymentAdapterRuntimePort, runPaymentAdapterConformance, } from "@voyant-travel/payments";
90
91
  export { buildPaidBookingCancellationSettlementNote, closeTerminalBookingPaymentSchedules, financeBookingLifecycle, recordPaidBookingCancellationSettlement, } from "./booking-lifecycle.js";
91
92
  export { computeBookingItemTaxLine, createBookingTaxApiExtension, createBookingTaxRoutes, createBookingTaxVoyantRuntime, loadProductTaxFacts, matchesTaxPolicyCondition, mountBookingTaxRoutes, resolveBookingSellTaxRate, } from "./booking-tax.js";
93
+ export { createPaymentAdapterCardPaymentStarter } from "./card-payment.js";
92
94
  export { resolveStoredDocumentDownload, } from "./document-download.js";
93
95
  export { createInvoiceFxApiExtension, createInvoiceFxRoutes, createVoyantDataFxExchangeRateResolver, mountInvoiceFxRoutes, resolveInvoiceFxContext, resolveInvoiceFxSettingsOrDefault, } from "./invoice-fx.js";
94
96
  export { createOrderPaymentSessions, } from "./order-payment-sessions.js";
97
+ export { applyPaymentAdapterCallbackEvent } from "./payment-adapter-events.js";
95
98
  export { computePaymentSchedule, isPaymentPolicyEmpty, noDepositPolicy, normalizePaymentPolicy, policyShouldRequireFullPayment, resolveEffectivePaymentPolicy, } from "./payment-policy.js";
96
99
  export { createPaymentPolicyCascade, readPolicySourceFromInternalNotes, stampPolicySourceOnBooking, } from "./payment-policy-cascade.js";
97
100
  export { createBookingScheduleAdminRoutes, createBookingScheduleApiExtension, createBookingScheduleVoyantRuntime, createPaymentPolicyPublicRoutes, generatePaymentScheduleForBooking, } from "./payment-schedule/routes.js";
@@ -0,0 +1,47 @@
1
+ import type { PaymentCallbackEvent } from "@voyant-travel/payments";
2
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
3
+ import type { FinanceServiceRuntime } from "./service-shared.js";
4
+ export declare function applyPaymentAdapterCallbackEvent(db: PostgresJsDatabase, event: PaymentCallbackEvent, runtime?: FinanceServiceRuntime): Promise<{
5
+ id: string;
6
+ targetType: "other" | "booking" | "order" | "invoice" | "booking_payment_schedule" | "booking_guarantee" | "flight_order";
7
+ targetId: string | null;
8
+ bookingId: string | null;
9
+ orderId: string | null;
10
+ invoiceId: string | null;
11
+ bookingPaymentScheduleId: string | null;
12
+ bookingGuaranteeId: string | null;
13
+ paymentInstrumentId: string | null;
14
+ paymentAuthorizationId: string | null;
15
+ paymentCaptureId: string | null;
16
+ paymentId: string | null;
17
+ status: "paid" | "pending" | "failed" | "requires_redirect" | "processing" | "authorized" | "cancelled" | "expired";
18
+ provider: string | null;
19
+ providerSessionId: string | null;
20
+ providerPaymentId: string | null;
21
+ externalReference: string | null;
22
+ idempotencyKey: string | null;
23
+ clientReference: string | null;
24
+ currency: string;
25
+ amountCents: number;
26
+ paymentMethod: "bank_transfer" | "credit_card" | "debit_card" | "cash" | "cheque" | "wallet" | "direct_bill" | "travel_credit" | "other" | null;
27
+ payerPersonId: string | null;
28
+ payerOrganizationId: string | null;
29
+ payerEmail: string | null;
30
+ payerName: string | null;
31
+ redirectUrl: string | null;
32
+ returnUrl: string | null;
33
+ cancelUrl: string | null;
34
+ callbackUrl: string | null;
35
+ expiresAt: Date | null;
36
+ completedAt: Date | null;
37
+ failedAt: Date | null;
38
+ cancelledAt: Date | null;
39
+ expiredAt: Date | null;
40
+ failureCode: string | null;
41
+ failureMessage: string | null;
42
+ notes: string | null;
43
+ providerPayload: Record<string, unknown> | null;
44
+ metadata: Record<string, unknown> | null;
45
+ createdAt: Date;
46
+ updatedAt: Date;
47
+ } | null>;
@@ -0,0 +1,34 @@
1
+ import { financePaymentSessionCompletionService } from "./service-payment-session-completion.js";
2
+ import { financePaymentSessionService } from "./service-payment-sessions.js";
3
+ export async function applyPaymentAdapterCallbackEvent(db, event, runtime = {}) {
4
+ const providerData = {
5
+ providerSessionId: event.processorSessionId ?? undefined,
6
+ providerPaymentId: event.processorPaymentId ?? undefined,
7
+ providerPayload: event.raw === undefined ? undefined : { callback: event.raw },
8
+ metadata: { paymentAdapterEventId: event.eventId, paymentAdapterOccurredAt: event.occurredAt },
9
+ };
10
+ if (event.nextState === "paid" || event.nextState === "authorized") {
11
+ return financePaymentSessionCompletionService.completePaymentSession(db, event.paymentSessionId, {
12
+ status: event.nextState,
13
+ captureMode: "automatic",
14
+ ...providerData,
15
+ }, runtime);
16
+ }
17
+ if (event.nextState === "failed") {
18
+ return financePaymentSessionService.failPaymentSession(db, event.paymentSessionId, {
19
+ ...providerData,
20
+ failureCode: "payment_adapter_callback",
21
+ failureMessage: "Payment adapter callback mapped this session to failed.",
22
+ }, runtime);
23
+ }
24
+ if (event.nextState === "cancelled") {
25
+ return financePaymentSessionService.cancelPaymentSession(db, event.paymentSessionId, providerData, runtime);
26
+ }
27
+ if (event.nextState === "expired") {
28
+ return financePaymentSessionService.expirePaymentSession(db, event.paymentSessionId, providerData, runtime);
29
+ }
30
+ return financePaymentSessionService.updatePaymentSession(db, event.paymentSessionId, {
31
+ status: event.nextState,
32
+ ...providerData,
33
+ }, runtime);
34
+ }
@@ -385,11 +385,9 @@ export const createBookingScheduleVoyantRuntime = defineGraphRuntimeFactory(asyn
385
385
  resolveRoutesOptions: () => provider.options,
386
386
  withDb: provider.withDb,
387
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.
388
+ // Same host wiring powers the proforma-conversion subscriber:
389
+ // when a fully-paid proforma settles it mints the fiscal invoice.
391
390
  container.register(PROFORMA_CONVERSION_SUBSCRIBER_RUNTIME_KEY, {
392
- resolveInvoicingMode: operatorSettings.resolveInvoicingMode,
393
391
  withDb: provider.withDb,
394
392
  eventBus,
395
393
  });
@@ -1,26 +1,29 @@
1
1
  import type { EventBus, SubscriberRuntimeDescriptor } from "@voyant-travel/core";
2
2
  import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
3
- import type { InvoicingMode } from "./booking-tax.js";
4
3
  /**
5
4
  * Standard proforma → fiscal invoice conversion.
6
5
  *
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).
6
+ * A proforma is issued for deferred (bank-transfer) checkouts when the
7
+ * operator runs the `proforma-first` invoicing mode, or by the manual
8
+ * `POST /invoices/{id}/convert-to-invoice` flow. This subscriber watches
9
+ * settlement signals (`invoice.settled` from the settlement poller and
10
+ * `invoice.payment.recorded` from manual/processed payments) and converts
11
+ * a fully-paid proforma to its fiscal invoice via
12
+ * {@link convertProformaToInvoice} (which copies lines, assigns the fiscal
13
+ * number, links both documents, and voids the proforma).
14
+ *
15
+ * The conversion is not gated on the invoicing mode: any fully-paid
16
+ * proforma should mint its fiscal invoice, and the `invoiceType ===
17
+ * "proforma"` guard already scopes it to proformas. Gating on the current
18
+ * mode would strand a proforma that is still outstanding when an operator
19
+ * switches from `proforma-first` to `direct`. When no proforma exists
20
+ * (card checkouts, or bank transfer in `direct` mode) the subscriber
21
+ * simply never fires. The conversion is idempotent (the service guards
22
+ * against double-conversion under an advisory lock).
18
23
  */
19
24
  export declare const FINANCE_PROFORMA_CONVERSION_SUBSCRIBER_ID = "@voyant-travel/finance#subscriber.proforma-conversion";
20
25
  export declare const PROFORMA_CONVERSION_SUBSCRIBER_RUNTIME_KEY = "finance.proformaConversionSubscriberRuntime";
21
26
  export interface ProformaConversionSubscriberRuntime {
22
- /** Operator invoicing mode; defaults to `direct` when unconfigured. */
23
- resolveInvoicingMode(db: PostgresJsDatabase): Promise<InvoicingMode>;
24
27
  /** Resolve the deployment database and retain ownership of its lifecycle. */
25
28
  withDb<T>(bindings: unknown, operation: (db: PostgresJsDatabase) => Promise<T>): Promise<T>;
26
29
  /** Event bus used to re-emit conversion events for downstream plugins. */
@@ -3,17 +3,23 @@ import { convertProformaToInvoice } from "./service-issue.js";
3
3
  /**
4
4
  * Standard proforma → fiscal invoice conversion.
5
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).
6
+ * A proforma is issued for deferred (bank-transfer) checkouts when the
7
+ * operator runs the `proforma-first` invoicing mode, or by the manual
8
+ * `POST /invoices/{id}/convert-to-invoice` flow. This subscriber watches
9
+ * settlement signals (`invoice.settled` from the settlement poller and
10
+ * `invoice.payment.recorded` from manual/processed payments) and converts
11
+ * a fully-paid proforma to its fiscal invoice via
12
+ * {@link convertProformaToInvoice} (which copies lines, assigns the fiscal
13
+ * number, links both documents, and voids the proforma).
14
+ *
15
+ * The conversion is not gated on the invoicing mode: any fully-paid
16
+ * proforma should mint its fiscal invoice, and the `invoiceType ===
17
+ * "proforma"` guard already scopes it to proformas. Gating on the current
18
+ * mode would strand a proforma that is still outstanding when an operator
19
+ * switches from `proforma-first` to `direct`. When no proforma exists
20
+ * (card checkouts, or bank transfer in `direct` mode) the subscriber
21
+ * simply never fires. The conversion is idempotent (the service guards
22
+ * against double-conversion under an advisory lock).
17
23
  */
18
24
  export const FINANCE_PROFORMA_CONVERSION_SUBSCRIBER_ID = "@voyant-travel/finance#subscriber.proforma-conversion";
19
25
  export const PROFORMA_CONVERSION_SUBSCRIBER_RUNTIME_KEY = "finance.proformaConversionSubscriberRuntime";
@@ -26,15 +32,13 @@ export function createProformaConversionSubscriberRuntime(dependencies = {}) {
26
32
  eventType: "invoice.settled",
27
33
  register: ({ bindings, container, eventBus }) => {
28
34
  const handler = async ({ data }) => {
29
- // Runtime is only registered when the operator-settings port is
30
- // available. Absent → treat as `direct` mode → no-op.
35
+ // Runtime is registered by the finance booking-schedule host wiring.
36
+ // Absent → the deployment did not mount finance settlement → no-op.
31
37
  if (!container.has(PROFORMA_CONVERSION_SUBSCRIBER_RUNTIME_KEY))
32
38
  return;
33
39
  const runtime = resolveProformaConversionSubscriberRuntime(container);
34
40
  try {
35
41
  await runtime.withDb(bindings, async (db) => {
36
- if ((await runtime.resolveInvoicingMode(db)) !== "proforma-first")
37
- return;
38
42
  const invoice = await financeService.getInvoiceById(db, data.invoiceId);
39
43
  if (!invoice)
40
44
  return;
@@ -1,7 +1,7 @@
1
1
  import type { VoyantRuntimeHostPrimitives } from "@voyant-travel/core";
2
2
  import type { AnyDrizzleDb } from "@voyant-travel/db";
3
3
  import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
4
- import type { BookingTaxRouteOptions } from "./booking-tax.js";
4
+ import type { BookingTaxRouteOptions, FxReferenceSource } from "./booking-tax.js";
5
5
  import type { CheckoutRoutesOptions } from "./checkout-routes.js";
6
6
  import type { CheckoutPaymentStarter } from "./checkout-service.js";
7
7
  import type { PaymentPolicy } from "./payment-policy.js";
@@ -24,11 +24,50 @@ export interface FinanceOperatorSettingsRuntime {
24
24
  * settled proforma should be auto-converted to a fiscal invoice.
25
25
  */
26
26
  resolveInvoicingMode: (db: PostgresJsDatabase) => Promise<"direct" | "proforma-first">;
27
+ /**
28
+ * Resolve the operator's official FX reference-rate source
29
+ * (`ecb` | `bnr`, default `ecb`). Read by the finance fx-reference
30
+ * helper to pick the host-provided rate source for the operator.
31
+ */
32
+ resolveFxReferenceSource: (db: PostgresJsDatabase) => Promise<FxReferenceSource>;
27
33
  }
28
34
  export interface FinanceNotificationsRuntime {
29
35
  resolveNotificationDispatcher: NonNullable<CheckoutRoutesOptions["resolveNotificationDispatcher"]>;
30
36
  listBookingReminderRuns: NonNullable<CheckoutRoutesOptions["listBookingReminderRuns"]>;
31
37
  }
38
+ /** Request for one official FX reference rate on a given date. */
39
+ export interface FxReferenceRateRequest {
40
+ /** Currency to convert from, e.g. `EUR` (ISO 4217). */
41
+ base: string;
42
+ /** Currency to convert into, e.g. `RON` (ISO 4217). */
43
+ quote: string;
44
+ /** Reference date in `YYYY-MM-DD`. Defaults to the host's latest published rate. */
45
+ date?: string;
46
+ }
47
+ /** One resolved official FX reference rate. */
48
+ export interface FxReferenceRate {
49
+ /** Units of `quote` per one unit of `base`. */
50
+ rate: number;
51
+ /** The reference source that published the rate, e.g. `ecb` or `bnr`. */
52
+ source: FxReferenceSource;
53
+ /** Date the returned rate was published (`YYYY-MM-DD`). */
54
+ asOf: string;
55
+ }
56
+ /**
57
+ * Host-provided official FX reference-rate source. Finance defines the
58
+ * seam only; hosts/deployments wire it to their own FX data source. No
59
+ * HTTP client or API key lives inside finance.
60
+ */
61
+ export interface FinanceFxReferenceRuntime {
62
+ /**
63
+ * Resolve one official reference rate. `source` is the operator's
64
+ * configured reference source (e.g. `ecb`, `bnr`); the host uses it
65
+ * to pick the correct published series.
66
+ */
67
+ resolveReferenceRate(request: FxReferenceRateRequest & {
68
+ source: FxReferenceSource;
69
+ }): Promise<FxReferenceRate>;
70
+ }
32
71
  export interface FinanceDistributionPaymentPolicyRuntime {
33
72
  resolveSupplierPolicy: PolicyReader;
34
73
  resolveSupplierPolicyById(db: PostgresJsDatabase, supplierId: string): Promise<PaymentPolicy | null>;
@@ -67,6 +106,35 @@ export interface FinanceBookingScheduleRuntime {
67
106
  export declare const financeHostRuntimePort: import("@voyant-travel/core/project").VoyantPort<FinanceHostRuntime>;
68
107
  export declare const financeOperatorSettingsRuntimePort: import("@voyant-travel/core/project").VoyantPort<FinanceOperatorSettingsRuntime>;
69
108
  export declare const financeNotificationsRuntimePort: import("@voyant-travel/core/project").VoyantPort<FinanceNotificationsRuntime>;
109
+ export declare const financeFxReferenceRuntimePort: import("@voyant-travel/core/project").VoyantPort<FinanceFxReferenceRuntime>;
110
+ /**
111
+ * Raised when a caller explicitly requests an official FX reference
112
+ * rate but no `finance.fx-reference.runtime` provider is wired. Callers
113
+ * that never request a reference rate are unaffected — the seam is
114
+ * inert until used.
115
+ */
116
+ export declare class FinanceFxReferenceSourceUnavailableError extends Error {
117
+ readonly code = "finance_fx_reference_source_unavailable";
118
+ constructor(source: FxReferenceSource);
119
+ }
120
+ export interface ResolveReferenceRateHelperInput {
121
+ base: string;
122
+ quote: string;
123
+ date?: string;
124
+ /** The operator's configured reference source (e.g. `ecb`, `bnr`). */
125
+ source: FxReferenceSource;
126
+ /** Host-provided implementation. Absent → typed unavailable error. */
127
+ provider?: FinanceFxReferenceRuntime | null;
128
+ }
129
+ /**
130
+ * Typed helper that resolves an official FX reference rate for an
131
+ * operator's configured source by delegating to the host-provided
132
+ * `finance.fx-reference.runtime` implementation. When no provider is
133
+ * wired, throws {@link FinanceFxReferenceSourceUnavailableError} — a
134
+ * clear, typed signal rather than a silent fallback. Finance holds no
135
+ * FX data itself.
136
+ */
137
+ export declare function resolveReferenceRate(input: ResolveReferenceRateHelperInput): Promise<FxReferenceRate>;
70
138
  export declare const financeDistributionPaymentPolicyRuntimePort: import("@voyant-travel/core/project").VoyantPort<FinanceDistributionPaymentPolicyRuntime>;
71
139
  export declare const financeAccommodationsPaymentPolicyRuntimePort: import("@voyant-travel/core/project").VoyantPort<FinanceAccommodationsPaymentPolicyRuntime>;
72
140
  export declare const financeCruisesPaymentPolicyRuntimePort: import("@voyant-travel/core/project").VoyantPort<FinanceCruisesPaymentPolicyRuntime>;
@@ -20,8 +20,38 @@ export const financeOperatorSettingsRuntimePort = objectPort("finance.operator-s
20
20
  "resolveBookingTaxSettings",
21
21
  "updateBookingTaxSettings",
22
22
  "resolveInvoicingMode",
23
+ "resolveFxReferenceSource",
23
24
  ]);
24
25
  export const financeNotificationsRuntimePort = objectPort("finance.notifications.runtime", ["resolveNotificationDispatcher", "listBookingReminderRuns"]);
26
+ export const financeFxReferenceRuntimePort = objectPort("finance.fx-reference.runtime", ["resolveReferenceRate"]);
27
+ /**
28
+ * Raised when a caller explicitly requests an official FX reference
29
+ * rate but no `finance.fx-reference.runtime` provider is wired. Callers
30
+ * that never request a reference rate are unaffected — the seam is
31
+ * inert until used.
32
+ */
33
+ export class FinanceFxReferenceSourceUnavailableError extends Error {
34
+ code = "finance_fx_reference_source_unavailable";
35
+ constructor(source) {
36
+ super(`No FX reference-rate source is configured for "${source}". A host must provide the finance.fx-reference.runtime port.`);
37
+ this.name = "FinanceFxReferenceSourceUnavailableError";
38
+ }
39
+ }
40
+ /**
41
+ * Typed helper that resolves an official FX reference rate for an
42
+ * operator's configured source by delegating to the host-provided
43
+ * `finance.fx-reference.runtime` implementation. When no provider is
44
+ * wired, throws {@link FinanceFxReferenceSourceUnavailableError} — a
45
+ * clear, typed signal rather than a silent fallback. Finance holds no
46
+ * FX data itself.
47
+ */
48
+ export function resolveReferenceRate(input) {
49
+ const { provider, source, base, quote, date } = input;
50
+ if (!provider) {
51
+ throw new FinanceFxReferenceSourceUnavailableError(source);
52
+ }
53
+ return provider.resolveReferenceRate({ base, quote, date, source });
54
+ }
25
55
  export const financeDistributionPaymentPolicyRuntimePort = objectPort("finance.distribution-payment-policy.runtime", ["resolveSupplierPolicy", "resolveSupplierPolicyById"]);
26
56
  export const financeAccommodationsPaymentPolicyRuntimePort = objectPort("finance.accommodations-payment-policy.runtime", ["resolveBookingPolicy", "resolveEntityPolicy"]);
27
57
  export const financeCruisesPaymentPolicyRuntimePort = objectPort("finance.cruises-payment-policy.runtime", [
@@ -9,6 +9,7 @@ export const financePaymentSessionCompletionService = {
9
9
  if (!session) {
10
10
  return null;
11
11
  }
12
+ const shouldEmitPaymentCompleted = data.status === "paid" && session.status !== "paid";
12
13
  const txResult = await db.transaction(async (tx) => {
13
14
  let authorizationId = session.paymentAuthorizationId;
14
15
  let captureId = session.paymentCaptureId;
@@ -257,7 +258,7 @@ export const financePaymentSessionCompletionService = {
257
258
  // Some aggregate flows, such as composed trips, intentionally use a
258
259
  // generic target instead of booking/order/invoice columns; those still
259
260
  // need the completion event keyed by targetType/targetId.
260
- if (data.status === "paid" && txResult.updated) {
261
+ if (shouldEmitPaymentCompleted && txResult.updated) {
261
262
  await runtime.eventBus?.emit("payment.completed", buildPaymentCompletedEvent(txResult.updated), {
262
263
  category: "domain",
263
264
  source: "service",
@@ -36,7 +36,8 @@
36
36
  "properties": {
37
37
  "taxPriceMode": { "type": "string", "enum": ["inclusive", "exclusive"] },
38
38
  "taxPolicyProfileId": { "type": ["string", "null"] },
39
- "invoicingMode": { "type": "string", "enum": ["direct", "proforma-first"] }
39
+ "invoicingMode": { "type": "string", "enum": ["direct", "proforma-first"] },
40
+ "fxReferenceSource": { "type": "string", "enum": ["ecb", "bnr"] }
40
41
  }
41
42
  }
42
43
  }
@@ -76,11 +77,12 @@
76
77
  "schemas": {
77
78
  "BookingTaxSettings": {
78
79
  "type": "object",
79
- "required": ["taxPriceMode", "taxPolicyProfileId", "invoicingMode"],
80
+ "required": ["taxPriceMode", "taxPolicyProfileId", "invoicingMode", "fxReferenceSource"],
80
81
  "properties": {
81
82
  "taxPriceMode": { "type": "string", "enum": ["inclusive", "exclusive"] },
82
83
  "taxPolicyProfileId": { "type": ["string", "null"] },
83
- "invoicingMode": { "type": "string", "enum": ["direct", "proforma-first"] }
84
+ "invoicingMode": { "type": "string", "enum": ["direct", "proforma-first"] },
85
+ "fxReferenceSource": { "type": "string", "enum": ["ecb", "bnr"] }
84
86
  }
85
87
  }
86
88
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voyant-travel/finance",
3
- "version": "0.165.0",
3
+ "version": "0.167.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -151,12 +151,13 @@
151
151
  "hono": "^4.12.27",
152
152
  "zod": "^4.4.3",
153
153
  "@voyant-travel/action-ledger": "^0.111.1",
154
- "@voyant-travel/bookings": "^0.165.0",
154
+ "@voyant-travel/bookings": "^0.167.0",
155
155
  "@voyant-travel/core": "^0.125.0",
156
156
  "@voyant-travel/db": "^0.114.10",
157
157
  "@voyant-travel/types": "^0.109.3",
158
158
  "@voyant-travel/finance-contracts": "^0.106.2",
159
159
  "@voyant-travel/hono": "^0.128.1",
160
+ "@voyant-travel/payments": "^0.2.1",
160
161
  "@voyant-travel/public-document-delivery": "^0.4.1",
161
162
  "@voyant-travel/storage": "^0.111.1",
162
163
  "@voyant-travel/utils": "^0.107.1",