@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.
- package/dist/api-runtime.js +6 -1
- package/dist/app-api-contracts.d.ts +27 -0
- package/dist/app-api-contracts.js +28 -0
- package/dist/app-api-finance-routes.test.d.ts +1 -0
- package/dist/app-api-finance-routes.test.js +153 -0
- package/dist/app-api-routes.js +14 -1
- package/dist/app-api-service.d.ts +19 -4
- package/dist/app-api-service.js +75 -0
- package/dist/app-api-service.test.js +52 -0
- package/dist/contracts.d.ts +3 -3
- package/dist/voyant.js +41 -0
- package/package.json +7 -6
package/dist/api-runtime.js
CHANGED
|
@@ -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
|
+
});
|
package/dist/app-api-routes.js
CHANGED
|
@@ -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: {
|
package/dist/app-api-service.js
CHANGED
|
@@ -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
|
});
|
package/dist/contracts.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
127
|
-
"@voyant-travel/custom-fields": "^0.2.
|
|
128
|
-
"@voyant-travel/
|
|
129
|
-
"@voyant-travel/
|
|
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.
|
|
132
|
+
"@voyant-travel/webhook-delivery": "^0.4.2"
|
|
132
133
|
},
|
|
133
134
|
"devDependencies": {
|
|
134
135
|
"@types/node": "^25.5.2",
|