@voyant-travel/finance 0.173.0 → 0.175.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.
@@ -0,0 +1,310 @@
1
+ export declare const FINANCE_RECEIVABLES_DATASET_ID = "finance.receivables";
2
+ /**
3
+ * Finance-owned semantics for issued customer receivables.
4
+ *
5
+ * Amounts remain in the document currency. Consumers must group by currency
6
+ * or constrain a query to one currency; the executor enforces that invariant.
7
+ */
8
+ export declare const financeReceivablesDatasetDefinition: {
9
+ id: string;
10
+ version: number;
11
+ label: string;
12
+ description: string;
13
+ grain: string;
14
+ requiredScopes: string[];
15
+ defaultLimit: number;
16
+ maximumLimit: number;
17
+ fields: ({
18
+ id: string;
19
+ label: string;
20
+ description: string;
21
+ role: "dimension";
22
+ valueType: "date";
23
+ sensitivity: "internal";
24
+ requiredScopes: string[];
25
+ aggregations: never[];
26
+ } | {
27
+ id: string;
28
+ label: string;
29
+ role: "dimension";
30
+ valueType: "date";
31
+ sensitivity: "internal";
32
+ requiredScopes: string[];
33
+ aggregations: never[];
34
+ description?: undefined;
35
+ } | {
36
+ id: string;
37
+ label: string;
38
+ description: string;
39
+ role: "dimension";
40
+ valueType: "string";
41
+ sensitivity: "internal";
42
+ requiredScopes: string[];
43
+ aggregations: ("count" | "countDistinct")[];
44
+ } | {
45
+ id: string;
46
+ label: string;
47
+ description: string;
48
+ role: "measure";
49
+ valueType: "currency";
50
+ sensitivity: "sensitive";
51
+ requiredScopes: string[];
52
+ aggregations: "sum"[];
53
+ })[];
54
+ };
55
+ export declare const financeReportingWidgets: ({
56
+ id: string;
57
+ version: number;
58
+ label: string;
59
+ description: string;
60
+ query: {
61
+ dataset: {
62
+ id: string;
63
+ version: number;
64
+ };
65
+ select: ({
66
+ kind: "field";
67
+ field: string;
68
+ operation?: undefined;
69
+ as?: undefined;
70
+ } | {
71
+ kind: "aggregate";
72
+ operation: "sum";
73
+ field: string;
74
+ as: string;
75
+ } | {
76
+ kind: "aggregate";
77
+ operation: "count";
78
+ as: string;
79
+ field?: undefined;
80
+ })[];
81
+ filters: never[];
82
+ groupBy: {
83
+ field: string;
84
+ }[];
85
+ orderBy: {
86
+ by: string;
87
+ direction: "descending";
88
+ }[];
89
+ limit: number;
90
+ };
91
+ visualization: {
92
+ type: "bar";
93
+ options: {
94
+ category: string;
95
+ value: string;
96
+ currencyField: string;
97
+ minorUnit: boolean;
98
+ x?: undefined;
99
+ y?: undefined;
100
+ series?: undefined;
101
+ };
102
+ };
103
+ defaultSize: {
104
+ width: number;
105
+ height: number;
106
+ };
107
+ minimumSize: {
108
+ width: number;
109
+ height: number;
110
+ };
111
+ maximumSize: {
112
+ width: number;
113
+ height: number;
114
+ };
115
+ } | {
116
+ id: string;
117
+ version: number;
118
+ label: string;
119
+ description: string;
120
+ query: {
121
+ dataset: {
122
+ id: string;
123
+ version: number;
124
+ };
125
+ select: ({
126
+ kind: "field";
127
+ field: string;
128
+ as: string;
129
+ operation?: undefined;
130
+ } | {
131
+ kind: "field";
132
+ field: string;
133
+ as?: undefined;
134
+ operation?: undefined;
135
+ } | {
136
+ kind: "aggregate";
137
+ operation: "sum";
138
+ field: string;
139
+ as: string;
140
+ })[];
141
+ filters: never[];
142
+ groupBy: ({
143
+ field: string;
144
+ timeGrain: "month";
145
+ } | {
146
+ field: string;
147
+ timeGrain?: undefined;
148
+ })[];
149
+ orderBy: {
150
+ by: string;
151
+ direction: "ascending";
152
+ }[];
153
+ limit: number;
154
+ };
155
+ visualization: {
156
+ type: "line";
157
+ options: {
158
+ x: string;
159
+ y: string;
160
+ series: string;
161
+ currencyField: string;
162
+ minorUnit: boolean;
163
+ category?: undefined;
164
+ value?: undefined;
165
+ };
166
+ };
167
+ defaultSize: {
168
+ width: number;
169
+ height: number;
170
+ };
171
+ minimumSize: {
172
+ width: number;
173
+ height: number;
174
+ };
175
+ maximumSize: {
176
+ width: number;
177
+ height: number;
178
+ };
179
+ } | {
180
+ id: string;
181
+ version: number;
182
+ label: string;
183
+ description: string;
184
+ query: {
185
+ dataset: {
186
+ id: string;
187
+ version: number;
188
+ };
189
+ select: ({
190
+ kind: "field";
191
+ field: string;
192
+ operation?: undefined;
193
+ as?: undefined;
194
+ } | {
195
+ kind: "aggregate";
196
+ operation: "count";
197
+ as: string;
198
+ field?: undefined;
199
+ })[];
200
+ filters: never[];
201
+ groupBy: {
202
+ field: string;
203
+ }[];
204
+ orderBy: {
205
+ by: string;
206
+ direction: "descending";
207
+ }[];
208
+ limit: number;
209
+ };
210
+ visualization: {
211
+ type: "pie";
212
+ options: {
213
+ category: string;
214
+ value: string;
215
+ currencyField?: undefined;
216
+ minorUnit?: undefined;
217
+ x?: undefined;
218
+ y?: undefined;
219
+ series?: undefined;
220
+ };
221
+ };
222
+ defaultSize: {
223
+ width: number;
224
+ height: number;
225
+ };
226
+ minimumSize: {
227
+ width: number;
228
+ height: number;
229
+ };
230
+ maximumSize: {
231
+ width: number;
232
+ height: number;
233
+ };
234
+ } | {
235
+ id: string;
236
+ version: number;
237
+ label: string;
238
+ description: string;
239
+ query: {
240
+ dataset: {
241
+ id: string;
242
+ version: number;
243
+ };
244
+ select: ({
245
+ kind: "field";
246
+ field: string;
247
+ operation?: undefined;
248
+ as?: undefined;
249
+ } | {
250
+ kind: "aggregate";
251
+ operation: "sum";
252
+ field: string;
253
+ as: string;
254
+ })[];
255
+ filters: never[];
256
+ groupBy: {
257
+ field: string;
258
+ }[];
259
+ orderBy: {
260
+ by: string;
261
+ direction: "descending";
262
+ }[];
263
+ limit: number;
264
+ };
265
+ visualization: {
266
+ type: "table";
267
+ options: {
268
+ currencyField: string;
269
+ minorUnit: boolean;
270
+ category?: undefined;
271
+ value?: undefined;
272
+ x?: undefined;
273
+ y?: undefined;
274
+ series?: undefined;
275
+ };
276
+ };
277
+ defaultSize: {
278
+ width: number;
279
+ height: number;
280
+ };
281
+ minimumSize: {
282
+ width: number;
283
+ height: number;
284
+ };
285
+ maximumSize: {
286
+ width: number;
287
+ height: number;
288
+ };
289
+ })[];
290
+ export declare const financeReportingTemplates: {
291
+ id: string;
292
+ version: number;
293
+ label: string;
294
+ description: string;
295
+ parameters: never[];
296
+ widgets: {
297
+ id: string;
298
+ source: {
299
+ kind: "preset";
300
+ widgetId: string;
301
+ version: number;
302
+ };
303
+ layout: {
304
+ x: number;
305
+ y: number;
306
+ width: number;
307
+ height: number;
308
+ };
309
+ }[];
310
+ }[];
@@ -0,0 +1,271 @@
1
+ export const FINANCE_RECEIVABLES_DATASET_ID = "finance.receivables";
2
+ /**
3
+ * Finance-owned semantics for issued customer receivables.
4
+ *
5
+ * Amounts remain in the document currency. Consumers must group by currency
6
+ * or constrain a query to one currency; the executor enforces that invariant.
7
+ */
8
+ export const financeReceivablesDatasetDefinition = {
9
+ id: FINANCE_RECEIVABLES_DATASET_ID,
10
+ version: 1,
11
+ label: "Receivables",
12
+ description: "Final, non-void customer invoices with issued credit notes and completed or refunded collections attributed in the invoice currency. Proformas and draft documents are excluded.",
13
+ grain: "One issued, partially paid, paid, or overdue final customer invoice.",
14
+ requiredScopes: ["finance:read"],
15
+ defaultLimit: 100,
16
+ maximumLimit: 1_000,
17
+ fields: [
18
+ {
19
+ id: "issueDate",
20
+ label: "Issue date",
21
+ description: "The final invoice issue date; trends bucket this date in UTC calendar units.",
22
+ role: "dimension",
23
+ valueType: "date",
24
+ sensitivity: "internal",
25
+ requiredScopes: ["finance:read"],
26
+ aggregations: [],
27
+ },
28
+ {
29
+ id: "dueDate",
30
+ label: "Due date",
31
+ role: "dimension",
32
+ valueType: "date",
33
+ sensitivity: "internal",
34
+ requiredScopes: ["finance:read"],
35
+ aggregations: [],
36
+ },
37
+ {
38
+ id: "status",
39
+ label: "Invoice status",
40
+ description: "Current status of the final invoice; draft, pending allocation, and void documents are outside this dataset.",
41
+ role: "dimension",
42
+ valueType: "string",
43
+ sensitivity: "internal",
44
+ requiredScopes: ["finance:read"],
45
+ aggregations: ["count", "countDistinct"],
46
+ },
47
+ {
48
+ id: "currency",
49
+ label: "Document currency",
50
+ description: "ISO currency of the final invoice. No report-time FX conversion is applied.",
51
+ role: "dimension",
52
+ valueType: "string",
53
+ sensitivity: "internal",
54
+ requiredScopes: ["finance:read"],
55
+ aggregations: ["count", "countDistinct"],
56
+ },
57
+ {
58
+ id: "grossIssuedCents",
59
+ label: "Gross issued",
60
+ description: "Final invoice total before issued or applied credit notes, in document-currency minor units.",
61
+ role: "measure",
62
+ valueType: "currency",
63
+ sensitivity: "sensitive",
64
+ requiredScopes: ["finance:read"],
65
+ aggregations: ["sum"],
66
+ },
67
+ {
68
+ id: "creditedCents",
69
+ label: "Credited",
70
+ description: "Issued or applied credit notes converted with their persisted invoice-currency snapshot, in minor units.",
71
+ role: "measure",
72
+ valueType: "currency",
73
+ sensitivity: "sensitive",
74
+ requiredScopes: ["finance:read"],
75
+ aggregations: ["sum"],
76
+ },
77
+ {
78
+ id: "netIssuedCents",
79
+ label: "Net issued",
80
+ description: "Gross final invoice value less issued or applied credit notes, attributed to the final invoice issue date.",
81
+ role: "measure",
82
+ valueType: "currency",
83
+ sensitivity: "sensitive",
84
+ requiredScopes: ["finance:read"],
85
+ aggregations: ["sum"],
86
+ },
87
+ {
88
+ id: "settledCents",
89
+ label: "Settled",
90
+ description: "Payments whose current status is completed, converted with their persisted invoice-currency snapshot.",
91
+ role: "measure",
92
+ valueType: "currency",
93
+ sensitivity: "sensitive",
94
+ requiredScopes: ["finance:read"],
95
+ aggregations: ["sum"],
96
+ },
97
+ {
98
+ id: "refundedCents",
99
+ label: "Refunded",
100
+ description: "Payments whose current status is refunded, converted with their persisted invoice-currency snapshot.",
101
+ role: "measure",
102
+ valueType: "currency",
103
+ sensitivity: "sensitive",
104
+ requiredScopes: ["finance:read"],
105
+ aggregations: ["sum"],
106
+ },
107
+ {
108
+ id: "outstandingBalanceCents",
109
+ label: "Outstanding balance",
110
+ description: "Net issued value less currently completed payments, floored at zero, in document-currency minor units.",
111
+ role: "measure",
112
+ valueType: "currency",
113
+ sensitivity: "sensitive",
114
+ requiredScopes: ["finance:read"],
115
+ aggregations: ["sum"],
116
+ },
117
+ ],
118
+ };
119
+ export const financeReportingWidgets = [
120
+ {
121
+ id: "finance.outstanding-by-currency",
122
+ version: 1,
123
+ label: "Outstanding by currency",
124
+ description: "Current unpaid final-invoice balance, kept separate by document currency.",
125
+ query: {
126
+ dataset: { id: FINANCE_RECEIVABLES_DATASET_ID, version: 1 },
127
+ select: [
128
+ { kind: "field", field: "currency" },
129
+ {
130
+ kind: "aggregate",
131
+ operation: "sum",
132
+ field: "outstandingBalanceCents",
133
+ as: "outstandingBalanceCents",
134
+ },
135
+ { kind: "aggregate", operation: "count", as: "invoiceCount" },
136
+ ],
137
+ filters: [],
138
+ groupBy: [{ field: "currency" }],
139
+ orderBy: [{ by: "outstandingBalanceCents", direction: "descending" }],
140
+ limit: 20,
141
+ },
142
+ visualization: {
143
+ type: "bar",
144
+ options: {
145
+ category: "currency",
146
+ value: "outstandingBalanceCents",
147
+ currencyField: "currency",
148
+ minorUnit: true,
149
+ },
150
+ },
151
+ defaultSize: { width: 4, height: 3 },
152
+ minimumSize: { width: 3, height: 2 },
153
+ maximumSize: { width: 12, height: 8 },
154
+ },
155
+ {
156
+ id: "finance.net-issued-trend",
157
+ version: 1,
158
+ label: "Net issued trend",
159
+ description: "Final invoices less issued/applied credit notes, attributed to the invoice issue month and separated by currency.",
160
+ query: {
161
+ dataset: { id: FINANCE_RECEIVABLES_DATASET_ID, version: 1 },
162
+ select: [
163
+ { kind: "field", field: "issueDate", as: "issueMonth" },
164
+ { kind: "field", field: "currency" },
165
+ {
166
+ kind: "aggregate",
167
+ operation: "sum",
168
+ field: "netIssuedCents",
169
+ as: "netIssuedCents",
170
+ },
171
+ ],
172
+ filters: [],
173
+ groupBy: [{ field: "issueDate", timeGrain: "month" }, { field: "currency" }],
174
+ orderBy: [
175
+ { by: "issueMonth", direction: "ascending" },
176
+ { by: "currency", direction: "ascending" },
177
+ ],
178
+ limit: 240,
179
+ },
180
+ visualization: {
181
+ type: "line",
182
+ options: {
183
+ x: "issueMonth",
184
+ y: "netIssuedCents",
185
+ series: "currency",
186
+ currencyField: "currency",
187
+ minorUnit: true,
188
+ },
189
+ },
190
+ defaultSize: { width: 8, height: 3 },
191
+ minimumSize: { width: 4, height: 3 },
192
+ maximumSize: { width: 12, height: 8 },
193
+ },
194
+ {
195
+ id: "finance.invoice-status-breakdown",
196
+ version: 1,
197
+ label: "Final invoice status",
198
+ description: "Count of recognized final invoices by their current receivable status.",
199
+ query: {
200
+ dataset: { id: FINANCE_RECEIVABLES_DATASET_ID, version: 1 },
201
+ select: [
202
+ { kind: "field", field: "status" },
203
+ { kind: "aggregate", operation: "count", as: "invoiceCount" },
204
+ ],
205
+ filters: [],
206
+ groupBy: [{ field: "status" }],
207
+ orderBy: [{ by: "invoiceCount", direction: "descending" }],
208
+ limit: 10,
209
+ },
210
+ visualization: { type: "pie", options: { category: "status", value: "invoiceCount" } },
211
+ defaultSize: { width: 4, height: 3 },
212
+ minimumSize: { width: 3, height: 3 },
213
+ maximumSize: { width: 8, height: 8 },
214
+ },
215
+ {
216
+ id: "finance.collections-by-currency",
217
+ version: 1,
218
+ label: "Collections by currency",
219
+ description: "Currently completed and refunded customer payments attributed to final invoices.",
220
+ query: {
221
+ dataset: { id: FINANCE_RECEIVABLES_DATASET_ID, version: 1 },
222
+ select: [
223
+ { kind: "field", field: "currency" },
224
+ { kind: "aggregate", operation: "sum", field: "settledCents", as: "settledCents" },
225
+ { kind: "aggregate", operation: "sum", field: "refundedCents", as: "refundedCents" },
226
+ ],
227
+ filters: [],
228
+ groupBy: [{ field: "currency" }],
229
+ orderBy: [{ by: "settledCents", direction: "descending" }],
230
+ limit: 20,
231
+ },
232
+ visualization: {
233
+ type: "table",
234
+ options: { currencyField: "currency", minorUnit: true },
235
+ },
236
+ defaultSize: { width: 4, height: 3 },
237
+ minimumSize: { width: 4, height: 2 },
238
+ maximumSize: { width: 12, height: 8 },
239
+ },
240
+ ];
241
+ export const financeReportingTemplates = [
242
+ {
243
+ id: "finance.overview",
244
+ version: 1,
245
+ label: "Finance overview",
246
+ description: "Issued value, collections, outstanding receivables, and current final-invoice status.",
247
+ parameters: [],
248
+ widgets: [
249
+ {
250
+ id: "outstanding",
251
+ source: { kind: "preset", widgetId: "finance.outstanding-by-currency", version: 1 },
252
+ layout: { x: 0, y: 0, width: 4, height: 3 },
253
+ },
254
+ {
255
+ id: "net-issued",
256
+ source: { kind: "preset", widgetId: "finance.net-issued-trend", version: 1 },
257
+ layout: { x: 4, y: 0, width: 8, height: 3 },
258
+ },
259
+ {
260
+ id: "status",
261
+ source: { kind: "preset", widgetId: "finance.invoice-status-breakdown", version: 1 },
262
+ layout: { x: 0, y: 3, width: 4, height: 3 },
263
+ },
264
+ {
265
+ id: "collections",
266
+ source: { kind: "preset", widgetId: "finance.collections-by-currency", version: 1 },
267
+ layout: { x: 4, y: 3, width: 8, height: 3 },
268
+ },
269
+ ],
270
+ },
271
+ ];
@@ -0,0 +1,17 @@
1
+ import type { ReportDatasetContribution, ReportDatasetExecutionInput, ReportResult } from "@voyant-travel/reporting-contracts";
2
+ import { type SQL } from "drizzle-orm";
3
+ /** A query shape outside Finance's deliberately small reporting surface. */
4
+ export declare class FinanceReportingQueryError extends Error {
5
+ constructor(message: string);
6
+ }
7
+ /**
8
+ * Compile the public single-dataset AST to parameter-bound SQL over the
9
+ * Finance-owned semantic receivables relation. Only the small set implemented
10
+ * here is accepted; query text can never address tables or columns directly.
11
+ */
12
+ export declare function compileFinanceReceivablesQuery(input: ReportDatasetExecutionInput): {
13
+ statement: SQL;
14
+ columns: ReportResult["columns"];
15
+ rowLimit: number;
16
+ };
17
+ export declare const financeReceivablesDataset: ReportDatasetContribution;
@@ -0,0 +1,363 @@
1
+ import { hasApiKeyPermission, permissionStringsToPermissions } from "@voyant-travel/types/api-keys";
2
+ import { sql } from "drizzle-orm";
3
+ import { FINANCE_RECEIVABLES_DATASET_ID, financeReceivablesDatasetDefinition, } from "./reporting-definitions.js";
4
+ import { executeBoundaryRows } from "./service-boundary-sql.js";
5
+ const MONEY_FIELDS = new Set([
6
+ "grossIssuedCents",
7
+ "creditedCents",
8
+ "netIssuedCents",
9
+ "settledCents",
10
+ "refundedCents",
11
+ "outstandingBalanceCents",
12
+ ]);
13
+ const fieldSql = {
14
+ issueDate: sql `receivable."issueDate"`,
15
+ dueDate: sql `receivable."dueDate"`,
16
+ status: sql `receivable.status`,
17
+ currency: sql `receivable.currency`,
18
+ grossIssuedCents: sql `receivable."grossIssuedCents"`,
19
+ creditedCents: sql `receivable."creditedCents"`,
20
+ netIssuedCents: sql `receivable."netIssuedCents"`,
21
+ settledCents: sql `receivable."settledCents"`,
22
+ refundedCents: sql `receivable."refundedCents"`,
23
+ outstandingBalanceCents: sql `receivable."outstandingBalanceCents"`,
24
+ };
25
+ const semanticReceivables = sql `
26
+ SELECT
27
+ invoice.issue_date AS "issueDate",
28
+ invoice.due_date AS "dueDate",
29
+ invoice.status::text AS status,
30
+ invoice.currency,
31
+ invoice.total_cents::bigint AS "grossIssuedCents",
32
+ credit_totals.credited_cents::bigint AS "creditedCents",
33
+ greatest(invoice.total_cents::bigint - credit_totals.credited_cents, 0) AS "netIssuedCents",
34
+ payment_totals.settled_cents::bigint AS "settledCents",
35
+ payment_totals.refunded_cents::bigint AS "refundedCents",
36
+ greatest(
37
+ invoice.total_cents::bigint
38
+ - credit_totals.credited_cents
39
+ - payment_totals.settled_cents,
40
+ 0
41
+ ) AS "outstandingBalanceCents"
42
+ FROM invoices invoice
43
+ LEFT JOIN LATERAL (
44
+ SELECT coalesce(sum(
45
+ CASE
46
+ WHEN credit.currency = invoice.currency THEN credit.amount_cents
47
+ WHEN credit.base_currency = invoice.currency THEN coalesce(credit.base_amount_cents, 0)
48
+ ELSE 0
49
+ END
50
+ ), 0)::bigint AS credited_cents
51
+ FROM credit_notes credit
52
+ WHERE credit.invoice_id = invoice.id
53
+ AND credit.status IN ('issued', 'applied')
54
+ ) credit_totals ON true
55
+ LEFT JOIN LATERAL (
56
+ SELECT
57
+ coalesce(sum(CASE WHEN payment.status = 'completed' THEN
58
+ CASE
59
+ WHEN payment.currency = invoice.currency THEN payment.amount_cents
60
+ WHEN payment.base_currency = invoice.currency THEN coalesce(payment.base_amount_cents, 0)
61
+ ELSE 0
62
+ END
63
+ ELSE 0 END), 0)::bigint AS settled_cents,
64
+ coalesce(sum(CASE WHEN payment.status = 'refunded' THEN
65
+ CASE
66
+ WHEN payment.currency = invoice.currency THEN payment.amount_cents
67
+ WHEN payment.base_currency = invoice.currency THEN coalesce(payment.base_amount_cents, 0)
68
+ ELSE 0
69
+ END
70
+ ELSE 0 END), 0)::bigint AS refunded_cents
71
+ FROM payments payment
72
+ WHERE payment.invoice_id = invoice.id
73
+ ) payment_totals ON true
74
+ WHERE invoice.invoice_type = 'invoice'
75
+ AND invoice.status IN ('issued', 'partially_paid', 'paid', 'overdue')
76
+ `;
77
+ /** A query shape outside Finance's deliberately small reporting surface. */
78
+ export class FinanceReportingQueryError extends Error {
79
+ constructor(message) {
80
+ super(message);
81
+ this.name = "FinanceReportingQueryError";
82
+ }
83
+ }
84
+ /**
85
+ * Compile the public single-dataset AST to parameter-bound SQL over the
86
+ * Finance-owned semantic receivables relation. Only the small set implemented
87
+ * here is accepted; query text can never address tables or columns directly.
88
+ */
89
+ export function compileFinanceReceivablesQuery(input) {
90
+ const { query, parameters } = input;
91
+ if (!Number.isInteger(input.maximumRows) || input.maximumRows < 1) {
92
+ throw new FinanceReportingQueryError("maximumRows must be a positive integer.");
93
+ }
94
+ if (query.dataset.id !== FINANCE_RECEIVABLES_DATASET_ID) {
95
+ throw new FinanceReportingQueryError(`Unsupported dataset ${JSON.stringify(query.dataset.id)}.`);
96
+ }
97
+ if (query.dataset.version !== undefined && query.dataset.version !== 1) {
98
+ throw new FinanceReportingQueryError(`Unsupported receivables dataset version ${query.dataset.version}.`);
99
+ }
100
+ const definitions = new Map(financeReceivablesDatasetDefinition.fields.map((field) => [field.id, field]));
101
+ const groups = new Map();
102
+ for (const group of query.groupBy) {
103
+ const field = requireField(definitions, group.field);
104
+ if (field.role !== "dimension") {
105
+ throw new FinanceReportingQueryError(`Cannot group by measure ${JSON.stringify(group.field)}.`);
106
+ }
107
+ if (groups.has(group.field)) {
108
+ throw new FinanceReportingQueryError(`Duplicate group ${JSON.stringify(group.field)}.`);
109
+ }
110
+ if (group.timeGrain && field.valueType !== "date" && field.valueType !== "datetime") {
111
+ throw new FinanceReportingQueryError(`Time grain is not valid for ${JSON.stringify(group.field)}.`);
112
+ }
113
+ groups.set(group.field, group);
114
+ }
115
+ const aggregateQuery = query.select.some((selection) => selection.kind === "aggregate");
116
+ const selections = query.select.map((selection) => {
117
+ if (selection.kind === "field") {
118
+ const field = requireField(definitions, selection.field);
119
+ if (aggregateQuery && !groups.has(field.id)) {
120
+ throw new FinanceReportingQueryError(`Selected field ${JSON.stringify(field.id)} must appear in groupBy.`);
121
+ }
122
+ const id = selection.as ?? field.id;
123
+ return {
124
+ id,
125
+ field,
126
+ expression: groupExpression(field.id, groups.get(field.id)?.timeGrain),
127
+ column: { id, label: field.label, valueType: field.valueType },
128
+ };
129
+ }
130
+ if (selection.operation !== "count" &&
131
+ selection.operation !== "countDistinct" &&
132
+ selection.operation !== "sum") {
133
+ throw new FinanceReportingQueryError(`Finance receivables does not support ${selection.operation}.`);
134
+ }
135
+ const field = selection.field ? requireField(definitions, selection.field) : undefined;
136
+ if (selection.operation === "sum" && field?.role !== "measure") {
137
+ throw new FinanceReportingQueryError("sum() requires a Finance measure.");
138
+ }
139
+ if (selection.operation === "countDistinct" && !field) {
140
+ throw new FinanceReportingQueryError("countDistinct() requires a field.");
141
+ }
142
+ if (field && !field.aggregations.includes(selection.operation)) {
143
+ throw new FinanceReportingQueryError(`${selection.operation} is not declared for ${JSON.stringify(field.id)}.`);
144
+ }
145
+ const expression = selection.operation === "count"
146
+ ? field
147
+ ? sql `count(${fieldExpression(field.id)})::bigint`
148
+ : sql `count(*)::bigint`
149
+ : selection.operation === "countDistinct"
150
+ ? sql `count(DISTINCT ${fieldExpression(field?.id ?? "")})::bigint`
151
+ : sql `coalesce(sum(${fieldExpression(field?.id ?? "")}), 0)::bigint`;
152
+ return {
153
+ id: selection.as,
154
+ field,
155
+ expression,
156
+ column: {
157
+ id: selection.as,
158
+ label: field?.label ?? "Count",
159
+ valueType: selection.operation === "count" || selection.operation === "countDistinct"
160
+ ? "integer"
161
+ : (field?.valueType ?? "number"),
162
+ },
163
+ };
164
+ });
165
+ const aliases = new Set();
166
+ for (const selection of selections) {
167
+ if (aliases.has(selection.id)) {
168
+ throw new FinanceReportingQueryError(`Duplicate selection alias ${JSON.stringify(selection.id)}.`);
169
+ }
170
+ aliases.add(selection.id);
171
+ }
172
+ const moneySelections = query.select.filter((selection) => selection.field !== undefined && MONEY_FIELDS.has(selection.field));
173
+ const currencyIsExplicit = aggregateQuery
174
+ ? groups.has("currency")
175
+ : query.select.some((selection) => selection.kind === "field" && selection.field === "currency");
176
+ if (moneySelections.length > 0 &&
177
+ !currencyIsExplicit &&
178
+ !hasSingleCurrencyFilter(query, parameters)) {
179
+ throw new FinanceReportingQueryError("Currency measures must include or group by currency, or be filtered to exactly one currency.");
180
+ }
181
+ const filters = query.filters.map((filter) => compileFilter(filter, definitions, parameters));
182
+ const selectSql = sql.join(selections.map(({ expression, id }) => sql `${expression} AS ${sql.identifier(id)}`), sql `, `);
183
+ const groupSql = [...groups.values()].map((group) => groupExpression(group.field, group.timeGrain));
184
+ const orderSql = query.orderBy.map((order) => {
185
+ if (!aliases.has(order.by)) {
186
+ throw new FinanceReportingQueryError(`Ordering must reference a selected output; ${JSON.stringify(order.by)} is unavailable.`);
187
+ }
188
+ return sql `${sql.identifier(order.by)} ${order.direction === "descending" ? sql `DESC` : sql `ASC`}`;
189
+ });
190
+ const rowLimit = Math.min(query.limit ?? input.maximumRows, input.maximumRows);
191
+ return {
192
+ statement: sql `
193
+ WITH receivable AS (${semanticReceivables})
194
+ SELECT ${selectSql}
195
+ FROM receivable
196
+ ${filters.length ? sql `WHERE ${sql.join(filters, sql ` AND `)}` : sql ``}
197
+ ${groupSql.length ? sql `GROUP BY ${sql.join(groupSql, sql `, `)}` : sql ``}
198
+ ${orderSql.length ? sql `ORDER BY ${sql.join(orderSql, sql `, `)}` : sql ``}
199
+ LIMIT ${rowLimit + 1}
200
+ `,
201
+ columns: selections.map(({ column }) => column),
202
+ rowLimit,
203
+ };
204
+ }
205
+ export const financeReceivablesDataset = {
206
+ definition: financeReceivablesDatasetDefinition,
207
+ async execute(context, input) {
208
+ if (!hasApiKeyPermission(permissionStringsToPermissions(context.grantedScopes), "finance", "read")) {
209
+ throw new FinanceReportingQueryError("finance:read is required to query receivables.");
210
+ }
211
+ if (context.signal?.aborted)
212
+ throw abortReason(context.signal);
213
+ const compiled = compileFinanceReceivablesQuery(input);
214
+ const rows = await executeBoundaryRows(context.db, compiled.statement);
215
+ if (context.signal?.aborted)
216
+ throw abortReason(context.signal);
217
+ const truncated = rows.length > compiled.rowLimit;
218
+ return {
219
+ columns: compiled.columns,
220
+ rows: rows.slice(0, compiled.rowLimit).map((row) => normalizeRow(row, compiled.columns)),
221
+ truncated,
222
+ warnings: [],
223
+ };
224
+ },
225
+ };
226
+ function requireField(fields, id) {
227
+ const field = fields.get(id);
228
+ if (!field || !fieldSql[id]) {
229
+ throw new FinanceReportingQueryError(`Unknown Finance field ${JSON.stringify(id)}.`);
230
+ }
231
+ return field;
232
+ }
233
+ function fieldExpression(id) {
234
+ const expression = fieldSql[id];
235
+ if (!expression)
236
+ throw new FinanceReportingQueryError(`Unknown Finance field ${JSON.stringify(id)}.`);
237
+ return expression;
238
+ }
239
+ function groupExpression(id, grain) {
240
+ const expression = fieldExpression(id);
241
+ if (!grain)
242
+ return expression;
243
+ switch (grain) {
244
+ case "day":
245
+ return sql `date_trunc('day', ${expression}::timestamp)::date`;
246
+ case "week":
247
+ return sql `date_trunc('week', ${expression}::timestamp)::date`;
248
+ case "month":
249
+ return sql `date_trunc('month', ${expression}::timestamp)::date`;
250
+ case "quarter":
251
+ return sql `date_trunc('quarter', ${expression}::timestamp)::date`;
252
+ case "year":
253
+ return sql `date_trunc('year', ${expression}::timestamp)::date`;
254
+ }
255
+ }
256
+ function compileFilter(filter, fields, parameters) {
257
+ const field = requireField(fields, filter.field);
258
+ if (field.role !== "dimension") {
259
+ throw new FinanceReportingQueryError(`Filtering measures is not supported.`);
260
+ }
261
+ const expression = fieldExpression(field.id);
262
+ if (filter.operator === "isNull")
263
+ return sql `${expression} IS NULL`;
264
+ if (filter.operator === "isNotNull")
265
+ return sql `${expression} IS NOT NULL`;
266
+ const value = requireFilterValue(filter, parameters);
267
+ switch (filter.operator) {
268
+ case "equal":
269
+ return value === null ? sql `${expression} IS NULL` : sql `${expression} = ${scalar(value)}`;
270
+ case "notEqual":
271
+ return value === null
272
+ ? sql `${expression} IS NOT NULL`
273
+ : sql `${expression} <> ${scalar(value)}`;
274
+ case "greaterThan":
275
+ return sql `${expression} > ${scalar(value)}`;
276
+ case "greaterThanOrEqual":
277
+ return sql `${expression} >= ${scalar(value)}`;
278
+ case "lessThan":
279
+ return sql `${expression} < ${scalar(value)}`;
280
+ case "lessThanOrEqual":
281
+ return sql `${expression} <= ${scalar(value)}`;
282
+ case "contains": {
283
+ if (typeof value !== "string" || field.valueType !== "string") {
284
+ throw new FinanceReportingQueryError("contains requires a string dimension and value.");
285
+ }
286
+ return sql `${expression} ILIKE ${`%${value}%`}`;
287
+ }
288
+ case "in":
289
+ case "notIn": {
290
+ if (!Array.isArray(value) || value.length === 0) {
291
+ throw new FinanceReportingQueryError(`${filter.operator} requires a non-empty array.`);
292
+ }
293
+ const values = sql.join(value.map((entry) => sql `${scalar(entry)}`), sql `, `);
294
+ return filter.operator === "in"
295
+ ? sql `${expression} IN (${values})`
296
+ : sql `${expression} NOT IN (${values})`;
297
+ }
298
+ case "between": {
299
+ if (!Array.isArray(value) || value.length !== 2) {
300
+ throw new FinanceReportingQueryError("between requires exactly two values.");
301
+ }
302
+ return sql `${expression} BETWEEN ${scalar(value[0])} AND ${scalar(value[1])}`;
303
+ }
304
+ default:
305
+ throw new FinanceReportingQueryError(`Unsupported filter operator ${filter.operator}.`);
306
+ }
307
+ }
308
+ function requireFilterValue(filter, parameters) {
309
+ if (!filter.value)
310
+ throw new FinanceReportingQueryError(`${filter.operator} requires a value.`);
311
+ if (filter.value.kind === "literal")
312
+ return filter.value.value;
313
+ if (!(filter.value.name in parameters)) {
314
+ throw new FinanceReportingQueryError(`Missing query parameter ${JSON.stringify(filter.value.name)}.`);
315
+ }
316
+ const value = parameters[filter.value.name];
317
+ if (value === undefined) {
318
+ throw new FinanceReportingQueryError(`Missing query parameter ${JSON.stringify(filter.value.name)}.`);
319
+ }
320
+ return value;
321
+ }
322
+ function scalar(value) {
323
+ if (Array.isArray(value))
324
+ throw new FinanceReportingQueryError("Expected a scalar value.");
325
+ return value;
326
+ }
327
+ function hasSingleCurrencyFilter(query, parameters) {
328
+ return query.filters.some((filter) => {
329
+ if (filter.field !== "currency" || !filter.value)
330
+ return false;
331
+ const value = filter.value.kind === "literal" ? filter.value.value : parameters[filter.value.name];
332
+ if (filter.operator === "equal")
333
+ return typeof value === "string" && value.length > 0;
334
+ return (filter.operator === "in" &&
335
+ Array.isArray(value) &&
336
+ value.length === 1 &&
337
+ typeof value[0] === "string");
338
+ });
339
+ }
340
+ function normalizeRow(row, columns) {
341
+ return Object.fromEntries(columns.map((column) => {
342
+ const value = row[column.id];
343
+ if (column.valueType === "date" && value instanceof Date) {
344
+ return [column.id, value.toISOString().slice(0, 10)];
345
+ }
346
+ if ((column.valueType === "integer" ||
347
+ column.valueType === "number" ||
348
+ column.valueType === "currency") &&
349
+ typeof value === "string") {
350
+ const numeric = Number(value);
351
+ if (!Number.isFinite(numeric) ||
352
+ ((column.valueType === "integer" || column.valueType === "currency") &&
353
+ !Number.isSafeInteger(numeric))) {
354
+ throw new FinanceReportingQueryError(`Result ${JSON.stringify(column.id)} is outside the supported numeric range.`);
355
+ }
356
+ return [column.id, numeric];
357
+ }
358
+ return [column.id, value];
359
+ }));
360
+ }
361
+ function abortReason(signal) {
362
+ return signal.reason ?? new Error("Finance report execution was aborted.");
363
+ }
package/dist/voyant.js CHANGED
@@ -1,8 +1,10 @@
1
+ // agent-quality: file-size exception -- owner: finance; the package-owned deployment declarations remain centralized in one manifest.
1
2
  import { actionLedgerFinanceDriftRuntimePort } from "@voyant-travel/action-ledger/runtime-port";
