@voyant-travel/finance 0.159.0 → 0.162.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/README.md +28 -5
- package/dist/booking-tax.d.ts +3 -3
- package/dist/booking-tax.js +2 -2
- package/dist/index.d.ts +8 -8
- package/dist/index.js +6 -6
- package/dist/invoice-fx.d.ts +2 -2
- package/dist/invoice-fx.js +1 -1
- package/dist/invoice-issue-authorization.d.ts +69 -0
- package/dist/invoice-issue-authorization.js +156 -0
- package/dist/mcp-runtime.js +262 -2
- package/dist/payment-schedule/routes.d.ts +3 -3
- package/dist/payment-schedule/routes.js +2 -2
- package/dist/refund-authorization.d.ts +68 -0
- package/dist/refund-authorization.js +184 -0
- package/dist/routes-booking-create.d.ts +2 -2
- package/dist/routes-invoice-issue.js +10 -76
- package/dist/routes-runtime.d.ts +1 -0
- package/dist/routes-shared.d.ts +1 -0
- package/dist/runtime.d.ts +3 -3
- package/dist/service-action-ledger-accounting.d.ts +22 -0
- package/dist/service-action-ledger-accounting.js +59 -45
- package/dist/service-booking-create.js +1 -1
- package/dist/service-invoice-credit-notes.d.ts +15 -0
- package/dist/service-invoice-credit-notes.js +20 -0
- package/dist/service-invoices.d.ts +15 -0
- package/dist/service-issue.d.ts +14 -0
- package/dist/service-issue.js +95 -2
- package/dist/service-shared.d.ts +12 -0
- package/dist/service.d.ts +15 -0
- package/dist/tools.d.ts +933 -3
- package/dist/tools.js +262 -11
- package/dist/voyant.js +88 -8
- package/package.json +9 -9
package/dist/mcp-runtime.js
CHANGED
|
@@ -1,16 +1,276 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { buildActionLedgerApprovedExecutionFields, } from "@voyant-travel/action-ledger";
|
|
2
|
+
import { defineToolContextContribution, ToolError } from "@voyant-travel/tools";
|
|
3
|
+
import { authorizeFinanceInvoiceIssue, FINANCE_INVOICE_ISSUE_ACTION_NAME, FINANCE_INVOICE_ISSUE_CAPABILITY, FINANCE_INVOICE_ISSUE_TOOL_NAME, } from "./invoice-issue-authorization.js";
|
|
4
|
+
import { authorizeFinanceRefund, FINANCE_REFUND_ACTION_NAME, FINANCE_REFUND_CAPABILITY, FINANCE_REFUND_ROUTE_OR_TOOL_NAME, } from "./refund-authorization.js";
|
|
5
|
+
import { getActionLedgerRequestContext, getFinanceRouteRuntime } from "./routes-runtime.js";
|
|
2
6
|
import { financeService } from "./service.js";
|
|
7
|
+
import { createBooking } from "./service-booking-create.js";
|
|
8
|
+
import { issueInvoiceFromBookingCommand } from "./service-issue.js";
|
|
3
9
|
export * from "./tools.js";
|
|
4
10
|
export const voyantToolContextContribution = defineToolContextContribution({
|
|
5
11
|
context: ["finance"],
|
|
6
|
-
contribute: ({ context }) => {
|
|
12
|
+
contribute: ({ context, request }) => {
|
|
13
|
+
const c = request;
|
|
7
14
|
const db = context.db;
|
|
8
15
|
return {
|
|
9
16
|
finance: {
|
|
10
17
|
listInvoices: (query) => financeService.listInvoices(db, query),
|
|
11
18
|
getInvoiceById: (id) => financeService.getInvoiceById(db, id),
|
|
19
|
+
getFinanceAggregates: (query) => financeService.getFinanceAggregates(db, query),
|
|
12
20
|
voidInvoice: (id, input) => financeService.voidInvoice(db, id, input),
|
|
21
|
+
createBooking: (input) => createBooking(db, input, {
|
|
22
|
+
userId: c.get("userId") ?? undefined,
|
|
23
|
+
runtime: {
|
|
24
|
+
...getFinanceRouteRuntime(c),
|
|
25
|
+
actionLedgerContext: getActionLedgerRequestContext(c),
|
|
26
|
+
actionLedgerAuthorizationSource: "finance.booking_create.tool",
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
async issueInvoiceFromBooking(input) {
|
|
30
|
+
const { command, idempotencyKey, approvalId } = input;
|
|
31
|
+
const requestContext = financeToolActionLedgerContext(c);
|
|
32
|
+
const authorization = await authorizeFinanceInvoiceIssue({
|
|
33
|
+
db,
|
|
34
|
+
commandInput: command,
|
|
35
|
+
actor: c.get("actor"),
|
|
36
|
+
callerType: c.get("callerType"),
|
|
37
|
+
scopes: c.get("scopes"),
|
|
38
|
+
isInternalRequest: c.get("isInternalRequest"),
|
|
39
|
+
requestContext,
|
|
40
|
+
approvalId: approvalId ?? null,
|
|
41
|
+
idempotencyKey,
|
|
42
|
+
});
|
|
43
|
+
if (authorization.status === "approval_required") {
|
|
44
|
+
return pendingApprovalResult(authorization);
|
|
45
|
+
}
|
|
46
|
+
if (authorization.status === "already_executed") {
|
|
47
|
+
const invoice = await financeService.getInvoiceById(db, authorization.invoiceId);
|
|
48
|
+
if (!invoice) {
|
|
49
|
+
throw new ToolError("The previously issued invoice was not found.", "NOT_FOUND", {
|
|
50
|
+
invoiceId: authorization.invoiceId,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return { status: "issued", invoice: toJsonValue(invoice), replayed: true };
|
|
54
|
+
}
|
|
55
|
+
if (authorization.status !== "authorized") {
|
|
56
|
+
throw financeInvoiceIssueAuthorizationError(authorization);
|
|
57
|
+
}
|
|
58
|
+
const approved = buildActionLedgerApprovedExecutionFields(authorization.approvedAction);
|
|
59
|
+
const outcome = await issueInvoiceFromBookingCommand(db, command, {
|
|
60
|
+
...getFinanceRouteRuntime(c),
|
|
61
|
+
actionLedgerContext: requestContext,
|
|
62
|
+
actionLedgerAuthorizationSource: authorization.access.authorizationSource,
|
|
63
|
+
actionLedgerActionName: FINANCE_INVOICE_ISSUE_ACTION_NAME,
|
|
64
|
+
actionLedgerRouteOrToolName: FINANCE_INVOICE_ISSUE_TOOL_NAME,
|
|
65
|
+
actionLedgerCapabilityId: FINANCE_INVOICE_ISSUE_CAPABILITY.id,
|
|
66
|
+
actionLedgerCapabilityVersion: FINANCE_INVOICE_ISSUE_CAPABILITY.version,
|
|
67
|
+
actionLedgerEvaluatedRisk: FINANCE_INVOICE_ISSUE_CAPABILITY.risk,
|
|
68
|
+
actionLedgerCausationActionId: approved.causationActionId,
|
|
69
|
+
actionLedgerApprovalId: approved.approvalId,
|
|
70
|
+
actionLedgerIdempotencyScope: approved.idempotencyScope,
|
|
71
|
+
actionLedgerIdempotencyKey: approved.idempotencyKey,
|
|
72
|
+
actionLedgerIdempotencyFingerprint: approved.idempotencyFingerprint,
|
|
73
|
+
});
|
|
74
|
+
if (outcome.status !== "issued") {
|
|
75
|
+
const subject = outcome.status === "booking_not_found" ? "Booking" : "Booking payment schedule";
|
|
76
|
+
throw new ToolError(`${subject} was not found.`, "NOT_FOUND", { outcome });
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
status: "issued",
|
|
80
|
+
invoice: toJsonValue(outcome.invoice),
|
|
81
|
+
replayed: false,
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
async issueInvoiceRefund(input) {
|
|
85
|
+
const command = {
|
|
86
|
+
creditNoteNumber: input.creditNoteNumber,
|
|
87
|
+
status: "issued",
|
|
88
|
+
amountCents: input.amountCents,
|
|
89
|
+
currency: input.currency,
|
|
90
|
+
baseCurrency: input.baseCurrency,
|
|
91
|
+
baseAmountCents: input.baseAmountCents,
|
|
92
|
+
fxRateSetId: input.fxRateSetId,
|
|
93
|
+
reason: input.reason,
|
|
94
|
+
notes: input.notes,
|
|
95
|
+
};
|
|
96
|
+
const requestContext = financeToolActionLedgerContext(c);
|
|
97
|
+
const authorization = await authorizeFinanceRefund({
|
|
98
|
+
db,
|
|
99
|
+
invoiceId: input.invoiceId,
|
|
100
|
+
commandInput: command,
|
|
101
|
+
actor: c.get("actor"),
|
|
102
|
+
callerType: c.get("callerType"),
|
|
103
|
+
scopes: c.get("scopes"),
|
|
104
|
+
isInternalRequest: c.get("isInternalRequest"),
|
|
105
|
+
requestContext,
|
|
106
|
+
approvalId: input.approvalId ?? null,
|
|
107
|
+
idempotencyKey: input.idempotencyKey,
|
|
108
|
+
});
|
|
109
|
+
if (authorization.status === "approval_required") {
|
|
110
|
+
return {
|
|
111
|
+
status: "approval_required",
|
|
112
|
+
requestedAction: {
|
|
113
|
+
id: authorization.requestedAction.id,
|
|
114
|
+
status: authorization.requestedAction.status,
|
|
115
|
+
actionName: authorization.requestedAction.actionName,
|
|
116
|
+
targetType: authorization.requestedAction.targetType,
|
|
117
|
+
targetId: authorization.requestedAction.targetId,
|
|
118
|
+
},
|
|
119
|
+
approval: {
|
|
120
|
+
id: authorization.approval.id,
|
|
121
|
+
status: authorization.approval.status,
|
|
122
|
+
requestedActionId: authorization.approval.requestedActionId,
|
|
123
|
+
policyName: authorization.approval.policyName,
|
|
124
|
+
policyVersion: authorization.approval.policyVersion,
|
|
125
|
+
riskSnapshot: authorization.approval.riskSnapshot,
|
|
126
|
+
reasonCode: authorization.approval.reasonCode,
|
|
127
|
+
expiresAt: toIsoString(authorization.approval.expiresAt),
|
|
128
|
+
createdAt: toIsoString(authorization.approval.createdAt),
|
|
129
|
+
},
|
|
130
|
+
replayed: authorization.replayed,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (authorization.status === "already_executed") {
|
|
134
|
+
const creditNote = await financeService.getCreditNoteById(db, authorization.creditNoteId);
|
|
135
|
+
if (!creditNote) {
|
|
136
|
+
throw new ToolError("The previously issued credit note was not found.", "NOT_FOUND", {
|
|
137
|
+
creditNoteId: authorization.creditNoteId,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
if (creditNote.invoiceId !== input.invoiceId || creditNote.status !== "issued") {
|
|
141
|
+
throw new ToolError("The previous refund result does not match this issued invoice credit note.", "INVALID_INPUT", { creditNoteId: authorization.creditNoteId, invoiceId: input.invoiceId });
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
status: "issued",
|
|
145
|
+
creditNote: toJsonValue(creditNote),
|
|
146
|
+
replayed: true,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (authorization.status !== "authorized") {
|
|
150
|
+
throw financeRefundAuthorizationError(authorization);
|
|
151
|
+
}
|
|
152
|
+
const approved = buildActionLedgerApprovedExecutionFields(authorization.approvedAction);
|
|
153
|
+
const creditNote = await financeService.createCreditNote(db, input.invoiceId, command, {
|
|
154
|
+
eventBus: c.get("eventBus"),
|
|
155
|
+
actionLedgerContext: requestContext,
|
|
156
|
+
actionLedgerAuthorizationSource: authorization.access.authorizationSource,
|
|
157
|
+
actionLedgerActionName: FINANCE_REFUND_ACTION_NAME,
|
|
158
|
+
actionLedgerRouteOrToolName: FINANCE_REFUND_ROUTE_OR_TOOL_NAME,
|
|
159
|
+
actionLedgerTargetType: "invoice",
|
|
160
|
+
actionLedgerTargetId: input.invoiceId,
|
|
161
|
+
actionLedgerCapabilityId: FINANCE_REFUND_CAPABILITY.id,
|
|
162
|
+
actionLedgerCapabilityVersion: FINANCE_REFUND_CAPABILITY.version,
|
|
163
|
+
actionLedgerEvaluatedRisk: FINANCE_REFUND_CAPABILITY.risk,
|
|
164
|
+
actionLedgerCausationActionId: approved.causationActionId,
|
|
165
|
+
actionLedgerApprovalId: approved.approvalId,
|
|
166
|
+
actionLedgerIdempotencyScope: approved.idempotencyScope,
|
|
167
|
+
actionLedgerIdempotencyKey: approved.idempotencyKey,
|
|
168
|
+
actionLedgerIdempotencyFingerprint: approved.idempotencyFingerprint,
|
|
169
|
+
});
|
|
170
|
+
if (!creditNote) {
|
|
171
|
+
throw new ToolError(`Invoice "${input.invoiceId}" was not found.`, "NOT_FOUND", {
|
|
172
|
+
invoiceId: input.invoiceId,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
status: "issued",
|
|
177
|
+
creditNote: toJsonValue(creditNote),
|
|
178
|
+
replayed: false,
|
|
179
|
+
};
|
|
180
|
+
},
|
|
13
181
|
},
|
|
14
182
|
};
|
|
15
183
|
},
|
|
16
184
|
});
|
|
185
|
+
function financeToolActionLedgerContext(c) {
|
|
186
|
+
return {
|
|
187
|
+
userId: c.get("userId") ?? null,
|
|
188
|
+
agentId: c.get("agentId") ?? null,
|
|
189
|
+
workflowPrincipalId: c.get("workflowPrincipalId") ?? null,
|
|
190
|
+
principalSubtype: c.get("principalSubtype") ?? null,
|
|
191
|
+
sessionId: c.get("sessionId") ?? null,
|
|
192
|
+
apiTokenId: c.get("apiTokenId") ?? c.get("apiKeyId") ?? null,
|
|
193
|
+
callerType: c.get("callerType") ?? null,
|
|
194
|
+
actor: c.get("actor") ?? null,
|
|
195
|
+
isInternalRequest: c.get("isInternalRequest") ?? false,
|
|
196
|
+
organizationId: c.get("organizationId") ?? null,
|
|
197
|
+
workflowRunId: c.get("workflowRunId") ?? null,
|
|
198
|
+
workflowStepId: c.get("workflowStepId") ?? null,
|
|
199
|
+
correlationId: c.req.header("x-correlation-id") ?? c.req.header("x-request-id") ?? null,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function financeRefundAuthorizationError(result) {
|
|
203
|
+
switch (result.status) {
|
|
204
|
+
case "denied":
|
|
205
|
+
return new ToolError("Invoice refund is not authorized.", "AUTHORIZATION_DENIED", {
|
|
206
|
+
reason: result.access.reason,
|
|
207
|
+
});
|
|
208
|
+
case "missing_idempotency_key":
|
|
209
|
+
return new ToolError("Invoice refund requires an idempotency key.", "INVALID_INPUT");
|
|
210
|
+
case "idempotency_conflict":
|
|
211
|
+
return new ToolError(result.message, "INVALID_INPUT", {
|
|
212
|
+
existingActionId: result.existingActionId,
|
|
213
|
+
});
|
|
214
|
+
case "invalid_approval":
|
|
215
|
+
return new ToolError("The approval does not authorize this exact invoice credit-note refund.", "INVALID_INPUT", {
|
|
216
|
+
reason: result.validation.reason,
|
|
217
|
+
approvalId: result.validation.approval?.id,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function pendingApprovalResult(input) {
|
|
222
|
+
return {
|
|
223
|
+
status: "approval_required",
|
|
224
|
+
requestedAction: {
|
|
225
|
+
id: input.requestedAction.id,
|
|
226
|
+
status: input.requestedAction.status,
|
|
227
|
+
actionName: input.requestedAction.actionName,
|
|
228
|
+
targetType: input.requestedAction.targetType,
|
|
229
|
+
targetId: input.requestedAction.targetId,
|
|
230
|
+
},
|
|
231
|
+
approval: {
|
|
232
|
+
id: input.approval.id,
|
|
233
|
+
status: input.approval.status,
|
|
234
|
+
requestedActionId: input.approval.requestedActionId,
|
|
235
|
+
policyName: input.approval.policyName,
|
|
236
|
+
policyVersion: input.approval.policyVersion,
|
|
237
|
+
riskSnapshot: input.approval.riskSnapshot,
|
|
238
|
+
reasonCode: input.approval.reasonCode ?? "approval_required",
|
|
239
|
+
expiresAt: toIsoString(input.approval.expiresAt),
|
|
240
|
+
createdAt: toIsoString(input.approval.createdAt),
|
|
241
|
+
},
|
|
242
|
+
replayed: input.replayed,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
function financeInvoiceIssueAuthorizationError(result) {
|
|
246
|
+
switch (result.status) {
|
|
247
|
+
case "denied":
|
|
248
|
+
return new ToolError("Invoice issue is not authorized.", "AUTHORIZATION_DENIED", {
|
|
249
|
+
reason: result.access.reason,
|
|
250
|
+
});
|
|
251
|
+
case "missing_idempotency_key":
|
|
252
|
+
return new ToolError("Invoice issue requires an idempotency key.", "INVALID_INPUT");
|
|
253
|
+
case "idempotency_conflict":
|
|
254
|
+
return new ToolError(result.message, "INVALID_INPUT", {
|
|
255
|
+
existingActionId: result.existingActionId,
|
|
256
|
+
});
|
|
257
|
+
case "invalid_approval":
|
|
258
|
+
return new ToolError("The approval does not authorize this exact invoice issue command.", "INVALID_INPUT", { reason: result.validation.reason, approvalId: result.validation.approval?.id });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function toIsoString(value) {
|
|
262
|
+
if (!value)
|
|
263
|
+
return null;
|
|
264
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
265
|
+
}
|
|
266
|
+
function toJsonValue(value) {
|
|
267
|
+
if (value instanceof Date)
|
|
268
|
+
return value.toISOString();
|
|
269
|
+
if (Array.isArray(value))
|
|
270
|
+
return value.map(toJsonValue);
|
|
271
|
+
if (typeof value !== "object" || value === null)
|
|
272
|
+
return value;
|
|
273
|
+
return Object.fromEntries(Object.entries(value)
|
|
274
|
+
.map(([key, nested]) => [key, toJsonValue(nested)])
|
|
275
|
+
.filter(([, nested]) => nested !== undefined));
|
|
276
|
+
}
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* `booking-schedule.ts`.
|
|
24
24
|
*/
|
|
25
25
|
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
26
|
-
import type {
|
|
26
|
+
import type { ApiExtension } from "@voyant-travel/hono/module";
|
|
27
27
|
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
28
28
|
import type { Context } from "hono";
|
|
29
29
|
import { type PaymentPolicy, type PaymentPolicySource } from "../payment-policy.js";
|
|
@@ -155,5 +155,5 @@ export declare function createPaymentPolicyPublicRoutes(options: BookingSchedule
|
|
|
155
155
|
};
|
|
156
156
|
}, "/">;
|
|
157
157
|
/** Package-owned extension descriptor; deployments inject the policy cascade readers. */
|
|
158
|
-
export declare function
|
|
159
|
-
export declare const createBookingScheduleVoyantRuntime: import("@voyant-travel/core/project").VoyantGraphRuntimeFactory<
|
|
158
|
+
export declare function createBookingScheduleApiExtension(options: BookingScheduleRoutesOptions): ApiExtension;
|
|
159
|
+
export declare const createBookingScheduleVoyantRuntime: import("@voyant-travel/core/project").VoyantGraphRuntimeFactory<ApiExtension>;
|
|
@@ -363,7 +363,7 @@ export function createPaymentPolicyPublicRoutes(options) {
|
|
|
363
363
|
return new OpenAPIHono({ defaultHook: openApiValidationHook }).openapi(resolvePolicyRoute, (c) => asRouteResponse(handleResolvePolicy(c, options, c.req.valid("json"))));
|
|
364
364
|
}
|
|
365
365
|
/** Package-owned extension descriptor; deployments inject the policy cascade readers. */
|
|
366
|
-
export function
|
|
366
|
+
export function createBookingScheduleApiExtension(options) {
|
|
367
367
|
return {
|
|
368
368
|
extension: { name: "booking-schedule", module: "bookings" },
|
|
369
369
|
lazyAdminRoutes: async () => createBookingScheduleAdminRoutes(options),
|
|
@@ -374,7 +374,7 @@ export function createBookingScheduleHonoExtension(options) {
|
|
|
374
374
|
}
|
|
375
375
|
export const createBookingScheduleVoyantRuntime = defineGraphRuntimeFactory(async ({ api, getPort }) => {
|
|
376
376
|
const provider = createFinanceBookingScheduleRuntime(await getPort(financeHostRuntimePort), await getPort(financeOperatorSettingsRuntimePort), await getPort(financeDistributionPaymentPolicyRuntimePort), await getPort(financeAccommodationsPaymentPolicyRuntimePort), await getPort(financeCruisesPaymentPolicyRuntimePort), await getPort(financeInventoryPaymentPolicyRuntimePort));
|
|
377
|
-
const configured =
|
|
377
|
+
const configured = createBookingScheduleApiExtension(provider.options);
|
|
378
378
|
const selected = {
|
|
379
379
|
extension: {
|
|
380
380
|
...configured.extension,
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Approval orchestration for issuing an invoice credit note as a refund. */
|
|
2
|
+
import { type ActionLedgerCapabilityAccessResult, type ActionLedgerRequestContextValues, actionLedgerService, type BuildActionLedgerApprovedExecutionFieldsInput, requestActionLedgerApproval } from "@voyant-travel/action-ledger";
|
|
3
|
+
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
4
|
+
export declare const FINANCE_REFUND_CAPABILITY: {
|
|
5
|
+
readonly id: "finance:refund";
|
|
6
|
+
readonly version: "v1";
|
|
7
|
+
readonly resource: "invoice";
|
|
8
|
+
readonly action: "refund";
|
|
9
|
+
readonly risk: "critical";
|
|
10
|
+
readonly ledgerPolicy: "required";
|
|
11
|
+
readonly approvalPolicy: "required";
|
|
12
|
+
readonly reversible: false;
|
|
13
|
+
readonly allowedActorTypes: readonly ["staff", "system"];
|
|
14
|
+
readonly requiredGrants: readonly [{
|
|
15
|
+
readonly resource: "finance";
|
|
16
|
+
readonly action: "refund";
|
|
17
|
+
}];
|
|
18
|
+
};
|
|
19
|
+
export declare const FINANCE_REFUND_APPROVAL_POLICY = "finance-credit-note-refund-approval-v1";
|
|
20
|
+
export declare const FINANCE_REFUND_ACTION_NAME = "finance.credit_note.issue_refund";
|
|
21
|
+
export declare const FINANCE_REFUND_ROUTE_OR_TOOL_NAME = "finance.issue_invoice_refund";
|
|
22
|
+
export interface FinanceRefundAuthorizationInput {
|
|
23
|
+
db: PostgresJsDatabase;
|
|
24
|
+
invoiceId: string;
|
|
25
|
+
commandInput: unknown;
|
|
26
|
+
actor?: string | null;
|
|
27
|
+
callerType?: string | null;
|
|
28
|
+
scopes?: readonly string[] | null;
|
|
29
|
+
isInternalRequest?: boolean | null;
|
|
30
|
+
requestContext: ActionLedgerRequestContextValues;
|
|
31
|
+
approvalId?: string | null;
|
|
32
|
+
idempotencyKey?: string | null;
|
|
33
|
+
}
|
|
34
|
+
export type FinanceRefundAuthorizationResult = {
|
|
35
|
+
status: "authorized";
|
|
36
|
+
access: ActionLedgerCapabilityAccessResult;
|
|
37
|
+
approvedAction: BuildActionLedgerApprovedExecutionFieldsInput;
|
|
38
|
+
} | {
|
|
39
|
+
status: "approval_required";
|
|
40
|
+
access: ActionLedgerCapabilityAccessResult;
|
|
41
|
+
requestedAction: Awaited<ReturnType<typeof requestActionLedgerApproval>>["requestedAction"];
|
|
42
|
+
approval: Awaited<ReturnType<typeof requestActionLedgerApproval>>["approval"];
|
|
43
|
+
replayed: boolean;
|
|
44
|
+
} | {
|
|
45
|
+
status: "already_executed";
|
|
46
|
+
access: ActionLedgerCapabilityAccessResult;
|
|
47
|
+
creditNoteId: string;
|
|
48
|
+
} | {
|
|
49
|
+
status: "denied";
|
|
50
|
+
access: ActionLedgerCapabilityAccessResult;
|
|
51
|
+
} | {
|
|
52
|
+
status: "missing_idempotency_key";
|
|
53
|
+
access: ActionLedgerCapabilityAccessResult;
|
|
54
|
+
} | {
|
|
55
|
+
status: "idempotency_conflict";
|
|
56
|
+
access: ActionLedgerCapabilityAccessResult;
|
|
57
|
+
message: string;
|
|
58
|
+
existingActionId: string;
|
|
59
|
+
} | {
|
|
60
|
+
status: "invalid_approval";
|
|
61
|
+
access: ActionLedgerCapabilityAccessResult;
|
|
62
|
+
validation: Exclude<Awaited<ReturnType<typeof actionLedgerService.validateApprovedAction>>, {
|
|
63
|
+
ok: true;
|
|
64
|
+
}>;
|
|
65
|
+
};
|
|
66
|
+
export declare function authorizeFinanceRefund(input: FinanceRefundAuthorizationInput): Promise<FinanceRefundAuthorizationResult>;
|
|
67
|
+
export declare function parseCreditNoteCommandResultRef(resultRef: string | null): string | null;
|
|
68
|
+
export declare function resolveExecutedRefundCreditNoteId(db: PostgresJsDatabase, existingActionId: string): Promise<string | null>;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/** Approval orchestration for issuing an invoice credit note as a refund. */
|
|
2
|
+
import { ActionLedgerIdempotencyConflictError, actionLedgerService, appendActionLedgerMutation, buildActionApprovalCommandFingerprint, evaluateActionLedgerApprovalRequirement, evaluateActionLedgerCapabilityAccess, mapActionLedgerRequestContext, requestActionLedgerApproval, } from "@voyant-travel/action-ledger";
|
|
3
|
+
import { financeService } from "./service.js";
|
|
4
|
+
export const FINANCE_REFUND_CAPABILITY = {
|
|
5
|
+
id: "finance:refund",
|
|
6
|
+
version: "v1",
|
|
7
|
+
resource: "invoice",
|
|
8
|
+
action: "refund",
|
|
9
|
+
risk: "critical",
|
|
10
|
+
ledgerPolicy: "required",
|
|
11
|
+
approvalPolicy: "required",
|
|
12
|
+
reversible: false,
|
|
13
|
+
allowedActorTypes: ["staff", "system"],
|
|
14
|
+
requiredGrants: [{ resource: "finance", action: "refund" }],
|
|
15
|
+
};
|
|
16
|
+
export const FINANCE_REFUND_APPROVAL_POLICY = "finance-credit-note-refund-approval-v1";
|
|
17
|
+
export const FINANCE_REFUND_ACTION_NAME = "finance.credit_note.issue_refund";
|
|
18
|
+
export const FINANCE_REFUND_ROUTE_OR_TOOL_NAME = "finance.issue_invoice_refund";
|
|
19
|
+
export async function authorizeFinanceRefund(input) {
|
|
20
|
+
const access = evaluateActionLedgerCapabilityAccess({
|
|
21
|
+
definition: FINANCE_REFUND_CAPABILITY,
|
|
22
|
+
actor: input.actor,
|
|
23
|
+
callerType: input.callerType,
|
|
24
|
+
scopes: input.scopes,
|
|
25
|
+
isInternalRequest: input.isInternalRequest,
|
|
26
|
+
});
|
|
27
|
+
if (!access.allowed) {
|
|
28
|
+
await appendActionLedgerMutation(input.db, {
|
|
29
|
+
context: input.requestContext,
|
|
30
|
+
actionName: FINANCE_REFUND_ACTION_NAME,
|
|
31
|
+
actionVersion: FINANCE_REFUND_CAPABILITY.version,
|
|
32
|
+
actionKind: "create",
|
|
33
|
+
status: "denied",
|
|
34
|
+
evaluatedRisk: access.evaluatedRisk,
|
|
35
|
+
targetType: "invoice",
|
|
36
|
+
targetId: input.invoiceId,
|
|
37
|
+
routeOrToolName: FINANCE_REFUND_ROUTE_OR_TOOL_NAME,
|
|
38
|
+
capabilityId: access.capabilityId,
|
|
39
|
+
capabilityVersion: access.capabilityVersion,
|
|
40
|
+
authorizationSource: access.authorizationSource,
|
|
41
|
+
mutationDetail: {
|
|
42
|
+
summary: `Invoice refund denied: ${access.reason}`,
|
|
43
|
+
reversalKind: "none",
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
return { status: "denied", access };
|
|
47
|
+
}
|
|
48
|
+
const approvalRequirement = evaluateActionLedgerApprovalRequirement({
|
|
49
|
+
access,
|
|
50
|
+
conditionalApprovalRequired: true,
|
|
51
|
+
reasonCode: "invoice_credit_note_refund_requested_by_agent",
|
|
52
|
+
});
|
|
53
|
+
const targetState = await loadInvoiceRefundTargetState(input.db, input.invoiceId);
|
|
54
|
+
const fingerprint = await buildActionApprovalCommandFingerprint({
|
|
55
|
+
actionName: FINANCE_REFUND_ACTION_NAME,
|
|
56
|
+
actionVersion: FINANCE_REFUND_CAPABILITY.version,
|
|
57
|
+
targetType: "invoice",
|
|
58
|
+
targetId: input.invoiceId,
|
|
59
|
+
commandInput: { command: input.commandInput, targetState },
|
|
60
|
+
approvalPolicy: approvalRequirement.approvalPolicy,
|
|
61
|
+
capabilityId: access.capabilityId,
|
|
62
|
+
capabilityVersion: access.capabilityVersion,
|
|
63
|
+
evaluatedRisk: approvalRequirement.evaluatedRisk,
|
|
64
|
+
reasonCode: approvalRequirement.reasonCode,
|
|
65
|
+
});
|
|
66
|
+
if (input.approvalId) {
|
|
67
|
+
const principal = mapActionLedgerRequestContext(input.requestContext);
|
|
68
|
+
const validation = await actionLedgerService.validateApprovedAction(input.db, {
|
|
69
|
+
approvalId: input.approvalId,
|
|
70
|
+
actionName: FINANCE_REFUND_ACTION_NAME,
|
|
71
|
+
actionVersion: FINANCE_REFUND_CAPABILITY.version,
|
|
72
|
+
requestedActionKind: "create",
|
|
73
|
+
requestedActionStatus: "awaiting_approval",
|
|
74
|
+
targetType: "invoice",
|
|
75
|
+
targetId: input.invoiceId,
|
|
76
|
+
routeOrToolName: FINANCE_REFUND_ROUTE_OR_TOOL_NAME,
|
|
77
|
+
principalType: principal.principalType,
|
|
78
|
+
principalId: principal.principalId,
|
|
79
|
+
idempotencyFingerprint: fingerprint,
|
|
80
|
+
executionActionKind: "create",
|
|
81
|
+
executionStatus: "succeeded",
|
|
82
|
+
});
|
|
83
|
+
if (!validation.ok) {
|
|
84
|
+
const requestedAction = validation.requestedAction;
|
|
85
|
+
const isExactReplay = validation.reason === "already_executed" &&
|
|
86
|
+
requestedAction !== undefined &&
|
|
87
|
+
requestedAction.idempotencyFingerprint === fingerprint &&
|
|
88
|
+
(!principal.principalType ||
|
|
89
|
+
!principal.principalId ||
|
|
90
|
+
(requestedAction.principalType === principal.principalType &&
|
|
91
|
+
requestedAction.principalId === principal.principalId));
|
|
92
|
+
if (isExactReplay && validation.existingActionId) {
|
|
93
|
+
const creditNoteId = await resolveExecutedRefundCreditNoteId(input.db, validation.existingActionId);
|
|
94
|
+
if (creditNoteId)
|
|
95
|
+
return { status: "already_executed", access, creditNoteId };
|
|
96
|
+
}
|
|
97
|
+
return { status: "invalid_approval", access, validation };
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
status: "authorized",
|
|
101
|
+
access,
|
|
102
|
+
approvedAction: {
|
|
103
|
+
requestedActionId: validation.requestedAction.id,
|
|
104
|
+
approvalId: validation.approval.id,
|
|
105
|
+
idempotencyFingerprint: validation.idempotencyFingerprint,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (!input.idempotencyKey)
|
|
110
|
+
return { status: "missing_idempotency_key", access };
|
|
111
|
+
try {
|
|
112
|
+
const result = await requestActionLedgerApproval(input.db, {
|
|
113
|
+
context: input.requestContext,
|
|
114
|
+
actionName: FINANCE_REFUND_ACTION_NAME,
|
|
115
|
+
actionVersion: FINANCE_REFUND_CAPABILITY.version,
|
|
116
|
+
actionKind: "create",
|
|
117
|
+
evaluatedRisk: approvalRequirement.evaluatedRisk,
|
|
118
|
+
targetType: "invoice",
|
|
119
|
+
targetId: input.invoiceId,
|
|
120
|
+
routeOrToolName: FINANCE_REFUND_ROUTE_OR_TOOL_NAME,
|
|
121
|
+
capabilityId: access.capabilityId,
|
|
122
|
+
capabilityVersion: access.capabilityVersion,
|
|
123
|
+
authorizationSource: access.authorizationSource,
|
|
124
|
+
idempotencyScope: `${FINANCE_REFUND_ROUTE_OR_TOOL_NAME}:${input.invoiceId}`,
|
|
125
|
+
idempotencyKey: input.idempotencyKey,
|
|
126
|
+
idempotencyFingerprint: fingerprint,
|
|
127
|
+
mutationDetail: {
|
|
128
|
+
summary: "Invoice credit-note refund awaiting approval",
|
|
129
|
+
reversalKind: "none",
|
|
130
|
+
},
|
|
131
|
+
approval: {
|
|
132
|
+
policyName: FINANCE_REFUND_APPROVAL_POLICY,
|
|
133
|
+
policyVersion: FINANCE_REFUND_CAPABILITY.version,
|
|
134
|
+
riskSnapshot: approvalRequirement.evaluatedRisk,
|
|
135
|
+
reasonCode: approvalRequirement.reasonCode,
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
return {
|
|
139
|
+
status: "approval_required",
|
|
140
|
+
access,
|
|
141
|
+
requestedAction: result.requestedAction,
|
|
142
|
+
approval: result.approval,
|
|
143
|
+
replayed: result.replayed,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (error instanceof ActionLedgerIdempotencyConflictError) {
|
|
148
|
+
return {
|
|
149
|
+
status: "idempotency_conflict",
|
|
150
|
+
access,
|
|
151
|
+
message: error.message,
|
|
152
|
+
existingActionId: error.existingActionId,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
export function parseCreditNoteCommandResultRef(resultRef) {
|
|
159
|
+
const prefix = "credit_note:";
|
|
160
|
+
if (!resultRef?.startsWith(prefix))
|
|
161
|
+
return null;
|
|
162
|
+
const creditNoteId = resultRef.slice(prefix.length).trim();
|
|
163
|
+
return creditNoteId || null;
|
|
164
|
+
}
|
|
165
|
+
export async function resolveExecutedRefundCreditNoteId(db, existingActionId) {
|
|
166
|
+
const existing = await actionLedgerService.getEntry(db, existingActionId);
|
|
167
|
+
return parseCreditNoteCommandResultRef(existing?.mutationDetail?.commandResultRef ?? null);
|
|
168
|
+
}
|
|
169
|
+
async function loadInvoiceRefundTargetState(db, invoiceId) {
|
|
170
|
+
const invoice = await financeService.getInvoiceById(db, invoiceId);
|
|
171
|
+
if (!invoice)
|
|
172
|
+
return { exists: false };
|
|
173
|
+
return {
|
|
174
|
+
exists: true,
|
|
175
|
+
status: invoice.status,
|
|
176
|
+
invoiceType: invoice.invoiceType,
|
|
177
|
+
invoiceNumber: invoice.invoiceNumber,
|
|
178
|
+
currency: invoice.currency,
|
|
179
|
+
totalCents: invoice.totalCents,
|
|
180
|
+
paidCents: invoice.paidCents,
|
|
181
|
+
balanceDueCents: invoice.balanceDueCents,
|
|
182
|
+
updatedAt: invoice.updatedAt.toISOString(),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export declare const bookingsCreateExtension:
|
|
1
|
+
import type { ApiExtension } from "@voyant-travel/hono/module";
|
|
2
|
+
export declare const bookingsCreateExtension: ApiExtension;
|