@voyant-travel/apps 0.6.2 → 0.7.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,12 +1,16 @@
1
1
  import { defineGraphRuntimeFactory } from "@voyant-travel/core/project";
2
2
  import { customFieldValueLifecycleRuntimePort, customFieldValueOperationsRuntimePort, } from "@voyant-travel/core/runtime-port";
3
3
  import { createCustomFieldTargetRegistry } from "@voyant-travel/custom-fields";
4
+ import { financeAppApiRuntimePort } from "@voyant-travel/finance-contracts/app-api";
4
5
  import { createAppsAppApiRoutes } from "./app-api-routes.js";
5
6
  import { createAppsAdminRoutes } from "./routes.js";
6
- export const createAppsApiModule = defineGraphRuntimeFactory(async ({ getPorts, graph }) => {
7
+ export const createAppsApiModule = defineGraphRuntimeFactory(async ({ getPort, getPorts, graph, hasPort }) => {
7
8
  const customFieldTargets = createCustomFieldTargetRegistry(graph.customFieldTargets ?? []);
8
9
  const customFieldValueLifecycles = await getPorts(customFieldValueLifecycleRuntimePort);
9
10
  const customFieldValueOperations = await getPorts(customFieldValueOperationsRuntimePort);
11
+ const finance = hasPort(financeAppApiRuntimePort)
12
+ ? await getPort(financeAppApiRuntimePort)
13
+ : undefined;
10
14
  return {
11
15
  module: { name: "apps" },
12
16
  adminRoutes: createAppsAdminRoutes({ eventCatalog: graph.eventCatalog }),
@@ -16,6 +20,7 @@ export const createAppsApiModule = defineGraphRuntimeFactory(async ({ getPorts,
16
20
  customFieldTargets,
17
21
  customFieldValueLifecycles,
18
22
  customFieldValueOperations,
23
+ finance,
19
24
  }),
20
25
  },
21
26
  };
@@ -1,4 +1,5 @@
1
1
  import { customFieldDefinitionInputSchema, customFieldDefinitionListQuerySchema, customFieldValueListQuerySchema, updateCustomFieldDefinitionSchema, upsertCustomFieldValueSchema } from "@voyant-travel/custom-fields";
2
+ import type { FinanceAppApiExternalReference, FinanceAppApiIssuanceDocument } from "@voyant-travel/finance-contracts/app-api";
2
3
  import { z } from "zod";
3
4
  export declare const APP_API_VERSION = "2026-07-01";
4
5
  export declare const appApiVersionHeader = "voyant-app-api-version";
@@ -24,6 +25,29 @@ export declare const appApiFinanceActionSchema: z.ZodObject<{
24
25
  idempotencyKey: z.ZodString;
25
26
  payload: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
26
27
  }, z.core.$strict>;
28
+ export declare const appApiFinanceExternalReferenceSchema: z.ZodObject<{
29
+ externalId: z.ZodNullable<z.ZodString>;
30
+ externalNumber: z.ZodNullable<z.ZodString>;
31
+ externalUrl: z.ZodNullable<z.ZodString>;
32
+ status: z.ZodNullable<z.ZodString>;
33
+ metadata: z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
34
+ syncedAt: z.ZodNullable<z.ZodString>;
35
+ syncError: z.ZodNullable<z.ZodString>;
36
+ }, z.core.$strict>;
37
+ export declare const appApiFinanceExternalReferenceUpsertSchema: z.ZodObject<{
38
+ reference: z.ZodObject<{
39
+ externalId: z.ZodNullable<z.ZodString>;
40
+ externalNumber: z.ZodNullable<z.ZodString>;
41
+ externalUrl: z.ZodNullable<z.ZodString>;
42
+ status: z.ZodNullable<z.ZodString>;
43
+ metadata: z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
44
+ syncedAt: z.ZodNullable<z.ZodString>;
45
+ syncError: z.ZodNullable<z.ZodString>;
46
+ }, z.core.$strict>;
47
+ allocation: z.ZodOptional<z.ZodObject<{
48
+ invoiceNumber: z.ZodString;
49
+ }, z.core.$strict>>;
50
+ }, z.core.$strict>;
27
51
  export declare const appApiWebhookReplaySchema: z.ZodObject<{
28
52
  deliveryId: z.ZodString;
29
53
  signingKeyId: z.ZodString;
@@ -47,5 +71,8 @@ export { customFieldDefinitionInputSchema as appApiCustomFieldDefinitionCreateSc
47
71
  export type AppApiEntityReadQuery = z.infer<typeof appApiEntityReadQuerySchema>;
48
72
  export type AppApiFinanceDocumentQuery = z.infer<typeof appApiFinanceDocumentQuerySchema>;
49
73
  export type AppApiFinanceActionInput = z.infer<typeof appApiFinanceActionSchema>;
74
+ export type AppApiFinanceExternalReferenceUpsertInput = z.infer<typeof appApiFinanceExternalReferenceUpsertSchema>;
50
75
  export type AppApiWebhookReplayInput = z.infer<typeof appApiWebhookReplaySchema>;
51
76
  export type AppApiAuditQuery = z.infer<typeof appApiAuditQuerySchema>;
77
+ export type AppApiFinanceIssuanceDocument = FinanceAppApiIssuanceDocument;
78
+ export type AppApiFinanceExternalReference = FinanceAppApiExternalReference;
@@ -22,6 +22,34 @@ export const appApiFinanceActionSchema = z
22
22
  payload: z.record(z.string(), z.unknown()).default({}),
23
23
  })
24
24
  .strict();
25
+ export const appApiFinanceExternalReferenceSchema = z
26
+ .object({
27
+ externalId: z.string().min(1).max(500).nullable(),
28
+ externalNumber: z.string().min(1).max(500).nullable(),
29
+ externalUrl: z
30
+ .string()
31
+ .url()
32
+ .max(2_048)
33
+ .refine((value) => new URL(value).protocol === "https:", "External URL must use HTTPS.")
34
+ .nullable(),
35
+ status: z.string().min(1).max(100).nullable(),
36
+ metadata: z
37
+ .record(z.string().min(1).max(100), z.unknown())
38
+ .refine((value) => JSON.stringify(value).length <= 16_384, "Metadata is too large.")
39
+ .nullable(),
40
+ syncedAt: z.string().datetime().nullable(),
41
+ syncError: z.string().max(4_000).nullable(),
42
+ })
43
+ .strict();
44
+ export const appApiFinanceExternalReferenceUpsertSchema = z
45
+ .object({
46
+ reference: appApiFinanceExternalReferenceSchema,
47
+ allocation: z
48
+ .object({ invoiceNumber: z.string().trim().min(1).max(500) })
49
+ .strict()
50
+ .optional(),
51
+ })
52
+ .strict();
25
53
  export const appApiWebhookReplaySchema = z
26
54
  .object({
27
55
  deliveryId: z.string().min(1),
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,153 @@
1
+ import { Hono } from "hono";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { createAppsAppApiRoutes } from "./app-api-routes.js";
4
+ import { appGrants, appInstallations, appReleases } from "./schema.js";
5
+ function accessDb(scopes = ["finance-documents:read"]) {
6
+ const db = Object.create(null);
7
+ Object.assign(db, {
8
+ select: () => ({
9
+ from: (table) => ({
10
+ where: () => {
11
+ const rows = () => {
12
+ if (table === appInstallations) {
13
+ return [
14
+ {
15
+ id: "inst_1",
16
+ appId: "app_1",
17
+ deploymentId: "dep_1",
18
+ releaseId: "rel_1",
19
+ status: "active",
20
+ namespace: "app--one",
21
+ },
22
+ ];
23
+ }
24
+ if (table === appReleases) {
25
+ return [
26
+ {
27
+ id: "rel_1",
28
+ appId: "app_1",
29
+ releaseVersion: "1.0.0",
30
+ apiCompatibility: { min: "2026-07-01", max: "2026-12-31" },
31
+ },
32
+ ];
33
+ }
34
+ if (table === appGrants)
35
+ return scopes.map((scope) => ({ scope }));
36
+ return [];
37
+ };
38
+ return {
39
+ // biome-ignore lint/suspicious/noThenProperty: models Drizzle's awaitable query builder.
40
+ then: (resolve) => Promise.resolve(rows()).then(resolve),
41
+ limit: async () => rows(),
42
+ };
43
+ },
44
+ }),
45
+ }),
46
+ });
47
+ return db;
48
+ }
49
+ describe("finance App API routes", () => {
50
+ it("hydrates a finance issuance document by stable id", async () => {
51
+ const getIssuanceDocument = vi.fn().mockResolvedValue({ id: "inv_1" });
52
+ const app = new Hono();
53
+ app.use("*", async (c, next) => {
54
+ c.set("db", accessDb());
55
+ c.set("callerType", "app");
56
+ c.set("appId", "app_1");
57
+ c.set("appInstallationId", "inst_1");
58
+ c.set("appReleaseId", "rel_1");
59
+ c.set("appTokenMode", "offline");
60
+ c.set("scopes", ["finance-documents:read"]);
61
+ await next();
62
+ });
63
+ app.route("/", createAppsAppApiRoutes({ finance: { getIssuanceDocument } }));
64
+ const response = await app.request("/v1/app/finance/documents/inv_1");
65
+ expect(response.status).toBe(200);
66
+ await expect(response.json()).resolves.toEqual({ data: { id: "inv_1" } });
67
+ });
68
+ it("does not expose a caller-selected provider reference route", async () => {
69
+ const getExternalReference = vi.fn().mockResolvedValue({ id: "ref_1" });
70
+ const upsertExternalReference = vi.fn();
71
+ const app = new Hono();
72
+ app.use("*", async (c, next) => {
73
+ c.set("db", accessDb(["finance-external-references:read"]));
74
+ c.set("callerType", "app");
75
+ c.set("appId", "app_1");
76
+ c.set("appInstallationId", "inst_1");
77
+ c.set("appReleaseId", "rel_1");
78
+ c.set("appTokenMode", "offline");
79
+ c.set("scopes", ["finance-external-references:read"]);
80
+ await next();
81
+ });
82
+ app.route("/", createAppsAppApiRoutes({ finance: { getExternalReference, upsertExternalReference } }));
83
+ const ownedResponse = await app.request("/v1/app/finance/documents/inv_1/external-reference");
84
+ expect(ownedResponse.status).toBe(200);
85
+ expect(getExternalReference).toHaveBeenCalledWith(expect.anything(), "inv_1", "app_1");
86
+ const response = await app.request("/v1/app/finance/documents/inv_1/external-references/another-provider", {
87
+ method: "PUT",
88
+ headers: { "content-type": "application/json" },
89
+ body: JSON.stringify({
90
+ reference: {
91
+ externalId: null,
92
+ externalNumber: null,
93
+ externalUrl: null,
94
+ status: null,
95
+ metadata: null,
96
+ syncedAt: null,
97
+ syncError: null,
98
+ },
99
+ }),
100
+ });
101
+ expect(response.status).toBe(404);
102
+ expect(upsertExternalReference).not.toHaveBeenCalled();
103
+ });
104
+ it("atomically writes the owned reference, allocation, and audit through HTTP", async () => {
105
+ const scopes = ["finance-external-references:write", "finance-external-allocation:write"];
106
+ const db = accessDb(scopes);
107
+ const auditValues = vi.fn().mockResolvedValue(undefined);
108
+ Object.assign(db, {
109
+ transaction: (callback) => callback(db),
110
+ insert: () => ({ values: auditValues }),
111
+ });
112
+ const upsertExternalReference = vi.fn().mockResolvedValue({
113
+ status: "ok",
114
+ reference: { id: "ref_1" },
115
+ referenceOutcome: "created",
116
+ allocationOutcome: "applied",
117
+ });
118
+ const app = new Hono();
119
+ app.use("*", async (c, next) => {
120
+ c.set("db", db);
121
+ c.set("callerType", "app");
122
+ c.set("appId", "app_1");
123
+ c.set("appInstallationId", "inst_1");
124
+ c.set("appReleaseId", "rel_1");
125
+ c.set("appTokenMode", "offline");
126
+ c.set("scopes", scopes);
127
+ await next();
128
+ });
129
+ app.route("/", createAppsAppApiRoutes({ finance: { upsertExternalReference } }));
130
+ const response = await app.request("/v1/app/finance/documents/inv_1/external-reference", {
131
+ method: "PUT",
132
+ headers: { "content-type": "application/json" },
133
+ body: JSON.stringify({
134
+ reference: {
135
+ externalId: "remote_1",
136
+ externalNumber: "SB-42",
137
+ externalUrl: null,
138
+ status: "issued",
139
+ metadata: null,
140
+ syncedAt: null,
141
+ syncError: null,
142
+ },
143
+ allocation: { invoiceNumber: "SB-42" },
144
+ }),
145
+ });
146
+ expect(response.status).toBe(200);
147
+ expect(upsertExternalReference).toHaveBeenCalledWith(db, "inv_1", "app_1", expect.anything());
148
+ expect(auditValues).toHaveBeenCalledWith(expect.objectContaining({
149
+ appId: "app_1",
150
+ action: "finance.external-reference.upserted",
151
+ }));
152
+ });
153
+ });
@@ -1,7 +1,7 @@
1
1
  import { ApiHttpError, parseJsonBody, parseQuery, RequestValidationError, } from "@voyant-travel/hono";
2
2
  import { Hono } from "hono";
3
3
  import { z } from "zod";
4
- import { appApiAuditQuerySchema, appApiCustomFieldDefinitionCreateSchema, appApiCustomFieldDefinitionListQuerySchema, appApiCustomFieldDefinitionUpdateSchema, appApiCustomFieldValueListQuerySchema, appApiCustomFieldValueUpsertSchema, appApiEntityReadQuerySchema, appApiFinanceActionSchema, appApiFinanceDocumentQuerySchema, appApiTokenExchangeSchema, appApiVersionHeader, appApiWebhookReplaySchema, } from "./app-api-contracts.js";
4
+ import { appApiAuditQuerySchema, appApiCustomFieldDefinitionCreateSchema, appApiCustomFieldDefinitionListQuerySchema, appApiCustomFieldDefinitionUpdateSchema, appApiCustomFieldValueListQuerySchema, appApiCustomFieldValueUpsertSchema, appApiEntityReadQuerySchema, appApiFinanceActionSchema, appApiFinanceDocumentQuerySchema, appApiFinanceExternalReferenceUpsertSchema, appApiTokenExchangeSchema, appApiVersionHeader, appApiWebhookReplaySchema, } from "./app-api-contracts.js";
5
5
  import { createAppApiService, withAppApiDeadline, } from "./app-api-service.js";
6
6
  import { createAppOAuthService } from "./oauth-service.js";
7
7
  import { replayAppWebhookDelivery } from "./webhook-delivery.js";
@@ -50,6 +50,19 @@ export function createAppsAppApiRoutes(options = {}) {
50
50
  return run(c, service.listEntities(c.get("db"), appContext(c), entity, query), options.deadlineMs);
51
51
  });
52
52
  routes.get("/finance/documents", (c) => run(c, service.listFinanceDocuments(c.get("db"), appContext(c), parseQuery(c, appApiFinanceDocumentQuerySchema)), options.deadlineMs));
53
+ routes.get("/finance/documents/:id", (c) => {
54
+ const { id } = parseIdParams(c.req.param());
55
+ return run(c, service.getFinanceIssuanceDocument(c.get("db"), appContext(c), id), options.deadlineMs);
56
+ });
57
+ routes.get("/finance/documents/:id/external-reference", (c) => {
58
+ const { id } = parseIdParams(c.req.param());
59
+ return run(c, service.getFinanceExternalReference(c.get("db"), appContext(c), id), options.deadlineMs);
60
+ });
61
+ routes.put("/finance/documents/:id/external-reference", async (c) => {
62
+ const { id } = parseIdParams(c.req.param());
63
+ const body = await parseJsonBody(c, appApiFinanceExternalReferenceUpsertSchema);
64
+ return run(c, service.upsertFinanceExternalReference(c.get("db"), appContext(c), id, body), options.deadlineMs);
65
+ });
53
66
  routes.post("/finance/actions", async (c) => {
54
67
  const body = await parseJsonBody(c, appApiFinanceActionSchema);
55
68
  return run(c, service.executeFinanceAction(c.get("db"), appContext(c), body), options.deadlineMs);
@@ -1,7 +1,8 @@
1
1
  import type { CustomFieldValueLifecycleRuntime, CustomFieldValueOperationsRuntime } from "@voyant-travel/core/runtime-port";
2
2
  import { type CustomFieldDefinitionOwner, createCustomFieldsService } from "@voyant-travel/custom-fields";
3
+ import { type FinanceAppApiRuntime } from "@voyant-travel/finance-contracts/app-api";
3
4
  import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
4
- import type { AppApiAuditQuery, AppApiEntityReadQuery, AppApiFinanceActionInput, AppApiFinanceDocumentQuery } from "./app-api-contracts.js";
5
+ import type { AppApiAuditQuery, AppApiEntityReadQuery, AppApiFinanceActionInput, AppApiFinanceDocumentQuery, AppApiFinanceExternalReferenceUpsertInput } from "./app-api-contracts.js";
5
6
  export interface AppApiAccessContext {
6
7
  appId: string;
7
8
  installationId: string;
@@ -14,9 +15,9 @@ export interface AppApiAccessContext {
14
15
  export interface AppApiEntityReader {
15
16
  list(db: PostgresJsDatabase, query: AppApiEntityReadQuery): Promise<unknown>;
16
17
  }
17
- export interface AppApiFinanceRuntime {
18
- listDocuments(db: PostgresJsDatabase, query: AppApiFinanceDocumentQuery): Promise<unknown>;
19
- executeAction(db: PostgresJsDatabase, input: AppApiFinanceActionInput): Promise<unknown>;
18
+ export interface AppApiFinanceRuntime extends Partial<FinanceAppApiRuntime> {
19
+ listDocuments?(db: PostgresJsDatabase, query: AppApiFinanceDocumentQuery): Promise<unknown>;
20
+ executeAction?(db: PostgresJsDatabase, input: AppApiFinanceActionInput): Promise<unknown>;
20
21
  }
21
22
  export interface AppApiRateLimitPolicy {
22
23
  installationLimit: number;
@@ -63,9 +64,23 @@ export declare function createAppApiService(options?: AppApiServiceOptions): {
63
64
  listFinanceDocuments: (db: PostgresJsDatabase, context: AppApiAccessContext, query: AppApiFinanceDocumentQuery) => Promise<{
64
65
  data: unknown;
65
66
  }>;
67
+ getFinanceIssuanceDocument: (db: PostgresJsDatabase, context: AppApiAccessContext, documentId: string) => Promise<{
68
+ data: import("@voyant-travel/finance-contracts/app-api").FinanceAppApiIssuanceDocument;
69
+ }>;
66
70
  executeFinanceAction: (db: PostgresJsDatabase, context: AppApiAccessContext, input: AppApiFinanceActionInput) => Promise<{
67
71
  data: unknown;
68
72
  }>;
73
+ getFinanceExternalReference: (db: PostgresJsDatabase, context: AppApiAccessContext, documentId: string) => Promise<{
74
+ data: import("@voyant-travel/finance-contracts/app-api").FinanceAppApiExternalReference;
75
+ }>;
76
+ upsertFinanceExternalReference: (db: PostgresJsDatabase, context: AppApiAccessContext, documentId: string, input: AppApiFinanceExternalReferenceUpsertInput) => Promise<{
77
+ data: {
78
+ status: "ok";
79
+ reference: import("@voyant-travel/finance-contracts/app-api").FinanceAppApiExternalReference;
80
+ referenceOutcome: "created" | "updated" | "unchanged";
81
+ allocationOutcome: "not_requested" | "applied" | "already_applied";
82
+ };
83
+ }>;
69
84
  listCustomFieldDefinitions: (db: PostgresJsDatabase, context: AppApiAccessContext, query: Parameters<NonNullable<{
70
85
  list(db: PostgresJsDatabase, query: import("@voyant-travel/custom-fields").CustomFieldDefinitionListQuery): Promise<{
71
86
  data: {
@@ -1,8 +1,10 @@
1
1
  import { createAppCustomFieldDefinitionOwner, createCustomFieldsService, } from "@voyant-travel/custom-fields";
2
+ import { FinanceAppApiNumberConflictError, } from "@voyant-travel/finance-contracts/app-api";
2
3
  import { ApiHttpError } from "@voyant-travel/hono";
3
4
  import { and, asc, eq, sql } from "drizzle-orm";
4
5
  import { assertActiveAppInstallationAccess } from "./access-boundary.js";
5
6
  import { APP_API_VERSION } from "./app-api-contracts.js";
7
+ import { audit } from "./installation-reconciliation.js";
6
8
  import { appAuditEvents, appGrants, appReleases, appWebhookSubscriptions } from "./schema.js";
7
9
  const DEFAULT_RATE_LIMIT = { installationLimit: 120, appLimit: 600, windowMs: 60_000 };
8
10
  const REMOTE_APP_NAMESPACE = /^\$app(?::[a-z][a-z0-9-]*)?$/;
@@ -52,12 +54,26 @@ export function createAppApiService(options = {}) {
52
54
  await requireAccess(db, context, ["finance-documents:read"]);
53
55
  if (!options.finance)
54
56
  throw notSupported("Finance App API runtime is not configured.");
57
+ if (!options.finance.listDocuments)
58
+ throw notSupported("Finance document listing is unavailable.");
55
59
  return { data: await options.finance.listDocuments(db, query) };
56
60
  }
61
+ async function getFinanceIssuanceDocument(db, context, documentId) {
62
+ await requireAccess(db, context, ["finance-documents:read"]);
63
+ const getDocument = options.finance?.getIssuanceDocument;
64
+ if (!getDocument)
65
+ throw notSupported("Finance document hydration is unavailable.");
66
+ const data = await getDocument(db, documentId);
67
+ if (!data)
68
+ throw notFound("Finance document not found.");
69
+ return { data };
70
+ }
57
71
  async function executeFinanceAction(db, context, input) {
58
72
  await requireAccess(db, context, [`finance-actions:${input.action}`]);
59
73
  if (!options.finance)
60
74
  throw notSupported("Finance App API runtime is not configured.");
75
+ if (!options.finance.executeAction)
76
+ throw notSupported("Finance actions are unavailable.");
61
77
  if (!input.approvalId) {
62
78
  throw new ApiHttpError("Finance action requires action-ledger approval.", {
63
79
  status: 403,
@@ -66,6 +82,62 @@ export function createAppApiService(options = {}) {
66
82
  }
67
83
  return { data: await options.finance.executeAction(db, input) };
68
84
  }
85
+ async function getFinanceExternalReference(db, context, documentId) {
86
+ await requireAccess(db, context, ["finance-external-references:read"]);
87
+ const getReference = options.finance?.getExternalReference;
88
+ if (!getReference)
89
+ throw notSupported("Finance external references are unavailable.");
90
+ const data = await getReference(db, documentId, context.appId);
91
+ if (!data)
92
+ throw notFound("Finance external reference not found.");
93
+ return { data };
94
+ }
95
+ async function upsertFinanceExternalReference(db, context, documentId, input) {
96
+ const scopes = ["finance-external-references:write"];
97
+ if (input.allocation)
98
+ scopes.push("finance-external-allocation:write");
99
+ const access = await requireAccess(db, context, scopes);
100
+ const upsertReference = options.finance?.upsertExternalReference;
101
+ if (!upsertReference)
102
+ throw notSupported("Finance external reference writes are unavailable.");
103
+ let result;
104
+ try {
105
+ result = await db.transaction(async (tx) => {
106
+ const mutation = await upsertReference(tx, documentId, context.appId, input);
107
+ if (mutation.status === "ok") {
108
+ await audit(tx, access.installation, `app:${context.appId}`, "reconciliation", "finance.external-reference.upserted", {
109
+ documentId,
110
+ provider: context.appId,
111
+ referenceOutcome: mutation.referenceOutcome,
112
+ allocationOutcome: mutation.allocationOutcome,
113
+ releaseId: context.releaseId,
114
+ tokenMode: context.tokenMode,
115
+ });
116
+ }
117
+ return mutation;
118
+ });
119
+ }
120
+ catch (error) {
121
+ if (error instanceof FinanceAppApiNumberConflictError) {
122
+ throw new ApiHttpError("Finance document number is already in use.", {
123
+ status: 409,
124
+ code: "app_api_finance_number_conflict",
125
+ details: { invoiceNumber: error.invoiceNumber },
126
+ });
127
+ }
128
+ throw error;
129
+ }
130
+ if (result.status === "not_found")
131
+ throw notFound("Finance document not found.");
132
+ if (result.status === "allocation_conflict") {
133
+ throw new ApiHttpError("Finance document has already received a different allocation.", {
134
+ status: 409,
135
+ code: "app_api_finance_allocation_conflict",
136
+ details: result,
137
+ });
138
+ }
139
+ return { data: result };
140
+ }
69
141
  async function listCustomFieldDefinitions(db, context, query) {
70
142
  const access = await requireAccess(db, context, ["custom-field-definitions:read"]);
71
143
  return customFieldsRequired().listForOwner(db, owner(access), query);
@@ -120,7 +192,10 @@ export function createAppApiService(options = {}) {
120
192
  introspect,
121
193
  listEntities,
122
194
  listFinanceDocuments,
195
+ getFinanceIssuanceDocument,
123
196
  executeFinanceAction,
197
+ getFinanceExternalReference,
198
+ upsertFinanceExternalReference,
124
199
  listCustomFieldDefinitions,
125
200
  createCustomFieldDefinition,
126
201
  updateCustomFieldDefinition,
@@ -1,5 +1,7 @@
1
+ import { FinanceAppApiNumberConflictError } from "@voyant-travel/finance-contracts/app-api";
1
2
  import { ApiHttpError } from "@voyant-travel/hono";
2
3
  import { describe, expect, it, vi } from "vitest";
4
+ import { appApiFinanceExternalReferenceUpsertSchema } from "./app-api-contracts.js";
3
5
  import { assertAppNamespaceAlias, assertCompatibleVersion, createAppApiService, } from "./app-api-service.js";
4
6
  import { appGrants, appInstallations, appReleases } from "./schema.js";
5
7
  function postgresStub(implementation) {
@@ -86,6 +88,42 @@ describe("App API service boundary", () => {
86
88
  })).rejects.toMatchObject({ status: 403, code: "app_api_finance_approval_required" });
87
89
  expect(executeAction).not.toHaveBeenCalled();
88
90
  });
91
+ it("hydrates one finance document through the stable App API identifier", async () => {
92
+ const getIssuanceDocument = vi.fn().mockResolvedValue({ id: "inv_1" });
93
+ const service = createAppApiService({
94
+ finance: { listDocuments: vi.fn(), executeAction: vi.fn(), getIssuanceDocument },
95
+ });
96
+ await expect(service.getFinanceIssuanceDocument(createAccessDb({ scopes: ["finance-documents:read"] }), { ...context, scopes: ["finance-documents:read"] }, "inv_1")).resolves.toEqual({ data: { id: "inv_1" } });
97
+ expect(getIssuanceDocument).toHaveBeenCalledWith(expect.anything(), "inv_1");
98
+ });
99
+ it("maps a provider-owned invoice number conflict to a stable App API conflict", async () => {
100
+ const upsertExternalReference = vi
101
+ .fn()
102
+ .mockRejectedValue(new FinanceAppApiNumberConflictError("SB-42"));
103
+ const service = createAppApiService({ finance: { upsertExternalReference } });
104
+ const db = createAccessDb({
105
+ scopes: ["finance-external-references:write", "finance-external-allocation:write"],
106
+ });
107
+ Object.assign(db, {
108
+ transaction: (callback) => callback(db),
109
+ });
110
+ await expect(service.upsertFinanceExternalReference(db, {
111
+ ...context,
112
+ scopes: ["finance-external-references:write", "finance-external-allocation:write"],
113
+ }, "inv_1", {
114
+ reference: {
115
+ externalId: null,
116
+ externalNumber: null,
117
+ externalUrl: null,
118
+ status: null,
119
+ metadata: null,
120
+ syncedAt: null,
121
+ syncError: null,
122
+ },
123
+ allocation: { invoiceNumber: "SB-42" },
124
+ })).rejects.toMatchObject({ status: 409, code: "app_api_finance_number_conflict" });
125
+ expect(upsertExternalReference).toHaveBeenCalledWith(expect.anything(), "inv_1", "app_1", expect.anything());
126
+ });
89
127
  it("isolates rate limits per installation", async () => {
90
128
  const service = createAppApiService({
91
129
  rateLimit: { installationLimit: 1, appLimit: 10, windowMs: 60_000 },
@@ -106,4 +144,18 @@ describe("App API service boundary", () => {
106
144
  expect(() => assertCompatibleVersion("2026-07-01", { min: "2026-07-01", max: "2026-12-31" })).not.toThrow();
107
145
  expect(() => assertCompatibleVersion("2027-01-01", { min: "2026-07-01", max: "2026-12-31" })).toThrow(ApiHttpError);
108
146
  });
147
+ it("requires a complete bounded external-reference replacement", () => {
148
+ expect(appApiFinanceExternalReferenceUpsertSchema.safeParse({ reference: {} }).success).toBe(false);
149
+ expect(appApiFinanceExternalReferenceUpsertSchema.safeParse({
150
+ reference: {
151
+ externalId: null,
152
+ externalNumber: null,
153
+ externalUrl: null,
154
+ status: null,
155
+ metadata: { payload: "x".repeat(17_000) },
156
+ syncedAt: null,
157
+ syncError: null,
158
+ },
159
+ }).success).toBe(false);
160
+ });
109
161
  });
@@ -29,8 +29,8 @@ export declare const appOwnedCustomFieldDeclarationSchema: z.ZodObject<{
29
29
  }, z.core.$strip>>>>;
30
30
  logicalNamespace: z.ZodOptional<z.ZodString>;
31
31
  dataClassification: z.ZodDefault<z.ZodEnum<{
32
- internal: "internal";
33
32
  public: "public";
33
+ internal: "internal";
34
34
  confidential: "confidential";
35
35
  restricted: "restricted";
36
36
  personal: "personal";
@@ -106,8 +106,8 @@ export declare const appManifestSchema: z.ZodPipe<z.ZodRecord<z.ZodString, z.Zod
106
106
  }, z.core.$strip>>>>;
107
107
  logicalNamespace: z.ZodOptional<z.ZodString>;
108
108
  dataClassification: z.ZodDefault<z.ZodEnum<{
109
- internal: "internal";
110
109
  public: "public";
110
+ internal: "internal";
111
111
  confidential: "confidential";
112
112
  restricted: "restricted";
113
113
  personal: "personal";
@@ -134,8 +134,8 @@ export declare const appManifestSchema: z.ZodPipe<z.ZodRecord<z.ZodString, z.Zod
134
134
  }, z.core.$strict>;
135
135
  data: z.ZodObject<{
136
136
  classifications: z.ZodArray<z.ZodEnum<{
137
- internal: "internal";
138
137
  public: "public";
138
+ internal: "internal";
139
139
  confidential: "confidential";
140
140
  restricted: "restricted";
141
141
  personal: "personal";
package/dist/voyant.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { defineModule, requirePort } from "@voyant-travel/core/project";
2
2
  import { customFieldValueLifecycleRuntimePort, customFieldValueOperationsRuntimePort, } from "@voyant-travel/core/runtime-port";
3
+ import { financeAppApiRuntimePort } from "@voyant-travel/finance-contracts/runtime-port";
3
4
  const appsAdminRuntime = {
4
5
  entry: "@voyant-travel/apps-react/admin",
5
6
  export: "createSelectedAppsAdminExtension",
@@ -27,6 +28,7 @@ export const appsVoyantModule = defineModule({
27
28
  optional: true,
28
29
  cardinality: "many",
29
30
  }),
31
+ requirePort(financeAppApiRuntimePort, { optional: true }),
30
32
  ],
31
33
  api: [
32
34
  {
@@ -259,6 +261,45 @@ export const appsVoyantModule = defineModule({
259
261
  ],
260
262
  wildcard: "explicit-resource",
261
263
  },
264
+ {
265
+ id: "@voyant-travel/apps#access.finance-external-references",
266
+ resource: "finance-external-references",
267
+ label: "Finance external references",
268
+ description: "Read and record provider-owned finance document references.",
269
+ remoteSafe: true,
270
+ actions: [
271
+ {
272
+ action: "read",
273
+ label: "Read external references",
274
+ description: "Read the app provider's reference for a finance document.",
275
+ },
276
+ {
277
+ action: "write",
278
+ label: "Write external references",
279
+ description: "Idempotently record the app provider's finance document reference.",
280
+ sensitive: true,
281
+ wildcard: "explicit",
282
+ },
283
+ ],
284
+ wildcard: "explicit-resource",
285
+ },
286
+ {
287
+ id: "@voyant-travel/apps#access.finance-external-allocation",
288
+ resource: "finance-external-allocation",
289
+ label: "External finance number allocation",
290
+ description: "Write a provider-owned number to a pending finance document.",
291
+ remoteSafe: true,
292
+ actions: [
293
+ {
294
+ action: "write",
295
+ label: "Allocate external finance number",
296
+ description: "Atomically finalize a pending provider-owned document number.",
297
+ sensitive: true,
298
+ wildcard: "explicit",
299
+ },
300
+ ],
301
+ wildcard: "explicit-resource",
302
+ },
262
303
  {
263
304
  id: "@voyant-travel/apps#access.apps",
264
305
  resource: "apps",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voyant-travel/apps",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -123,12 +123,13 @@
123
123
  "zod": "^4.4.3",
124
124
  "@voyant-travel/admin": "^0.127.0",
125
125
  "@voyant-travel/admin-extension-sdk": "^0.2.0",
126
- "@voyant-travel/core": "^0.126.0",
127
- "@voyant-travel/custom-fields": "^0.2.2",
128
- "@voyant-travel/db": "^0.114.11",
129
- "@voyant-travel/hono": "^0.128.4",
126
+ "@voyant-travel/core": "^0.127.0",
127
+ "@voyant-travel/custom-fields": "^0.2.3",
128
+ "@voyant-travel/finance-contracts": "^0.107.0",
129
+ "@voyant-travel/db": "^0.114.13",
130
+ "@voyant-travel/hono": "^0.128.6",
130
131
  "@voyant-travel/types": "^0.109.4",
131
- "@voyant-travel/webhook-delivery": "^0.4.1"
132
+ "@voyant-travel/webhook-delivery": "^0.4.2"
132
133
  },
133
134
  "devDependencies": {
134
135
  "@types/node": "^25.5.2",