2
3
  import { bookingsFinanceRuntimePort } from "@voyant-travel/bookings/runtime-port";
3
4
  import { defineExtension, defineModule, providePort, requirePort, } from "@voyant-travel/core/project";
4
5
  import { customFieldsRuntimePort } from "@voyant-travel/core/runtime-port";
5
6
  import { financeAppApiRuntimePort } from "@voyant-travel/finance-contracts/runtime-port";
7
+ import { financeReceivablesDatasetDefinition, financeReportingTemplates, financeReportingWidgets, } from "./reporting-definitions.js";
6
8
  import { financeAccommodationsPaymentPolicyRuntimePort, financeCheckoutPaymentStartersRuntimePort, financeCruisesPaymentPolicyRuntimePort, financeDistributionPaymentPolicyRuntimePort, financeHostRuntimePort, financeInventoryPaymentPolicyRuntimePort, financeInvoiceSettlementPollerRuntimePort, financeNotificationsRuntimePort, financeOperatorSettingsRuntimePort, } from "./runtime-port.js";
7
9
  import { financeVoyantAdmin } from "./voyant-admin.js";
8
10
  import { bookingContractDocumentRequestedPayloadSchema, bookingCreatedPayloadSchema, bookingCreateRejectedPayloadSchema, bookingDualCreatedPayloadSchema, bookingPaymentSchedulePaidPayloadSchema, invoiceDocumentGeneratedPayloadSchema, invoiceIssuanceExternalPayloadSchema, invoicePaymentRecordedExternalPayloadSchema, invoiceProformaConvertedExternalPayloadSchema, invoiceRenderedPayloadSchema, invoiceSettledPayloadSchema, invoiceVoidedExternalPayloadSchema, paymentCompletedPayloadSchema, } from "./voyant-event-schemas.js";
