From c7ebce2bb8d52b2bc08b8822a5711806058655be Mon Sep 17 00:00:00 2001 From: Flamur Maliqi Date: Thu, 13 Aug 2026 18:57:33 +0300 Subject: [PATCH] feat(finance): expose Booking publication snapshot [VAY-1060] --- .../financeBookingPublicationSnapshot.ts | 93 ++++++++++++++++ .../financeBookingPublicationSnapshot.test.ts | 103 ++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 apps/api/src/domains/financeBookingPublicationSnapshot.ts create mode 100644 apps/api/src/financeBookingPublicationSnapshot.test.ts diff --git a/apps/api/src/domains/financeBookingPublicationSnapshot.ts b/apps/api/src/domains/financeBookingPublicationSnapshot.ts new file mode 100644 index 0000000000..71099797f7 --- /dev/null +++ b/apps/api/src/domains/financeBookingPublicationSnapshot.ts @@ -0,0 +1,93 @@ +import { + createFinancePaymentMethodsSourceEntityRevision, + parseFinancePaymentReadinessSnapshot, + type FinancePaymentReadinessReadPort, +} from "@vayada/domain-finance"; +import { + BOOKING_OWNER_SNAPSHOT_VERSION, + type BookingPublicationOwnerSnapshotPort, +} from "@vayada/domain-distribution/booking-publication-owner-snapshots"; + +export function createFinanceBookingPublicationSnapshotPort(dependencies: { + financeReadPort: FinancePaymentReadinessReadPort; +}): BookingPublicationOwnerSnapshotPort<"finance"> { + return { + owner: "finance", + async getSnapshot(request) { + try { + const value = await dependencies.financeReadPort.getPaymentReadiness({ + organizationId: request.organizationId, + propertyId: request.propertyId, + }); + const snapshot = value === null ? null : parseFinancePaymentReadinessSnapshot(value); + if ( + !snapshot || + snapshot.propertyId !== request.propertyId || + !snapshot.updatedAt || + !snapshot.bookingPaymentReady || + !snapshot.pricingCurrency.committed || + !snapshot.pricingCurrency.matchesCurrent + ) { + return unavailable(); + } + const source = createFinancePaymentMethodsSourceEntityRevision( + snapshot.propertyId, + snapshot.paymentMethodsRevision, + ); + const expectedSources = request.sourceManifest.sources.filter( + ({ ownerDomain }) => ownerDomain === "finance", + ); + if (expectedSources.length !== 1 || sourceKey(expectedSources[0]!) !== sourceKey(source)) { + return unavailable(); + } + const readyPaymentMethods = snapshot.methods + .filter( + ({ method, selected, availability, readiness }) => + (method === "card" || method === "pay_at_property") && + selected && + availability === "available" && + readiness === "ready", + ) + .map(({ method }) => method as "card" | "pay_at_property"); + if (readyPaymentMethods.length === 0) return unavailable(); + const currency = snapshot.pricingCurrency.committed.currency; + return deepFreeze({ + outcome: "snapshot", + contractVersion: BOOKING_OWNER_SNAPSHOT_VERSION, + owner: "finance", + organizationId: request.organizationId, + propertyId: request.propertyId, + sourceManifestHash: request.sourceManifestHash, + resolvedSources: [source], + content: { + defaultCurrency: currency, + supportedCurrencies: [currency], + onlinePayment: readyPaymentMethods.includes("card"), + payAtProperty: readyPaymentMethods.includes("pay_at_property"), + readyPaymentMethods, + freshness: { status: "fresh", lastUpdatedAt: snapshot.updatedAt }, + }, + }); + } catch { + return unavailable(); + } + }, + }; +} + +const sourceKey = (source: { + ownerDomain: string; + entityType: string; + entityId: string; + revision: string; +}) => JSON.stringify([source.ownerDomain, source.entityType, source.entityId, source.revision]); + +const unavailable = () => ({ outcome: "unavailable" as const, owner: "finance" as const }); + +function deepFreeze(value: T): T { + if (value && typeof value === "object" && !Object.isFrozen(value)) { + Object.freeze(value); + for (const nested of Object.values(value)) deepFreeze(nested); + } + return value; +} diff --git a/apps/api/src/financeBookingPublicationSnapshot.test.ts b/apps/api/src/financeBookingPublicationSnapshot.test.ts new file mode 100644 index 0000000000..6f19690df1 --- /dev/null +++ b/apps/api/src/financeBookingPublicationSnapshot.test.ts @@ -0,0 +1,103 @@ +import { hashSourceManifest, type SourceEntityRevision } from "@vayada/domain-hotels"; +import { + createFinancePaymentReadinessSnapshot, + type FinancePaymentReadinessReadPort, +} from "@vayada/domain-finance"; +import { bookingPublicationOwnerSnapshotProvenanceMatches } from "@vayada/domain-distribution/booking-publication-owner-snapshots"; +import { describe, expect, it } from "vitest"; + +import { createFinanceBookingPublicationSnapshotPort } from "./domains/financeBookingPublicationSnapshot.js"; + +const organizationId = "123e4567-e89b-42d3-a456-426614174000"; +const propertyId = "223e4567-e89b-42d3-a456-426614174000"; +const source: SourceEntityRevision = { + ownerDomain: "finance", + entityType: "finance_payment_methods.v1", + entityId: propertyId, + revision: "4", +}; + +describe("Finance Booking publication snapshot", () => { + it("binds ready public methods and currency to the exact accepted Finance revision", async () => { + const request = await snapshotRequest([source]); + const result = await port(readySnapshot()).getSnapshot(request); + + expect(result).toMatchObject({ + outcome: "snapshot", + owner: "finance", + organizationId, + propertyId, + sourceManifestHash: request.sourceManifestHash, + resolvedSources: [source], + content: { + defaultCurrency: "EUR", + supportedCurrencies: ["EUR"], + onlinePayment: false, + payAtProperty: true, + readyPaymentMethods: ["pay_at_property"], + freshness: { status: "fresh", lastUpdatedAt: "2026-08-03T12:00:00.000Z" }, + }, + }); + expect(bookingPublicationOwnerSnapshotProvenanceMatches(result, "finance", request)).toBe(true); + }); + + it("fails closed for stale, missing, or provider-failed Finance evidence", async () => { + const stale = await snapshotRequest([{ ...source, revision: "3" }]); + await expect(port(readySnapshot()).getSnapshot(stale)).resolves.toEqual({ + outcome: "unavailable", + owner: "finance", + }); + await expect(port(null).getSnapshot(await snapshotRequest([source]))).resolves.toEqual({ + outcome: "unavailable", + owner: "finance", + }); + await expect( + createFinanceBookingPublicationSnapshotPort({ + financeReadPort: { + async getPaymentReadiness() { + throw new Error("database unavailable"); + }, + }, + }).getSnapshot(await snapshotRequest([source])), + ).resolves.toEqual({ outcome: "unavailable", owner: "finance" }); + }); +}); + +function port(value: ReturnType | null) { + const financeReadPort: FinancePaymentReadinessReadPort = { + async getPaymentReadiness() { + return value; + }, + }; + return createFinanceBookingPublicationSnapshotPort({ financeReadPort }); +} + +function readySnapshot() { + const pricing = { + contractVersion: "pms-pricing.v1", + currency: "EUR", + pricingCurrencyRevision: 7, + }; + return createFinancePaymentReadinessSnapshot({ + propertyId, + paymentMethodsRevision: 4, + selectedMethods: ["pay_at_property", "card"], + committedPricing: pricing, + currentPricing: pricing, + updatedAt: "2026-08-03T12:00:00.000Z", + }); +} + +async function snapshotRequest(sources: readonly SourceEntityRevision[]) { + const sourceManifest = { + contractVersion: "onboarding-source-manifest.v1" as const, + propertyId, + sources, + }; + return { + organizationId, + propertyId, + sourceManifest, + sourceManifestHash: await hashSourceManifest(sourceManifest), + }; +}