@frihet/mcp-server 1.16.6 → 1.17.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 +92 -31
- package/dist/agent-onboarding.d.ts +98 -0
- package/dist/agent-onboarding.js +275 -0
- package/dist/api-origin.d.ts +9 -0
- package/dist/api-origin.js +41 -0
- package/dist/canary/mcp-harness.d.ts +77 -0
- package/dist/canary/mcp-harness.js +549 -0
- package/dist/capability-truth.d.ts +29 -0
- package/dist/capability-truth.js +254 -0
- package/dist/client-interface.d.ts +22 -14
- package/dist/client.d.ts +114 -18
- package/dist/client.js +488 -56
- package/dist/demo-client.d.ts +16 -13
- package/dist/demo-client.js +171 -17
- package/dist/demo-fixtures.d.ts +3 -3
- package/dist/demo-fixtures.js +7 -7
- package/dist/fiscal-aliases.d.ts +44 -0
- package/dist/fiscal-aliases.js +62 -0
- package/dist/index.js +41 -56
- package/dist/logger.d.ts +2 -2
- package/dist/logger.js +140 -8
- package/dist/observability.d.ts +18 -29
- package/dist/observability.js +192 -105
- package/dist/openai-profile.d.ts +25 -21
- package/dist/openai-profile.js +1166 -156
- package/dist/openai-review-contract.d.ts +15 -6
- package/dist/openai-review-contract.js +715 -41
- package/dist/openai-review-oauth.d.ts +47 -0
- package/dist/openai-review-oauth.js +73 -3
- package/dist/prompts/register-all.d.ts +2 -0
- package/dist/prompts/register-all.js +2 -0
- package/dist/public-capability-contract.d.ts +39 -0
- package/dist/public-capability-contract.js +186 -0
- package/dist/redaction.d.ts +10 -0
- package/dist/redaction.js +74 -0
- package/dist/resources/register-all.js +6 -12
- package/dist/server-composition.d.ts +16 -0
- package/dist/server-composition.js +44 -0
- package/dist/tool-exposure.d.ts +25 -22
- package/dist/tool-exposure.js +199 -77
- package/dist/tools/accountingClose.d.ts +14 -5
- package/dist/tools/accountingClose.js +41 -23
- package/dist/tools/backend-availability.d.ts +3 -3
- package/dist/tools/backend-availability.js +3 -3
- package/dist/tools/clients.js +9 -9
- package/dist/tools/crm.js +22 -21
- package/dist/tools/deposits.js +30 -11
- package/dist/tools/einvoice.d.ts +1 -1
- package/dist/tools/expenses.d.ts +16 -0
- package/dist/tools/expenses.js +47 -27
- package/dist/tools/hr.d.ts +2 -3
- package/dist/tools/hr.js +8 -9
- package/dist/tools/intelligence.js +2 -2
- package/dist/tools/invoices.js +150 -47
- package/dist/tools/payroll.d.ts +5 -8
- package/dist/tools/payroll.js +19 -32
- package/dist/tools/permissions.d.ts +4 -7
- package/dist/tools/permissions.js +11 -14
- package/dist/tools/products.js +6 -6
- package/dist/tools/quotes.js +109 -24
- package/dist/tools/shared.d.ts +1431 -75
- package/dist/tools/shared.js +386 -76
- package/dist/tools/team.js +18 -14
- package/dist/tools/vendors.js +9 -9
- package/dist/tools/webhooks.js +35 -30
- package/dist/types.d.ts +28 -4
- package/docs/agent-onboarding.json +381 -0
- package/package.json +30 -7
package/dist/client.js
CHANGED
|
@@ -11,11 +11,34 @@
|
|
|
11
11
|
* (`req.query.cursor`, functions/src/publicApi.ts in Frihet-ERP). `after`
|
|
12
12
|
* is never sent on the wire. See src/__tests__/pagination-cursor-param.test.ts.
|
|
13
13
|
*/
|
|
14
|
+
import { sanitizeServerRemediation } from "./redaction.js";
|
|
14
15
|
import { logApiCall, logRetry } from "./logger.js";
|
|
15
16
|
const BASE_URL = "https://api.frihet.io/v1";
|
|
17
|
+
const OAUTH_CLOUD_FUNCTION_BASE_URL = "https://europe-west1-gen-lang-client-0335716041.cloudfunctions.net/publicApi/api/v1";
|
|
18
|
+
const OAUTH_SERVICE_TRUSTED_BASE_URLS = new Set([
|
|
19
|
+
BASE_URL,
|
|
20
|
+
OAUTH_CLOUD_FUNCTION_BASE_URL,
|
|
21
|
+
]);
|
|
16
22
|
const MAX_RETRIES = 3;
|
|
17
23
|
const DEFAULT_RETRY_DELAY_MS = 1000;
|
|
18
24
|
const REQUEST_TIMEOUT_MS = 30000;
|
|
25
|
+
/**
|
|
26
|
+
* Hard size caps for document responses. Enforced TWICE: precheck on
|
|
27
|
+
* `Content-Length` (so an honest server doesn't waste bandwidth) and after
|
|
28
|
+
* streaming (so a missing/lying `Content-Length` still can't trigger an
|
|
29
|
+
* unbounded allocation).
|
|
30
|
+
*
|
|
31
|
+
* - PDF: 25 MiB — generous for any ERP-issued invoice PDF, including
|
|
32
|
+
* embedded logos, Facturae XML attachments, and stamp signatures.
|
|
33
|
+
* - XML: 5 MiB — UBL / Facturae / PEPPOL documents stay well under 1 MiB
|
|
34
|
+
* in practice; 5 MiB absorbs any historical / annex-laden outlier.
|
|
35
|
+
*
|
|
36
|
+
* Anything larger is rejected with `413 payload_too_large` BEFORE we allocate
|
|
37
|
+
* — the user sees a clean error, the worker doesn't OOM.
|
|
38
|
+
*/
|
|
39
|
+
export const MAX_PDF_BYTES = 25 * 1024 * 1024;
|
|
40
|
+
export const MAX_XML_BYTES = 5 * 1024 * 1024;
|
|
41
|
+
const MAX_ERROR_BYTES = 64 * 1024;
|
|
19
42
|
/**
|
|
20
43
|
* HTTP methods for which the backend treats the request as a mutation and
|
|
21
44
|
* therefore accepts (and for some endpoints REQUIRES) an `Idempotency-Key`
|
|
@@ -55,12 +78,11 @@ const SOURCE_USER_AGENT = "frihet-mcp-server";
|
|
|
55
78
|
/**
|
|
56
79
|
* Fresh idempotency key, always a syntactically valid UUID v4.
|
|
57
80
|
*
|
|
58
|
-
* `crypto.randomUUID` is
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* src/__tests__/idempotency-key-contract.test.ts asserts the shape.
|
|
81
|
+
* `crypto.randomUUID` is global on Cloudflare Workers and the supported Node
|
|
82
|
+
* runtime. The fallback remains defense in depth for constrained embedders and
|
|
83
|
+
* tests that intentionally remove the global; it must still produce a UUID,
|
|
84
|
+
* not an ad-hoc string. The backend recommends UUID v4 and the contract test
|
|
85
|
+
* asserts the shape.
|
|
64
86
|
*/
|
|
65
87
|
function newIdempotencyKey() {
|
|
66
88
|
const c = globalThis.crypto;
|
|
@@ -86,27 +108,159 @@ function normalizeIdempotencyKey(key) {
|
|
|
86
108
|
const trimmed = key?.trim();
|
|
87
109
|
return trimmed ? trimmed : undefined;
|
|
88
110
|
}
|
|
111
|
+
function normalizedContentType(contentType) {
|
|
112
|
+
return contentType.split(";", 1)[0].trim().toLowerCase();
|
|
113
|
+
}
|
|
114
|
+
function isXmlContentType(contentType) {
|
|
115
|
+
const normalized = normalizedContentType(contentType);
|
|
116
|
+
return normalized === "application/xml" || normalized === "text/xml" || normalized.endsWith("+xml");
|
|
117
|
+
}
|
|
118
|
+
function hasPdfSignature(bytes) {
|
|
119
|
+
return bytes.byteLength >= 5 &&
|
|
120
|
+
bytes[0] === 0x25 &&
|
|
121
|
+
bytes[1] === 0x50 &&
|
|
122
|
+
bytes[2] === 0x44 &&
|
|
123
|
+
bytes[3] === 0x46 &&
|
|
124
|
+
bytes[4] === 0x2d;
|
|
125
|
+
}
|
|
126
|
+
function attachmentFilename(contentDisposition) {
|
|
127
|
+
if (!contentDisposition)
|
|
128
|
+
return undefined;
|
|
129
|
+
const encoded = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
|
|
130
|
+
const quoted = contentDisposition.match(/filename="([^"]+)"/i)?.[1];
|
|
131
|
+
const plain = contentDisposition.match(/filename=([^;]+)/i)?.[1];
|
|
132
|
+
const candidate = (encoded ?? quoted ?? plain)?.trim();
|
|
133
|
+
if (!candidate)
|
|
134
|
+
return undefined;
|
|
135
|
+
try {
|
|
136
|
+
return decodeURIComponent(candidate).split(/[\\/]/).pop();
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return candidate.split(/[\\/]/).pop();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function isAbortError(error) {
|
|
143
|
+
return error instanceof Error && error.name === "AbortError";
|
|
144
|
+
}
|
|
145
|
+
function decodeUtf8(bytes, label) {
|
|
146
|
+
try {
|
|
147
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
throw new FrihetApiError(200, "invalid_response", `${label} is not valid UTF-8`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
154
|
+
/** Encode bytes without relying on Node's Buffer in browser/Worker runtimes. */
|
|
155
|
+
function encodeBase64(bytes) {
|
|
156
|
+
let encoded = "";
|
|
157
|
+
for (let index = 0; index < bytes.length; index += 3) {
|
|
158
|
+
const first = bytes[index];
|
|
159
|
+
const hasSecond = index + 1 < bytes.length;
|
|
160
|
+
const hasThird = index + 2 < bytes.length;
|
|
161
|
+
const second = hasSecond ? bytes[index + 1] : 0;
|
|
162
|
+
const third = hasThird ? bytes[index + 2] : 0;
|
|
163
|
+
const chunk = (first << 16) | (second << 8) | third;
|
|
164
|
+
encoded += BASE64_ALPHABET[(chunk >>> 18) & 0x3f];
|
|
165
|
+
encoded += BASE64_ALPHABET[(chunk >>> 12) & 0x3f];
|
|
166
|
+
encoded += hasSecond ? BASE64_ALPHABET[(chunk >>> 6) & 0x3f] : "=";
|
|
167
|
+
encoded += hasThird ? BASE64_ALPHABET[chunk & 0x3f] : "=";
|
|
168
|
+
}
|
|
169
|
+
return encoded;
|
|
170
|
+
}
|
|
89
171
|
export class FrihetApiError extends Error {
|
|
90
172
|
statusCode;
|
|
91
173
|
errorCode;
|
|
92
|
-
|
|
93
|
-
|
|
174
|
+
detail;
|
|
175
|
+
constructor(statusCode, errorCode, message, detail) {
|
|
176
|
+
super(statusCode === 403
|
|
177
|
+
? sanitizeServerRemediation(message, errorCode) ?? errorCode
|
|
178
|
+
: message ?? errorCode);
|
|
94
179
|
this.statusCode = statusCode;
|
|
95
180
|
this.errorCode = errorCode;
|
|
181
|
+
this.detail = detail;
|
|
96
182
|
this.name = "FrihetApiError";
|
|
97
183
|
}
|
|
98
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* Enforce the webhook secret's one-time write echo and normalize the one
|
|
187
|
+
* Firestore Timestamp that the ERP webhook route currently emits without its
|
|
188
|
+
* usual serializer. Invalid timestamp objects are preserved so the strict MCP
|
|
189
|
+
* output schema fails closed instead of fabricating a value.
|
|
190
|
+
*/
|
|
191
|
+
function normalizeWebhookRecord(webhook, allowedSecret) {
|
|
192
|
+
const normalized = { ...webhook };
|
|
193
|
+
if (typeof allowedSecret !== "string" ||
|
|
194
|
+
typeof normalized.secret !== "string" ||
|
|
195
|
+
normalized.secret !== allowedSecret) {
|
|
196
|
+
delete normalized.secret;
|
|
197
|
+
}
|
|
198
|
+
const lastTriggeredAt = normalized.lastTriggeredAt;
|
|
199
|
+
if (lastTriggeredAt !== null &&
|
|
200
|
+
typeof lastTriggeredAt === "object" &&
|
|
201
|
+
!Array.isArray(lastTriggeredAt)) {
|
|
202
|
+
const timestamp = lastTriggeredAt;
|
|
203
|
+
const seconds = timestamp._seconds;
|
|
204
|
+
const nanoseconds = timestamp._nanoseconds;
|
|
205
|
+
if (typeof seconds === "number" &&
|
|
206
|
+
Number.isInteger(seconds) &&
|
|
207
|
+
Number.isFinite(seconds) &&
|
|
208
|
+
typeof nanoseconds === "number" &&
|
|
209
|
+
Number.isInteger(nanoseconds) &&
|
|
210
|
+
nanoseconds >= 0 &&
|
|
211
|
+
nanoseconds < 1_000_000_000) {
|
|
212
|
+
const date = new Date(seconds * 1000 + Math.floor(nanoseconds / 1_000_000));
|
|
213
|
+
if (Number.isFinite(date.getTime())) {
|
|
214
|
+
normalized.lastTriggeredAt = date.toISOString();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return normalized;
|
|
219
|
+
}
|
|
220
|
+
function normalizeApiError(value, statusCode, statusText) {
|
|
221
|
+
const fallbackError = `http_${statusCode}`;
|
|
222
|
+
const record = typeof value === "object" && value !== null
|
|
223
|
+
? value
|
|
224
|
+
: undefined;
|
|
225
|
+
const error = typeof record?.error === "string" && record.error.trim()
|
|
226
|
+
? record.error
|
|
227
|
+
: fallbackError;
|
|
228
|
+
const message = typeof record?.message === "string"
|
|
229
|
+
? record.message
|
|
230
|
+
: (error !== fallbackError ? error : statusText || fallbackError);
|
|
231
|
+
return {
|
|
232
|
+
error,
|
|
233
|
+
message,
|
|
234
|
+
...(typeof record?.detail === "string" ? { detail: record.detail } : {}),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
99
237
|
export class FrihetClient {
|
|
100
238
|
apiKey;
|
|
101
239
|
baseUrl;
|
|
102
240
|
timeoutMs;
|
|
241
|
+
oauthServiceSecret;
|
|
103
242
|
constructor(apiKey, baseUrl, options) {
|
|
104
243
|
if (!apiKey) {
|
|
105
244
|
throw new Error("FRIHET_API_KEY is required. Set it as an environment variable or pass it to the constructor.");
|
|
106
245
|
}
|
|
246
|
+
const resolvedBaseUrl = baseUrl ?? BASE_URL;
|
|
107
247
|
this.apiKey = apiKey;
|
|
108
|
-
this.baseUrl =
|
|
248
|
+
this.baseUrl = resolvedBaseUrl;
|
|
109
249
|
this.timeoutMs = options?.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
250
|
+
if (options?.oauthServiceSecret !== undefined) {
|
|
251
|
+
if (new TextEncoder().encode(options.oauthServiceSecret).byteLength < 32
|
|
252
|
+
|| /[\r\n\0]/u.test(options.oauthServiceSecret)) {
|
|
253
|
+
throw new Error("OAuth API-key service authentication is not configured");
|
|
254
|
+
}
|
|
255
|
+
// A dashboard/API-key caller may still use a custom API base. The
|
|
256
|
+
// internal OAuth second factor may not: pin its authority at the sink so
|
|
257
|
+
// a future caller cannot exfiltrate both credentials by changing only
|
|
258
|
+
// constructor input while leaving the approved fetch fingerprint intact.
|
|
259
|
+
if (!OAUTH_SERVICE_TRUSTED_BASE_URLS.has(resolvedBaseUrl)) {
|
|
260
|
+
throw new Error("OAuth API-key service authority is not trusted");
|
|
261
|
+
}
|
|
262
|
+
this.oauthServiceSecret = options.oauthServiceSecret;
|
|
263
|
+
}
|
|
110
264
|
}
|
|
111
265
|
// ------------------------------------------------------------------ HTTP
|
|
112
266
|
// ------------------------------------------------------------------
|
|
@@ -133,6 +287,9 @@ export class FrihetClient {
|
|
|
133
287
|
"X-Frihet-Source": SOURCE_MARKER,
|
|
134
288
|
"User-Agent": SOURCE_USER_AGENT,
|
|
135
289
|
};
|
|
290
|
+
if (this.oauthServiceSecret) {
|
|
291
|
+
headers["x-frihet-oauth-key"] = this.oauthServiceSecret;
|
|
292
|
+
}
|
|
136
293
|
if (resolvedIdempotencyKey) {
|
|
137
294
|
headers["Idempotency-Key"] = resolvedIdempotencyKey;
|
|
138
295
|
}
|
|
@@ -146,23 +303,23 @@ export class FrihetClient {
|
|
|
146
303
|
headers,
|
|
147
304
|
body: body ? JSON.stringify(body) : undefined,
|
|
148
305
|
signal: controller.signal,
|
|
306
|
+
redirect: "error",
|
|
149
307
|
});
|
|
150
308
|
}
|
|
151
309
|
catch (error) {
|
|
310
|
+
clearTimeout(timeoutId);
|
|
152
311
|
const durationMs = Math.round(Date.now() - startTime);
|
|
153
312
|
if (error instanceof Error && error.name === "AbortError") {
|
|
154
313
|
logApiCall(method, path, 408, durationMs);
|
|
155
314
|
throw new FrihetApiError(408, "request_timeout", `Request timed out after ${this.timeoutMs / 1000} seconds`);
|
|
156
315
|
}
|
|
157
316
|
logApiCall(method, path, 0, durationMs);
|
|
158
|
-
throw
|
|
159
|
-
}
|
|
160
|
-
finally {
|
|
161
|
-
clearTimeout(timeoutId);
|
|
317
|
+
throw new FrihetApiError(503, "network_error", "The network request failed before Frihet could confirm its outcome.");
|
|
162
318
|
}
|
|
163
319
|
const durationMs = Math.round(Date.now() - startTime);
|
|
164
320
|
// Rate limit handling
|
|
165
321
|
if (response.status === 429) {
|
|
322
|
+
clearTimeout(timeoutId);
|
|
166
323
|
logApiCall(method, path, 429, durationMs);
|
|
167
324
|
if (retryCount >= MAX_RETRIES) {
|
|
168
325
|
throw new FrihetApiError(429, "rate_limit_exceeded", "Rate limit exceeded after multiple retries. Please try again later.");
|
|
@@ -180,28 +337,207 @@ export class FrihetClient {
|
|
|
180
337
|
logApiCall(method, path, response.status, durationMs);
|
|
181
338
|
let errorBody;
|
|
182
339
|
try {
|
|
183
|
-
errorBody = (await response.json());
|
|
340
|
+
errorBody = normalizeApiError(await response.json(), response.status, response.statusText);
|
|
184
341
|
}
|
|
185
342
|
catch {
|
|
186
|
-
errorBody =
|
|
187
|
-
error: `http_${response.status}`,
|
|
188
|
-
message: response.statusText,
|
|
189
|
-
};
|
|
343
|
+
errorBody = normalizeApiError(undefined, response.status, response.statusText);
|
|
190
344
|
}
|
|
191
|
-
|
|
345
|
+
clearTimeout(timeoutId);
|
|
346
|
+
throw new FrihetApiError(response.status, errorBody.error, errorBody.message ?? errorBody.error, typeof errorBody.detail === "string" ? errorBody.detail : undefined);
|
|
192
347
|
}
|
|
193
348
|
logApiCall(method, path, response.status, durationMs);
|
|
194
349
|
// 204 No Content (e.g. DELETE)
|
|
195
350
|
if (response.status === 204) {
|
|
351
|
+
clearTimeout(timeoutId);
|
|
196
352
|
return undefined;
|
|
197
353
|
}
|
|
198
|
-
|
|
354
|
+
let data;
|
|
355
|
+
try {
|
|
356
|
+
// Keep the request AbortSignal live through body consumption. A server
|
|
357
|
+
// can send 2xx headers after committing a write and then stall or truncate
|
|
358
|
+
// the JSON body; clearing the timer at headers would let this hang forever
|
|
359
|
+
// and make a committed mutation look safely retryable.
|
|
360
|
+
data = await response.json();
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
clearTimeout(timeoutId);
|
|
364
|
+
const timedOut = error instanceof Error && error.name === "AbortError";
|
|
365
|
+
throw new FrihetApiError(timedOut ? 408 : response.status, timedOut ? "response_timeout" : "invalid_response_after_success", timedOut
|
|
366
|
+
? `Response body timed out after ${this.timeoutMs / 1000} seconds`
|
|
367
|
+
: "Frihet returned an unreadable success response after processing the request.");
|
|
368
|
+
}
|
|
369
|
+
clearTimeout(timeoutId);
|
|
199
370
|
// Basic response validation
|
|
200
371
|
if (data === null || data === undefined) {
|
|
201
|
-
throw new FrihetApiError(response.status,
|
|
372
|
+
throw new FrihetApiError(response.status, "invalid_response_after_success", "Frihet returned an empty success response after processing the request.");
|
|
202
373
|
}
|
|
203
374
|
return data;
|
|
204
375
|
}
|
|
376
|
+
/** Fetch a raw response. The caller owns timeout and body consumption. */
|
|
377
|
+
async fetchRaw(method, path, body, query, idempotencyKey, signal) {
|
|
378
|
+
const resolvedIdempotencyKey = normalizeIdempotencyKey(idempotencyKey) ??
|
|
379
|
+
(MUTATING_METHODS.has(method) ? newIdempotencyKey() : undefined);
|
|
380
|
+
const url = new URL(`${this.baseUrl}${path}`);
|
|
381
|
+
if (query) {
|
|
382
|
+
for (const [key, value] of Object.entries(query)) {
|
|
383
|
+
if (value !== undefined)
|
|
384
|
+
url.searchParams.set(key, String(value));
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const headers = {
|
|
388
|
+
"X-API-Key": this.apiKey,
|
|
389
|
+
"Content-Type": "application/json",
|
|
390
|
+
// CRITICAL: do NOT set `Accept: application/json` here. The PDF / XML
|
|
391
|
+
// endpoints must be allowed to return their native content type;
|
|
392
|
+
// forcing `Accept: application/json` would make the server negotiate
|
|
393
|
+
// an error envelope instead of the document bytes.
|
|
394
|
+
Accept: "*/*",
|
|
395
|
+
"X-Frihet-Source": SOURCE_MARKER,
|
|
396
|
+
"User-Agent": SOURCE_USER_AGENT,
|
|
397
|
+
};
|
|
398
|
+
if (this.oauthServiceSecret) {
|
|
399
|
+
headers["x-frihet-oauth-key"] = this.oauthServiceSecret;
|
|
400
|
+
}
|
|
401
|
+
if (resolvedIdempotencyKey) {
|
|
402
|
+
headers["Idempotency-Key"] = resolvedIdempotencyKey;
|
|
403
|
+
}
|
|
404
|
+
return fetch(url.toString(), {
|
|
405
|
+
method,
|
|
406
|
+
headers,
|
|
407
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
408
|
+
signal,
|
|
409
|
+
redirect: "error",
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
/** Consume one response body without ever retaining more than `maxBytes`. */
|
|
413
|
+
async readBoundedBody(response, maxBytes, controller) {
|
|
414
|
+
const declaredLengthRaw = response.headers.get("content-length")?.trim();
|
|
415
|
+
const declaredLength = declaredLengthRaw && /^\d+$/.test(declaredLengthRaw)
|
|
416
|
+
? Number(declaredLengthRaw)
|
|
417
|
+
: Number.NaN;
|
|
418
|
+
if (Number.isSafeInteger(declaredLength) && declaredLength > maxBytes) {
|
|
419
|
+
controller.abort();
|
|
420
|
+
try {
|
|
421
|
+
await response.body?.cancel();
|
|
422
|
+
}
|
|
423
|
+
catch { /* best effort */ }
|
|
424
|
+
throw new FrihetApiError(413, "payload_too_large", `Document response exceeds ${maxBytes} bytes (Content-Length: ${declaredLength})`);
|
|
425
|
+
}
|
|
426
|
+
if (!response.body) {
|
|
427
|
+
throw new FrihetApiError(response.status, "invalid_response", "Response body is null");
|
|
428
|
+
}
|
|
429
|
+
const reader = response.body.getReader();
|
|
430
|
+
const chunks = [];
|
|
431
|
+
let totalBytes = 0;
|
|
432
|
+
try {
|
|
433
|
+
while (true) {
|
|
434
|
+
const { value, done } = await reader.read();
|
|
435
|
+
if (done)
|
|
436
|
+
break;
|
|
437
|
+
if (!value)
|
|
438
|
+
continue;
|
|
439
|
+
totalBytes += value.byteLength;
|
|
440
|
+
if (totalBytes > maxBytes) {
|
|
441
|
+
controller.abort();
|
|
442
|
+
try {
|
|
443
|
+
await reader.cancel("document response exceeded size limit");
|
|
444
|
+
}
|
|
445
|
+
catch { /* best effort */ }
|
|
446
|
+
throw new FrihetApiError(413, "payload_too_large", `Document response exceeds ${maxBytes} bytes during streaming`);
|
|
447
|
+
}
|
|
448
|
+
chunks.push(value);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
if (error instanceof FrihetApiError || isAbortError(error))
|
|
453
|
+
throw error;
|
|
454
|
+
try {
|
|
455
|
+
await reader.cancel("document stream failed");
|
|
456
|
+
}
|
|
457
|
+
catch { /* best effort */ }
|
|
458
|
+
controller.abort();
|
|
459
|
+
throw new FrihetApiError(response.status, "stream_failed", `Failed while reading document response: ${error instanceof Error ? error.message : String(error)}`);
|
|
460
|
+
}
|
|
461
|
+
finally {
|
|
462
|
+
reader.releaseLock();
|
|
463
|
+
}
|
|
464
|
+
const flat = new Uint8Array(totalBytes);
|
|
465
|
+
let offset = 0;
|
|
466
|
+
for (const chunk of chunks) {
|
|
467
|
+
flat.set(chunk, offset);
|
|
468
|
+
offset += chunk.byteLength;
|
|
469
|
+
}
|
|
470
|
+
return flat;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Bounded document fetch — ONE call, ONE Response, dispatch on Content-Type.
|
|
474
|
+
*
|
|
475
|
+
* Success and error bodies use the same bounded reader. The abort timer stays
|
|
476
|
+
* active until the body is complete, and a normal success performs one GET.
|
|
477
|
+
* A 429 may retry, matching the generic JSON client path.
|
|
478
|
+
*/
|
|
479
|
+
async requestDocument(method, path, body, query, idempotencyKey, maxBytesForContentType, retryCount = 0) {
|
|
480
|
+
const controller = new AbortController();
|
|
481
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
482
|
+
const startedAt = Date.now();
|
|
483
|
+
try {
|
|
484
|
+
const response = await this.fetchRaw(method, path, body, query, idempotencyKey, controller.signal);
|
|
485
|
+
const contentType = response.headers.get("content-type") ?? "application/octet-stream";
|
|
486
|
+
const maxBytes = response.ok ? maxBytesForContentType(contentType) : MAX_ERROR_BYTES;
|
|
487
|
+
const bytes = await this.readBoundedBody(response, maxBytes, controller);
|
|
488
|
+
const durationMs = Math.round(Date.now() - startedAt);
|
|
489
|
+
if (response.status === 429) {
|
|
490
|
+
logApiCall(method, path, 429, durationMs);
|
|
491
|
+
if (retryCount >= MAX_RETRIES) {
|
|
492
|
+
throw new FrihetApiError(429, "rate_limit_exceeded", "Rate limit exceeded after multiple retries. Please try again later.");
|
|
493
|
+
}
|
|
494
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
495
|
+
const parsedRetryAfter = retryAfter ? Number.parseInt(retryAfter, 10) : Number.NaN;
|
|
496
|
+
const delayMs = Number.isFinite(parsedRetryAfter)
|
|
497
|
+
? parsedRetryAfter * 1000
|
|
498
|
+
: DEFAULT_RETRY_DELAY_MS * Math.pow(2, retryCount);
|
|
499
|
+
logRetry(method, path, retryCount, delayMs);
|
|
500
|
+
clearTimeout(timeoutId);
|
|
501
|
+
await this.sleep(delayMs);
|
|
502
|
+
return this.requestDocument(method, path, body, query, idempotencyKey, maxBytesForContentType, retryCount + 1);
|
|
503
|
+
}
|
|
504
|
+
if (!response.ok) {
|
|
505
|
+
logApiCall(method, path, response.status, durationMs);
|
|
506
|
+
let errorBody = normalizeApiError(undefined, response.status, response.statusText);
|
|
507
|
+
try {
|
|
508
|
+
errorBody = normalizeApiError(JSON.parse(decodeUtf8(bytes, "API error response")), response.status, response.statusText);
|
|
509
|
+
}
|
|
510
|
+
catch (error) {
|
|
511
|
+
if (error instanceof FrihetApiError && error.errorCode !== "invalid_response")
|
|
512
|
+
throw error;
|
|
513
|
+
}
|
|
514
|
+
throw new FrihetApiError(response.status, errorBody.error, errorBody.message ?? errorBody.error, typeof errorBody.detail === "string" ? errorBody.detail : undefined);
|
|
515
|
+
}
|
|
516
|
+
logApiCall(method, path, response.status, durationMs);
|
|
517
|
+
const filename = attachmentFilename(response.headers.get("content-disposition"));
|
|
518
|
+
return {
|
|
519
|
+
contentType,
|
|
520
|
+
bytes,
|
|
521
|
+
sizeBytes: bytes.byteLength,
|
|
522
|
+
...(filename ? { filename } : {}),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
catch (error) {
|
|
526
|
+
if (isAbortError(error)) {
|
|
527
|
+
const durationMs = Math.round(Date.now() - startedAt);
|
|
528
|
+
logApiCall(method, path, 408, durationMs);
|
|
529
|
+
throw new FrihetApiError(408, "request_timeout", `Request timed out after ${this.timeoutMs / 1000} seconds`);
|
|
530
|
+
}
|
|
531
|
+
if (error instanceof FrihetApiError)
|
|
532
|
+
throw error;
|
|
533
|
+
const durationMs = Math.round(Date.now() - startedAt);
|
|
534
|
+
logApiCall(method, path, 0, durationMs);
|
|
535
|
+
throw new FrihetApiError(503, "network_error", "The network request failed before Frihet could confirm its outcome.");
|
|
536
|
+
}
|
|
537
|
+
finally {
|
|
538
|
+
clearTimeout(timeoutId);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
205
541
|
sleep(ms) {
|
|
206
542
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
207
543
|
}
|
|
@@ -220,10 +556,15 @@ export class FrihetClient {
|
|
|
220
556
|
* output schema (id/clientName/items/success all read as `undefined`). This
|
|
221
557
|
* unwraps to `body.data`. Every create/update/action mutation routes through
|
|
222
558
|
* here for exactly this reason; single-object reads (`getInvoice`/…) have since
|
|
223
|
-
* #64. `deleteX` methods
|
|
224
|
-
*
|
|
225
|
-
*
|
|
559
|
+
* #64. Most `deleteX` methods keep `request` (they return `void` — the tool
|
|
560
|
+
* discards the body and synthesizes `{ success, id }`, so there is nothing to
|
|
561
|
+
* unwrap). `deleteInvoice`/`deleteQuote` are the EXCEPTION and route through
|
|
562
|
+
* here: their backend answers 204 for a real delete but 200 + `{ data, meta }`
|
|
563
|
+
* when it soft-CANCELS a non-draft document instead (see those methods).
|
|
564
|
+
* Only unwraps a non-array-object `data` (see guard below), so an
|
|
226
565
|
* array-`data` list envelope and any non-enveloped body pass through unchanged.
|
|
566
|
+
* A 204 yields `undefined` from `request`, which falls through untouched — that
|
|
567
|
+
* `undefined` is what tells the delete tools "destroyed, not cancelled".
|
|
227
568
|
*
|
|
228
569
|
* Only unwraps when the body is an object carrying a `data` property that is
|
|
229
570
|
* itself a (non-array) object — i.e. a genuine single-object envelope. It is
|
|
@@ -282,8 +623,22 @@ export class FrihetClient {
|
|
|
282
623
|
async updateInvoice(id, data) {
|
|
283
624
|
return this.requestUnwrapped("PATCH", `/invoices/${encodeURIComponent(id)}`, data);
|
|
284
625
|
}
|
|
626
|
+
/**
|
|
627
|
+
* DELETE /invoices/{id} — NOT an unconditional destroy.
|
|
628
|
+
*
|
|
629
|
+
* The backend refuses to destroy a non-draft invoice (VeriFactu hash-chain
|
|
630
|
+
* integrity) and soft-CANCELS it instead, and it distinguishes the two
|
|
631
|
+
* outcomes on the wire (`publicApi.ts` DELETE branch):
|
|
632
|
+
* - 204 No Content → the draft row was really removed
|
|
633
|
+
* - 200 + `{ data: { id, status: 'cancelled', previousStatus,
|
|
634
|
+
* cancelledVia }, meta }` → the document still exists, now cancelled
|
|
635
|
+
*
|
|
636
|
+
* This used to be typed `Promise<void>` and the body was thrown away, so the
|
|
637
|
+
* tool reported "deleted successfully" for a document that was still there.
|
|
638
|
+
* Resolves to the unwrapped soft-cancel payload on 200, `undefined` on 204.
|
|
639
|
+
*/
|
|
285
640
|
async deleteInvoice(id) {
|
|
286
|
-
return this.
|
|
641
|
+
return this.requestUnwrapped("DELETE", `/invoices/${encodeURIComponent(id)}`);
|
|
287
642
|
}
|
|
288
643
|
async searchInvoices(query, params) {
|
|
289
644
|
return this.requestPaginated("GET", "/invoices", undefined, {
|
|
@@ -356,7 +711,9 @@ export class FrihetClient {
|
|
|
356
711
|
cursor: params?.after,
|
|
357
712
|
fields: params?.fields,
|
|
358
713
|
q: params?.q,
|
|
359
|
-
|
|
714
|
+
// Public API accepts the literal strings "true"/"false". Numeric 1/0
|
|
715
|
+
// makes `isActive=true` evaluate as false in the backend parser.
|
|
716
|
+
isActive: params?.isActive !== undefined ? String(params.isActive) : undefined,
|
|
360
717
|
});
|
|
361
718
|
}
|
|
362
719
|
async getProduct(id) {
|
|
@@ -395,8 +752,14 @@ export class FrihetClient {
|
|
|
395
752
|
async updateQuote(id, data) {
|
|
396
753
|
return this.requestUnwrapped("PATCH", `/quotes/${encodeURIComponent(id)}`, data);
|
|
397
754
|
}
|
|
755
|
+
/**
|
|
756
|
+
* DELETE /quotes/{id} — same two-outcome contract as {@link deleteInvoice}:
|
|
757
|
+
* the backend soft-CANCELS a non-draft quote (200 + body), destroys only a
|
|
758
|
+
* clean draft with no delivery/response/attachment/conversion evidence (204),
|
|
759
|
+
* and refuses a protected draft with 409.
|
|
760
|
+
*/
|
|
398
761
|
async deleteQuote(id) {
|
|
399
|
-
return this.
|
|
762
|
+
return this.requestUnwrapped("DELETE", `/quotes/${encodeURIComponent(id)}`);
|
|
400
763
|
}
|
|
401
764
|
// ---------------------------------------------------------------- Vendors
|
|
402
765
|
// ----------------------------------------------------------------
|
|
@@ -424,21 +787,72 @@ export class FrihetClient {
|
|
|
424
787
|
// ---------------------------------------------------------------- Invoice Actions
|
|
425
788
|
// ----------------------------------------------------------------
|
|
426
789
|
async sendInvoice(id, to) {
|
|
427
|
-
return this.requestUnwrapped("POST", `/invoices/${encodeURIComponent(id)}/send`, to ? { to } : undefined);
|
|
790
|
+
return this.requestUnwrapped("POST", `/invoices/${encodeURIComponent(id)}/send`, to ? { recipientEmail: to } : undefined);
|
|
428
791
|
}
|
|
429
792
|
async markInvoicePaid(id, paidDate) {
|
|
430
793
|
return this.requestUnwrapped("POST", `/invoices/${encodeURIComponent(id)}/paid`, paidDate ? { paidDate } : undefined);
|
|
431
794
|
}
|
|
432
795
|
async getInvoicePdf(id) {
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
|
|
796
|
+
// One fetch, one Response: validate MIME and signature only after the
|
|
797
|
+
// bounded stream completes. The ERP endpoint has always returned bytes;
|
|
798
|
+
// there is no pre-signed-URL JSON success contract to emulate.
|
|
799
|
+
const url = `/invoices/${encodeURIComponent(id)}/pdf`;
|
|
800
|
+
const doc = await this.requestDocument("GET", url, undefined, undefined, undefined, (contentType) => normalizedContentType(contentType) === "application/pdf"
|
|
801
|
+
? MAX_PDF_BYTES
|
|
802
|
+
: MAX_ERROR_BYTES);
|
|
803
|
+
if (normalizedContentType(doc.contentType) !== "application/pdf") {
|
|
804
|
+
throw new FrihetApiError(200, "invalid_response", `Invoice PDF endpoint returned unexpected Content-Type: ${doc.contentType}`);
|
|
805
|
+
}
|
|
806
|
+
if (!hasPdfSignature(doc.bytes)) {
|
|
807
|
+
throw new FrihetApiError(200, "invalid_response", "Invoice PDF response is malformed");
|
|
808
|
+
}
|
|
809
|
+
return {
|
|
810
|
+
id,
|
|
811
|
+
contentType: doc.contentType,
|
|
812
|
+
sizeBytes: doc.sizeBytes,
|
|
813
|
+
base64: encodeBase64(doc.bytes),
|
|
814
|
+
...(doc.filename ? { filename: doc.filename } : {}),
|
|
815
|
+
};
|
|
436
816
|
}
|
|
437
817
|
async getInvoiceEInvoice(invoiceId) {
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
|
|
441
|
-
|
|
818
|
+
// Stored e-invoices are either XML or Factur-X PDF. Select the size cap
|
|
819
|
+
// from the actual MIME before reading and preserve the request identity.
|
|
820
|
+
const url = `/invoices/${encodeURIComponent(invoiceId)}/xml`;
|
|
821
|
+
const doc = await this.requestDocument("GET", url, undefined, undefined, undefined, (contentType) => {
|
|
822
|
+
const normalized = normalizedContentType(contentType);
|
|
823
|
+
if (normalized === "application/pdf")
|
|
824
|
+
return MAX_PDF_BYTES;
|
|
825
|
+
if (isXmlContentType(contentType))
|
|
826
|
+
return MAX_XML_BYTES;
|
|
827
|
+
return MAX_ERROR_BYTES;
|
|
828
|
+
});
|
|
829
|
+
const normalized = normalizedContentType(doc.contentType);
|
|
830
|
+
if (normalized === "application/pdf") {
|
|
831
|
+
if (!hasPdfSignature(doc.bytes)) {
|
|
832
|
+
throw new FrihetApiError(200, "invalid_response", "Factur-X PDF response is malformed");
|
|
833
|
+
}
|
|
834
|
+
return {
|
|
835
|
+
id: invoiceId,
|
|
836
|
+
contentType: doc.contentType,
|
|
837
|
+
sizeBytes: doc.sizeBytes,
|
|
838
|
+
base64: encodeBase64(doc.bytes),
|
|
839
|
+
...(doc.filename ? { filename: doc.filename } : {}),
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
if (!isXmlContentType(doc.contentType)) {
|
|
843
|
+
throw new FrihetApiError(200, "invalid_response", `E-invoice endpoint returned unexpected Content-Type: ${doc.contentType}`);
|
|
844
|
+
}
|
|
845
|
+
const xmlText = decodeUtf8(doc.bytes, "E-invoice XML response");
|
|
846
|
+
if (!xmlText.trimStart().startsWith("<")) {
|
|
847
|
+
throw new FrihetApiError(200, "invalid_response", "E-invoice XML response is malformed");
|
|
848
|
+
}
|
|
849
|
+
return {
|
|
850
|
+
id: invoiceId,
|
|
851
|
+
contentType: doc.contentType,
|
|
852
|
+
xml: xmlText,
|
|
853
|
+
sizeBytes: doc.sizeBytes,
|
|
854
|
+
...(doc.filename ? { filename: doc.filename } : {}),
|
|
855
|
+
};
|
|
442
856
|
}
|
|
443
857
|
/**
|
|
444
858
|
* `POST /v1/invoices/:id/credit-note` — creates a rectificativa DRAFT.
|
|
@@ -461,24 +875,38 @@ export class FrihetClient {
|
|
|
461
875
|
// ---------------------------------------------------------------- Quote Actions
|
|
462
876
|
// ----------------------------------------------------------------
|
|
463
877
|
async sendQuote(id, to) {
|
|
464
|
-
return this.requestUnwrapped("POST", `/quotes/${encodeURIComponent(id)}/send`, to ? { to } : undefined);
|
|
878
|
+
return this.requestUnwrapped("POST", `/quotes/${encodeURIComponent(id)}/send`, to ? { recipientEmail: to } : undefined);
|
|
465
879
|
}
|
|
466
880
|
// ---------------------------------------------------------------- Webhooks
|
|
467
881
|
// ----------------------------------------------------------------
|
|
468
|
-
async listWebhooks(
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
882
|
+
async listWebhooks() {
|
|
883
|
+
const result = await this.request("GET", "/webhooks");
|
|
884
|
+
if (result === null ||
|
|
885
|
+
typeof result !== "object" ||
|
|
886
|
+
!("data" in result) ||
|
|
887
|
+
!("total" in result) ||
|
|
888
|
+
!Array.isArray(result.data) ||
|
|
889
|
+
typeof result.total !== "number") {
|
|
890
|
+
throw new FrihetApiError(200, "invalid_response", "API returned invalid webhook list response");
|
|
891
|
+
}
|
|
892
|
+
// `/webhooks` is deliberately unpaginated. Keep its `{ data, total }`
|
|
893
|
+
// contract and discard only the transport-level `meta` sibling.
|
|
894
|
+
return {
|
|
895
|
+
data: result.data.map((webhook) => normalizeWebhookRecord(webhook)),
|
|
896
|
+
total: result.total,
|
|
897
|
+
};
|
|
473
898
|
}
|
|
474
899
|
async getWebhook(id) {
|
|
475
|
-
|
|
900
|
+
const result = await this.requestUnwrapped("GET", `/webhooks/${encodeURIComponent(id)}`);
|
|
901
|
+
return normalizeWebhookRecord(result);
|
|
476
902
|
}
|
|
477
903
|
async createWebhook(data) {
|
|
478
|
-
|
|
904
|
+
const result = await this.requestUnwrapped("POST", "/webhooks", data);
|
|
905
|
+
return normalizeWebhookRecord(result, typeof data.secret === "string" ? data.secret : undefined);
|
|
479
906
|
}
|
|
480
907
|
async updateWebhook(id, data) {
|
|
481
|
-
|
|
908
|
+
const result = await this.requestUnwrapped("PATCH", `/webhooks/${encodeURIComponent(id)}`, data);
|
|
909
|
+
return normalizeWebhookRecord(result);
|
|
482
910
|
}
|
|
483
911
|
async deleteWebhook(id) {
|
|
484
912
|
return this.request("DELETE", `/webhooks/${encodeURIComponent(id)}`);
|
|
@@ -938,7 +1366,8 @@ export class FrihetClient {
|
|
|
938
1366
|
return this.requestUnwrapped("POST", "/banking/rules", data);
|
|
939
1367
|
}
|
|
940
1368
|
// ---------------------------------------------------------------- HR (Leaves + Attendance + Anomalies)
|
|
941
|
-
//
|
|
1369
|
+
// The ERP routes are live. Single-object family reports use the standard
|
|
1370
|
+
// `{ data, meta }` response envelope and must be unwrapped exactly once.
|
|
942
1371
|
async listLeaves(params) {
|
|
943
1372
|
return this.requestPaginated("GET", "/leaves", undefined, {
|
|
944
1373
|
employeeId: params?.employeeId,
|
|
@@ -969,7 +1398,7 @@ export class FrihetClient {
|
|
|
969
1398
|
return this.requestUnwrapped("PATCH", `/time-entries/${encodeURIComponent(entryId)}/clock-out`, {});
|
|
970
1399
|
}
|
|
971
1400
|
async getOvertimeReport(params) {
|
|
972
|
-
return this.
|
|
1401
|
+
return this.requestUnwrapped("GET", "/time-entries/overtime", undefined, {
|
|
973
1402
|
period: params.period,
|
|
974
1403
|
employeeId: params.employeeId,
|
|
975
1404
|
});
|
|
@@ -990,15 +1419,16 @@ export class FrihetClient {
|
|
|
990
1419
|
return this.requestUnwrapped("POST", `/webhooks/${encodeURIComponent(id)}/test`, data ?? {});
|
|
991
1420
|
}
|
|
992
1421
|
// ---------------------------------------------------------------- Payroll
|
|
993
|
-
//
|
|
1422
|
+
// These live read routes return normalized payroll-preparation records in
|
|
1423
|
+
// the standard single-object `{ data, meta }` envelope.
|
|
994
1424
|
async exportPayroll(params) {
|
|
995
|
-
return this.
|
|
1425
|
+
return this.requestUnwrapped("GET", "/payroll/prep/export", undefined, {
|
|
996
1426
|
format: params.format,
|
|
997
1427
|
month: params.month,
|
|
998
1428
|
});
|
|
999
1429
|
}
|
|
1000
1430
|
async getPayrollChecklist(params) {
|
|
1001
|
-
return this.
|
|
1431
|
+
return this.requestUnwrapped("GET", "/payroll/prep/employees", undefined, {
|
|
1002
1432
|
month: params.month,
|
|
1003
1433
|
});
|
|
1004
1434
|
}
|
|
@@ -1011,20 +1441,22 @@ export class FrihetClient {
|
|
|
1011
1441
|
return this.requestUnwrapped("PATCH", "/onboarding/persona", data);
|
|
1012
1442
|
}
|
|
1013
1443
|
// ---------------------------------------------------------------- Permissions
|
|
1014
|
-
//
|
|
1444
|
+
// These are standard ERP family responses: unwrap their single-object
|
|
1445
|
+
// `{ data, meta }` envelope before handing the payload to MCP output schemas.
|
|
1015
1446
|
async getPermissionsMatrix() {
|
|
1016
|
-
return this.
|
|
1447
|
+
return this.requestUnwrapped("GET", "/permissions/matrix");
|
|
1017
1448
|
}
|
|
1018
1449
|
async getMyPermissions() {
|
|
1019
|
-
return this.
|
|
1450
|
+
return this.requestUnwrapped("GET", "/permissions/me");
|
|
1020
1451
|
}
|
|
1021
1452
|
// ---------------------------------------------------------------- Period Close
|
|
1022
|
-
//
|
|
1453
|
+
// Period reads are live and enveloped. Fiscal close/reopen writes remain
|
|
1454
|
+
// honest ERP 501 responses and are intentionally unchanged here.
|
|
1023
1455
|
async getCurrentPeriod(params) {
|
|
1024
|
-
if (params?.
|
|
1025
|
-
return this.
|
|
1456
|
+
if (params?.fiscalYear) {
|
|
1457
|
+
return this.requestUnwrapped("GET", `/periods/${encodeURIComponent(params.fiscalYear)}`);
|
|
1026
1458
|
}
|
|
1027
|
-
return this.
|
|
1459
|
+
return this.requestUnwrapped("GET", "/periods/current");
|
|
1028
1460
|
}
|
|
1029
1461
|
async closePeriod(data) {
|
|
1030
1462
|
return this.requestUnwrapped("POST", "/periods/close", data);
|