@@ -68,6 +70,64 @@ export const financeVoyantModule = defineModule({
68
70
  source: "@voyant-travel/finance/schema",
69
71
  },
70
72
  ],
73
+ reporting: {
74
+ datasets: [
75
+ {
76
+ id: financeReceivablesDatasetDefinition.id,
77
+ version: financeReceivablesDatasetDefinition.version,
78
+ label: financeReceivablesDatasetDefinition.label,
79
+ description: financeReceivablesDatasetDefinition.description,
80
+ descriptor: financeReceivablesDatasetDefinition,
81
+ requiredScopes: financeReceivablesDatasetDefinition.requiredScopes,
82
+ runtime: {
83
+ entry: "@voyant-travel/finance/reporting",
84
+ export: "financeReceivablesDataset",
85
+ },
86
+ },
87
+ ],
88
+ widgets: financeReportingWidgets.map((widget) => ({
89
+ id: widget.id,
90
+ version: widget.version,
91
+ label: widget.label,
92
+ description: widget.description,
93
+ datasetId: widget.query.dataset.id,
94
+ ...(widget.query.dataset.version ? { datasetVersion: widget.query.dataset.version } : {}),
95
+ query: {
96
+ select: widget.query.select,
97
+ filters: widget.query.filters,
98
+ groupBy: widget.query.groupBy,
99
+ orderBy: widget.query.orderBy,
100
+ ...(widget.query.limit ? { limit: widget.query.limit } : {}),
101
+ },
102
+ visualization: {
103
+ type: widget.visualization.type,
104
+ options: omitUndefinedJsonOptions(widget.visualization.options),
105
+ },
106
+ defaultSize: widget.defaultSize,
107
+ ...(widget.minimumSize ? { minSize: widget.minimumSize } : {}),
108
+ ...(widget.maximumSize ? { maxSize: widget.maximumSize } : {}),
109
+ })),
110
+ templates: financeReportingTemplates.map((template) => ({
111
+ id: template.id,
112
+ version: template.version,
113
+ label: template.label,
114
+ description: template.description,
115
+ requirements: template.widgets.map((widget) => ({
116
+ kind: "widget",
117
+ id: widget.source.kind === "preset" ? widget.source.widgetId : widget.id,
118
+ })),
119
+ widgets: template.widgets.flatMap((widget) => widget.source.kind === "preset"
120
+ ? [
121
+ {
122
+ id: widget.id,
123
+ widgetId: widget.source.widgetId,
124
+ ...(widget.source.version ? { widgetVersion: widget.source.version } : {}),
125
+ layout: widget.layout,
126
+ },
127
+ ]
128
+ : []),
129
+ })),
130
+ },
71
131
  migrations: [
72
132
  {
73
133
  id: "@voyant-travel/finance#migrations",
@@ -566,4 +626,12 @@ export const financeBookingScheduleVoyantPlugin = defineExtension({
566
626
  ownership: "package",
567
627
  },
568
628
  });
629
+ function omitUndefinedJsonOptions(options) {
630
+ const jsonOptions = {};
631
+ for (const [key, value] of Object.entries(options)) {
632
+ if (value !== undefined)
633
+ jsonOptions[key] = value;
634
+ }
635
+ return jsonOptions;
636
+ }
569
637
  export default financeVoyantModule;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voyant-travel/finance",
3
- "version": "0.173.0",
3
+ "version": "0.175.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -44,6 +44,11 @@
44
44
  "import": "./dist/invoice-fx.js",
45
45
  "default": "./dist/invoice-fx.js"
46
46
  },
47
+ "./reporting": {
48
+ "types": "./dist/reporting.d.ts",
49
+ "import": "./dist/reporting.js",
50
+ "default": "./dist/reporting.js"
51
+ },
47
52
  "./order-payment-sessions": {
48
53
  "types": "./dist/order-payment-sessions.d.ts",
49
54
  "import": "./dist/order-payment-sessions.js",
@@ -151,23 +156,24 @@
151
156
  "fflate": "^0.8.2",
152
157
  "hono": "^4.12.27",
153
158
  "zod": "^4.4.3",
154
- "@voyant-travel/action-ledger": "^0.111.5",
155
- "@voyant-travel/bookings": "^0.173.0",
156
- "@voyant-travel/core": "^0.128.0",
157
- "@voyant-travel/db": "^0.114.14",
158
- "@voyant-travel/types": "^0.109.4",
159
+ "@voyant-travel/action-ledger": "^0.111.7",
160
+ "@voyant-travel/bookings": "^0.175.0",
161
+ "@voyant-travel/core": "^0.130.0",
162
+ "@voyant-travel/db": "^0.115.0",
163
+ "@voyant-travel/types": "^0.109.6",
159
164
  "@voyant-travel/finance-contracts": "^0.107.0",
160
- "@voyant-travel/hono": "^0.130.0",
161
- "@voyant-travel/payments": "^0.2.4",
162
- "@voyant-travel/public-document-delivery": "^0.4.4",
163
- "@voyant-travel/storage": "^0.111.4",
165
+ "@voyant-travel/hono": "^0.131.0",
166
+ "@voyant-travel/payments": "^0.2.6",
167
+ "@voyant-travel/public-document-delivery": "^0.4.6",
168
+ "@voyant-travel/reporting-contracts": "^0.2.1",
169
+ "@voyant-travel/storage": "^0.111.6",
164
170
  "@voyant-travel/utils": "^0.108.0",
165
171
  "@voyant-travel/tools": "^0.3.0"
166
172
  },
167
173
  "devDependencies": {
168
174
  "drizzle-kit": "^0.31.10",
169
175
  "typescript": "^6.0.3",
170
- "@voyant-travel/webhook-delivery": "^0.4.3",
176
+ "@voyant-travel/webhook-delivery": "^0.4.5",
171
177
  "@voyant-travel/voyant-typescript-config": "^0.1.0"
172
178
  },
173
179
  "files": [